上一篇介绍了怎样把 NGU IDLE 放进独立桌面,并做一个游戏内右侧辅助面板。这一篇往下走一层:NGUInjector 的 DLL 到底是什么,如何进入游戏生命周期,配置为什么会即时保存,自动化又是如何调度的?
研究对象是实际安装的 NGUInjector 4.1.7 中文版核心 DLL,不是外部启动器,也不是游戏本体。方法是先用 REA 确认程序集身份和声明边界,再对关键 CIL 做独立核验,最后对照已经完成的原生面板交互测试。目标是找到可靠的扩展位置,而不是改写游戏数值、存档或绕过授权。
如果只是想使用面板,可以先看部署与操作教程;这篇主要讲分析方法和工程取舍。

一、锁定样本:文件名和版本号都不够
同名 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 补上关键调用结构;实际游戏交互确认改造后的行为。静态观察、结构推断和运行验证各自负责一部分,不需要把任何一个工具包装成“全部都懂”。