NOTE
本文分析对象是 32 位 Torchlight2.exe,IDA ImageBase 为 0x400000。地址均为 IDB 中的绝对虚地址。字段偏移不仅来自 Hex-Rays:关键判断、计数累加和状态写入均附有原始指令与机器码。数据侧用发行版 MEDIA/QUESTS/*.DAT 做独立交叉验证。
0. 结论速览
sub_6F97C0不是“判断任务是否完成”的函数,而是已经决定最终完成之后的提交入口:发奖励、撤销 active、写 completed、通知任务系统,并销毁任务实例。- 通用完成谓词在
sub_6F1DB0。任务必须处于 active 状态;所有 required FLAG objective、所有 ACQUIRE 树、所有 DEFEAT 树,以及[QUESTS_REQUIRED_TO_COMPLETE]中的前置任务都必须完成。 sub_6F3970在通用谓词之外再加一层交付门槛:若任务声明了INTRO对话,至少有一条匹配的 INTRO 实例必须已被消费。它随后把实例+0x88标为 ready/complete。- ready 不一定等于立即提交。若存在
COMPLETENPC 对话,任务等待玩家回到该 NPC;只有没有 COMPLETE 对话、且QUESTCONTROLLERCOMPLETES=false时,才自动进入sub_6F97C0。 - DEFEAT 任务实例的目标数在
+0x3C,当前击杀数在+0x40,完成位在+0x54。常规击杀路径的累加点是0x7002C5: FF 46 40;达到目标后0x7007FB: C6 46 54 01置完成。 [DEFEATCOUNT]显示的是所有 DEFEAT 根节点中+0x40的递归总和;[DEFEATCOMPLETECOUNT]显示+0x3C的递归总和。名称容易误读:前者是当前进度,后者是目标总数。- 四类 NPC 对话列表按模板到实例的映射为:
INTRO → +0x28、RETURN → +0x38、COMPLETE → +0x48、PASSIVE → +0x58。选择入口是sub_6F42F0。 DETAILS、MOREDETAILS、HUDDETAILS都是单独的文本提供器,并各有DIALOG/DIALOG_COMPLETE两态。发行任务普查常见的“六类”之外,代码表还保留了第七个MOREDETAILS。GLOBAL_TRILLBOT的具名特判不是发物品或接后续任务,而是:从CAchievements取键 113,调用CAchievement::complete。硬编码成就表的第 113 项内部名正是TRILLBOTASSEMBLED。
1. 对象布局
1.1 QuestInstance
下表只列本文实际由消费指令钉住的字段:
| 偏移 | 含义 | 证据 |
|---|---|---|
+0x08/+0x0C/+0x10 | ACQUIRE 根任务数组 / count / capacity | sub_6F1DB0 @ 0x6F1E0F..0x6F1E35 |
+0x18/+0x1C/+0x20 | DEFEAT 根任务数组 / count / capacity | sub_6F1DB0 @ 0x6F1E39..0x6F1E5C |
+0x28/+0x2C/+0x30 | INTRO 对话实例数组 | sub_6F4BC0 @ 0x6F4DC8;sub_6F42F0 @ 0x6F435C |
+0x38/+0x3C/+0x40 | RETURN 对话实例数组 | sub_6F4BC0 @ 0x6F4DE0;sub_6F42F0 @ 0x6F430E |
+0x48/+0x4C/+0x50 | COMPLETE 对话实例数组 | sub_6F4BC0 @ 0x6F4DF8;sub_6F42F0 @ 0x6F4336 |
+0x58/+0x5C/+0x60 | PASSIVE 对话实例数组 | sub_6F4BC0 @ 0x6F4E0F;sub_6F42F0 @ 0x6F43D3 |
+0x68/+0x6C/+0x70 | FLAG objective 实例数组 | sub_6F1DB0 @ 0x6F1DD8..0x6F1E0A |
+0x80 | QuestTemplate 指针 | 多处,例如 0x6F1E5E |
+0x84 | QuestManager 指针 | 0x6F39FB、0x6F3A07 |
+0x88 | ready / completion 条件已满足 | 0x6F39D0: C6 86 88 00 00 00 01 |
+0x8A | active | 0x6F1DC2;sub_6F1F60 @ 0x6F1F74 |
+0x88 与“已写进全局 completed 集合”不是同一层。前者属于活跃实例,后者由 sub_6F97C0 通过任务 GUID 写入 QuestManager 的完成记录。
1.2 QuestUnitDataInstance(ACQUIRE / DEFEAT 树节点)
| 偏移 | 含义 | 证据 |
|---|---|---|
+0x08 | 任务模板节点指针 | sub_6FF030 @ 0x6FF043 读取模板 +0x9C 类型 |
+0x0C | 所属 QuestInstance | HUD 和完成回调多处读取 |
+0x3C | 本节点目标总数 | sub_6FF080 @ 0x6FF095;完成比较 0x7007C7 |
+0x40 | 本节点当前进度 | 累加 0x7002C5;格式化 sub_6FF030 @ 0x6FF045 |
+0x44/+0x48/+0x4C | 子任务数组 / count / capacity | sub_6FF030、sub_6FF080 递归遍历 |
+0x54 | 本节点完成位 | sub_6FF130 @ 0x6FF133;0x7007FB 写 1 |
模板节点 +0x9C 是类别:0 计入 ACQUIRE,1 计入 DEFEAT。独立证据有两处:
sub_6EDAF0的调用参数:解析[ACQUIRE]时传a5=1,解析[DEFEAT]时传a5=0;- 运行期聚合函数按模板
+0x9C == a2过滤,而sub_6F2580对 ACQUIRE 传 1、对 DEFEAT 传 0。
2. 完成条件:从 active 到 ready,再到最终提交
2.1 通用谓词 sub_6F1DB0
等价伪代码:
bool QuestInstance::all_completion_conditions(bool check_flags) {
if (ready) return true; // +0x88
if (!active) return false; // +0x8A
if (check_flags) {
for (FlagObjective *f : flags) {
if (f->required /* +0x5D */ && !f->complete /* +0x5C */)
return false;
}
}
for (TaskNode *a : acquire_roots)
if (!a->complete_recursive(false)) return false;
for (TaskNode *d : defeat_roots)
if (!d->complete_recursive(false)) return false;
for (QuestRef &q : template->quests_required_to_complete)
if (!quest_manager->is_completed(q)) return false;
return true;
}关键指令:
| 条件 | 地址与字节 | 指令 |
|---|---|---|
| 已 ready 直接成功 | 0x6F1DB3 80 BE 88 00 00 00 00 | cmp byte ptr [esi+88h], 0 |
| 必须 active | 0x6F1DC2 80 BE 8A 00 00 00 00 | cmp byte ptr [esi+8Ah], 0 |
| FLAG 是否 required | 0x6F1DEE 80 79 5D 00 | cmp byte ptr [ecx+5Dh], 0 |
| required FLAG 是否完成 | 0x6F1E01 80 79 5C 00 | cmp byte ptr [ecx+5Ch], 0 |
| ACQUIRE 根递归完成 | 0x6F1E28 E8 03 D3 00 00 | call sub_6FF130 |
| DEFEAT 根递归完成 | 0x6F1E4F E8 DC D2 00 00 | call sub_6FF130 |
| 前置任务完成 | 0x6F1E88 E8 73 4D 00 00 | call sub_6F6C00 |
sub_6FF130(node, false) 的规则是“本节点 +0x54 已完成,并且所有子节点也递归完成”。sub_6F6C00 则把模板中的任务引用解析成 GUID,再查 QuestManager completed map 的 node+0x18 完成字节。
2.2 INTRO 门槛与 ready 写入:sub_6F3970
sub_6F3970 先调用上述通用谓词,然后处理 authored INTRO:
if (template->quest_controller_completes) return false;
if (!all_completion_conditions(true)) return false;
if (intro_dialogs.count != 0) {
bool any_intro_consumed = false;
for (auto *d : intro_dialogs)
any_intro_consumed |= d->consumed; // CQuestDialogInstance +0x10
if (!any_intro_consumed) return false;
}
ready = true;精确现场:
0x6F3986: 80 78 1F 00检查模板+0x1F,即QUESTCONTROLLERCOMPLETES;0x6F398C: 6A 01+0x6F398E: E8 1D E4 FF FF调sub_6F1DB0(true);0x6F39B3: 80 79 10 00检查 INTRO 实例+0x10的 consumed 位;0x6F39D0: C6 86 88 00 00 00 01把 QuestInstance+0x88写为 1。
这解释了一个看似奇怪的行为:完成目标并不允许跳过任务介绍。只要任务 authored 了 INTRO,至少有一条与交互 NPC 匹配的 INTRO 必须实际进入已消费态。
2.3 立即完成还是回 NPC 交付
ready 后,sub_6F3970 只在以下条件同时满足时直接调用 sub_6F97C0:
complete_dialogs.count == 0
&& !template->quest_controller_completes
&& quest_has_any_condition()
&& all_completion_conditions(true)对应现场为 0x6F39CC(实例 +0x4C,COMPLETE count)、0x6F39DF(模板 +0x1F)、0x6F39E7(sub_6F1D00)和 0x6F39F2(再次调 sub_6F1DB0)。最终提交调用位于:
0x6F39FB 8B 8E 84 00 00 00 mov ecx, [esi+84h]
0x6F3A01 56 push esi
0x6F3A02 E8 B9 5D 00 00 call sub_6F97C0因此复刻时至少要区分三态:
active + 条件未满足
│
├─ 条件满足 ─► ready / 可交付
│ │
│ ├─ 有 COMPLETE 对话 ─► 等待指定 NPC 交付
│ └─ 无 COMPLETE 对话且非 Controller 模式 ─► 自动最终完成
│
└─ QUESTCONTROLLERCOMPLETES=true ─► 等控制器显式提交2.4 接受即完成与控制器完成
模板加载器 sub_6EDF40 明确给出:
| DAT 键 | 模板偏移 | 默认值 |
|---|---|---|
COMPLETESONACCEPT | +0x20 | false |
FORCEACCEPT | +0x1D | false |
SHOWQUESTCOMPLETE | +0x1E | true |
QUESTCONTROLLERCOMPLETES | +0x1F | false |
NO_NOTIFICATION | +0x1C | false |
AUTOASSIGN | +0x23 | false |
DEFEAT_TASKS_AS_ONE | +0x24 | false |
任务激活函数 sub_6F99C0 在 COMPLETESONACCEPT 为真时调用 sub_6F3970,条件成立便直接提交。QUESTCONTROLLERCOMPLETES 则反过来禁止 sub_6F3970 的自动 ready/submit 路径,必须由任务控制器显式推进。
3. DEFEAT 计数:匹配、累加、封顶与存档
3.1 模板目标数
sub_6FD770 从每个 [DEFEAT]/[UNIT] 或 [ACQUIRE]/[UNIT] 块读取:
MINCOUNT -> template node +0x90,默认 0,随后下限修正到 1
MAXCOUNT -> template node +0x94,默认 MINCOUNT,随后保证 >= MINCOUNT运行时 sub_700B80 使用同步 RNG 在 [MINCOUNT, MAXCOUNT] 内抽取本局目标数,并写入实例 +0x3C;当前进度 +0x40 清零。
3.2 常规击杀路径 sub_700180
任务事件分发器 sub_6F3FE0 在事件类型 1 或 6 时,把事件单位传给 DEFEAT 根节点的 sub_700180。后者先做单位匹配:
- 若节点绑定了具体 spawn result,比较事件单位 GUID;
- 否则用
CUnit__hasUnitType(event_unit, template->unit_type); - 可选的单位类型标志还会在此前附加过滤;
- 已完成节点不会重复累加。
匹配后累加与完成写入:
0x7002BF 74 47 jz short no_increment
0x7002C5 FF 46 40 inc dword ptr [esi+40h] ; current
...
0x7007C4 8B 46 40 mov eax, [esi+40h]
0x7007C7 3B 46 3C cmp eax, [esi+3Ch] ; current vs target
...
0x7007FB C6 46 54 01 mov byte ptr [esi+54h],1 ; complete这是 backlog 验收要求的主累加点。另有两条同构事件路径:
| 路径 | 累加指令 | 完成比较 / 写入 |
|---|---|---|
sub_6FFC50 | 0x6FFD5B: FF 46 40 | 0x6FFD66: 3B 56 3C;0x6FFD6B: C6 46 54 01 |
sub_700890 | 0x700905: FF 46 40 | 0x70090B: 3B 46 3C;0x70092F: C6 46 54 01 |
sub_6FFC50 还在 0x6FFD72..0x6FFD77 把超出的 current 封顶为 target,避免显示大于目标数。
3.3 [DEFEATCOUNT] 到底显示什么
sub_6F2580 在格式化任务文本前递归汇总任务树:
defeat_target_total += sub_6FF080(defeat_root, type=0); // node +0x3C
defeat_current_total += sub_6FF030(defeat_root, type=0); // node +0x40随后:
| 占位符 | 实际替换值 | 关键地址 |
|---|---|---|
[DEFEATCOMPLETECOUNT] | defeat_target_total | 0x6F27C2..0x6F280C |
[DEFEATCOUNT] | defeat_current_total | 0x6F2863..0x6F28AD |
[DEFEATnCOUNT] | 第 n 个节点的 +0x3C 目标数 | 0x6F2EE0 |
[DEFEATnDEFEATED] | 第 n 个节点的 +0x40 当前数 | 0x6F2F7E |
[DEFEATnCOMPLETE] | sub_6FF130(node, true) | 0x6F30B7 |
换句话说,DEFEATCOMPLETECOUNT 不是“已经完成多少”,而是完成所需的总数;这属于原版命名遗留,复刻时不应按英文直觉交换两者。
3.4 存档位置
sub_700F30 是任务节点的存档载入函数。它按序从 buffer 读取:
read_u8 (&saved_complete);
read_u8 (&instance->byte_55);
read_i32(&saved_target);
read_i32(&instance->current); // +0x40, 0x700F85
read_i32(&saved_instance_id);
read_u64(&instance->bound_guid); // +0x18
instance->complete = saved_complete; // +0x54, 0x700FC5
instance->target = saved_target; // +0x3C, 0x700FC8当前计数的直接读回现场是:
0x700F78 ... ; 读 saved_target
0x700F85 ... ; sub_67D960(..., this+40h, 4)
0x700F93 ... ; 读 saved_instance_id因此 DEFEAT 进度不是临时 HUD 值;+0x40 本身就是被持久化的任务节点状态。
4. 六类对话的触发时机
4.1 静态名称表与模板字段
静态初始化函数 sub_1BD5B60 给出无歧义名称映射:
0x36FD734 = L"INTRO";
0x36FD750 = L"RETURN";
0x36FD76C = L"COMPLETE";
0x36FD788 = L"PASSIVE";
0x36FD7A4 = L"DETAILS";
0x36FD7C0 = L"HUDDETAILS";
0x36FD7DC = L"MOREDETAILS";加载器 sub_6EDF40 的对应模板字段:
| 子块 | 模板字段 | 形态 |
|---|---|---|
INTRO | +0x11C | 列表 |
RETURN | +0x12C | 列表 |
COMPLETE | +0x13C | 列表 |
PASSIVE | +0x14C | 列表 |
DETAILS | +0x15C / 完成态 +0x164 | 单对象,两态 |
MOREDETAILS | +0x160 / 完成态 +0x168 | 单对象,两态 |
HUDDETAILS | +0x16C / 完成态 +0x170 | 单对象,两态 |
4.2 NPC 交互列表:INTRO / RETURN / COMPLETE / PASSIVE
sub_6F42F0(quest, npc) 先用 sub_6F32B0 / sub_6F06D0 检查列表中是否有 UNITNAME 匹配当前 NPC 的条目。匹配的底层证据是 sub_6EF600:对话模板解析出的 unit id dialog+0x58 必须等于 NPC 模板 id npc+0x1B0。
选择优先级可以概括为:
| 状态 | 选择 |
|---|---|
sub_6F3970() 判定 ready,且当前 NPC 有匹配条目 | COMPLETE |
| 任务 active,匹配的 INTRO 尚未消费 | INTRO |
| 任务 active,匹配的 INTRO 已消费,且有同 NPC RETURN | RETURN |
| 上述都没选中,但允许该任务在当前单位上显示兜底对话 | PASSIVE |
INTRO / RETURN 的切换由 CQuestDialogInstance+0x10 consumed 位控制。sub_6F3970 也读取同一个位,所以 INTRO 是否实际播过同时影响“能否 ready”。
COMPLETE 并非条件满足瞬间自动播放。它是在下一次对指定 NPC 的交互里被选择;这就是带交付 NPC 的任务不会在最后一个怪死亡时直接从任务列表消失的原因。
4.3 文本提供器:DETAILS / MOREDETAILS / HUDDETAILS
三组 getter 的行为完全同构:先调用 sub_6F1DB0(true),若条件已满足且 DIALOG_COMPLETE 对象存在,则选择完成态;否则选择普通 DIALOG。
| 对话 | getter | 普通字段 | 完成字段 | 使用场景 |
|---|---|---|---|---|
DETAILS | sub_6F3EA0 | 模板 +0x15C | +0x164 | 任务详情文本、对话/界面查询 |
MOREDETAILS | sub_6F3F40 | +0x160 | +0x168 | 扩展详情;发行任务很少/未使用,但引擎完整支持 |
HUDDETAILS | sub_6F3E00 | +0x16C | +0x170 | HUD 任务追踪文本 |
例如 sub_6F3E00 在 0x6F3E1B 检查 HUD 两态对象,0x6F3E66 在条件完成时优先取 +0x170,否则回退 +0x16C。选中的对话对象再由 sub_6EF640 按同步 RNG 从多条文本中选一条,最后经过 sub_6F2580 展开计数占位符。
4.4 为什么旧普查只数出六类
发行版任务 DAT 的常用 authored 集合是 INTRO / DETAILS / COMPLETE / RETURN / HUDDETAILS / PASSIVE,所以资产普查得到六类是对发行数据域的正确结论。但引擎静态表和加载器还支持 MOREDETAILS。两者的定义域不同:
- “发行数据用了六类”——数据事实;
- “引擎解析器支持七个名字”——代码能力。
不能把后者写回前者,也不能因发行数据没用就把 MOREDETAILS 从兼容解析器删除。
5. sub_6F97C0:最终提交事务
整理后的主干:
bool QuestManager::finish(QuestInstance *q) {
if (!q) return false;
Guid guid = q->template->guid;
if (!completed_map[guid]) {
q->grant_rewards_and_followups();
q->set_active(false);
q->set_ready(true);
active_quests.remove_swap(q);
completed_map[guid] = true;
notify_quest_completed(q->template);
refresh_quest_state();
}
if (q->template->name == L"GLOBAL_TRILLBOT")
achievements->by_key(113)->complete();
publish_quest_state(guid, true);
q->release_dialog_instances();
destroy_quest_instance(q);
return true;
}关键提交现场:
| 动作 | 地址与字节 |
|---|---|
| completed map 幂等检查 | 0x6F97F8: 80 78 08 00 |
| 发奖励、统计、后续任务 | 0x6F9800: E8 8B A2 FF FF 调 sub_6F3A90 |
| active 清零 | 0x6F9809: E8 52 87 FF FF 调 sub_6F1F60(false) |
| ready 置一 | 0x6F9812: E8 A9 86 FF FF 调 sub_6F1EC0(true) |
| 从 active vector 移除 | 0x6F981B: E8 40 8E 03 00 |
| completed map 写 1 | 0x6F983B: C6 40 08 01 |
| 最终销毁实例 | 0x6F98A0: E8 5B FC FF FF 调 sub_6F9500 |
幂等保护只包住通用奖励和 completed-map 写入;TRILLBOT 特判位于保护块之后。但 CAchievement::complete 自己先检查成就 +0x28,已完成就不重复入队,因此仍然幂等。
6. GLOBAL_TRILLBOT 特判:解锁 TRILLBOTASSEMBLED
sub_6F97C0 的原始指令:
0x6F9854 68 9C 58 14 02 push offset L"GLOBAL_TRILLBOT"
0x6F985B E8 90 7A FF FF call sub_6F12F0 ; quest name
0x6F986F 6A 71 push 71h ; key 113
0x6F9871 E8 DA 8C 0D 00 call sub_7D2550 ; CAchievements singleton
0x6F9878 E8 53 90 0D 00 call sub_7D28D0 ; lookup(113)
0x6F987F E8 AC 8B 0D 00 call sub_7D2430 ; complete achievement对“113 是什么”的第二条独立证据来自成就表构造器 sub_7D2C30:按构造调用顺序从 0 编号,第 111、112、113、114、115 项分别是:
111 USEPOTIONS_1
112 USEPOTIONS_2
113 TRILLBOTASSEMBLED
114 FULLARMORSET
115 SQUISH_0第 113 项的构造现场:
0x7D61E6 68 18 2E 14 02 push offset "TRILLBOTASSEMBLED"
0x7D61F3 E8 B8 C0 FF FF call sub_7D22B0 ; CAchievement ctorsub_7D2430 在未完成时把 achievement 指针追加到 CAchievements+0x0C 的待发布队列(0x7D24B7 调 sub_7D2C00),然后执行:
0x7D24BC C6 46 28 01 mov byte ptr [esi+28h], 1所以准确语义是:最终完成隐藏收集任务 GLOBAL_TRILLBOT 时,解锁内部成就 TRILLBOTASSEMBLED。
数据侧也与此一致:GLOBAL_TRILLBOT.DAT 要求收集 Trillbot_Arm / Body / Drum / Head / Pipes 五个 ACQUIRE 任务物品;完成后由 A3-PROFESSORSTOKER 的 COMPLETE 对话把机器人组装起来。它还要求 GLOBAL_TRILLBOT_DOOROPENED 已完成。代码名“assembled”与 authored 任务内容相互独立地对上。
7. 复刻建议
最小兼容实现不应只有 is_completed: bool,而应保留以下状态:
enum QuestState { INACTIVE, ACTIVE, READY_TO_TURN_IN, COMPLETED }
class_name QuestTaskProgress
var target_count: int # 原版 +0x3C
var current_count: int # 原版 +0x40,需存档
var complete: bool # 原版 +0x54
var children: Array[QuestTaskProgress]完成判定顺序建议严格复刻:
- 验证 active;
- 验证 required flags;
- 递归验证全部 ACQUIRE 与 DEFEAT 根;
- 验证
[QUESTS_REQUIRED_TO_COMPLETE]; - 若有 INTRO,验证至少一条匹配 INTRO 已消费;
- 写 READY;
- 若存在 COMPLETE 对话,等 NPC 交付;若
QUESTCONTROLLERCOMPLETES,等控制器;否则自动提交; - 提交时再发奖励、后续任务、全局 completed 标志和成就。
对话数据结构也不能把六/七个子块压成一条字符串:INTRO、RETURN、COMPLETE、PASSIVE 是带 UNITNAME 匹配和多条候选的列表;DETAILS、MOREDETAILS、HUDDETAILS 是带完成前后两态的文本对象。
8. 仍未覆盖的边界
DEFEAT_TASKS_AS_ONE的完整合并算法未单独展开;本文只钉住加载偏移、根节点递归和三个事件累加入口。sub_6F3FE0的事件类型 0/1/2/5/6 的上游枚举名称尚未全部命名。可以确定 1/6 进入 DEFEAT 更新,0/2 进入另两种单位任务更新,但不强行给未知枚举命名。MOREDETAILS在引擎内完整存在,但发行版 184 份任务是否实际 authored 过需要单独做全量数据统计;本文不把“支持”写成“使用”。- 成就提交队列最终如何映射到 Steam API 不影响本条特判语义,本文没有继续展开平台层。
这些边界不影响 backlog A-2 的四项验收:完成条件、DEFEAT 累加与存档、对话触发时机,以及 GLOBAL_TRILLBOT 特判均已落到具体指令。