对象:
D:\rxjh2025\client\YGOnline.exe(capstone 逐指令口径,373 万条指令全扫)
问题:实机遍历证实「控件 ID = [控件+0x364]」,那游戏自己在哪里读它、写它?这个偏移还有没有别的含义?
生成:2026-09-18
| 结论 | 内容 |
|---|---|
| 总量 | 全 .text 共 879 处 [x+0x364] 访问,分布在约 300 个函数 —— 是个高频复用偏移 |
| ★ ID 写入点找到了 | 0x0075B230:控件工厂 InterUI_Manufacture(虚表 0x010831E0,RTTI 验证)构造控件时写 [控件+0x364] = 控件ID(样本值 0x38) |
| ★ 游戏自己的 0x3F4 分发原文挖到了 | 0x0075A6F8:push 0 / push 1 / push 0x3F4 → ecx = [[0x183C52C]+0x3EC]+0x39C → call [虚表+4] —— 与你的机器码写法逐条同款 |
| ★ 引擎级取控件新链路 | [0x183C52C](UI管理器) → +0x3EC(当前活动窗口) → +0x39C(当前目标控件),不依赖助手窗口 +0x414 链 |
| ⚠ 语义随类而变 | 同一个 +0x364,在控件上是 ID、在 Inter* 窗口上是「当前活动子对象指针」、在数据结构上是数组首 —— 不能见 364 就当 ID |
方法与《全局地址与基址分析报告》一致:capstone detail=True + skipdata=True 逐指令,收所有 disp==0x364 的内存操作数,区分读/写、标量/变址、位宽。
| 形态 | 次数 | 说明 |
|---|---|---|
| 读 · 标量 · 32位 | 550 | 大多数是取 ID 或取指针 |
| 写 · 标量 · 32位 | 185 | 含构造初始化(ID / -1 / 指针) |
读 · 变址 [reg+reg*4+0x364] |
86 | 数组用法(另一批类) |
| 写 · 标量 · 8位 | 24 | 字节标志 |
| 写 · 变址 · 32位 | 13 | 数组写入 |
| 读 · 标量 · 8/16位 | 10 + 6 | 字节/字标志 |
| 写 · 标量 · 16位 | 5 | — |
第一课:879 处里只有一部分是「控件 ID」。判定对象身份必须先看
[x]是不是.rdata虚表(RTTI 法),不能见 364 就当 ID。
InterUI_Manufacture 控件工厂0x0075B230 处的构造序列(上下文直接可读):
0075B272 mov dword ptr [eax], 0x10831E0 ; ← 虚表 = 0x010831E0
0075B27B mov dword ptr [ecx + 0x358], 5 ; 类型/样式字段
0075B288 mov dword ptr [edx + 0x35C], 0x80
0075B295 mov dword ptr [eax + 0x360], 0x9D
0075B2A2 mov dword ptr [ecx + 0x364], 0x38 ; ★ 控件 ID 写入点(样本 0x38)
0075B2AF mov dword ptr [edx + 0x330], 0
0075B2BC mov dword ptr [eax + 0x354], 0
0075B2C9 mov dword ptr [ecx + 0x334], 0
虚表 0x010831E0 走 RTTI 链([-4]→COL 0x01185D44 → TD 0x011F7458)解出类名:
.?AVInterUI_Manufacture@@ ← 「UI 制造」,即控件工厂类
解读:这个类生产的每个控件实例都把「分配到的控件 ID」放在自己的 +0x364。与你实机遍历 74 个控件读出的 [控件+0x364]=ID 完全吻合 —— 静态与活体两头闭合。相邻字段族(+0x330 / +0x334 / +0x354 / +0x358 / +0x35C / +0x360 / +0x364)就是控件对象头的初始化区。
0x0075A6F8 起,一个函数的收尾处(ret 8 前):
0075A6FF push 0 ; 参数3(lparam?)
0075A701 push 1 ; 参数2
0075A703 push 0x3F4 ; ★ 动作码 —— 和你 push 0x3F4 一模一样
0075A708 mov eax, [0x183C52C] ; ★ UI 管理器单例(.data 全局)
0075A70D mov ecx, [eax + 0x3EC] ; → 当前活动窗口
0075A713 mov edx, [ecx + 0x39C] ; → 窗口的「当前目标控件」
0075A719 mov eax, [0x183C52C]
0075A71E mov ecx, [eax + 0x3EC]
0075A724 mov edx, [edx] ; 控件虚表
0075A726 mov ecx, [ecx + 0x39C] ; ecx = 控件(this)
0075A72C mov eax, [edx + 4] ; 虚表槽1 = 动作分派
0075A72F call eax ; 槽1(0x3F4, 1, 0)
0075A732 mov esp, ebp / pop ebp / ret 8
这是引擎原生的「点控件」调用,与你的机器码逐条对应:
| 你的机器码 | 游戏引擎(0x75A6FF) |
|---|---|
push 0 ×2 |
push 0 / push 1 |
push 0x3F4 |
push 0x3F4 |
mov ecx, 控件 |
mov ecx, [[[0x183C52C]+0x3EC]+0x39C] |
mov edx,[ecx] |
mov edx,[edx] |
call [edx+4] |
call [edx+4] |
→ 你的写法不是"旁门左道",就是引擎自己的手法。双向证实。
[0x183C52C] UI 管理器单例(.data,与已知的 0x183C520 相邻)
+0x3EC → 当前活动窗口
+0x39C → 当前目标控件
这条链不依赖「[[NPC基址值]+0x414] 助手窗口」那条路,是引擎级的「当前窗口 → 当前控件」旁路。值得开 CE 实测:打开助手窗口后看 [[0x183C52C]+0x3EC] 是否等于助手窗口句柄、+0x39C 是否随鼠标悬停/点击变化。
Inter* 窗口槽1 里的 +0x364:父 → 子动作转发0x00C7FCC0)—— 0x3EE 转发 ×32push [ebp+0x10]
push [ebp+0xC]
push 0x3EE ; 通用动作码
mov edx, [ebp-0x4078] ; 窗口 this
mov eax, [edx + 0x364] ; ★ [窗口+0x364] = 当前活动子对象(指针!)
mov ecx, [ebp-0x4078]
mov edx, [eax] ; 子对象虚表
mov ecx, [ecx + 0x364]
mov eax, [edx + 4] ; 子对象虚表槽1
call eax ; 子对象.槽1(0x3EE, ...)
→ 在 Inter* 窗口类上,+0x364 不是 ID,是「当前活动子对象」指针:窗口收到动作后转手递给活动子对象的槽1。这解释了为什么 0x3EE/0x3F4 会"跨类通用"—— 大家都在走同一条转发链。
| 函数 | 身份 | 对 +0x364 的用法 |
|---|---|---|
0x006254F0 |
InterAutosell 槽1(11 读) | mov edx,[ecx+0x364] 后当指针用:mov eax,[edx+0xB3C] |
0x00A59C10 |
InterByulho 槽1 | movzx ecx, byte [eax+0x364]、[edx+0x365] —— 字节标志 |
0x00652520 |
高引用(12 读 + 0x3F4/0x3EE 各 1) | 混合用法 |
0x00A3F3D0 |
InterAutoPlay 槽1 | 0 次 —— 助手分派不读 +0x364,ID 全靠栈上参数(这正是你 push ID 就能驱动它的原因) |
| 函数 | 命中 | +0x364 的真实语义(证据) |
|---|---|---|
0x00723070 |
32 读 | 数据块指针族:[obj+0x364] → 解引用访问 +0x53C 数组 |
0x00646080 |
20 读 | 同上:mov eax,[edx+0x364] → cmp [eax+edx*4+0x53C], 0 |
0x00BA6F70 |
30 读 + 3 写 | malloc(0x37C) 出来的结构体,+0x364 是其数组首([eax+edx+0x364] 变址写) |
0x0072F4F0 |
1 写 | 写 0xBAADF00D —— CRT 堆调试标记,与控件无关 |
0x0064F7C0 / 0x00BA4A60 / 0x00EFFC30 |
各 1 写 | 写 0xFFFFFFFF —— 字段初始化为 -1 |
0x00CBA960 / 0x00CBAC00 |
各 1 写 | 写 0xC8FFFFFF(-56)—— 数据字段 |
第二课:
+0x364在这 exe 里至少被 4 个类族复用:①控件 ID(InterUI_Manufacture系)②Inter*窗口的活动子对象指针 ③数据结构数组首 ④杂项初始化。遍历控件时务必先用[ptr]是否落在.rdata虚表区 + RTTI 名来验明正身,否则会把数据块当控件。
| 函数 | 0x3F4 | 0x3EE | +0x364 | 备注 |
|---|---|---|---|---|
0x00A05B30 |
16 | 78 | 0 | 0x3EE 最大户(通用动作转发器) |
0x00A3CBC0 |
29 | 0 | 0 | |
0x009F7F50 |
19 | 0 | 0 | |
0x00CDABE0 |
16 | 0 | 0 | |
0x007E63B0 |
15 | 0 | 0 | |
0x009FC290 |
14 | 0 | 1 | ★ 联合命中 |
0x0075CF30 |
7 | 0 | 1 | ★ 联合命中 |
0x00C7FCC0 |
0 | 32 | 2 | ★ InterNpc 槽1(§5.1) |
0x0068B2B0 |
4 | 0 | 0 | InterInGameTotalShop 槽1 自身 |
0x00A3F700 |
2 | 0 | 0 | 小助手下一层分发 |
同时命中 +0x364 与分派特征的 13 个函数(见脚本输出 _s364b.txt):0x652520 / 0x6254F0 / 0x741710 / 0x75A310 / 0x9FC290 / 0x75CF30 / 0xC7FCC0 / 0xAFACE0 / 0xC572F0 / 0xCB33F0 / 0xCB70D0 / 0x6FC030 / 0xBED4C0 —— 这一批就是「读到控件/子对象 → 直接分派动作」的引擎枢纽,是后续逆向的最佳切入点。
push 0x3F4 → mov ecx,控件 → call [edx+4] 就是引擎原生手法(0x75A6FF 原文对照),不用再怀疑这层。[[0x183C52C]+0x3EC] = 当前活动窗口、[+0x39C] = 当前目标控件。若成立,找控件不再依赖 +0x414,而且对所有窗口通用(不止助手)。[ptr] → 是否 .rdata → RTTI 解名 → 再决定把 +0x364 当 ID 还是当指针。| 脚本 | 作用 | 输出 |
|---|---|---|
scan_364.py |
全 .text 扫 disp==0x364 + 上下文反汇编 + 函数归属 |
_s364.txt / _s364_fn.txt |
scan_364b.py |
虚表 RTTI 验证 + 0x3F4 分发原文 + 联合特征 | _s364b.txt |
scan_364c.py |
形态统计(读写/标量/变址/位宽) | _s364c.txt |
配套:《助手 0x3F4 分派机制与控件 ID 实现表》·《助手控件实机对照表(74 个)》·《全局地址与基址分析报告》
分析对象:D:\rxjh2025\client\YGOnline.exe · capstone 逐指令口径