火炬之光 2 十格快捷栏:内容类型、运行时记录与存档格式
结论状态:已完成(C-0207 /
HANDOFF_RE_BACKLOGC-3)
分析日期:2026-09-07
目标程序:E:/Torchlight 2/Torchlight2.exe
SHA-256:186472C3057B38F4CDFF4696959A943C396AE6166995F7418997B5EA853E8A5E
ImageBase:0x400000
1. 最终结论
原版 HUD 的十格快捷栏不是 SPELLSLOTS 容器。它是十个 LINK 型拖放控件,槽号为 0..9,分别绑定 QK1..QK9/QK0。
每一格可以保存两类内容:
CONSUMABLE:消耗品,运行时类型码为0;SKILL:已经学会的技能,运行时类型码为2。
快捷栏明确拒绝 DYNAMITE、MAP、SPELL。这里的 SPELL 是技能书/法术卷这种物品 类别,不是已经学会的技能;技能书用掉以后产生的 SKILL 才能放进快捷栏。
快捷栏内容保存在角色 .SVB 主存档的顶层 KeyMapping 块中。该块位于角色数据 blob 之后、12 项 Function-key 块和角色统计块之前。它不在 SPELLSLOTS,也不在 sub_56EE90 序列化的 CCharacterSaveState blob 内。
当前 TL2 发行版的线格式为:
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 不是快捷栏
SPELLSLOTS.DAT 的 4 格定义确实存在,但 C-0180 已证明它没有被发行版布局或控制器挂载。 本轮找到的真实快捷栏则有一条完全独立的链:
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_SPELLS | 24 | 装 UNITTYPE:SPELL 的技能书物品 |
| 鼠标/基本攻击绑定 | 3 个特殊索引 | 1000..1002,不属于十格快捷栏 |
3. 数据侧:十个槽允许放什么
3.1 HUD 的十个 LINK 控件
MEDIA/UI/MENUS/INGAMEMENUS/HUD.LAYOUT 中恰有十个 WIDGET NAME:LINK 属性块。每块都包含相同的策略:
<BOOL>LINK ONLY:true
<STRING>ALLOWED TYPES:CONSUMABLE,SKILL
<STRING>NOT ALLOWED TYPES:DYNAMITE,MAP,SPELL十个 INTEGER DATA 与按键的对应关系是:
| 槽下标 | Key binding | 默认键 |
|---|---|---|
| 0 | QK1 | 1 |
| 1 | QK2 | 2 |
| 2 | QK3 | 3 |
| 3 | QK4 | 4 |
| 4 | QK5 | 5 |
| 5 | QK6 | 6 |
| 6 | QK7 | 7 |
| 7 | QK8 | 8 |
| 8 | QK9 | 9 |
| 9 | QK0 | 0 |
LINK ONLY:true 也解释了它为何不是库存容器:槽里保存的是对物品或技能的引用,而不是把 物品实体从背包移动到快捷栏。
3.2 右键弹出菜单也分成 Items 与 Skills
MEDIA/UI/MENUS/CONTEXTMENUS/HOTBARROLLOUT.LAYOUT 同时定义 ITEMSLIST 和 SKILLSLIST,提示文本为:
Select an item or skill to assign代码侧与此吻合:
sub_791400从玩家库存候选中构建物品列表;sub_791890遍历CSkillMgr中可见、已启用且类型合格的技能,构建技能列表;- 两类条目最终进入不同的点击回调。
3.3 最佳药水键是另一套机制
QUICKKEYS.DAT 在十条 QK* 后还定义:
BESTPOTIONBESTMANABESTPETPOTIONBESTPETMANA
它们不是快捷槽。HUD 中 BESTPOTION 对应一个独立、隐藏的 1×1 拖放控件, INTEGER DATA=1011。它的语义是“自动选择最佳药水”,不是在十格栏之外再增加药水格。
4. 代码侧:同一个槽如何区分物品和技能
4.1 两个赋值回调
物品点击回调 sub_790830 在 0x790882 调用 CCharacter__setHotbarSlot。调用前的关键机器码为:
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对应字节:
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:
0x7908DF push 2 ; datatype = skill
0x7908E2 call CCharacter__setHotbarSlot对应字节:
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:
83 7C 24 08 02 ; skill
83 7C 24 08 00 ; item这比仅凭 HUD 文本推断更强:引擎确实让两类内容共用同一张表,并靠显式类型字段分派。
5. 运行时结构
普通快捷槽保存在 CCharacter 的 vector:
| 偏移 | 含义 |
|---|---|
+0xB80 | 记录数组指针 |
+0xB84 | size |
+0xB88 | capacity |
每条运行时记录步长为 0x10:
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 给出相同顺序:
ReadCharData
ReadKeyMappingList
ReadStatistic6.2 只统计并写出非空绑定
0x436881..0x4368BB 遍历整个运行时 vector。每次调用 sub_5B66D0,把 GUID 的高低 dword 做 AND,再与 0xFFFFFFFF 比较。只有 GUID 不是 -1/-1 才增加活动记录数。
活动数在 0x4368CB 以 2 字节写入:
uint16_t active_count = count(binding.guid != -1);
fwrite(&active_count, 2, 1, stream);第二遍循环只对非空记录执行三次 fwrite:
| 调用点 | 大小 | 字段 |
|---|---|---|
0x436946 | 8 | GUID |
0x436956 | 1 | datatype |
0x436966 | 2 | 当前 vector 下标,即 key |
因此实际记录是无填充的 8+1+2=11 字节,而不是运行时的 16 字节结构体直拷贝。
6.3 key 为什么必须存
假设十格中只使用第 0、4、9 格,文件写的是三条:
count = 3
{guidA, type, key=0}
{guidB, type, key=4}
{guidC, type, key=9}它不是“数组前三项”。key 使保存器可以省掉七个空记录,同时在加载时恢复原来的洞。
7. 存档加载与恢复
加载后的绑定数组在 sub_441C40 中恢复到角色对象。0x4423BC..0x4423D0 的指令为:
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完整锚点字节:
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 实现定义:
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. 移植实现建议
移植侧应把快捷栏建模为十个可空引用,而不是一个物品容器:
QuickbarSlot {
kind: EMPTY | CONSUMABLE | SKILL
source_guid: int64
}最低兼容要求:
- 槽位固定为 10,UI 顺序
QK1..QK9/QK0; - 同时允许消耗品与已学技能;
- 技能书
SPELL本体不能直接放入; - 快捷栏只保存引用,不把消耗品移出背包;
- 消耗品数量变化后快捷栏引用仍指向相同物品定义/最佳可用实例;
- 保存时必须保留槽下标,不能把稀疏栏压成连续栏;
BESTPOTION/BESTMANA应实现为独立动作,不占十格槽位。
10. 已知边界
本轮不影响主结论、但仍未正式命名的字段有:
- 16 字节运行时记录的
+0x0C辅助字段; - 快捷栏块后 12 项 Function-key 记录中两个 qword 的正式语义;
- TL1 旧格式把物品和技能拆开保存的兼容分支。
这些边界不会改变 TL2 当前发行版的十格内容政策、类型码或 11 字节存档记录格式。
11. 可重复验收
注册表:
E:/Torchlight2-Remaster-Project/原版游戏分析/reversed_cpp/REGISTRY/quickbar_contents_and_save_format.json强制测试:
python -m unittest tests.truth.test_quickbar_contents_and_save_format -v测试同时校验发行 EXE 哈希、赋值类型码、运行时 vector/步长、保存端 8/1/2 字段形状、 加载端 qword/byte/word 恢复,以及 HUD、快捷键和独立解析器语料。