NOTE

本文分析对象是 32 位 Torchlight2.exe,IDA ImageBase 为 0x400000。地址均为 IDB 中的绝对虚地址。字段偏移不仅来自 Hex-Rays:关键判断、计数累加和状态写入均附有原始指令与机器码。数据侧用发行版 MEDIA/QUESTS/*.DAT 做独立交叉验证。

0. 结论速览

  1. sub_6F97C0 不是“判断任务是否完成”的函数,而是已经决定最终完成之后的提交入口:发奖励、撤销 active、写 completed、通知任务系统,并销毁任务实例。
  2. 通用完成谓词在 sub_6F1DB0。任务必须处于 active 状态;所有 required FLAG objective、所有 ACQUIRE 树、所有 DEFEAT 树,以及 [QUESTS_REQUIRED_TO_COMPLETE] 中的前置任务都必须完成。
  3. sub_6F3970 在通用谓词之外再加一层交付门槛:若任务声明了 INTRO 对话,至少有一条匹配的 INTRO 实例必须已被消费。它随后把实例 +0x88 标为 ready/complete。
  4. ready 不一定等于立即提交。若存在 COMPLETE NPC 对话,任务等待玩家回到该 NPC;只有没有 COMPLETE 对话、且 QUESTCONTROLLERCOMPLETES=false 时,才自动进入 sub_6F97C0
  5. DEFEAT 任务实例的目标数在 +0x3C,当前击杀数在 +0x40,完成位在 +0x54。常规击杀路径的累加点是 0x7002C5: FF 46 40;达到目标后 0x7007FB: C6 46 54 01 置完成。
  6. [DEFEATCOUNT] 显示的是所有 DEFEAT 根节点中 +0x40 的递归总和;[DEFEATCOMPLETECOUNT] 显示 +0x3C 的递归总和。名称容易误读:前者是当前进度,后者是目标总数
  7. 四类 NPC 对话列表按模板到实例的映射为:INTRO → +0x28RETURN → +0x38COMPLETE → +0x48PASSIVE → +0x58。选择入口是 sub_6F42F0
  8. DETAILSMOREDETAILSHUDDETAILS 都是单独的文本提供器,并各有 DIALOG / DIALOG_COMPLETE 两态。发行任务普查常见的“六类”之外,代码表还保留了第七个 MOREDETAILS
  9. GLOBAL_TRILLBOT 的具名特判不是发物品或接后续任务,而是:从 CAchievements 取键 113,调用 CAchievement::complete。硬编码成就表的第 113 项内部名正是 TRILLBOTASSEMBLED

1. 对象布局

1.1 QuestInstance

下表只列本文实际由消费指令钉住的字段:

偏移含义证据
+0x08/+0x0C/+0x10ACQUIRE 根任务数组 / count / capacitysub_6F1DB0 @ 0x6F1E0F..0x6F1E35
+0x18/+0x1C/+0x20DEFEAT 根任务数组 / count / capacitysub_6F1DB0 @ 0x6F1E39..0x6F1E5C
+0x28/+0x2C/+0x30INTRO 对话实例数组sub_6F4BC0 @ 0x6F4DC8sub_6F42F0 @ 0x6F435C
+0x38/+0x3C/+0x40RETURN 对话实例数组sub_6F4BC0 @ 0x6F4DE0sub_6F42F0 @ 0x6F430E
+0x48/+0x4C/+0x50COMPLETE 对话实例数组sub_6F4BC0 @ 0x6F4DF8sub_6F42F0 @ 0x6F4336
+0x58/+0x5C/+0x60PASSIVE 对话实例数组sub_6F4BC0 @ 0x6F4E0Fsub_6F42F0 @ 0x6F43D3
+0x68/+0x6C/+0x70FLAG objective 实例数组sub_6F1DB0 @ 0x6F1DD8..0x6F1E0A
+0x80QuestTemplate 指针多处,例如 0x6F1E5E
+0x84QuestManager 指针0x6F39FB0x6F3A07
+0x88ready / completion 条件已满足0x6F39D0: C6 86 88 00 00 00 01
+0x8Aactive0x6F1DC2sub_6F1F60 @ 0x6F1F74

+0x88 与“已写进全局 completed 集合”不是同一层。前者属于活跃实例,后者由 sub_6F97C0 通过任务 GUID 写入 QuestManager 的完成记录。

1.2 QuestUnitDataInstance(ACQUIRE / DEFEAT 树节点)

偏移含义证据
+0x08任务模板节点指针sub_6FF030 @ 0x6FF043 读取模板 +0x9C 类型
+0x0C所属 QuestInstanceHUD 和完成回调多处读取
+0x3C本节点目标总数sub_6FF080 @ 0x6FF095;完成比较 0x7007C7
+0x40本节点当前进度累加 0x7002C5;格式化 sub_6FF030 @ 0x6FF045
+0x44/+0x48/+0x4C子任务数组 / count / capacitysub_6FF030sub_6FF080 递归遍历
+0x54本节点完成位sub_6FF130 @ 0x6FF1330x7007FB 写 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

等价伪代码:

cpp
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 00cmp byte ptr [esi+88h], 0
必须 active0x6F1DC2 80 BE 8A 00 00 00 00cmp byte ptr [esi+8Ah], 0
FLAG 是否 required0x6F1DEE 80 79 5D 00cmp byte ptr [ecx+5Dh], 0
required FLAG 是否完成0x6F1E01 80 79 5C 00cmp byte ptr [ecx+5Ch], 0
ACQUIRE 根递归完成0x6F1E28 E8 03 D3 00 00call sub_6FF130
DEFEAT 根递归完成0x6F1E4F E8 DC D2 00 00call sub_6FF130
前置任务完成0x6F1E88 E8 73 4D 00 00call 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:

cpp
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 FFsub_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

cpp
complete_dialogs.count == 0
&& !template->quest_controller_completes
&& quest_has_any_condition()
&& all_completion_conditions(true)

对应现场为 0x6F39CC(实例 +0x4C,COMPLETE count)、0x6F39DF(模板 +0x1F)、0x6F39E7sub_6F1D00)和 0x6F39F2(再次调 sub_6F1DB0)。最终提交调用位于:

asm
0x6F39FB  8B 8E 84 00 00 00    mov ecx, [esi+84h]
0x6F3A01  56                   push esi
0x6F3A02  E8 B9 5D 00 00       call sub_6F97C0

因此复刻时至少要区分三态:

text
active + 条件未满足

        ├─ 条件满足 ─► ready / 可交付
        │                 │
        │                 ├─ 有 COMPLETE 对话 ─► 等待指定 NPC 交付
        │                 └─ 无 COMPLETE 对话且非 Controller 模式 ─► 自动最终完成

        └─ QUESTCONTROLLERCOMPLETES=true ─► 等控制器显式提交

2.4 接受即完成与控制器完成

模板加载器 sub_6EDF40 明确给出:

DAT 键模板偏移默认值
COMPLETESONACCEPT+0x20false
FORCEACCEPT+0x1Dfalse
SHOWQUESTCOMPLETE+0x1Etrue
QUESTCONTROLLERCOMPLETES+0x1Ffalse
NO_NOTIFICATION+0x1Cfalse
AUTOASSIGN+0x23false
DEFEAT_TASKS_AS_ONE+0x24false

任务激活函数 sub_6F99C0COMPLETESONACCEPT 为真时调用 sub_6F3970,条件成立便直接提交。QUESTCONTROLLERCOMPLETES 则反过来禁止 sub_6F3970 的自动 ready/submit 路径,必须由任务控制器显式推进。

3. DEFEAT 计数:匹配、累加、封顶与存档

3.1 模板目标数

sub_6FD770 从每个 [DEFEAT]/[UNIT][ACQUIRE]/[UNIT] 块读取:

text
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)
  • 可选的单位类型标志还会在此前附加过滤;
  • 已完成节点不会重复累加。

匹配后累加与完成写入:

asm
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_6FFC500x6FFD5B: FF 46 400x6FFD66: 3B 56 3C0x6FFD6B: C6 46 54 01
sub_7008900x700905: FF 46 400x70090B: 3B 46 3C0x70092F: C6 46 54 01

sub_6FFC50 还在 0x6FFD72..0x6FFD77 把超出的 current 封顶为 target,避免显示大于目标数。

3.3 [DEFEATCOUNT] 到底显示什么

sub_6F2580 在格式化任务文本前递归汇总任务树:

cpp
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_total0x6F27C2..0x6F280C
[DEFEATCOUNT]defeat_current_total0x6F2863..0x6F28AD
[DEFEATnCOUNT]第 n 个节点的 +0x3C 目标数0x6F2EE0
[DEFEATnDEFEATED]第 n 个节点的 +0x40 当前数0x6F2F7E
[DEFEATnCOMPLETE]sub_6FF130(node, true)0x6F30B7

换句话说,DEFEATCOMPLETECOUNT 不是“已经完成多少”,而是完成所需的总数;这属于原版命名遗留,复刻时不应按英文直觉交换两者。

3.4 存档位置

sub_700F30 是任务节点的存档载入函数。它按序从 buffer 读取:

cpp
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

当前计数的直接读回现场是:

asm
0x700F78  ...                      ; 读 saved_target
0x700F85  ...                      ; sub_67D960(..., this+40h, 4)
0x700F93  ...                      ; 读 saved_instance_id

因此 DEFEAT 进度不是临时 HUD 值;+0x40 本身就是被持久化的任务节点状态。

4. 六类对话的触发时机

4.1 静态名称表与模板字段

静态初始化函数 sub_1BD5B60 给出无歧义名称映射:

cpp
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 RETURNRETURN
上述都没选中,但允许该任务在当前单位上显示兜底对话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普通字段完成字段使用场景
DETAILSsub_6F3EA0模板 +0x15C+0x164任务详情文本、对话/界面查询
MOREDETAILSsub_6F3F40+0x160+0x168扩展详情;发行任务很少/未使用,但引擎完整支持
HUDDETAILSsub_6F3E00+0x16C+0x170HUD 任务追踪文本

例如 sub_6F3E000x6F3E1B 检查 HUD 两态对象,0x6F3E66 在条件完成时优先取 +0x170,否则回退 +0x16C。选中的对话对象再由 sub_6EF640 按同步 RNG 从多条文本中选一条,最后经过 sub_6F2580 展开计数占位符。

4.4 为什么旧普查只数出六类

发行版任务 DAT 的常用 authored 集合是 INTRO / DETAILS / COMPLETE / RETURN / HUDDETAILS / PASSIVE,所以资产普查得到六类是对发行数据域的正确结论。但引擎静态表和加载器还支持 MOREDETAILS。两者的定义域不同:

  • “发行数据用了六类”——数据事实;
  • “引擎解析器支持七个名字”——代码能力。

不能把后者写回前者,也不能因发行数据没用就把 MOREDETAILS 从兼容解析器删除。

5. sub_6F97C0:最终提交事务

整理后的主干:

cpp
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 FFsub_6F3A90
active 清零0x6F9809: E8 52 87 FF FFsub_6F1F60(false)
ready 置一0x6F9812: E8 A9 86 FF FFsub_6F1EC0(true)
从 active vector 移除0x6F981B: E8 40 8E 03 00
completed map 写 10x6F983B: C6 40 08 01
最终销毁实例0x6F98A0: E8 5B FC FF FFsub_6F9500

幂等保护只包住通用奖励和 completed-map 写入;TRILLBOT 特判位于保护块之后。但 CAchievement::complete 自己先检查成就 +0x28,已完成就不重复入队,因此仍然幂等。

6. GLOBAL_TRILLBOT 特判:解锁 TRILLBOTASSEMBLED

sub_6F97C0 的原始指令:

asm
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 项分别是:

text
111 USEPOTIONS_1
112 USEPOTIONS_2
113 TRILLBOTASSEMBLED
114 FULLARMORSET
115 SQUISH_0

第 113 项的构造现场:

asm
0x7D61E6  68 18 2E 14 02       push offset "TRILLBOTASSEMBLED"
0x7D61F3  E8 B8 C0 FF FF       call sub_7D22B0       ; CAchievement ctor

sub_7D2430 在未完成时把 achievement 指针追加到 CAchievements+0x0C 的待发布队列(0x7D24B7sub_7D2C00),然后执行:

asm
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,而应保留以下状态:

gdscript
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]

完成判定顺序建议严格复刻:

  1. 验证 active;
  2. 验证 required flags;
  3. 递归验证全部 ACQUIRE 与 DEFEAT 根;
  4. 验证 [QUESTS_REQUIRED_TO_COMPLETE]
  5. 若有 INTRO,验证至少一条匹配 INTRO 已消费;
  6. 写 READY;
  7. 若存在 COMPLETE 对话,等 NPC 交付;若 QUESTCONTROLLERCOMPLETES,等控制器;否则自动提交;
  8. 提交时再发奖励、后续任务、全局 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 特判均已落到具体指令。