火炬之光 2 十格快捷栏:内容类型、运行时记录与存档格式

结论状态:已完成(C-0207 / HANDOFF_RE_BACKLOG C-3)
分析日期:2026-09-07
目标程序:E:/Torchlight 2/Torchlight2.exe
SHA-256:186472C3057B38F4CDFF4696959A943C396AE6166995F7418997B5EA853E8A5E
ImageBase:0x400000

1. 最终结论

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

每一格可以保存两类内容:

  1. CONSUMABLE:消耗品,运行时类型码为 0
  2. SKILL:已经学会的技能,运行时类型码为 2

快捷栏明确拒绝 DYNAMITEMAPSPELL。这里的 SPELL 是技能书/法术卷这种物品 类别,不是已经学会的技能;技能书用掉以后产生的 SKILL 才能放进快捷栏。

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

当前 TL2 发行版的线格式为:

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 不是快捷栏

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_SPELLS24UNITTYPE: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 同时定义 ITEMSLISTSKILLSLIST,提示文本为:

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_7908300x790882 调用 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 传入类型 4GUID=-1/-1

4.2 类型化读取器

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

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

对应比较指令分别在 0x5B68320x5B6962

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__setHotbarSlot0x5BFB86..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. 可重复验收

注册表:

text
E:/Torchlight2-Remaster-Project/原版游戏分析/reversed_cpp/REGISTRY/quickbar_contents_and_save_format.json

强制测试:

powershell
python -m unittest tests.truth.test_quickbar_contents_and_save_format -v

测试同时校验发行 EXE 哈希、赋值类型码、运行时 vector/步长、保存端 8/1/2 字段形状、 加载端 qword/byte/word 恢复,以及 HUD、快捷键和独立解析器语料。