26 / 08 / 22

Dance aROUND 逆向相关 (1)

KONAMI 和 Round1 合作的 BEMANI 音游 Dance aROUND (DaR),算是 Dance Evolution ARCADE 的续作,仅在 Round1 门店可玩。手上有一个好多年前流出的 dump(2022 年中旬版本),但只有资源目录,没有包括启动器在内的运行所需内容,翻遍全网也找不到能够运行的办法。问了许多人都说没法运行,那就只能自己下手做了。

目录结构

game Unity 播放器和全部托管程序集(缺少 exe 入口);

prop AVS 配置;

modules avs2-core.dll、avs2-ea3.dll、kamunity.dll 等;

vpprops VisionPose 配置、密钥;

data 仅一个 zerofill.bin,字面意思,test mode 下硬盘读取测试用。

game 下是游戏完整的资源目录(Unity 2020.3.18f1、Mono、x64),但没有游戏本体 exe、加密狗、IO板、读卡器以及摄像头。能跑的东西都在,就是不知道怎么启动。

初步分析

KONAMI 的 x86 街机已经有非常成熟的破解了,他们的文件结构也都大致相同,因此直接从 AVS 入手。

AVS 是 KONAMI x86 街机使用的一系列中间件,对应 modules 中的 avs2-core.dll 和 avs2-ea3.dll,提供虚拟文件系统、属性管理、网络应用层等功能。

在 DaR 中,kamunity.dll 是程序与 AVS 之间的桥接库,导出 FsOpen、PropertyGetS32、Ea3XrpcApply 等函数。程序用 LoadLibraryA 加载这个 DLL,使用 GetProcAddress 查找这些函数,并将它们绑定为 C# 委托,以托管代码调用 AVS 提供的功能。

读到 KamunityInstance.Boot() 的时候,意外发现程序是允许 LoadLibraryA("kamunity.dll") 调用失败的,加载失败后也不会中止启动。并且 kamunity.dll 在 modules 目录中,不在 DLL 的搜索路径里,这表示正常情况下就是加载失败。加载失败后,KamunityInstance.isActive 会保持为 false。

继续看 GameFs 和 GamePath,发现各个方法都会先判断 isActive,若为 false 时,程序会改用标准的 System.IO 进行文件操作,最终路径相对于游戏进程的目录。

综合来看,KONAMI 在这个版本中完整保留了不依赖 AVS 的启动路径,没有通过条件编译将其移除。因此可以使用 UnityMain 作为宿主来启动游戏,只需将进程的当前工作目录设置为游戏根目录即可。

搞明白整体的启动流程后就好办了。后续要做的就是 patch 掉那些会调用 AVS 的判断,以及因为某些编译为常量的开关导致影响运行的情况。

构建 EXE

Unity 的 Windows EXE 其实只是个壳,真正的入口是 UnityPlayer.dll 导出的

int UnityMain(HINSTANCE hInst, HINSTANCE hPrev, LPSTR lpCmdLine, int nShowCmd);

因此可以直接写一个 EXE,静态导入 UnityMain,进 wWinMain 之后把工作目录切到游戏根目录即可。

EXE 崩溃了

第一次运行,直接报错 STATUS_ACCESS_VIOLATION,并且没有任何 Unity 日志。既然是自己写的壳,那就先加个诊断观察下。最后拿到崩溃地址为 UnityPlayer.dll+0x1BE040,反编后得到如下内容:

mov rax, [rcx+0x38] ret

rcx 为 null,空指针传入了取字段的函数,但是光看也看不出是什么地方,于是从 .pdata 里反查这个函数的全部调用方,找到 150 处,再对每个调用方所在的函数扫 lea r64, [rip+disp32],把它们引用的字符串常量提出来,结果字符串全是 shader、material、GI 之类的。

大概明白了,Unity 会使用内置着色器 game\DANCEaROUND_Data\Resources 进行初始化 ,其中一个的真实名字是 unity default resources (名字带空格),而我去目录下看到的实际文件,却叫 unity-default-resources (名字带横杠)。估计是这份 dump 在打包的时候处理过文件名,什么大写转小写、空格转横杠之类的,所有的文件都是这样。别的地方无所谓,唯独这个文件 Unity 是按硬编码名字查的。直接把名字改了下,游戏就顺利启动了。

Debug 历程

(一)日志缺失

不出所料,立马就遇到了第一个错误:

这个错误有点特殊,代码直接给了 xxxx,也没有更多上下文,去看日志也没有任何内容。查看 Konami.GameSystem.SystemLogger,发现所有的结构都一样:

其中的两个开关,分别控制 AVS 和 Unity 两个不同的日志去向:

AVS 的日志接口为 false 也合理,因为根本没有用到 kamunity.dll,可以无视掉。不过 Application.isEditor 为 false,就导致 IO.Device、IO.BI2A、seckey、GameScreen、ErrorManager 这些前缀的日志都不会进到 Unity 的日志中,直接让其为 true 即可。

改完以后,日志正常生成,这下 debug 可以完全靠日志了。

(二)等长 IL 补丁

所有对托管程序的改动,都必须等长改写方法体,字节数不变、不改变任何元数据和 RVA。例如在修改 OnlinupdateObserver.Update 的时候,只在开头插了 br.s 跳转至无可用更新的分支,结果日志显示:

InvalidProgramException: Invalid IL code ... IL_0002: ldarg.1

因为 Mono 会校验每一条指令,以及那些因为跳转被绕过的部分,update 是静态无参方法,因此被拒绝执行。所以绕过的区域,必须全改为 nop。

(三)加密狗对启动的影响

打开日志后启动,首先看到和 seckey 有关的内容。

SecurityKeyDevice.Boot() 发现 KamunityInstance.isActive == false,把 m_bootError 设成 2,然后 DeviceManager.Update() 返回 ErrorManager.SetError(new ErrorCode(14, 0)),因此出现 5-xxxx-0007 这样奇怪的错误。但这又不是加密狗校验失败,似乎是托管层没法通过 AVS 查询加密狗的状态,但可以初步认为是加密狗导致的错误。

查看 Konami.GameSystem.DebugOptions,发现自带了许多假实现,其中就有加密狗。当 DebugOption.ConnectDummySecKeyInEditor == true 的时候,DeviceManager.Initialize 会改用 SecurityKeyDeviceDummy,不会再去查 KAMUNITY.Bootstrap.GetIkeyStatus。只是这个属性被编译成了常量,直接 patch 掉即可。另外,加密狗也是唯一的没有环境变量的硬件项目。

(四)启动过程中的环境变量

BI2A I/O、ICCA 读卡器、Intel RealSense 都存在环境变量,可以直接跳过;值用 bool.TryParse 解析,可以通过 launcher 加上环境变量,启动的时候跳过,不需要直接 patch。

CONNECT_DUMMY_BI2A #跳过 BI2A I/O CONNECT_DUMMY_ICCA #跳过 ICCA 读卡器 CONNECT_DUMMY_CAMERA #跳过 Intel RealSense STANDALONE_ENABLE #单机模式

(五)地区判断

解决掉上面的硬件问题后,出现了 5-1600-0003 错误。

首先,KONAMI 街机错误码的构成格式为 {level}-{code:d4}-{subcode:d4}。网上搜了下,0007 指的是 NullReferenceException。但是光看这个码也没有意义,还是要配合日志一起看:

Region-item counts error. ... GameLogoScene:Start ArgumentOutOfRangeException: Index was out of range. Must be non-negative and less than the size of the collection. Parameter name: index at GameLogoUI+<RunLogoAnimationAsync>d__15.MoveNext () [0x000f2] at GameLogoSceneController+<RunAsync>d__12.MoveNext () [0x00256] ... GameSceneManager: Goto ErrorMode with ProgramError 5-1600-0003

拿 Region-item counts error 这个字符串在 Assembly-CSharp.dll 里做交叉引用,发现写入方只有 GameLogoUI.Start() 一个:

返回 SystemInfoProvider 查看,三个判断的实现方式都一样,都是先取 Soft ID 再进行解析。

再往上翻,发现第一句判断,会判断 KamunityInstance.isActive 是否为 enable。然而当前是绕过 AVS 的,kamunity.dll 没有加载,因此自然没有 Soft ID,返回了 null,导致解析不到任何地区码,最后落到 else 上。

对于 WarningRegions,Assembly-CSharp.dll 中也有枚举定义:

感觉 unknown = -1 是 KONAMI 有意取的一个哨兵值,但这个值在下游并没有用上。RunLogoAnimationAsync 中也有类似的保护代码:

所以 -1 < count 也能成立,得到 spriteBemaniLogo[-1],最后 ArgumentOutOfRange 了。WarningRegions 那边用负数表示无效,这边判断只防止数值超出,有点左右脑互搏。到这里基本上可以看到,目前出现的问题,大多都是上游对 AVS 不存在的处理是返回 null 或者默认值,而下游则是按返回的值一定有效来判断,结果一串起来,就导致程序崩溃了。

至于究竟选择哪个地区,按照 KONAMI 街机的惯例,也非常好判断:

  1. prop 目录中的 bootstrap.xml,会有一行<security_code>GQUDNJAA</security_code>,第 6 位是 J,会被 Testmode4Unity.CabinetSettings.Awake 读取作为机台的区域,而且那个字段的硬编码默认值也是 J;

  2. 启动日志里出现 CabinetSettingsLoader: Apply GameMode Language 'Japanese' from CabinetSetting。

因此可以判断区域为 J。直接把 IsDestinationRegionJapan 整段 patch 掉,固定返回 true,这样 m_region = 0,就落在 WarningRegions.Japan 上了。

(六)USB 键盘输入

进到游戏界面之后,提示需要进入test mode,但按什么键都没反应。

游戏有一个非常完整的 USB 键盘输入封装。一开始怀疑是内嵌的按键表有问题,结果发现 Konami.GameSystem.IO.GameKeyboard 下还有这么一段:

游戏启动时,会主动禁用键盘设备,是故意为之,同样也 patch 掉。

(七)画面问题

总算是看到游戏标题画面了,然而却仅显示画面的其中一部分。

查看 UDNAppSetting.json 中的 windowSettings 字段,可看到街机的原始设置是独占全屏 1920×3260,对应街机三块 1920×1080 屏幕加中间的拼缝,游戏画面就是按照这个来裁切的,修改相关数值即可。配置里的 virtualScreenEnable,可以让游戏固定渲染成 1920×3252 的街机画面到 RenderTexture,再缩放显示到窗口里的 ScrollRect,并且按下 F1 即可循环三种显示方式:原始大小、适应高度、适应宽度。

(目前先写到这里,后续再补充进入游戏的相关内容。)