Torchlight II 游戏内 Console 输入链、命令派发与 Hook 方案

核心结论:可以完全绕开 INSERT → 聚焦输入框 → 键盘输入 → Enter。最合适的实现不是从外部进程直接覆盖输入框内存,而是在常驻 CHudMenu::tick 所在的游戏/UI 主线程取命令队列,再调用 ConsoleMenu 的原版 sub_78FE10(保留回显和历史)或 sub_789B00(只执行命令)。这样不受中文输入法影响,也不需要让 Console UI 可见。

分析依据:本地 IDA MCP 对 E:/Torchlight 2/Torchlight2.exe.i64 的反编译、反汇编和交叉引用;随后以一次性克隆 vtable Hook 在当前运行进程中完成 GOD 0 → 1 动态验证。Hook 已自恢复,未改游戏代码段。


1. 分析对象与地址口径

项目
游戏版本Torchlight II v.1.25.9.5
架构x86 / 32 位
IDA imagebase0x00400000
MD5a7e42dfb2e1fe583b40d84c621507550
SHA-256186472c3057bb38f4cdff4696959a943c396ae6166995f7418997b5ea853e8a5e

本文的“静态 VA”是 IDA 中的地址;真正运行时必须使用:

text
runtime_address = Torchlight2.exe runtime_base + (static_VA - 0x00400000)

不能把本次运行看到的 0x00FB0000 或任何堆地址写死。

2. CONSOLE.LAYOUT 提供的 UI 证据

文件:E:/Torchlight 2/MEDIA/UI/MENUS/GLOBALMENUS/CONSOLE.LAYOUT

菜单定义为:

text
MENU NAME: CONSOLE
TYPE: CONSOLE MENU
GAME STATE: All
PINNED: true
CREATE ON LOAD: true
KEY BINDING: CONSOLE

与输入有关的控件是:

控件作用
CONSOLEEDITFRAME输入框外框
CONSOLEEDITBOX / TextInput单行 Edit BoxMAX LENGTH = 255
CONSOLEBOX多行输出/历史框

布局逻辑把 Edit Box 的 Enter 输出连回 Activate 输入。这个连接只负责触发控件事件,并不解析命令;真正的命令处理在原生 CConsoleMenu 中。

3. CConsoleMenu 的创建与实例定位

CUIMenuAndWidgetManager::instantiateMenu 位于静态 VA 0x733E80。它读取 Menu Definition 的 TYPE;当类型值为 0x0CCONSOLE MENU)时:

  1. 从游戏分配器申请 0x154(340)字节;
  2. 调用 sub_782E20 构造 CConsoleMenu
  3. 把实例加入 UI 管理器 +0x6C 的菜单指针向量;
  4. 从 LAYOUT 构建控件树并完成控件绑定。

UI 管理器取得函数为 sub_731620

cpp
CUIMenuAndWidgetManager* GetUiManager() {
    return *(CUIMenuAndWidgetManager**)0x3881E34;
}

换成 RVA 后:

text
manager = *(moduleBase + 0x03481E34)
menu_begin = *(manager + 0x6C)
menu_count = *(manager + 0x70)
menu_capacity = *(manager + 0x74)

遍历 menu_begin[0..menu_count),查找首 DWORD 等于 moduleBase + 0x01D93E04CConsoleMenu vftable)的对象即可取得实例。比扫描全部 committed heap 稳定,也不必依赖本次启动的堆地址。

CConsoleMenu 已确认字段:

偏移类型/含义
+0x10CCEGUI::Editbox*,即 CONSOLEEDITBOX
+0x110输入框 frame,即 CONSOLEEDITFRAME
+0x114CEGUI::MultiLineEditbox*,即 CONSOLEBOX
+0x118当前历史索引
+0x11C历史容器首地址
+0x120历史元素数
+0x124历史容器容量
+0x12C是否把 Console 内容同步写到 Ogre log
+0x12D本帧刚接受过命令的标志
+0x12E输入 frame 当前是否可见

4. 从 Enter 到命令执行的完整调用链

text
CONSOLEEDITBOX 按 Enter
  └─ CEGUI::Editbox::EventTextAccepted
      └─ sub_78FE80  CConsoleMenu::onTextAccepted
          ├─ 读取 Editbox 内的 CEGUI::String
          ├─ UTF-8 → std::wstring
          ├─ 末尾追加 L'\n'
          ├─ sub_78FE10  CConsoleMenu::submitLine
          │   └─ sub_78FA80  CConsoleMenu::appendEchoAndDispatch
          │       ├─ 写入 CONSOLEBOX
          │       ├─ 维护约 10000 字符的输出上限
          │       ├─ 加入命令历史
          │       └─ sub_789B00  CConsoleMenu::executeCommand
          ├─ 清空 CONSOLEEDITBOX
          └─ this+0x12D = 1

4.1 控件绑定:sub_78FFA0

sub_78FFA0CConsoleMenu::linkWidgets。遇到 CONSOLEEDITBOX 时,它把 CEGUI 指针保存到 this+0x10C,解除只读、启用控件,并向 CEGUI::Editbox::EventTextAccepted 注册一个 MemberFunctionSlot<CConsoleMenu>;槽函数就是 sub_78FE80

因此 CONSOLE.LAYOUT 确实是定位入口的重要线索,但执行器不在 LAYOUT 内。

4.2 接受文本:sub_78FE80

该回调先检查 GetAsyncKeyState(VK_TAB);Tab 正按下时不提交。随后:

  1. Editbox + 0x104 的 CEGUI 字符串对象取得 UTF-8 buffer;
  2. sub_688030 转成 std::wstring
  3. 非空时追加换行并调用 sub_78FE10
  4. CEGUI::Window::setText 清空输入框。

Editbox+0x104 是一个完整的 CEGUI 字符串对象,不是 char*。直接用 WriteProcessMemory 覆盖这里会破坏长度、容量、SSO/堆指针或引用状态,因此不应采用“写输入框内部内存”的方式。

4.3 回显、历史与派发:sub_78FA80

sub_78FA80 把提交文本按换行拆开,追加到 CONSOLEBOX,更新命令历史,最后只在一个位置调用 sub_789B00。因此:

  • 想让命令像手工输入一样出现在 Console 历史中:调用 sub_78FE10
  • 只关心命令效果、追求最短路径:直接调用 sub_789B00

5. sub_789B00 命令解析器

sub_789B00 是巨型单函数命令分派器。IDA 统计 6,544 条指令、763 个基本块;函数主体含大量尾块,不能仅用首尾地址跨度估计代码大小。

入口预处理已经确认:

  1. getGameClient()->sub_428C90() 检查当前是否禁止 Console;多人游戏会输出 Console not allowed in multiplayer game
  2. sub_685DF0(command, L"\n", L"") 删除全部换行;
  3. toUpperW 把命令转成大写;
  4. 循环删除开头所有小于字符 'A' 的 UTF-16 code unit,因此前导空格、数字和大部分 ASCII 标点会被剥离;
  5. 对规范化后的字符串进入命令比较链。

这解释了为什么 Hook 端可以提交 god,也可以提交 GOD,完全不需要处理中文输入法状态。

5.1 关键 ABI

该游戏使用旧版 32 位 MSVC std::wstring,对象大小为 0x1C(28)字节。命令以 按值参数传入,sub_789B00 结尾由 callee retn 1Ch 清理这 28 字节。

因此不能把注入 DLL 自己的 std::wstring 直接跨 CRT 传给游戏。可靠桥接方式是在调用栈上预留 28 字节,调用游戏 IAT 中的 std::wstring(wchar_t const*) 构造函数直接构造参数,再调用原函数:

asm
; ECX/寄存器保存与 detour 框架相关,此处只表示关键 ABI
sub  esp, 1Ch
mov  ecx, esp
push command_utf16_ptr
call dword ptr [moduleBase + 01D1687Ch] ; game std::wstring ctor IAT

mov  ecx, console_menu
call moduleBase + 00389B00h             ; sub_789B00
; sub_789B00 负责析构并 retn 1Ch

若要保留回显和历史,把最后一个 call 目标改成 moduleBase + 0x0038FE10,并让传入文本末尾带 L'\n'。调用前必须验证 console_menu+0x114 非零。

6. GOD 的实际执行分支

规范化命令与 GOD / GODSPEED 比较后,0x78D447 开始执行共同的 God 切换:

text
gameClient = getGameClient()
player = gameClient->player                 // +0x2C
old = player->virtual_getGodState()         // vtable slot +0x1A8
player->god = !old                          // player +0x8F0

关键指令:

地址行为
0x78D447取得 GameClient
0x78D44C检查 GameClient+0x2C 的 player
0x78D467读取 player vtable +0x1A8 的 God 状态 getter
0x78D476把取反后的状态写入 player+0x8F0
0x78D49B输出 God mode turned on
0x78D4B5开启路径把 player+0x8F2 写为 0xD6
0x78D4BE输出 God mode turned off

GODSPEED 会先走同一 God 切换,再继续速度相关分支;GOD 则直接结束该命令。

如果只为了 God 状态,外部工具确实可以直接写 player+0x8F0,但这会绕过 getter、状态脏标志和原版提示,不适合作为通用 Console 自动化方案。

7. 当前活进程的只读核对

2026-09-07 对正在运行的 Torchlight2.exe 做了只读 ReadProcessMemory 核对,没有写入或改动进程。

本次运行:

text
module base       = 0x00FB0000
UI manager global = 0x04431E34
UI manager        = 0x11BC8200
menu vector       = 0x4B27C200
count/capacity    = 23 / 30

向量第 1 项命中:

text
CConsoleMenu      = 0x11BDFA00
vftable           = 0x02D43E04 == base + 0x01D93E04
CONSOLEEDITBOX    = 0x1159ED50
CONSOLEEDITFRAME  = 0x1F13BA48
CONSOLEBOX        = 0x1159F2D8
tick vslot        = 0x01330E00 == base + 0x00380E00

GameClient 链也能读取:

text
GameClient global = base + 0x024E95F4
GameClient+0x2C   = player
player+0x8F0      = God flag

本次观测时 God flag 为 0。以上对象/堆地址只证明结构与静态分析吻合,下一次启动都会变化,工程实现只能使用 RVA 和指针链。

7.1 2026-09-07 一次性 Hook 实测

第一版把 CConsoleMenu::tick 当作落点。Console 隐藏时 10 秒内没有进入该 tick,命令状态保持 1;工具随后从外部恢复原 vtable,游戏未崩溃。这个反例证明 CREATE ON LOAD 只保证对象存在,不保证隐藏菜单每帧更新。

第二版从同一个菜单向量查找 vtable +0x10 == moduleBase + 0x003922A0 的常驻 CHudMenu,本次命中:

text
CHudMenu          = 0x39503A00
CHudMenu vftable  = 0x02D49CBC
CHudMenu::tick    = 0x013422A0

工具复制 CHudMenu vtable,只把当前实例的 tick 槽改到一次性 shellcode。shellcode 在 HUD 主线程先调用原 tick,再在栈上用游戏自己的 MSVC wstring 构造函数创建 L"GOD\n",调用 sub_78FE10,最后自行恢复 CHudMenu 原 vtable。实测结果:

text
command=GOD state=3 GOD(after)=1
EXECUTE PASS: GOD 0 -> 1; original vtable restored

远程申请的 4 KiB 页本次刻意未释放,以避免 shellcode 刚返回时由外部线程释放产生竞态;一次验证只留下一个页。生产版应使用常驻桥和可复用队列,而不是每条命令重新分配。

8. 四种实现路线比较

路线是否推荐说明
外部 WriteProcessMemory 覆盖 Editbox 字符串CEGUI 字符串是复杂对象,且没有触发 Accepted 事件;容易损坏堆
外部线程直接调用 sub_789B00命令会访问 UI、GameClient、场景和设置;远程线程不满足线程亲和性
注入 DLL,在 UI 主线程调用 sub_78FE10推荐保留原版回显、历史、日志和完整命令语义
注入 DLL,在 UI 主线程调用 sub_789B00推荐路径最短;适合自动化探针,但不记录输入历史
直接改 player+0x8F0仅特殊用途只解决 GOD,绕过原版命令语义,不能扩展到任意命令

9. 推荐的 Hook 架构

9.1 Hook 点

CConsoleMenu vtable 的 +0x10 槽虽是 sub_780E00,但动态验证证明 Console 隐藏时它不被调度。最终使用常驻 CHudMenu vtable 的同一槽;该槽为 sub_7922A0,即 CHudMenu::tick(float dt)

推荐 detour sub_7922A0,但命令接收对象仍为从 UI manager 向量取得的 ConsoleMenu:

text
外部控制程序
  └─ Named Pipe / shared-memory ring buffer
      └─ 注入 DLL 收到 UTF-16 命令并排队
          └─ CHudMenu::tick detour(游戏主线程)
              ├─ 调原 tick
              ├─ 每帧限量取队列
              └─ submitLine 或 executeCommand

CONSOLE.LAYOUT 具有 CREATE ON LOAD:truePINNED:true,所以 ConsoleMenu 会很早创建。工程仍应在 UI 管理器非空后再遍历向量,并在调用前复核:

text
*(consoleMenu + 0x00) == moduleBase + 0x01D93E04
*(consoleMenu + 0x114) != 0       ; 使用回显路径时

9.2 生命周期与并发

  • 不在管道线程、DLL 初始化线程或 CreateRemoteThread 中调用游戏函数;这些线程只入队。
  • 主线程每帧只执行有限条命令,避免一次批量命令卡住渲染。
  • 菜单可能析构;删除析构器为 sub_783DE0,主体析构为 sub_782F10。若缓存实例指针,应在析构 Hook 中清空;更简单的做法是每次需要时从 manager 向量重新查找并校验 vtable。
  • 外部输入长度建议继续限制为 255 个字符,与原 Editbox 一致。直接派发器本身不会替你执行这项 UI 限制。
  • 命令执行器会自行拒绝多人游戏,自动化端应把该回显当成失败结果。

9.3 为什么 Hook HUD tick 而不是 Accepted 回调

Hook sub_78FE80 只能在用户真的按 Enter 时获得执行机会,仍不能主动消费外部命令。隐藏 Console 又不会进入 sub_780E00。常驻 HUD 的 sub_7922A0 已由动态实验确认每帧进入主线程,因此是当前最小、已经跑通的调度点。

10. 地址、RVA 与签名账本

语义静态 VARVA当前二进制唯一签名(IDA 格式)
UI manager getter0x7316200x00331620A1 ? ? ? ? C3 CC CC CC CC CC CC CC CC CC CC 8B 49 ? 85 C9
Menu 实例化总入口0x733E800x00333E806A FF 68 79 07 EC 00
CConsoleMenu ctor0x782E200x00382E206A FF 68 1C C8 FB 00
CConsoleMenu::tick0x780E000x00380E006A FF 64 A1 ? ? ? ? D9 44 24 ? 68 A0 C5 FB 00
CHudMenu::tick(最终 Hook 点)0x7922A00x003922A0建议以 CHudMenu vtable +0x10 解析
Accepted 回调0x78FE800x0038FE8064 A1 ? ? ? ? 6A FF 68 68 DC FB 00
submit/回显入口0x78FE100x0038FE10见附注
回显、历史、派发0x78FA800x0038FA806A FF 68 93 6C FB 00 64 A1 ? ? ? ? 50 64 89 25 ? ? ? ? 81 EC 38 01 00 00 53 55 56 57 8B F1
命令执行器0x789B000x00389B006A FF 68 3D DC FB 00
CConsoleMenu vftable0x2193E040x01D93E04用 ctor 引用或 vtable 内容定位
game wstring(wchar_t*) ctor IAT0x211687C0x01D1687CIAT 槽,运行时取其内容
GameClient getter0x41CE600x0001CE60A1 ? ? ? ? C3 CC CC CC CC CC CC CC CC CC CC 8A 81 ? ? ? ? ? C3
GameClient 全局槽0x28E95F40x024E95F4由 getter 的 A1 imm32 解出

附注:sub_78FE10 的当前唯一签名较长;工程上更适合从 sub_78FE80 的唯一 call xref 解出它,或直接使用此精确版本的 RVA。表中短签名只保证在本次分析的发行 EXE 内唯一,不承诺跨版本/跨编译稳定。

11. 最小运行期验收方案

实现 Hook 后建议先只测试无参数、可逆的 GOD

  1. 启动游戏并进入单人游戏;不需要打开 Console UI。
  2. 外部端向队列发送 GOD
  3. 确认主线程消费一条命令;若走 sub_78FE10,Console 输出应出现 God mode turned on
  4. 只读确认 player+0x8F0 == 1
  5. 再发送一次 GOD,确认输出 God mode turned off 且标志回到 0
  6. 测试小写 god 与前导空格 god,两者结果应一致。
  7. 最后测试多人状态,确认原版拒绝路径仍生效,而不是被 Hook 绕过。

12. 最终建议

下一步应实现一个很小的 x86 in-process Console bridge,而不是继续做 UI 自动化:

  • 实例来源:UI manager +0x6C 菜单向量 + vtable 校验;
  • 主线程调度:detour 常驻 CHudMenu::tick;隐藏 Console 的 tick 已实测不会运行;
  • IPC:命名管道或共享内存队列;
  • 默认执行入口:sub_78FE10,保留原版回显/历史;
  • 无 UI 探针模式:sub_789B00
  • 字符串 ABI:在参数栈上用游戏自己的 IAT 构造 28 字节 std::wstring
  • 不直接改 CEGUI String,不从远程线程调用游戏逻辑,不把本次 ASLR/堆地址写死。

这条路径把一次命令的成本降为“写入一条 IPC 消息”,也彻底消除了输入法、窗口焦点、键盘节奏和 Console UI 显隐带来的不确定性。