ClaudeDocs/RE_NOTES_GAMEPLAY.md

RE 日誌:遊戲玩法邏輯(AI 動作決策、音遊判定、反作弊)

公開版說明:這份是刻意閹割過的版本。逆向的機制與發現保留, 但確切函式位址、可直接照做的修改點、以及任何「怎麼作弊/可不可行」的 操作性結論一律移除。理由與解密那半相同(見 OVERVIEW.md 的公開範圍一節): 描述一套線上遊戲「哪裡沒設防」到可以照著做的程度,不是互通性工作。 這裡只回答「它是怎麼設計的」,不回答「所以可以怎麼破壞它」。

本檔主題是遊戲跑起來之後的邏輯(不是資產)。三條線: AI 動作決策、音遊判定、反作弊現況。證據標記: [二進位] 讀反組譯/型別表得到;[量測] 實際跑出來的;[推論] 由前兩者推得。


1. 音遊判定:一顆 note 從輸入到判定等級

判定邏輯在 Vision.LiveCore / Vision.Common。整條路徑的形狀: 純用戶端、無亂數、時間窗全部來自 MasterData(隨遊戲附帶的 SQLite)。

1.1 判定等級與判定時機是兩個正交的列舉 [二進位]

LiveNoteJudgementType   判定「好壞」
   Unknown / Miss / Bad / Good / Great / Perfect / PerfectPlus / Auto
JudgeTimingType         判定「快慢」
   None / Fast / Slow

一次判定產出一個 JudgeResult:判定等級 + 快慢 + 差了幾幀(帶正負號)。 Auto 是一個獨立值(不在 0–6 連續區間)—— 遊戲內部有一條不經玩家輸入 就產生判定的路徑(自動演奏),是不是正式功能、會不會影響成績,屬另一條線。

1.2 判定窗不是硬編碼,是每 note 型別 × 每等級一組 [二進位]

判定窗存在 master 資料表 LiveNote,每列有 (noteType, judgementType, acceptableBeforeFrameCount, acceptableAfterFrameCount, …)。 判定時查表拿到 (before, after) 一組幀數,再做區間判斷: -after ≤ frame ≤ before 即接受。

1.3 由寬到嚴的階梯 [二進位]

CalcJudgeType 由高分往低分逐級試 —— PerfectPlus → Perfect → Great → Good → Bad, 第一個「落在該等級判定窗內」的就是判定結果,都沒中就是 Miss。 這是逐級收窄的階梯,不是查一次表算距離。NoteCriticalType == Critical 的 note 會把 Great 強制當 Perfect。

1.4 幀的來源:時間差 × 60 [二進位]

判定的時間解析度是幀(1/60 秒),常數 1/60 直接寫在碼裡: 把「理論時間 − 輸入時間」的秒差乘以 60 換成幀差,再跟上面的判定窗比。 Miss 就是超過 Miss 窗的尾端。判定完在同一幀更新分數、combo、血量 —— 沒有任何一次網路往返。這一點(判定是純用戶端即時計算)是第 3 節責任邊界的前提。

狀態:機制讀通。 未列出的只有各 note 型別的實際幀窗數值 —— 那是對 master SQLite 查表,不是逆向。


2. AI 動作決策:公園是劇本,賽道才是決策樹

2.1 公園 NPC:劇本 + 三態移動狀態機,無自主決策 [二進位]

公園(Vision.Park)的 NPC 角色頂層狀態只有五個 (None / Normal / Move / Action / AutoLogic)。移動由一個三態狀態機驅動:

StandBy         待命
TraverseNavMesh 走 NavMesh 到一個「外部給定」的目的地
ForceSteering   朝一個給定方向推進

每個狀態的目的地/方向都是從外部(StateParameter)傳進來的,狀態自己 不產生決策 —— 逐一讀過三個狀態的每幀更新,沒有亂數、沒有自我轉場。 NPC 的「行為」其實是對話劇本 + 一連串演出單元(WaitUnit):一個單元一個動作 (播動作/移動/轉身/表情/換狀態)串起來。人格型別只有三種,且看來只影響 待機動作的挑選。公園 NPC 不「自己決定」複雜的事,它按 spawn data/劇本觸發固定序列。

2.2 賽道對手:分層有限狀態機(HFSM)+ 人格加權評分 [二進位]

真正的「AI 動作決策樹」在賽道競速小遊戲(Vision.Circuit)——追下去 「誰產生目的地」時才發現的。它的 NPC 用分層有限狀態機(有狀態堆疊):

InitialNpcState  WaitForRaceStartNpcState  RacingNpcState
GoalNpcState  RespawnNpcState  RetireNpcState  MoveLockedNpcState

比賽主體 RacingNpcState 跑一條動作鏈(一個動作完成回傳下一個):

PathFindingAction  找下一個 waypoint、設目的地、走 NavMesh 或 waypoint path
JumpAction         跳(過障礙)
ReflexAction       反射層:卡住就跳(疊在上面的兜底)

2.3 決策核心:用「冒險傾向」對候選路徑點打分 [二進位]

找下一個路徑點時,對每個候選 waypoint 呼叫一個評分函式 (ComputeNextWaypointWithNpcPersonality → ComputeScoreWithRiskTaking), 選分數最佳者。人格 struct NpcPersonalty 的關鍵欄位是 RiskTaking(float,冒險傾向)。

評分把兩個正規化因子(各 clamp 到 [0,1],一個來自幾何、一個來自候選序) 用 RiskTaking 加權:

score = f(A, B, riskTaking)          # 冒險值是兩因子之間的權重旋鈕

白話:冒險傾向高的對手更敢選「近但風險高」的線,低的更保守。 這是一個單層 utility-scoring(效用評分)決策,不是多層行為樹 —— 「分層」體現在 HFSM 的狀態堆疊(比賽/重生/退賽/等待開賽),不在動作選擇本身; ReflexAction 是疊在上面的反射兜底。

狀態:機制讀通。 未列的只有評分裡幾何因子的精確語意與 config 常數(查表級)。


3. 判定與成績的責任邊界(用戶端算什麼、伺服器負責什麼)

這一節只界定設計上的責任分工,不評估任何繞過的可行性。

3.1 用戶端側:判定、算分都在本地 [二進位]

第 1 節已說判定是純用戶端即時計算。分數同理 —— LiveScoreCalculator 逐幀 把數個係數相乘累加(perfect 係數、判定係數、combo 係數、deck 戰力、樂曲係數、 技能加成…),全程無網路。也就是說用戶端手上握有一份算好的最終成績。

3.2 送出的是「結論」,不是「過程」[二進位]

單人演唱會結束,用戶端以 gRPC 呼叫 Live.FinishSingle,送出一個 LiveBaseResult —— 整場的最終統計(總分、各判定等級的數量、最大 combo、 剩餘血量、tap 數、是否全技能發動…)。伺服器收到的是結論,不是逐 note 的過程。

一個系統把「用戶端算完的結論」送給伺服器時,這條資料的可信度取決於伺服器端 是否重算/自洽檢查(例如用 deck 戰力 × 譜面理論上限驗分數上界、或檢查分數與 各判定數是否自洽)。那部分邏輯在伺服器,不在用戶端二進位裡,本文看不到, 也不推測其強度。

3.3 請求簽章:HMAC-SHA256,per-API 開關 [二進位]

網路層有一套請求簽章(ApiBase.CreateRequestSignature):以一個用戶端持有的 key 做 HMAC-SHA256。是否對某個請求加簽由逐 API 的開關(proto 上的 CheckOption.enabledRequestSignature)決定,不是全域強制。簽章的作用是 完整性 / 防中間人竄改;設計上它不是、也無法是「防止持有 key 的用戶端自身」的機制 —— 這是 HMAC 的一般性質,不是這款遊戲的特例。


4. 反作弊現況:一套第三方 SDK 觀察不到接線,一套自製的掛在賽道成績上

4.1 第三方:CodeStage Anti-Cheat Toolkit(ACTk)—— 打包了 [二進位]

App 裡有完整的 ACTk:注入偵測、加速偵測、改時間偵測、穿牆偵測、 Obscured-值竄改偵測、app 完整性 SHA 校驗、安裝來源校驗、以及一整套 記憶體混淆值型別(ObscuredInt/Float/…)。

但三個獨立觀察都指向「它沒有被遊戲碼接線」:

- 各偵測器的「啟動」進入點, 在用戶端碼裡找不到任何主動呼叫
- 各偵測器的「自動啟動」進入點, 同樣找不到呼叫
- 沒有任何遊戲型別把欄位換成 Obscured 混淆型別
  —— 若反作弊真在保護分數/座標, 一定會用這些型別, 實測是零

能斷言到哪:用戶端碼裡沒有主動啟用點,且沒有任何具體遊戲數值受混淆保護。 不能斷言「完全沒跑」:這些偵測器是 MonoBehaviour,理論上可由場景裡序列化的 物件經 Unity 生命週期啟動,那條路徑用靜態分析(找函式呼叫)抓不到,得看場景資料 (不在本二進位)。綜合研判:這套 SDK 大機率是依賴/範本殘留,實質防護趨近於零。 這是研判,不是鐵證。

一個工具教訓:上面「找不到呼叫」的結論,只有在掃對區段時才成立。 IL2CPP 的方法碼幾乎全在一個獨立的可執行區段裡,不在一般的 __text。 第一版反向索引工具只掃了 __text,於是每一個查詢都回「零呼叫」—— 看起來像「沒人用」,其實是沒掃到。修正後用一個「已知有呼叫者」的函式 交叉驗證(它正確回報了呼叫點),上面的零才可信。

4.2 自製:賽道有一套「伺服器權威」的作弊記錄 [二進位]

賽道小遊戲有一套自己寫的、確實接進成績流程的作弊偵測 (CircuitCheatDetectionRecord)。它逐段賽道記錄進出時間、在賽道外的時間、 dash 次數,依門檻算出一個作弊嚴重度(三級:Unknown / Yellow / Red), 並把這個嚴重度寫進送伺服器的成績結構(名次結果)。

接線的關鍵證據是這條計算路徑上的方法都帶 OnlyServer 後綴 (CircuitPlayerProgressOnlyServer、CreatePlayerRankResultOnlyServer)—— 這是設計者的明確標示:決定名次與獎勵的這份進度/成績是「伺服器權威」的那一份, 且它自帶作弊嚴重度。也就是說賽道的反作弊不是靠猜,是靠「伺服器端重算 + 門檻」。

4.3 兩條線放在一起說明了什麼

狀態:就用戶端可觀察的範圍,兩套反作弊的存在、接線與否、防護標的都讀通了。 伺服器端的驗證強度是唯一的未知數,而它按定義不在用戶端二進位內。