分析对象:Torchlight2.exe(32-bit,静态基址 0x400000)
核心定位:界定 HUD 快捷栏与背包/技能容器的系统边界,解析槽位绑定、数据流向及 .SVB 存档序列化结构。

1. 结论速览 ​

原版 HUD 的十格快捷栏并非 SPELLSLOTS 容器,而是十个 LINK 型拖放控件,槽号为 0..9,分别绑定至按键 QK1..QK9/QK0。

每一格支持容纳两类内容:

  1. CONSUMABLE:消耗品,运行时类型码为 0;
  2. SKILL:已掌握技能,运行时类型码为 2。

快捷栏显式拒绝 DYNAMITE、MAP、SPELL 等类型。注意此处的 SPELL 指技能书/法术卷轴等消耗性物品类别,而非已学技能;技能书使用后激活的 SKILL 方可放置进快捷栏。

快捷栏内容序列化于角色 .SVB 主存档的顶层 KeyMapping 块中。该数据块位于角色数据 blob 之后、12 项 Function-key 块与角色统计块之前。它既不在 SPELLSLOTS 容器内,也不属于 sub_56EE90 序列化的 CCharacterSaveState 二进制结构。

当前发行版的流格式定义如下:

text
uint16 active_binding_count

repeat active_binding_count times:
    int64  guid
    uint8  datatype       // 0=item/consumable, 2=skill
    uint16 key            // 原始快捷槽下标,HUD 为 0..9

uint16 function_count     // PC 发行版固定为 12
repeat function_count times:
    int64 id
    int64 unknown

单条快捷栏存档记录固定为 11 字节。序列化过程仅写入非空槽位;key 字段保留真实的槽下标,因此稀疏配置不会造成后续槽位前移。

2. SPELLSLOTS 容器与 HUD 快捷栏的职责隔离 ​

SPELLSLOTS.DAT 的 4 格定义确实存在,但 C-0180 已证明它没有被发行版布局或控制器挂载。 本轮找到的真实快捷栏则有一条完全独立的链:

text
QUICKKEYS.DAT 的 QK1..QK0
        ↓
HUD.LAYOUT 的 10 个 WIDGET NAME:LINK(INTEGER DATA 0..9)
        ↓
CHotBarContextMenu 选择物品或技能
        ↓
CCharacter__setHotbarSlot(slot, type, guid, ...)
        ↓
CCharacter+0xB80 的绑定 vector
        ↓
主存档 KeyMapping 块

因此应使用以下术语:

对象数量真实用途
HUD LINK 快捷栏10装消耗品或已学技能,按 1–9/0 使用
SPELLSLOTS 容器4发行数据中未挂载的死容器
PLAYER_BAG_SPELLS24装 UNITTYPE:SPELL 的技能书物品
鼠标/基本攻击绑定3 个特殊索引1000..1002,不属于十格快捷栏

3. 数据侧:十个槽允许放什么 ​

MEDIA/UI/MENUS/INGAMEMENUS/HUD.LAYOUT 中恰有十个 WIDGET NAME:LINK 属性块。每块都包含相同的策略:

text
<BOOL>LINK ONLY:true
<STRING>ALLOWED TYPES:CONSUMABLE,SKILL
<STRING>NOT ALLOWED TYPES:DYNAMITE,MAP,SPELL

十个 INTEGER DATA 与按键的对应关系是:

槽下标Key binding默认键
0QK11
1QK22
2QK33
3QK44
4QK55
5QK66
6QK77
7QK88
8QK99
9QK00

LINK ONLY:true 也解释了它为何不是库存容器:槽里保存的是对物品或技能的引用,而不是把 物品实体从背包移动到快捷栏。

3.2 右键弹出菜单也分成 Items 与 Skills ​

MEDIA/UI/MENUS/CONTEXTMENUS/HOTBARROLLOUT.LAYOUT 同时定义 ITEMSLIST 和 SKILLSLIST,提示文本为:

text
Select an item or skill to assign

代码侧与此吻合:

  • sub_791400 从玩家库存候选中构建物品列表;
  • sub_791890 遍历 CSkillMgr 中可见、已启用且类型合格的技能,构建技能列表;
  • 两类条目最终进入不同的点击回调。

3.3 最佳药水键是另一套机制 ​

QUICKKEYS.DAT 在十条 QK* 后还定义:

  • BESTPOTION
  • BESTMANA
  • BESTPETPOTION
  • BESTPETMANA

它们不是快捷槽。HUD 中 BESTPOTION 对应一个独立、隐藏的 1×1 拖放控件, INTEGER DATA=1011。它的语义是“自动选择最佳药水”,不是在十格栏之外再增加药水格。

4. 代码侧:同一个槽如何区分物品和技能 ​

4.1 两个赋值回调 ​

物品点击回调 sub_790830 在 0x790882 调用 CCharacter__setHotbarSlot。调用前的关键机器码为:

asm
0x790870  mov ecx,[edi+13Ch]       ; 当前 LINK 控件
0x790876  push edx                 ; GUID high
0x790877  mov edx,[ecx+0DCh]       ; 控件 INTEGER DATA = slot
0x79087D  push 0                   ; datatype = item
0x79087F  push edx                 ; slot
0x790882  call CCharacter__setHotbarSlot

对应字节:

text
8B 8F 3C 01 00 00 52 8B 91 DC 00 00 00 6A 00 52 8B C8 E8 89 EE E2 FF

技能点击回调 sub_790890 的形状相同,但在 0x7908DF 压入 2:

asm
0x7908DF  push 2                   ; datatype = skill
0x7908E2  call CCharacter__setHotbarSlot

对应字节:

text
8B 8E 3C 01 00 00 52 8B 91 DC 00 00 00 6A 02 52 8B C8 E8 29 EE E2 FF

清空条目时,sub_790EF0 传入类型 4 和 GUID=-1/-1。

4.2 类型化读取器 ​

sub_5B66D0(character, out, slot) 是通用绑定读取器。两个薄包装进一步证明类型码:

  • sub_5B6820:只有 out.type == 2 才返回 GUID,否则返回 -1;
  • sub_5B6950:只有 out.type == 0 才返回 GUID,否则返回 -1。

对应比较指令分别在 0x5B6832 和 0x5B6962:

text
83 7C 24 08 02     ; skill
83 7C 24 08 00     ; item

这比仅凭 HUD 文本推断更强:引擎确实让两类内容共用同一张表,并靠显式类型字段分派。

5. 运行时结构 ​

普通快捷槽保存在 CCharacter 的 vector:

偏移含义
+0xB80记录数组指针
+0xB84size
+0xB88capacity

每条运行时记录步长为 0x10:

cpp
struct HotbarBindingRuntime {
    uint32_t guid_low;       // +0x00
    uint32_t guid_high;      // +0x04
    uint32_t datatype;       // +0x08: 0 item, 2 skill, 4 empty
    uint32_t auxiliary;      // +0x0C: 本轮未命名,不写入存档
};

CCharacter__setHotbarSlot 的 0x5BFB86..0x5BFC14 会把 vector 扩展到目标槽:

  • 新记录初始化为 {-1, -1, 4, auxiliary};
  • shl index,4 明确给出 16 字节步长;
  • 最后把 GUID 两个 dword、类型和辅助字段写入目标记录。

特殊索引 1000..1002 走角色对象中的独立字段;普通十格 0..9 走上述 vector。不要把 两者混在存档结构里解释。

6. 存档写入:稀疏的 11 字节记录 ​

6.1 写入位置 ​

存档总写入函数是 sub_435E10。在 0x43685B 写完角色状态 blob 并释放临时 CCharacterSaveState 后,0x436881 开始处理 CCharacter+0xB80 绑定表。

这意味着 KeyMapping 是角色存档的一个顶层兄弟块,而不是 sub_56EE90 那个角色 blob 内部的成员。写完快捷栏记录后,0x43698E 把下一块计数设为 12,再写 PC 的 Function-key 记录。

独立解析器 DEV-RELATED/Torchlight-master/TL2Save/tlsgparse.inc 给出相同顺序:

text
ReadCharData
ReadKeyMappingList
ReadStatistic

6.2 只统计并写出非空绑定 ​

0x436881..0x4368BB 遍历整个运行时 vector。每次调用 sub_5B66D0,把 GUID 的高低 dword 做 AND,再与 0xFFFFFFFF 比较。只有 GUID 不是 -1/-1 才增加活动记录数。

活动数在 0x4368CB 以 2 字节写入:

cpp
uint16_t active_count = count(binding.guid != -1);
fwrite(&active_count, 2, 1, stream);

第二遍循环只对非空记录执行三次 fwrite:

调用点大小字段
0x4369468GUID
0x4369561datatype
0x4369662当前 vector 下标,即 key

因此实际记录是无填充的 8+1+2=11 字节,而不是运行时的 16 字节结构体直拷贝。

6.3 key 为什么必须存 ​

假设十格中只使用第 0、4、9 格,文件写的是三条:

text
count = 3
{guidA, type, key=0}
{guidB, type, key=4}
{guidC, type, key=9}

它不是“数组前三项”。key 使保存器可以省掉七个空记录,同时在加载时恢复原来的洞。

7. 存档加载与恢复 ​

加载后的绑定数组在 sub_441C40 中恢复到角色对象。0x4423BC..0x4423D0 的指令为:

asm
mov ebx,[ecx+4]       ; GUID high
mov ecx,[ecx]         ; GUID low
movsx edx,byte ptr [edx]  ; datatype
movzx eax,word ptr [eax]  ; key
push 0
push ebx
push ecx
mov ecx,[esi+2Ch]     ; CCharacter*
push edx
push eax
call CCharacter__setHotbarSlot

完整锚点字节:

text
8B 59 04 8B 09 0F BE 12 0F B7 00 6A 00 53 51 8B 4E 2C 52 50 E8 3B D3 17 00

加载器读取 key 后直接把它作为 slot 参数交回同一个 setter,证实保存字段不是按键扫描码, 而是绑定表下标。

8. 独立存档解析器交叉验证 ​

仓库内的独立 Pascal 实现定义:

pascal
TTL2KeyMapping = packed record
  id      : TRGID;
  datatype: byte;   // 0=item, 2=skill
  key     : word;
end;

ReadKeyMappingList 先读 word 数量,再读取 count * SizeOf(TTL2KeyMapping);写入路径完全 对称。这个定义正好是 11 字节,并与发行 EXE 的三次 fwrite(8,1,2)、加载端 qword + byte + word 独立吻合。

该解析器是交叉证据,不是本报告结论的唯一来源;最终字段大小与顺序均已由发行 EXE 指令 直接固定。

9. 移植实现建议 ​

移植侧应把快捷栏建模为十个可空引用,而不是一个物品容器:

text
QuickbarSlot {
    kind: EMPTY | CONSUMABLE | SKILL
    source_guid: int64
}

最低兼容要求:

  • 槽位固定为 10,UI 顺序 QK1..QK9/QK0;
  • 同时允许消耗品与已学技能;
  • 技能书 SPELL 本体不能直接放入;
  • 快捷栏只保存引用,不把消耗品移出背包;
  • 消耗品数量变化后快捷栏引用仍指向相同物品定义/最佳可用实例;
  • 保存时必须保留槽下标,不能把稀疏栏压成连续栏;
  • BESTPOTION/BESTMANA 应实现为独立动作,不占十格槽位。

10. 已知边界 ​

本轮不影响主结论、但仍未正式命名的字段有:

  1. 16 字节运行时记录的 +0x0C 辅助字段;
  2. 快捷栏块后 12 项 Function-key 记录中两个 qword 的正式语义;
  3. TL1 旧格式把物品和技能拆开保存的兼容分支。

这些边界不会改变 TL2 当前发行版的十格内容政策、类型码或 11 字节存档记录格式。

11. 自动化验证覆盖 ​

配套自动化测试覆盖以下核心事实:

  • 发行版可执行文件特征字节与校验哈希;
  • 运行时条目类型码(0 与 2)与内存 vector 步长(16 字节);
  • 存档保存端 8/1/2 字节线格式字段形状;
  • 存档加载端 qword/byte/word 字段恢复与空槽处理;
  • HUD 布局与按键绑定的数据关联。