26 / 09 / 02

Dance aROUND 逆向相关 (2)

网络维护中

游戏进去了,但投币没有反应,游戏里显示网络维护中无法游戏。

Gatekeeper.playable 来自 GateStatus.EntryAcceptable,后者要求营业时段正常并且 participationAvailable 为 true 。而 participationAvailable 的实现是:

也就是说,离线时 EAStatusProvider.GetPpStatus() 返回 0,映射成 Boot,投币口一直锁着,投币和 Start 都被忽略,这一处 19 字节整段重写成固定返回 true。

另外 EA3Status.UpdateStatus 里有一句判断:

EAExecuteMode 是 EAOn=0, EAOff=1, Standalone=2, FixedState=3。单机模式下,SystemInitialization 会调 SetEAExecMode(2),和这里的 = 1 对不上,导致 Status 会一直停在 SetUp。我一开始把比较值改成了 2,结果游戏内 debug menu 中显示的 executeMode 是 EAOff,Status 反而卡住了。最后只能改成范围判断,也就是 executeMode < 3 都判成 Local。

站位校准

因为手上没有 Intel RealSense,想要进入游戏(而不玩游戏)还需要绕过这些流程。游戏有两次站位校准,投币选完语言进游戏时一次、选完曲目进游戏前一次。两次都走同一条链:

GameEntry_Calibration.DoTaskAsync //第一次 GamePlaySceneController.PlayerCalibration //第二次 → CalibrationController.StartCalibrationAsync → GamePlayerCalibration.StartCalibrationAsync → PlayerCalibrator.StartCalibrationAsync

链路最底下 PlayerCalibrator.StartCalibrationAsync 反编译出来是这样:

这块是两段循环。第一段(i < samplingQueueSize)是采集 60 个有效样本计算平均值,每 50ms 采集一次,中断一次(离开安全区或丢失追踪)就会清零重来。第二段(additonalSamplingCount,视具体调用方是否传大于 0 的值而定)是拿第一段算出的平均值继续跟新样本融合,只要滑动平均位移连续两次小于容忍值,就提前中断,但不清零,就是单纯暂停进度。

而 MotionCaptureSystem.CurrentPlayer 里头的 IsInSafeArea 和 IsTracked 这两个条件,CapturedPlayer.IsTracked 是 MotionCaptureSystem.OnUpdate() 从 SystemAvatar.IsTracked 抄过来的,后者只在 RealtimeFrameProvider.onPose3DRetrieved 回调里被赋值。而我没有摄像头,自然就没有这个回调,条件永远不成立,导致每帧检查、每帧清零,成了死循环。

这里还有一个反直觉的地方。校准画面上有提示可以按 Enter,我按下了,结果判定失败直接退回标题。返回查看

if (GameInput.Keyboard.enterKey.isPressed) cts.Cancel();

所以 Enter 是取消,不是跳过,取消会 OperationCanceledException 走到失败分支,isSuccessed = false,调用方看到失败就中止这次游戏。写着「エンターキーでSKIP」的是 DebugGamePlayerCalibration,正式流程用的不是它,所以直接改 CalibrationController.StartCalibrationAsync,不再调用 GamePlayerCalibration,直接返回 success 即可。

new CalibrationResult { isSuccessed = true, hipPosition = Vector3.zero }

没有 isAcitve 的判断

过了校准,选完模式又报了 5-1600-0007 错误,日志如下:

NullReferenceException at KAMUNITY.Avs.FsOpenW (System.String path) at Testmode4Unity.VirtualCoinSetting.SaveEaCoinXml () [0x001e0] at GameEntry_ModeSelect+<DoTaskAsync>d__18.MoveNext () [0x00136]

意思是,GameEntry_ModeSelect.DoTaskAsync 一进来就执行了

CabinetSettings.GetInstance.VirtualCoin.SaveEaCoinXml();

而 SaveEaCoinXml 里直接调用的是 KAMUNITY.Avs.FsOpenW("/dev/nvram/eacoin.xml"),没有 KamunityInstance.isActive 判断,委托是 null。同一个文件里,PaseliConsume 的所有同类方法都加了 isActive 判断,但这个没有。这个方法写的是给 e-amusement 服务读的 PASELI 计价文件,离线运行没有意义,整段用 nop 填充掉即可。这也是这套代码里少数几个忘了加防护的地方。

改完这一处,游戏就可以在没有 AVS 的环境下,完整走完开机、演示、投币、语言选择、入场流程、选曲、游戏、结算了。

Auto Play

没有摄像头,音符全都会 Miss,想整个 Auto Play 让它自己打完。本来打算自己写判定注入,结果发现已经有现成的。在 GamePlaySceneController 中有一个开关:

判定的三个入口(NoteController、PoseNoteControlle、TraceNoteController)都是同一个写法:

isAuto 为真的时候,RoutineOfJudgement 会跳过整段 Physics.BoxCastAll 体感判定,Note 直接返回 autoType。GameParam.CurrentAutoType 的类型就是 JudgeType(Unset=0, Bad=1, Good=2, Great=3, Perfect=4),GameParam.Init 会直接把它初始化成 JudgeType.Perfect。

所以把 GameParam.get_IsAuto 写死成 return true,选完歌曲进游戏就会全 Perfect 自动打完,然后到结算画面。

曲库数量分析

游戏能玩了,接下来想知道这份 dump 里到底有多少曲子。

曲目元数据在 StreamingAssets\aa\win64\audioWorks\musicassetgroup_assets_music_config\mdb_cabinet_base.bundle 中。这是一个 UnityFS 包,里面是一个 105 KB 的 JSON,music_count: 53。核对方式是把这个 MDB 里每个 fumens[] 条目的 level 和 playable,对照 music_catalog.json 里实际存在的 _bsc / _adv / _mas 谱面包逐条比对,两边完全一致。谱面总数 113 也和启动日志里的 Enumerated fumen file! file count:113 吻合。

  1. 曲目数量合计 53 首,其中 ID 90001–90003 是教程和两个课程模式,只有 BASIC,不出现在普通选曲画面,所以实际可玩曲目数量为 50 首

  2. 谱面 113 个,其中 BASIC 48 个、ADVANCED 50 个、MASTER 15 个

  3. 等级范围 BASIC 1–5,ADVANCED 3–9,MASTER 7–10

  4. 5 首版权 EDM 曲无 BASIC 谱面,只有 ADVANCED 和 MASTER

正常游戏时,选曲画面并没有出现那么多曲子。进入选曲画面前,日志会出现:

MakeOfflineFumenService: change limitation_type music: id:1/Basic ... MusicDatabaseUpdater: Merged remote DB. normalOpenFumenCount:25

也就是只有 25 个谱面是开放的,又得去倒查这个 25 是从哪来的了。

谱面是否开放由 FumenInfo.limitationAttribute 决定,枚举是 Unplayable=0, NormalOpen=1, Locked=2, ExtraOnly=3,由 MdbTypeExtensions.ToLimitationAttr 从 MDB 的 limitation_type 映射而来。

实际为:

Unplayable(1) > Unplayable(0) RequireUnlock(2) > Locked(2) Normal(3) > NormalOpen(1) Extra(4) > ExtraOnly(3)

这份 dump 的 MDB 里,53 首曲子的 limitation_type 全是 1,也就是全部 Unplayable。启动时 MakeOfflineFumenService 按一份硬编码的 kOfflinePlayableMusicIds 把其中 25 个改成 NormalOpen,剩下 88 个谱面 FumenInfoExtensions.CanPublish 全为 false,导致选曲画面里根本不出现。

曲目控制下,上层调用会用到 e-amusement 服务。MakeOfflineFumenService 被 MusicDatabaseUpdater.OnCommonDataFetched 调用

GetBodyIfSuccess() 会向 e-amusement 服务器发送一次 XRPC GetCommon,成功了才会把服务器返回的曲目列表放进 m_remoteDB、跟本地 MDB 合并成 m_mergedDB。不管是否成功,都会走到末尾的 MergeMusicListService 和 MakeOfflineFumenService。在没有 AVS 和网络的情况下,GetBodyIfSuccess 为 null,m_remoteDB 也是空的,没有数据可以 merge,m_mergedDB 实质上就只剩本地 MDB 那 53 首全被标记为 Unplayable 的数据。

MakeOfflineFumenService 这一步不看网络请求成不成功,用的是 LocalMDBValidator.kOfflinePlayableMusicIds,是写死的一份名单:

这是 KONAMI 特意内置的离线保底名单,哪怕联不上网,也至少有曲子能玩、不至于完全瘫痪,25 就是这么来的。MakeOfflineFumenService.Run 本身反编译出来是这样:

关键在中间那个 IsExtraOnlyOrLocked() 判断,因为这份名单只把 Unplayable 的谱面提到 NormalOpen,但遇到 Locked/ExtraOnly(未解锁或者活动限定之类的)是明确跳过的,不会越权解锁。所以解法是判断点往下移,让 MdbTypeExtensions 的 ToLimitationAttr 恒定返回 NormalOpen,不管 m_mergedDB 是什么状态,一律当作可玩处理。这样处理,比 MakeOfflineFumenService 原本的权限范围更宽,连 Locked/ExtraOnly 这两级也一起绕过了。

另外要注意 ALL_LEVEL_PLAYABLE 这个环境变量,它设置 DebugOption.OpenAllLevelMusic,让 MusicBagPreparers.GetPreparer 恒定返回 AllLevelPlayablePreparer,绕开轻量模式首曲等级不超过 6 的限制。两者互不替代。

用普通 webcam 代替 RealSense

vpprops\visionposeforunityconfig.json 里左右相机各有独立的设备索引、USB 端口号和世界坐标位姿:

"LeftCameraPose": { "X": 0.30, "Y": 1.10, "Z": 3.20, "Yaw": 180, "Pitch": -3 }, "RightCameraPose": { "X": 1.50, "Y": 1.10, "Z": 3.20, "Yaw": 180, "Pitch": -3 }, "StageBox": { "MinX": -0.20, "MaxX": 2.00, "MinY": -1.00, "MaxY": 2.50, "MinZ": -0.10, "MaxZ": 1.60 }

从 visionposewrapper.dll 对 realsense2.dll 的 69 个导入,可以确定实际取了哪些数据:

  • 彩色流加深度流,848×480 @ 60fps,深度格式 Z16

  • rs2_create_align 把深度对齐到彩色

  • rs2_create_hole_filling_filter_block 深度补洞

  • rs2_get_depth_scale 加 rs2_deproject_pixel_to_point,把 2D 像素配上该点的深度值算成三维坐标

  • rs2_load_json 下发 advanced-mode 预设,就是配置里那一大块 param-censusenablereg-*、param-scanlinep1/p2。红外结构光投射器是常开的(controls-laserstate: on,controls-laserpower: 240)

每台 RealSense 的彩色帧裁一块 480×480 的 ROI,送进 VisionPose 的 CNN(TensorRT,320×320 输入,置信度阈值 0.2,跑在 nvcuda.dll 上),得到 30 个关节的 2D 点;每个关节用深度反投影成该相机坐标系的三维点;两台 RealSense 的结果按 dev/nvram/stereo_cam.yml(4×3 棋盘格、格子 80mm 标定出来的)和配置里的相机位姿融合到世界坐标位姿。

游戏托管层拿到的,只有最后一步的 OnPose3DUpdate 事件以及 30 个关节的世界坐标,提供给 SystemAvatar.IsTracked 和 Position、CapturedPlayer.HipPosition(站位校准和区域判断)、FullBodyFK 的 IK 驱动虚拟形象、以及 NoteUmpire.RoutineOfJudgement(判定) 对贴在形象身上的 Note 做 Physics.BoxCastAll。

要用普通 webcam 来玩,可能要解决这些问题:

  1. 输入路径只有 librealsense2,按设备和 USB 端口枚举,没有 UVC / DirectShow 的采集通路。导入表里的 Media Foundation 只用于录像输出。

  2. 需要深度数据。rs2_deproject_pixel_to_point 是关节三维化的唯一手段。

  3. 需要两台设备。RealSenseLeftIsActive / RealSenseRightIsActive 任一为假,RealSenseActiveChecker.Check 就返回错误码 11 或 12。

  4. 需要 NVIDIA 显卡(nvcuda.dllnvcuvid.dll

理论上说,绕开 VisionPose 自己喂骨架也是有可能的。游戏的消费点在托管层,而且游戏里已经有一个 Vp4uPosePlayer,实现了同一个 onPose3DRetrieved 接口,可以从字节数组加载 DAM1 格式的动作数据播放,MotionPlayerGUI 能按文件路径加载它,不依赖相机的同类数据源。而且这个 GUI 在 Tab 调试菜单里就能打开。所以用 webcam 加单目 3D 姿态预测,映射成这 30 个关节注入这个委托,接口层面应该是通的。

一点感想

整个过程里最省力的一步,是发现游戏完整保留了无 AVS 直接跑的路径。这条路径本来应该是给 KONAMI 在 Unity 编辑器里调试用的,最终没有被条件编译掉。

第二省力的是把游戏自己的日志打开,大部分问题看一眼堆栈就知道改哪里,应该是性价比最高的一处改动。

最花时间的反而是那个文件名。unity default resources 里的空格被 dump 的打包流程规范化成了连字符,一个字符的差别,表现是没有任何日志的崩溃,反正查了很久。