用 REA 研究 NGUInjector:从托管 DLL 到原生界面改造

上一篇介绍了怎样把 NGU IDLE 放进独立桌面,并做一个游戏内右侧辅助面板。这一篇往下走一层:NGUInjector 的 DLL 到底是什么,如何进入游戏生命周期,配置为什么会即时保存,自动化又是如何调度的?

研究对象是实际安装的 NGUInjector 4.1.7 中文版核心 DLL,不是外部启动器,也不是游戏本体。方法是先用 REA 确认程序集身份和声明边界,再对关键 CIL 做独立核验,最后对照已经完成的原生面板交互测试。目标是找到可靠的扩展位置,而不是改写游戏数值、存档或绕过授权。

如果只是想使用面板,可以先看部署与操作教程;这篇主要讲分析方法和工程取舍。

分析示意图:程序集身份、游戏自动化核心、原生操作界面三层关系
从左到右:程序集身份、游戏自动化核心、原生操作界面。先确认分析的是哪份 DLL,再看逻辑,最后选择界面接入点。此图为示意,不是运行截图。

一、锁定样本:文件名和版本号都不够

同名 DLL 可以来自不同编译结果;中文修改版也不能直接等同于上游源码。此次样本信息如下,后文的方法 token 和 CIL 偏移都只对这份样本有意义。

项目 实际检查结果
文件 NGUInjector.dll,507,904 字节(496 KiB)
SHA-256 79b20cd32ee14b364d2aeda69ba3dc85ccef699a6fbe7a243cf1a00b70496bbf
程序集版本 1.0.9649.29884,与辅助界面显示的产品版本是两件事
MVID 743a7366-6af1-458c-888c-5a584f2321b7
实现 PE/CLI、CIL、AnyCPU;存在 Unity 引用
元数据规模 181 个 TypeDef、1,858 个 MethodDef、1,211 个字段、302 个属性

TypeDef 数量包括嵌套类型、编译器生成类型等,不等于有 181 个业务模块。另一个容易误判的地方是:PE 头显示 0x014c / PE32,但 CLI 标记为 IL-only,没有 requires-32bit,REA 得出的托管架构是 AnyCPU。因此,不能仅凭 PE 头把它判断成只能装进 32 位进程的原生 DLL。

样本引用了 Assembly-CSharp、Unity 的 Core、InputLegacy、JSONSerialize、UI 模块,也引用了 System.Windows.Forms 和 System.Drawing。这给出了两个入口方向:游戏逻辑在 Unity 的托管环境内,旧设置界面则依赖 WinForms。依赖清单只是声明,不是执行记录。

二、REA 实际跑了哪些检查

为了让结论可复现,我固定使用 npm 发布包 rea-agents@6.4.0,没有运行会修改 Agent 配置的 setup,也没有借分析命令启动目标 DLL。下面的路径应替换成自己拥有的样本文件。

sha256sum /path/to/NGUInjector.dll

npm exec --yes --package=rea-agents@6.4.0 -- \
  rea inspect-managed-artifact /path/to/NGUInjector.dll --format json

npm exec --yes --package=rea-agents@6.4.0 -- \
  rea inspect-managed-native-boundaries /path/to/NGUInjector.dll --format json

npm exec --yes --package=rea-agents@6.4.0 -- \
  rea inspect-managed-members /path/to/NGUInjector.dll --format json
检查 这次结果 能证明什么
程序集身份 complete 文件哈希、MVID、元数据规模、程序集引用、实现分类
托管 / 原生声明边界 complete;0 个 P/Invoke 导入、0 个原生方法实现声明 在这份 DLL 的声明表中没有找到这些条目
完整成员检查 unavailable;触及 64 MiB 元数据表示预算 这次没有拿到 REA 的完整成员与 CIL 输出

496 KiB 的文件也可能超过 64 MiB 的分析表示预算:后者是解析后的记录预算,不是输入文件大小限制。这里的失败不能解释成 DLL 没有方法、被加密,或所有成员都不存在。

REA 当前仓库文档提供按类型选择的分析方法;但本次固定的发布包,其 inspect-managed-members --schema 只暴露路径参数。我没有把另一份源码分支的命令当成这份发布包已经支持的用法。复现时,应先看自己的 --help 和 --schema。相关范围和限制见REA 托管代码分析文档。

成员分析随后使用 Mono.Cecil 的只读程序集解析补充。我编写了一个小检查器,提取选定类型的声明、CIL 和直接调用目标,不用 Assembly.Load 执行样本。因此,下文具体调用链标注为“Cecil 静态核验”,不冒充 REA 完整反编译的结果。

原生声明检查为零,也不能推导出“整个压缩包没有注入行为”或“运行期间一定没有网络访问”。这次只研究辅助核心 DLL;外部启动器、游戏、运行时和依赖库需要分别分析。静态直接调用也不等于这个调用已经在当前存档中执行。

三、Loader 如何把辅助核心接进 Unity

Loader.Init 的 CIL 很短,关键顺序是:

AttachAssemblyResolver()
→ new GameObject()
→ AddComponent<NGUInjector.Main>()
→ DontDestroyOnLoad()

核心不是在桌面外循环识别截图,而是把 Main 注册成 Unity 的 MonoBehaviour。随后它进入游戏对象的生命周期,能够访问游戏的角色、控制器和菜单对象。这也解释了为什么辅助状态和游戏内部数据能够直接关联。

实际中文 DLL 比我拿到的上游 Loader.cs 多了依赖解析流程:AttachAssemblyResolver 注册 AppDomain.AssemblyResolve,回调 ResolveFromInjectorDirectory 尝试从 Injector 所在目录寻找所需程序集名加 .dll,存在时才 LoadFrom。这个差异来自实际 CIL,不能只靠上游源码推测。

工程上,这意味着依赖文件的位置和目录写权限很重要:不要把不可信 DLL 丢进辅助目录,更不要允许公共上传直接写入该目录。哈希锁定只能确认“仍是同一份文件”,不能代替来源审查。

Main 的静态初始化还会查找 Character,并取得角色的背包控制器。对象没有准备好就启动,可能留下空引用。因此现有桥接插件先等待已验证存档载入,再初始化 Injector;不是打开程序后立刻盲目调用 Init。

四、挂机不是一个循环:五个定时任务与每帧输入

Main.Start 先建立日志和配置目录、读取设置、创建设置窗体、载入分配配置、注册文件监听,然后安排五组 InvokeRepeating。Cecil 核验到的名义间隔如下。

任务 设定间隔 角色
QuickStuff 0.5 秒 快速动作;存在战斗、任务、日常动作和分配相关直接调用
AutomationRoutine 10 秒 较重的管理动作;存在背包、世界树、烹饪、卡片、重生与存档分支
MonitorLog 1 秒 日志监测任务
SetResnipe 1 秒 重狙相关任务
ShowBoostProgress 60 秒 强化进度相关任务

这些是 Unity 调度参数,不是五个独立操作系统线程,也不保证按现实时间毫无偏差地执行。不能简单把辅助概括成“每十秒运行一次”。

例如,QuickStuff 的直接调用清单里有 CustomAllocation.DoAllocations、QuestManager.ManageQuests 和 Boss 战斗控制器;AutomationRoutine 中则能看到背包合成、果实收获、卡片使用和条件重生等路径。这证明这些逻辑在所选方法中存在,是否执行仍取决于总开关、功能解锁、配置和条件分支。没有为了验证这些路径而真的替当前存档执行自动重生或消费。

装备切换还经过 LockManager。它的 CanSwap 检查当前逻辑锁状态,并考虑任务锁;这里是协调冒险、泰坦、收获等动作的业务状态,不应直接理解成操作系统 mutex。新界面更适合修改既有配置、让原调度器执行,而不是在点击事件中绕过这些条件强行调用某个管理器。

五、追踪一次开关点击:怎样真正保存设置

在 SavedSettings 中核验到 119 个属性:72 个布尔值、18 个整数、7 个浮点数,其余包括数组与配置文件名。把原生面板仅做成几个看起来会亮的按钮,无法承接这套配置。

以总开关为例,实际 CIL 的顺序很清楚:比较新旧值,相同则返回;不同则写入私有字段,然后调用 SaveSettings。相应方法 token 在本构建中是 0x06000176。

set_GlobalEnabled(value)
  → 比较 _globalEnabled
  → 写入 _globalEnabled
  → SaveSettings()
      → IgnoreNextChange = true
      → JsonUtility.ToJson(this, true)
      → StreamWriter.Write / Flush
      → Main.UpdateForm(this)

所以原生面板调用已有属性 setter,可以保留原来的序列化、日志和界面同步链路。直接反射修改私有字段,可能只改变内存,没有完成保存;另建一份网页配置,则容易让两份状态互相覆盖。

这也揭示了一处仍然存在的耦合:保存配置最后会调用 UpdateForm。当前面板虽然不再展示旧窗口,但旧 SettingsForm 对象和 WinForms 依赖仍保留,用来兼容原有调用。隐藏界面与彻底移除 WinForms 是两件不同的事情。

Main.Start 还对 settings.json、zoneOverride.json 和 profiles/*.json 设置文件监听。自己的保存会使用 IgnoreNextChange 避免下一次监听通知重复加载;配置读取则经由 JsonUtility.FromJson 和 MassUpdate。这不是事务式配置系统:当前保存路径是直接写文件,不是临时文件加原子替换。

后续若扩展完整编辑器,我会优先考虑原子写入、重复文件事件合并,以及把跨线程文件事件送回 Unity 主线程的队列。这里是从调用结构得出的改进建议,尚未宣称原实现已经具备这些机制。

六、快捷键与存档:不要把 F3 当成云同步成功

Main.Update 处理每帧输入。在这份构建中,F1 路径读取窗体可见性,然后调用 Control.Show 和 BringToFront;F2 切换 GlobalEnabled;F3 调用 QuickSave;F7 调用 QuickLoad。F5 和 F8 还有装备相关功能,不能为了屏蔽 F1 而把整个 Update 方法停掉。

方法 本构建 token 分析用途
Loader.Init 0x060000c2 进入 Unity 生命周期
Main.Start 0x060000d3 配置、文件监听与任务调度
Main.Update 0x060000d5 快捷键与旧窗体唤起
Main.QuickSave 0x060000e1 存档导出与后续保存调用
SavedSettings.SaveSettings 0x060000f2 配置序列化与界面回写

QuickSave 先调用游戏的导出功能,把文本存档写到 NGUSave.txt,把游戏状态 JSON 写到 NGUSave.json。一个很具体的细节是:末尾调用 saveGamestateToSteamCloud 后,紧接着用 pop 丢弃布尔返回值,再 ret。这个方法没有把该返回值传回面板作为成功判据;函数名里有 Steam 云,并不等于远端已经接受同步。

此次运行已经验证本地快照文件生成和本地存档恢复,没有验证 Steam 云同步。因此界面提示使用“保存本地快照”。配置文件、导出快照、游戏本地自动存档与远端云存档,应该分别检查,不能混成一个“保存成功”的状态。

七、从分析结果到原生面板:尽量只改边界

当前方案是新增 BepInEx 插件,不修改原 DLL 文件。插件先检查 SHA-256 和 MVID,再用 Harmony 在内存中处理两个方法内的三处旧窗口调用:Start 中的 Show,Update 中的 Show 和 BringToFront;原有状态文字层另外关闭,避免叠加。调用点数量和目标方法签名不符合预期时,补丁拒绝继续。

新界面是 Unity Canvas、按钮和 ScrollRect,和游戏在同一窗口。F1 交给新面板收展,F2 和 F3 仍走已有逻辑。23 个开关分为常用、资源、冒险和进阶;涉及重生、消费、物品处理的开启操作需要二次确认。面板显示刷新与游戏自动化调度分开,状态每 250 毫秒读取一次,并不是每次刷新都写配置。

实际游戏内右侧原生面板,展示已验证的常用开关
此前部署的真实界面:新增显示层,复用已有设置与自动化逻辑。

已经完成的交互验证包括:总开关暂停和恢复后配置值随之落盘,F1 不再唤起旧窗口,长列表可以滚动,本地快照生成,以及重启后恢复已有存档。原 DLL 文件和所有原有配置值都经过前后比较。这些是端到端功能证据,并不等于对所有管理器做了运行期逐方法跟踪。

仍未完成的部分是复杂装备数组、完整阈值表单和分配配置编辑器。119 个属性并不适合不加区分地批量映射为控件:数组要校验合法元素,功能要考虑解锁条件,可能消费或重生的操作还要有清晰的确认和回退。真正值得继续抽象的是“配置模型—校验—界面”这层,而不是复制一套自动化引擎。

八、复现材料与结论

下载本次研究的只读检查器与分析摘要。材料包含自写的 InspectCalls.cs、去掉本机路径的元数据与直接调用摘要,以及使用说明;不包含游戏、原 DLL、个人存档、Cookie、登录信息或服务密钥。摘要是从本地原始证据派生的阅读材料,不冒充未经修改的 REA Evidence 原件。

使用自己拥有的样本与 Mono.Cecil,可以编译检查器进行静态读取:

mcs -r:/path/to/Mono.Cecil.dll \
  -out:InspectCalls.exe InspectCalls.cs

MONO_PATH=/path/to/cecil-directory \
  mono InspectCalls.exe /path/to/NGUInjector.dll

这次最重要的结论不是“把 DLL 反编译成几千行代码”,而是找到少量可核验的边界:初始化接入 Unity 生命周期,属性 setter 负责保存配置,周期任务负责自动化,旧窗口调用负责焦点变化。把显示层换掉,同时保留这些边界,比从头复刻挂机逻辑更稳妥。

REA 完成了样本身份与托管 / 原生声明检查,成员检查遇到的限制也原样保留;Cecil 补上关键调用结构;实际游戏交互确认改造后的行为。静态观察、结构推断和运行验证各自负责一部分,不需要把任何一个工具包装成“全部都懂”。

上一篇
下一篇