NOTE

逆向方法与数据支撑:本文基于 IDA 对 32 位客户端 Torchlight2.exe(ImageBase 0x400000,文内所有 sub_XXXXXX0xXXXXXX 均为绝对虚地址)执行逐指令反汇编与控制流分析。关键数字采用三类互相独立的 oracle:字节锚点与数据段浮点常量直接从发行 exe 里现读(O0)、反编译伪代码(O2)、以及对原版全量数据资产的普查对照(O4,涵盖 MEDIA/LAYOUTS 下 186 份 .TEMPLATE、1,696 条 CHUNKTYPE、2,740 份 .LAYOUT(传送门普查)、以及 25,255 份 .LAYOUT/.DAT/.TEMPLATE 的全量引用扫描)。本文严格收录已收敛至真值账本(Ledger C-0155 / C-0157 / C-0163 / C-0165 / C-0169)的技术结论;账本中标记为“未逆 / 未确定 / 墓碑 / 理由错”的边界逻辑,一律如实标注,不作过度推论。

核心工程结论:TL2 的关卡布局有两个来源 —— [LAYOUT] 预设装配与程序化生成器 —— 由一张 61 字节的判定表决定走哪条;不写 chunk 键的模板一定会跑生成器。两个生成器不是一个算法的两种参数,是两套完全不同的算法。传送门方面,WarperLEVEL DELTA 缺省是 1 而不是 0,管着全语料 87% 的门;写成 0 不崩不报错,只是绝大多数门原地不动。另有 6 个指向不存在 dungeon 的门是死内容而非数据缺陷,3 个 WAYPOINT=true 的门恰好全在其中,意味着引擎那条“踩 WAYPOINT 门注册路径点”的路径在发行数据里一次都没被走到。


0. 核心结论速览

  1. 关卡布局有两个来源,判定表在 sub_60C760(整个函数 61 字节)生成 = RANDOMIZEAFTERLOAD(+97) 或 预设条数(+324)==0 或 (MINCHUNKS(+216)!=0 且 MAXCHUNKS(+220)!=0),再按 GENERATION_TYPE(+44) 分派 {0: sub_6087A0, 1: sub_60A9A0}其余值静默不生成。构造函数 sub_60BBE0 的缺省是 GENERATION_TYPE=0 / MINCHUNKS=2 / MAXCHUNKS=3 / RANDOMIZEAFTERLOAD=false / REQUIRESEXIT=true不写 chunk 键就一定会跑生成器;关掉它的唯一写法是把 MINCHUNKSMAXCHUNKS 写成 0。
  2. RANDOMIZED 是死键0x605388 mov [esi+60h], al 存回 getBool("RANDOMIZED", …) 的结果,紧接着 0x6053A2 C6 46 60 01 无条件写成 1。181 个模板全部显式写了这个键,全都无效
  3. 全语料分母是 186 不是 181:早期按文件名后缀 RULES.TEMPLATE 过滤漏掉 5 个 .TEMPLATE,其中 3 个真的被 DUNGEONS 引用DRAGON_SPECTRAL / TESTESTHERIANPUZZLE / QA_TEST,各 2 处)。订正后:纯预设 44 / 纯生成 80 / 两者兼有 62 / 空池 0;预设式装配(接缝检查)只对 106 个模板成立,142 个模板的图里含生成产物。
  4. 两个生成器是两套算法sub_6087A0GENERATION_TYPE 0,8,689 B)是出口配对式 —— 从 ENTRANCE_CHUNK 起,每次挑一个新块,在已放置块上找出口类型相等且格偏移镜像相反的空出口接上去;sub_60A9A0GENERATION_TYPE 1,4,598 B)是格子 blob 生长 + 对偶格贴瓦 —— 先用 rand_int_between_syncedSeed(0,7)八邻域随机走长出一片格子,再逐格调四个近乎同构的贴瓦函数。两者只共用守卫、三个放置谓词和 MIN/MAXCHUNKS没有一个共同相位
  5. 生成器 B 的核心是 CLevelTemplateData + 1036 处的 13 个 WeightedRandom 数组(步长 68,1036 + 68×13 = 1920 正好落在下一个已知字段头上),由两个独立索引器共用同一套编号:装载侧 sub_600490 按四方向出口类型登记(lea ecx,[eax+edx*4+40Ch] @0x60073F),使用侧 sub_600280 按四个邻居格占用取出(lea ecx,[esi+ecx*4+40Ch] @0x604403)。16 种占用里 3 种无解(全空 + 两个对角棋盘格),16 − 1 − 2 = 13。
  6. 两个生成器吃的语料本来就不是同一批:13 个模式号在 1,094 个普通 CChunkType 里全部有几何上产出它的条目,但白名单只覆盖 204 / 1,094 = 18.6%;剩下 81% 进不了模式池却照样进 +356 主池给生成器 A 用。出口 TYPE 不止 0/1/2 —— 语料里还有 3(14 次)、4(32 次)、5(26 次),sub_600490 的判定树只认 −1/0/1/2,任一方向出现 3/4/5 整个 CChunkType 一个模式池都进不去(实测 38 个条目)——这不是 bug,那些是给生成器 A 用的。
  7. Warper descriptor(code 41)7 个字段的缺省值由两个互相独立的 oracle 对账、逐个相同:游戏 exe 的 CWarper::CWarper @0x615620 逐条反汇编,与 EditorGuts 的 descriptor 克隆表快照。★★★ LEVEL DELTA 缺省是 1,语料 6,686 / 7,716(87%) 个门没写这个键。
  8. 目标层深三条分支performWarpebp=0 已用支配性证明):LEVEL ABSOLUTE != 0 ⇒ 取绝对值 [0x43DD8E];否则 WARP NAME != "" ⇒ 层深钉成 0、delta 不参与 [0x43DDA7];否则 += LEVEL DELTA [0x43DDAD]。WARP OUT OF DUNGEON 不是第四条机制 —— 引擎把 delta 改写成 -1 - 当前层深、absolute 清 0,再走同一条链。
  9. 门 → 目标场景是五跳解析DUNGEON NAME(大小写不敏感)→ DUNGEONS/*.DAT → 逐 STRATUMRULESET[LAYOUT] 预设 → 变体目录。act1_town ⇄ EchoPass 这对原版真实的门两端都通,EchoPass 是单块关卡。目标层深 → 第几个 stratum 的映射没有在引擎里核过FLOORS 是最可能的键但那是推测。
  10. 6 个指向不存在 dungeon 的 Warper 是死内容TOWN ×3 / MAIN / HatchCrypt / A1-WarbeastLair):它们所在的 prop 布局在全语料 25,255 个文件里 0 处引用,两条独立查法(文件全名带扩展名、WaygatePortal 单位 GUID 7663373534943777246)都为 0,且配了阳性对照。7,716 个 Warper 里只有 3 个 WAYPOINT=true,正是这里 3 个死掉的 WAYGATEPORTAL
  11. 三块墓碑:① 「TL2 命名里 E = −X」是错的 —— 按坐标逐条核完 163 个条目 E = −X、91 个 E = +X,语料内部两种约定并存,结论 13/13 不变而理由错过;② sub_601310 / sub_600ED0 / sub_603D20 曾被注记成“路径几何三件套”,实为碰撞 / 出口有效性 / 连接落子;③ sub_6153D0 曾被叫成 CWarper::onActivate,它不在任何虚表里;performWarp 开头硬编的 ESTHERIANCITY 是试玩版 upsell 闸门,不是“未知 dungeon 回落主城”。
  12. 没有做产出复现:两个生成器的算法结构与常量已逆,但没把它们移植出来跑过,也没拿同一个种子对过原版的图。要到那一步还差 RNG 调用次序逐次对齐(生成器 A 一次尝试里 1..3 次调用,流是 synced)与出口配对的浮点精确相等-a == b)—— x87 → SSE2 可能配不上,症状会是“偶尔产出更小的图”而非崩溃。

1. 关卡布局的两个来源:sub_60C760 判定表

1.1 判定公式与分派

RULES.TEMPLATE 装载进 CLevelTemplateData(构造函数 sub_60BBE0)之后,引擎在 sub_60C760 里做一次判定,决定要不要跑程序化生成器、跑哪一个。这个函数整个只有 61 字节,已逐字节对发行 exe 校验:

生成   = RANDOMIZEAFTERLOAD(+97)  或  预设条数(+324) == 0  或  (MINCHUNKS(+216) != 0 且 MAXCHUNKS(+220) != 0)
生成器 = {0: sub_6087A0, 1: sub_60A9A0}.get(GENERATION_TYPE(+44))     # 其余值静默不生成
字段CLevelTemplateData 偏移构造缺省(sub_60BBE0缺省从哪条 store 指令解出
GENERATION_TYPE+440xor ebx,ebx ⇒ 0
RANDOMIZEAFTERLOAD+97(C-0165 的记法)false
REQUIRESEXIT+97(C-0169 的记法,见 §2.1)true
MINCHUNKS+2162mov ebp,2 ⇒ 2
MAXCHUNKS+2203C7 86 DC…03 ⇒ 3
预设条数+324vector 的 count)

NOTE

上表的缺省值不是从 JSON 字面量抄的:每一个都是从构造函数 store 指令的立即数 / 寄存器字节里解出来再比对。判定表的 61 字节、两条 RANDOMIZED 覆写、九条 ctor 缺省 store,全部由门按 VA 去发行的 Torchlight2.exe 读同样长度的字节再比(tools/pe_bytes.py)。表中“—”表示源材料未单独记录该项的 store 形态。

两条账本对 +97 的标注不一致:C-0165 的判定公式把 +97 记为 RANDOMIZEAFTERLOAD;C-0169 的数据模型与生成器 A 相位 5(if (!*(this+97)) 跳过)把它记为 REQUIRESEXIT。本文如实并列,不替材料裁决;要用这个偏移的人应回到 level_generation_semantics.json / level_generation_algorithms.json 复核。

直接推论:缺省 MINCHUNKS=2 / MAXCHUNKS=3 都非零 ⇒ 一个模板只要不写 chunk 键,就一定满足第三个条件,一定会跑生成器。想让一张图只走预设装配,唯一写法是把 MINCHUNKSMAXCHUNKS 写成 0 —— 语料里所有纯预设模板 44/44 都同时把两个键写成了 0,这是原版美术实际在用的开关。

1.2 全语料分类

把上面的判定原样搬到全语料上跑(tools/f2_level_generation.py),每个模板落到四类之一:

分类含义早期计数(分母 181)订正后(分母 186)
纯预设[LAYOUT] 预设,且 chunk 键写 0 关掉了生成器4344
纯生成没有预设,布局全靠生成器7980
两者兼有有预设,同时生成器也会跑5962
空池没有预设、且 GENERATION_TYPE 落到分派表之外00

分类器能产出 empty-pool:语料上它是 0,而“语料里没有”与“这把尺子量不出来”同形,所以九个合成对照里专门有一格(GENERATION_TYPE=2 且无预设)产出它。

1.3 分母墓碑:181 → 186

find_templates() 早期按文件名后缀 RULES.TEMPLATE 过滤,得到 181;而 MEDIA/LAYOUTS 下实有 186 个 .TEMPLATE。多出来的 5 个里 3 个真的被 DUNGEONS 引用DRAGON_SPECTRAL / TESTESTHERIANPUZZLE / QA_TEST,各 2 处)—— 3 个在用的模板从未进过任何普查的分母,而下游每个比例都以它算。引擎按完整路径加载、从不看文件名,过滤是分析侧加的,不是数据的性质

订正带动的连锁变化:纯预设 43→44、纯生成 79→80、两者兼有 59→62;“接缝检查只覆盖” 102→106;“含生成产物” 138→142。结论不变的两处:空池仍是 0;纯预设“两个 chunk 键都写 0”仍是全员(44/44)。

关于 106 / 142 这两个数的口径:m4_seam_check 曾报“实际被检查 114 / 181”,分子对、含义偏 —— 114 里有 12 个的预设因 RANDOMIZEAFTERLOAD 为真根本不进布局池;剩下 102 个里有 59 个另有生成的布局,接缝检查一条都没查。按 186 分母重算即 106 / 142。

1.4 RANDOMIZED 是死键

assembly
0x605388   mov  [esi+60h], al        ; 把 getBool("RANDOMIZED", …) 的结果存进 +0x60
0x6053A2   C6 46 60 01               ; 紧接着无条件写成 1 —— 任何值(含 false)都被覆盖

181 个模板全部显式写了这个键,全都无效;驱动里的 if (RANDOMIZED) 因此恒真,重掷预设 + 生成器对每一张图都会跑。

1.5 “那 67 个没预设的模板”不是死内容

早期一条读法是:无预设 ⇒ pickRandomLayout 返回 NULL ⇒ 驱动无条件读 [layout+12] ⇒ 崩,所以那 67 个没有 [LAYOUT] 预设的模板是死内容。写下之前查了语料:67 里有 52 个被 DUNGEON 的 RULESET 引用,是活关卡 —— 一定还有一条没看到的路,这才找到 sub_60C760。空指针那条路径是真的,只是语料里没有模板落到它上面(实测 empty-pool = 0);“原版跑得起来”反过来成了这条分类正确的一个独立旁证。

1.6 这对做地图意味着什么

  • 新建一个 RULES.TEMPLATE 时,不写 MINCHUNKS / MAXCHUNKS 等于打开生成器。要做一张固定拼法的图,必须同时把两个键写成 0,照原版 44 个纯预设模板的写法。
  • RANDOMIZED:false 写了等于没写。任何以为它能关掉随机的设计,在原版引擎里从未生效过。
  • GENERATION_TYPE 只认 0 和 1;写别的值不报错,只是静默不生成。如果这时又没有预设,就落到空池 —— 原版语料里没有一张图是这样的。
  • 一张有预设的图,只要 chunk 键非零,接缝之外还会长出生成器的产物;原版 62 个模板就是这么工作的。

2. 两个生成器是两套算法

2.1 共用的数据模型

两个生成器共用同一组结构,偏移单位是字节,vftable 值取自 IDA 的命名符号(??_7X@@6B@)。

CChunkType(128 字节,ctor sub_6023B0(this, wstring name),vftable 0x2179B70)—— 一个 [CHUNKTYPE] 块:

偏移字段备注
+8bool ENTRANCE_CHUNKctor 置 0
+9bool EXIT_CHUNKctor 置 0
+10bool “已分类”哨兵装载时每个类型只分类一次,重复出现直接跳过
+11bool MUST_PLACEctor 置 0
+12int WIDTH装载缺省 1
+16int HEIGHT装载缺省 1
+20int MAX_APPEARANCE装载缺省 10
+24wstring NAME
+68wstring FOLDER缺尾部斜杠时补上
+96/+100/+104/+108vector<Ogre::Vector3> 出口位置data/count/cap/hint=10
+112/+116/+120/+124vector<int> 出口类型data/count/cap/hint=10

CChunk(48 字节,建于 0x607A12,vftable 0x2179B30)—— 一个类型下的一个 .LAYOUT 变体:+8 所属 CChunkType*+12 weight = 1+16 maxUses = owner->MAX_APPEARANCE+20 wstring .LAYOUT 路径。

CChunkInstance(72 字节,ctor sub_603150(this, Ogre::Vector3* pos, CChunkType* type),vftable 0x2179BA0)—— 摆进图里的一个块:+8 CChunkType*+40/+44/+48 位置 x/y/z、+56/+60/+68 vector<int> 已占用的出口下标

CLevelLayout(44 字节,建于 sub_604D20,dtor sub_602590,vftable 0x2179B7C)—— 一张生成好的图:vector<CChunk*> + vector<CChunkInstance*>

CLevelTemplateData 里与生成相关的字段:

偏移内容
+44GENERATION_TYPE
+48/52/56vector<CChunkType*> 全部类型
+64/68/72vector<CChunk*> 全部变体
+80/84/88vector<CChunkType*> MUST_PLACE 名单
+96bool(INCLUSIVE_FILES 多于 1)
+97bool REQUIRESEXIT(C-0169;⚠ C-0165 在同一偏移记的是 RANDOMIZEAFTERLOAD,见 §1.1)
+216 / +220MINCHUNKS / MAXCHUNKS
+240TILEBASIS(缺省 4.0)
+244 / +248CHUNKWIDTHBASIS / CHUNKHEIGHTBASIS(25.0 / 25.0)
+304/308/312布局池 vector<CLevelLayout*>
+320/324/328预设 vector
+336WeightedRandom* 布局池(指针,不是内嵌)
+340/344/348vector<WeightedRandom*> —— 每个 chunk 类型一个“变体池”
+356WeightedRandom(内嵌)主循环用的 chunk 类型池 —— 既非入口也非出口的普通类型全进这里
+424WeightedRandom(内嵌)种子池 —— 只装 ENTRANCE_CHUNK 为真的类型
+452★ 就是 +424 那个池的 count 字段(424+28)
+492/+560/+628/+696四个方向池(EXIT_CHUNK 类型),= 492 + 68·k
+764/+832/+900/+968四个方向池(普通类型:出口恰好 1 个的必进;出口恰好 2 个的仅当 GENERATION_TYPE==1 时进),= 764 + 68·k
+1036 .. +1919★★★ 13 个 WeightedRandom,按“四方向出口类型模式”索引(§2.5)
+1920临时池:挑方向 0..3
+1988临时池:挑一个出口
+2056/2060/2064生成器 B 的格子集合 vector<Ogre::Vector3>

WeightedRandom 的步长是 68,+28 是条目数(+336pickRandomLayout+452 守卫都读它)。

守卫的真实含义:两个生成器开头那个 if (!*(this+452)) return 0 不是独立开关 —— +452 就是种子池的 count,所以它的意思是“没有入口块就不生成”。字节锚点 0x607A81(29 字节)钉住了装载侧的路径:cmp [ebp+0Ah],0(已分类哨兵)→ cmp [ebp+8],0ENTRANCE_CHUNK)→ lea ecx,[esi+1A8h](种子池 +424)。

2.2 两个生成器对照

维度生成器 A:sub_6087A0生成器 B:sub_60A9A0
GENERATION_TYPE0(缺省)1
函数大小8,689 B(IDA get_func;ALLFUNCS 记 9,278 B,差 589,多半是尾块,没查4,598 B
形状出口配对式:从入口块起,每次选一个新块,找一个已放置块上类型相同且方向相对的空出口接上去格子 blob 生长 + 对偶格贴瓦:先随机长出一片格子,再逐格按四邻居占用贴瓦
目标块数rand_int_between_syncedSeed(MINCHUNKS, MAXCHUNKS) @0x608AD6rand_int_between_syncedSeed(MINCHUNKS, MAXCHUNKS) @0x60AD55
失败上限放置循环 while (fails < 1000) @0x608B00;外层第二重 1000 上限同样 1000 次
核心随机rand_int_between_syncedSeed(0, 已放置数−1) 决定从哪个块开始扫;rand_float(0,100) < 50 决定先试 X 还是 Zrand_int_between_syncedSeed(0, 7) @0x60ADE9八邻域方向
读哪些池320/324/328/336/340/348/356/424/452/492/560/628/696/764/832/900/968/1920/1988320/324/328/336/340/348/424/452/2056/2060/2064
不读什么不碰 +1036 那 13 个模式池不碰 +356 主池,也不碰 A 的八个方向池
REQUIRESEXIT相位 5 读 +97
MUST_PLACE校验相位确认 +80 名单全部出现有「Failed to place MUSTPLACE:」日志串,在哪一相位起作用没逆

两者只共用守卫、三个放置谓词(§2.4)和 MIN/MAXCHUNKS没有一个共同相位

NOTE

早期 C-0165 曾记“生成器 A 认不认 MUST_PLACE 查不出来”—— 当时试过按结构体偏移做字节扫描,[eax+8] / [ebx+14h] 在任何 vector 访问上都命中,那把尺子没有判别力、当场作废。后来 C-0169 从 A 的校验相位直接确认了 +80 名单的消费,上表按此更新;表中“—”表示源材料未记录。

2.3 生成器 A:出口配对式(sub_6087A0

相位名称内容
0守卫if (!*(this+452)) return 0;(没有 ENTRANCE_CHUNK 就不生成);随后 if (a2) setSyncedSeed(a2)
1播种RANDOMIZEAFTERLOAD 为假 → 从 +424 种子池挑一个类型,在 (0,0,0) 放一个 CChunkInstance;为真 → rand_int_between_syncedSeed(0, 预设数−1)0x608849)挑一个预设,把它的实例整批当种子
2定目标目标数 = rand_int_between_syncedSeed(MINCHUNKS, MAXCHUNKS)0x608AD6
3放置循环while (fails < 1000)0x608B00)。每轮:从 +356 主池挑类型(0x608B14);出口数==1 且还没放过东西 → 跳过;该类型的变体池为空 → 跳过;把新类型每个出口换算成格坐标偏移:X 向 (exit.x − (w−1)·CHUNKWIDTHBASIS·TILEBASIS)/TILEBASIS,Z 向 (TILEBASIS·0.5 + exit.z − 0.5·h·TILEBASIS·CHUNKHEIGHTBASIS)/TILEBASISrand_int_between_syncedSeed(0, 已放置数−1)0x608C33)决定从哪个已放置块开始扫(环形回绕);对每个已放置块的每个出口:已占用则跳过、出口类型必须相等、偏移必须镜像相反rand_float(0,100) < 500x608D78)决定先试 X 还是先试 Z;候选位置 = 已放置块位置 ± 一个 chunk;if (sub_601310(...) || !sub_600ED0(...)) 拒绝,否则 sub_603D20(...) 落子
4开放度预算0x608F5Cn = 新类型的出口数; if (n <= 2) 收下; else { budget += n−2; if (budget <= 目标数) 收下; else 拒绝 }。★★ 预算是无条件写回的89 44 24 68),拒绝这一手时并不回滚 ⇒ 一次失败的尝试会永久吃掉预算。这不是推的语义,是写回指令落在两条分支的公共路径上。收下之后 ++budget。外层还有第二重 1000 上限(整体重试),失败则释放全部已放置块重来
5REQUIRESEXIT 收尾if (!*(this+97)) 跳过。把每个已放置实例的出口塞进临时池 +1988(每个 addEntry(k,100)),挑出不在 usedExits 里的悬空出口;对每个悬空出口用临时池 +1920 挑一个方向 0..3,从该方向的 EXIT_CHUNK 方向池+492 + 68k)挑一个封口块试着放上去
6校验与产出MUST_PLACE 名单(+80)里每个类型都必须出现在已放置实例中,否则本次作废;逐实例从该类型的变体池挑一个 CChunk++usage[idx],达到 CChunk+16(maxUses)就 sub_675850(idx,0) 摘掉;sub_600760(this) 把所有类型池的权重重置回 CChunk+12;通过则建一个 44 字节 CLevelLayoutaddEntry(池下标, 1)+336,压进 +304。日志串是「Creating layout # N」与「\tlayout # N + chunk M <path>

2.4 三个放置谓词(两个生成器共用)

函数签名语义
sub_601310(template, 候选实例, 已放置表) → 1 表示撞了Ogre::AxisAlignedBox::setExtents/intersects。Y 方向取 ±1000.0(flt_2141438 / flt_214475C)⇒ 实际上是二维 XZ 判定
sub_600ED0(template, ?, 候选实例, 已放置表) → 1 表示可以放对候选的每个出口 E:matched = 10x6010AE —— 伪代码读不出来,是从汇编拿的);探针 = E 的世界位置沿出口方向外推 1.5×TILEBASISflt_213C474,四个方向各用一次);以半展 CHUNKWIDTHBASIS·TILEBASIS − 0.1flt_2140C6C)扫已放置块;探针落进某块 P 内 ⇒ matched = 0,然后在 P 上找一个类型相同、且 |F.world − 探针| < 2·TILEBASIS0x601258)的出口 F,找到才接受这个出口;探针什么都没碰到且 matched 仍为 1 ⇒ 也接受。扫完全部已放置块都没接受 ⇒ xor al,al0x6012B1)返回 0。★ 结论:出口可以开向空地,但不许被一个没有对应出口的邻居堵住
sub_603D20(template, 候选实例, 已放置表) = 落子同样的探针几何。对每个距离 < 2×TILEBASIS 的邻居出口,把邻居的出口下标压进邻居的 usedExits、把候选的出口下标压进候选的 usedExits。⚠ 这里只按距离配对,不再查类型 —— 类型是 sub_600ED0 查过的。两个函数必须成对读,单看 sub_603D20 会得出“类型无所谓”的错结论

WARNING

墓碑:ALLFUNCS 里这三个函数曾被注记成「路径可达性判定 / 沿路径求点 / 路径点推进」—— 一组路径几何函数。它们其实是碰撞 / 出口有效性 / 连接落子,跟寻路毫无关系。这条错注记不会让任何门变红(它不是一个数,没有消费者),但它是“从哪里开始读”的入口:按路径几何去理解,整个生成器会被读成寻路算法,而“生成了一张图”这个结论照样对得上。13 个函数已在 IDB 里改名并写注记,ALLFUNCS 的 6 条旧注记存进 note_superseded

sub_600ED0 的极性只能从汇编拿:matched = 1 的初始化在 0x6010AE,Hex-Rays 的伪代码在那处自相矛盾。同理 WeightedRandom__addEntry接收者在伪代码里全被丢掉(19 个调用点共用一个 v128),全部靠扫 ecx 装载恢复。

2.5 生成器 B:格子生长 + 对偶格贴瓦(sub_60A9A0

相位名称内容
0守卫与播种同样先读 +452rand_int_between_syncedSeed(0, 预设数−1)0x60AA36,读 +324)挑预设,把预设里的格坐标经 sub_603040 塞进 +2056 那个 vector<Vector3>(格子集合)
1长格子n = rand_int_between_syncedSeed(MINCHUNKS, MAXCHUNKS)0x60AD55);同样 1000 次失败上限。每轮:rand_int(0, 格数−1)0x60ADAD,读 +2060)挑一个已有格子rand_int_between_syncedSeed(0, 7)0x60ADE9)挑一个八邻域方向 —— 0:+Z / 1:−Z / 2:+X / 3:−X / 4:(+X,+Z) / 5:(−X,+Z) / 6:(+X,−Z) / 7:(−X,−Z),步长 = CHUNKWIDTHBASIS·TILEBASIS(X)与 CHUNKHEIGHTBASIS·TILEBASIS(Z);if (!sub_600AE0(this, pos))(该格尚不存在)后再做四个角的 ±0.2 探测(sub_6015B0),全部不撞才 sub_603040 收下这个格子
2贴瓦遍历 +2056 的每个格子,依次调 sub_604270 / sub_604510 / sub_6047C0 / sub_604A70 —— 四个 662..686 字节、调用列表完全相同的函数(同一段代码的四方向副本)。每个内部做四次 sub_600AE0 邻居探测sub_600280(a1,a2,a3,a4) → 模式号 → WeightedRandom__pick(template + 1036 + 68·模式号)sub_603150 建实例 → sub_601310 / sub_600ED0 校验 → sub_603D20 落子

2.6 13 个模式池与两侧索引器

生成器 B 的核心是 CLevelTemplateData + 1036 处的 13 个 WeightedRandom(步长 68,末端 1920)。它由两个独立的索引器共用同一套编号:

函数按什么算模式号接收者指令
装载侧sub_600490(template, chunkType, typeIndex, weight)该类型四个方向上的出口类型 → 把类型登记进 pool[模式号]lea ecx,[eax+edx*4+40Ch] @0x60073F
使用侧sub_604270 / 604510 / 6047C0 / 604A70sub_600280四次 sub_600AE0 探测邻居格是否存在 → 模式号 → 从 pool[模式号] picklea ecx,[esi+ecx*4+40Ch] @0x604403

两处 disp32 都是 0x40C,逐字节可查 —— 两侧必须用同一套编号才能配上。

内部自洽1036 + 68×13 = 1920,而 +1920另一条线上早就认出来的字段(挑方向用的临时池)。基址、步长、条数分别从两处 leasub_600280 的返回值域独立得到,乘积正好落在下一个已知字段的头上。

占用真值表sub_600280 的四个 char 形参 → 模式号;出口类型四元组按引擎坐标轴命名为 N=+Z / W=−X / S=−Z / E=+X):

邻居占用 (a1,a2,a3,a4)模式号出口类型 (N,W,S,E)
(0,0,0,0)13 —— 无瓦
(1,0,0,0)11(2, −1, −1, 2)
(0,1,0,0)12(1, 2, −1, −1)
(1,1,0,0)4(0, 2, −1, 2)
(0,0,1,0)9(−1, −1, 2, 1)
(1,0,1,0)3(2, −1, 2, 0)
(0,1,1,0)13 —— 无瓦(对角)
(1,1,1,0)7(0, 2, 2, 0)
(0,0,0,1)10(−1, 1, 1, −1)
(1,0,0,1)13 —— 无瓦(对角)
(0,1,0,1)1(1, 0, 1, −1)
(1,1,0,1)8(0, 0, 1, 2)
(0,0,1,1)2(−1, 1, 0, 1)
(1,0,1,1)5(2, 1, 0, 0)
(0,1,1,1)6(1, 0, 0, 1)
(1,1,1,1)0(0, 0, 0, 0)

16 种占用里 3 种落到 13(= 没有对应瓦片):全空,以及两个对角 (0,1,1,0) 与 (1,0,0,1)。这是一张标准的 marching-squares / 对偶格瓦片表:对角棋盘格在对偶格上本来就有歧义,引擎直接不给瓦片。16 − 1 − 2 = 13

CAUTION

三处口径要分开读:

  • 四个贴瓦函数各自负责格子的哪个角、四次邻居探测的偏移分别指向哪个邻居格,没有逐字节钉死;只确认了四者结构相同、共用同一个池数组基址。所以上表的 (a1,a2,a3,a4) 是 sub_600280 形参的顺序,不要读成 N/W/S/E。
  • “type 0 = 通、type 2 = 墙、type 1 与 2 是墙的两种手性”是从占用真值表 + 命名(OPEN / WALL / CONCAVE的,代码里没有任何一处给这三个值起过名字。3/4/5 的含义更是完全未知 —— 只知道判定树不认它们。
  • 四元组的 N/W/S/E 轴向标签是分析侧按引擎坐标轴起的名,与语料命名对照时 E/W 是反的(见 §2.7)。轴向标签是命名问题,不影响表的结构。

2.7 语料侧对照:纯坐标,不看名字

第二 oracle 不看名字:直接拿每个 CHUNKTYPE 的出口坐标,跑一遍从 x87 汇编里解出来的方向分类公式(dz = exit.z − TILEBASIS·HEIGHT·CHUNKHEIGHTBASIS·0.5,再做四向八分测试),算出四方向出口类型模式,然后对白名单。

测量项数值
CHUNKTYPE 条目总数1,696
既非入口也非出口的普通条目1,094
出口落在对角线上的条目0
任一方向出口 TYPE ≥ 3 的条目38
语料中不同的几何模式数49
白名单 13 个模式号在语料中找到几何产出者13 / 13
被白名单覆盖的普通条目204(= 18.6%)

13/13,但白名单不是“等于语料”:语料一共 49 种几何模式,白名单只占 13 种;落选里最常见的几个(两出口走廊 N0/S0、N0/E0 之类)各出现 57–68 次。⇒ 这把尺子有判别力,不是“什么都命中”。

覆盖率 18.6% 不是逆错了:剩下 81% 进不了 13 个模式池 —— 但它们照样进 +356 主池,生成器 A 用得上两个生成器吃的语料本来就不是同一批;把这条读成“覆盖率低 = 逆错了”就正好反了。

出口 TYPE 的全语料分布

TYPE012345
出现次数(全部模板)331197187143226

sub_600490 的判定树只认 −1/0/1/2:任何一个方向上出现 3/4/5,整个 CHUNKTYPE落到 return、一个模式池都进不去,实测 38 个条目因此永远不可能被生成器 B 选中。这不是 bug 也不是死数据:那 38 个是给生成器 A用的,A 只要求“两侧类型相等”,不关心类型的具体值。这一条是门先红了才发现的:第一版写了“出口类型只有 0/1/2”并配了断言,断言当场红 —— 第一次统计把 [CHUNK_RANDOM]<STRING>TYPE[EXIT]<INTEGER>TYPE 混在了一起。

名字后缀复现(附带的第三条):254 个 CHUNKTYPE 的名字里写了方向数字(1X1_OPEN_N0S0E0W0 / 1X1_WALL_N2S2W0 / 1X1_CONCAVE_N1E2),分类公式把 254/254 个后缀逐字复现 —— 连出口类型的数字都对上。阳性对照:把 N/S 绑定翻过来,复现数从 254 掉到 88(剩下的是 N/S 对称的那些)⇒ “N = +Z”是被证过的,不是凑的。

WARNING

墓碑:「TL2 命名里 E = −X」是错的。 第一版对照是拿 CHUNKTYPE 的名字后缀比的,得到“把 W 与 E 互换后 13/13”,据此写下了这句话。按坐标逐条核完:163 个条目 E = −X、91 个 E = +X —— 语料内部两种约定并存,不存在统一的 E/W 约定。两次都得 13/13、门两次都绿,而中间那句理由错了。现在的对照只用坐标,命名彻底绕开。若拿那句话去指导做图(比如按名字推方向),会直接错。

2.8 字节锚点与浮点常量

十条字节锚点 + 七个浮点常量全部由门去发行 exe 里现读,不抄转录。

锚点VA字节含义
模式池基址(装载侧)0x60073F8D 8C 90 0C 04 00 00lea ecx,[eax+edx*4+40Ch]sub_600490 按出口类型登记
模式池基址(生成器 B)0x6044038D 8C 8E 0C 04 00 00lea ecx,[esi+ecx*4+40Ch],同一基址、使用侧
占用 → 模式号 序言0x60028080 7C 24 04 00 74 1D 8A 44 24 08 8A 4C 24 10 8Asub_600280 四个 char 形参;80 7C 24 04 00 = cmp byte [esp+4],0
ENTRANCE_CHUNK → 种子池0x607A8180 7D 0A 00 0F 85 98 04 00 00 80 7D 08 00 C6 45 0A 01 74 13 6A 01 53 8D 8E A8 01 00 00已分类哨兵 → ENTRANCE_CHUNKlea ecx,[esi+1A8h](+424)
生成器 B 八邻域0x60ADE16A 07 6A 00 89 44 24 68 E8 72 C1push 7 / push 0 / call rand_int_between_syncedSeed
生成器 B 块数0x60AD478B 96 DC 00 00 00 8B 86 D8 00 00 00 52 50 E8mov edx,[esi+0DCh] MAXCHUNKS、mov eax,[esi+0D8h] MINCHUNKS → rand_int(MIN,MAX)
生成器 A 失败上限0x608B0081 FF E8 03 00 00 0F 8Dcmp edi,3E8h = 1000
生成器 A 开放度预算0x608F5C8B 40 64 83 F8 02 0F 86 88 00 00 00 8B 54 24 68 8D 44 02 FE 3B 44 24 2C 89 44 24 68出口数 cmp eax,2 / jbe 直收;否则 budget += n−2 再比;末尾 89 44 24 68 无条件写回
生成器 A 轴向抛硬币0x608D78D9 05 58 14 14 02 D9 5C 24 04 D9 1C 24 E8 E6 E0 06 00 D8 1D 68rand_float_between_syncedSeed(0,100) < 50
出口配对距离0x601258FF 15 24 78 11 02 D9 44 24 24 DC C0 DE D9 DF E0 F6 C4 41 74|Δ| < 2 * TILEBASISDC C0 = fadd st,st 把 TILEBASIS 翻倍
常量符号VA用途
flt_213C5380x213C5380.5半个 chunk(出口格坐标换算、AABB 半展)
flt_21414580x2141458100.0生成器 A 轴向抛硬币的上界
flt_2143D680x2143D6850.0同上的阈值 —— 先 X 还是先 Z 各一半
flt_213C4740x213C4741.5sub_600ED0 出口探针外推 1.5×TILEBASIS
flt_2140C6C0x2140C6C0.10000000149011612扫邻居时 AABB 半展要减掉的 0.1(float,不是双精度)
flt_21414380x21414381000.0sub_601310 的 AABB +Y 上界
flt_214475C0x214475C−1000.0同上 −Y 下界 ⇒ 碰撞实为二维 XZ

2.9 这对做地图意味着什么

  • 先搞清你的模板走哪个生成器。 GENERATION_TYPE 0(缺省)是出口配对:块靠 [EXIT] 的位置与 TYPE 互相接;GENERATION_TYPE 1 是格子贴瓦:块靠四方向出口类型模式入池,池号由邻居占用决定。同一批 CHUNKTYPE 在两个生成器里的可用性完全不同。
  • 生成器 A 只看“类型相等 + 偏移镜像相反”,不关心 TYPE 值本身是什么。所以 TYPE 3/4/5 在 A 里完全合法;但放进 GENERATION_TYPE 1 的模板,带这些值的 CHUNKTYPE 一个模式池都进不去,也不会报错。
  • 生成器 B 的三种“无瓦”占用是引擎设计:全空和两个对角棋盘格不给瓦片。13 个模式号各对应一个池,原版语料每个模式号都有几何上产出它的 CHUNKTYPE;某个池为空时引擎怎么处理,材料没有记录。
  • 不要放 45° 的出口。 1,094 个普通条目里一个出口都没落在对角线上,那种情形引擎会静默把该出口丢掉(既不进任何方向池,也不参与模式)—— 这条死路原版没走过,但它存在
  • 单出口类型在“还没放过东西”时会被生成器 A 的主循环跳过(相位 3);REQUIRESEXIT 缺省 true,悬空出口会从对应方向的 EXIT_CHUNK 方向池挑块封口 —— 封不上时怎么办没跟到底
  • MAX_APPEARANCE 缺省 10,达到就从变体池摘掉;WIDTH / HEIGHT 缺省 1。
  • 出口“可以开向空地,但不许被一个没有对应出口的邻居堵住”sub_600ED0)。候选块的某个出口若正对着一个已放置块,而那块在对面没有类型相同的出口,这个候选就会被拒。
  • 不要指望复现原版的随机图。 本文守的是算法结构与常量;RNG 调用次序与浮点精确相等两件事都没对齐(§7)。

3. 传送门:Warper 的键、字段与缺省值

3.1 descriptor 注册与字段偏移

Warper descriptor(code 41)注册 7 个属性,code 97..103,注册点 CWarperDescriptor::CWarperDescriptor @0x487210。运行时对象 CWarper 分配 0x150 字节(CWarper::CWarper @0x615620)。

CWarper 字段偏移缺省值备注
LEVEL DELTA+0x1001★★★ 不是 0;语料 87% 的门没写
LEVEL ABSOLUTE+0x104非 0 时优先
ENABLED+0x108
WAYPOINT+0x109全语料只有 3 个为 true(§5)
WARP OUT OF DUNGEON+0x10A不是独立机制(§3.3)
DUNGEON NAME+0x10C大小写不敏感
WARP NAME+0x128非空时 delta 不参与

NOTE

表中“—”表示源材料只登记了字段偏移与“7 个逐个相同”的对账结论,没有单独把该项的缺省值写进账本。缺省值全表在 原版游戏分析/reversed_cpp/REGISTRY/warper_semantics.json

3.2 缺省值:单个 oracle 不够

LEVEL DELTA = 1 反直觉(“传送门默认往下走一层”),而它管着 6,686 / 7,716(87%) 的门 —— 写成 0 不崩不报错,只是绝大多数门原地不动。所以缺省值取了两个互相独立的 oracle,必须一致才采用:

  1. 游戏 exe 的 CWarper::CWarper @0x615620 逐条反汇编:分配 0x150xor ebx,ebx @0x61563A 先证 ebx = 0,再逐条读 store;
  2. EditorGuts 的 descriptor 克隆表快照(descriptor_defaults.jsonlWarper 行)。

两份是不同二进制、不同取法,7 个逐个相同。导出工具 load_defaults() 每次跑都重新对账,对不上就 SystemExit;交集为空时也 abort —— 否则“一致”会是因为根本没比过任何东西。

3.3 目标层深的三条分支

performWarp 里目标层深由三条分支决定(ebp = 0 已用支配性证明,见下):

if   LEVEL ABSOLUTE != 0   ->  目标层深 = LEVEL ABSOLUTE            [0x43DD8E]
elif WARP NAME     != ""   ->  目标层深 = 0   (按名字找落点,delta 不参与)   [0x43DDA7]
else                       ->  目标层深 += LEVEL DELTA               [0x43DDAD]

WARP OUT OF DUNGEON 不是第四条机制:引擎把 delta 改写成 -1 - 当前层深、absolute 清 0,再走同一条链。导出侧照此标注,不另开类别。

中间那条分支是漏点,而语料回归测不出它WARP NAME 非空时 delta 不参与 —— 语料里 absolute(1 个)与 warp-name(9 个)不重叠,所以把两者的判断顺序写反,跑遍全语料也不会有任何差异。那一格必须用合成输入absolute=8warp_name 非空),变异检验已证顺序一反它就红。那 9 个 by-warp-name 的门正好全是回城门Return To Town / Wellspring Temple / BroodHiveExit),漏掉只会把回城送到错层深,不会崩。

ebp = 0 是证出来的不是看出来的:枚举 performWarp 内 23 处写 ebp,最后一次在 0x43DBEB;再枚举 235 条跳转边,“源 < 0x43DBEB 且目标落在 (0x43DBEB, 0x43DD8A]”的为 0 条;同一把尺子对已知汇合点 0x43DBDF 报出 5 条入边。不证它,cmp eax,ebp 只能说明“和某个东西比”。

3.4 全语料普查

数值
普查的 .LAYOUT2,740
Warper / Teleport7,716 / 74
层深分支:delta / out-of-dungeon / by-warp-name / absolute7,688 / 18 / 9 / 1
目标反查:resolved5,184
目标反查:无 DUNGEON NAME2,284
目标反查:歧义237(全是 Catacombs 两个 DAT)
目标反查:未知(指向不存在的 dungeon)6(§5)
目标反查:LASTDUNGEON 魔法名5

普查口径里的一个坑 —— Group CHOICECHOICE=Weight 的裁剪在导出侧只作用在 renderable 列表上,而 Warper 不 renderable —— 直接遍历全部节点会把三选一的变体当成三个真门(1X1_CLIFF_CONCAVE_S1E1_BB_A 就是 GenericCaves / TheBurialChambers / NGBearCave)。为此把被裁掉的根提到模块级 _CHOICE_DROPPED_ROOTS(键是布局文件的 normcase 绝对路径,存的是装配后带前缀的 ID —— CHOICE 块跑在 Layout Link 内联之后)。

3.5 两块墓碑与两把作废的尺子

曾经的说法实际判别依据
sub_6153D0CWarper::onActivate不在任何虚表里全镜像对齐槽扫描 0 命中;同一把尺子对已知虚函数 CUnit__onDeath_vt120 报出 2 个槽(阳性对照通过)
performWarp 开头硬编的 ESTHERIANCITY 是“未知 dungeon 回落主城”它是试玩版 upsell 闸门;按名字查返回空时是直接跳过这段,不是回落控制流

作废的尺子:① “字段偏移 disp32 裸字节共现”——00 01 00 00 会命中任何立即数 256,584 个函数,里面确实有 performWarp,但和 583 个陪跑分不开;② “拿非虚函数当阳性对照”——它返回 0 是预期的,证明不了尺子能用,换成已知虚函数才立得住。

3.6 这对做门意味着什么

  • 不写 LEVEL DELTA 就是往下一层。 想让门原地不动(同一层深落地),得显式写 LEVEL DELTA:0。原版 87% 的门靠这条缺省工作。
  • 写了 WARP NAME 就别指望 LEVEL DELTA 起作用:层深钉成 0,按名字找落点。原版用它的 9 个门全是回城门。
  • LEVEL ABSOLUTE 非 0 时压过一切。 原版只有 1 个门这么写。
  • WARP OUT OF DUNGEON 不是独立的第四种门,它是 delta 的一种改写;不要把它当成“回主城”的开关来理解。
  • DUNGEON NAME 大小写不敏感(引擎两侧都过 toUpperW)。
  • 一个 CHOICE=Weight 的 Group 里放三个 Warper,游戏里只会出现一个;普查工具若不过 CHOICE 裁剪会数成三个。

4. 传送门 → 目标场景的五跳解析链

4.1 五跳

场景里的 Warper 节点
  -> DUNGEON NAME            (大小写不敏感,引擎两侧都过 toUpperW)
  -> MEDIA/DUNGEONS/*.DAT
  -> STRATA.RULESET          -> LAYOUTS/<ZONE>/<TEMPLATE>      (逐 STRATUM 解析,顶层取 stratum 0)
  -> [LAYOUT] 预设的 chunk 列表
  -> 单块 ⇒ LAYOUTS/<ZONE>/<TYPE>_x/ 下的变体 LAYOUT

每一跳都能悄悄错,所以这一步单独成工具(tools/f2_warp_manifest.py),结果写成 manifest(torchlight-2-poc/warp_manifest.json)。

第五跳不许伪造:预设不止一块的关卡,目标是“要拼装的”,不是“随便挑一张转好的图”。那种情况记 needs-assembly 并把块数写出来,不给一个能跑但是错的答案。变体挑哪一个是引擎的一次掷骰:manifest 把候选全列出来、自己不挑(工具里零 RNG / 零时钟)。

4.2 act1_town ⇄ EchoPass:一对原版真实的门

  • 去程act1_town --[EchoPass]--> ECHOPASS.DAT --[act1_pass1/rules.template:3 预设 × 1 块 1X1SINGLE_ROOM]--> 1X1SINGLE_ROOM_A/ 四个变体 = echopass_lm / jd / jt / pb
  • 回程echopass_jt --[EstherianCity]--> act1_town

⇒ EchoPass 是单块关卡LAYOUTS/ACT1_PASS1/RULES.TEMPLATE 三个预设每个只有一块,一个变体就是一整张图,不需要铺瓦。这不是“拿一张图冒充”。

4.3 已转换场景里 17 道门的解析结果

状态数量含义
scene5目标是单块关卡且变体已转换
needs-assembly4目标预设不止一块,需要拼装
not-converted6目标解析到了变体目录,但那张图没转
no-presets2目标模板没有 [LAYOUT] 预设(走生成器,§1)

4.4 三个“语义通顺、能跑、但是错”的 bug

#错法症状修法
空序列上的 all() 恒真0 个预设的模板被判成“单块”,再因为类型列表为空被贴上 multi-chunk-type —— 两个 bug 叠出一个通顺的错答案;multi-chunk-typeno-chunk-type 是两件事分开两个标签
拿后面的 stratum 冒充第一层IceDeepCaverns 的 stratum 0 没预设、stratum 1 是 boss 房;把 strata 揉在一起判,boss 房就成了“这张图”逐 stratum 解析,顶层取 stratum 0
变体目录后缀写死 _AACT1_PASS2 / PASS3 的目录叫 _B,全部报“找不到”,而“找不到”与“那些图不存在”同形改成转发 m4_seam_check.variants_of:精确同名优先 ⇒ <TYPE>_x ⇒ 去 _LANDMARK(472 处两者都命中、顺序反了是摆错图)

变异检验里有一条“没红”,错的是变异不是门:写的变异是“取第一个 status=='scene' 的 stratum”,而 IceDeepCaverns 的 stratum 1 是 not-converted 不是 scene,那个 next() 从没触发。换成 bug 的真实形态(跳过 no-presets 的 stratum)之后立刻红了 2 格。

4.5 未核:目标层深 → 第几个 stratum

_stratum_for_depth_is_undetermined:目标层深 → 第几个 stratum 的映射没有在引擎里核过FLOORS(全库 263 处,ICEDEEP 的 spawnclass 就叫 ..._STRATA0/1)是最可能的键,但那是推测,已写进 manifest 每一条 target 里。

4.6 这对做门意味着什么

  • 一个 DUNGEON NAME 落到哪张图,中间隔着 DUNGEON DAT 的 STRATA.RULESET 和模板的 [LAYOUT] 预设;多层 dungeon 要按 stratum 逐个看,不能把所有层揉在一起。
  • 变体目录不一定叫 _AACT1_PASS2 / PASS3 就是 _B。写工具的人按 _A 硬编会把整批图报成“不存在”。
  • 目标层深和 stratum 编号之间的对应目前没有引擎级证据FLOORS 只是最可能的键。

5. 六个指向不存在 dungeon 的 Warper 是死内容

5.1 判据

6 个门的 DUNGEON NAMEDUNGEONS/ 里查不到:TOWN ×3 / MAIN / HatchCrypt / A1-WarbeastLair。它们是死内容,不是数据缺陷,判据是:

  • 它们所在的 prop 布局在全语料 25,255 个 .LAYOUT/.DAT/.TEMPLATE0 处引用
  • 两条独立查法:文件全名带扩展名、以及 WaygatePortal 单位 GUID 7663373534943777246,两条都是 0;
  • 阳性对照:同一次扫描里 PORTALTOZERYPHESH.LAYOUT 命中 1 处、SHARED_STASH 命中 5 处 —— 否则“0”与“扫描器坏了”同形。

假阳性差点让结论反过来:第一版按裸子串 WAYGATEPORTAL 搜,命中了 UNITS/PROPS/ENTRANCEANDEXITS/WAYGATE.DAT 里的 <STRING>NAME:WaygatePortal —— 那是它自己的名字,不是对布局文件的引用(那个 DAT 根本没有 FILE 键),当时已经写下“WAYGATEPORTAL 是活的”。改成“文件全名带扩展名 + 单位 GUID”两条查法之后,两条都是 0。⇒ 子串匹配一个恰好等于某个名字的词,必然有假阳性

5.2 顺带查出:WAYPOINT=true 只有 3 个,全是死的

全语料 7,716 个 Warper 里只有 3 个 WAYPOINT=true,而那 3 个正是这里 3 个死掉的 WAYGATEPORTAL ⇒ C-0155 记的“踩到 WAYPOINT 门就用 sub_5C4E90 注册路径点”这条引擎路径,在发行数据里一次都没有被走到

5.3 没追的部分

  • “不走这条路”不等于“追清楚了它走哪条”。 传送阵在游戏里是真功能;LAYOUTS/WAYGATE.LAYOUT 只有 2 个 Sound + 1 个 Layout Link Particle(纯特效)、没有 Warper。真正的传送由引擎/UI 驱动 —— 那条链没有追,不写成结论。
  • C-0155 那条未决仍然未决:“引擎按名字查返回空之后怎么走”没有追。本条只证明这 6 个永远走不到那一步,没证明那条分支不存在。

5.4 这对做门意味着什么

  • 这 6 个门可以当它们不存在;manifest 把它们标成 unknown-dungeon 是对的。
  • WAYPOINT:true 在原版发行数据里从未生效过一次。把传送阵做成 Warper 不是原版的做法 —— 原版传送阵的真实触发链未追,别照着这 3 个死门去仿。
  • 自己写的门若 DUNGEON NAME 查不到,引擎“按名字查返回空之后”的行为没有逆过;不要假设它会回落到主城(那个 ESTHERIANCITY 是试玩版闸门,§3.5)。

6. 给 MOD 作者的工程参考

6.1 无效配置与陷阱清单

配置书写引擎真实处理章节
RULES.TEMPLATERANDOMIZED:false0x6053A2 无条件覆盖成 1,任何值都无效§1.4
RULES.TEMPLATE 不写 MINCHUNKS / MAXCHUNKS缺省 2 / 3 ⇒ 一定跑生成器§1.1
GENERATION_TYPE 写 0/1 之外的值静默不生成;若又无预设则落空池§1.1
GENERATION_TYPE 1 的模板里 [EXIT] TYPE 写 3/4/5CHUNKTYPE 一个模式池都进不去,不报错§2.7
GENERATION_TYPE 1 的瓦片集缺某个模式号空池时的行为材料未记;原版每个模式号都有产出者§2.6
出口位置落在块的对角线上引擎静默丢掉该出口§2.7
单出口 CHUNKTYPE 在主循环“还没放过东西”时生成器 A 跳过§2.3
模板里没有 ENTRANCE_CHUNK:true 的类型+452 == 0 ⇒ 两个生成器都直接 return 0§2.1
Warper 不写 LEVEL DELTA缺省 1 —— 往下一层§3.2
Warper 同时写 WARP NAMELEVEL DELTA层深钉成 0,delta 不参与§3.3
Warper 同时写 LEVEL ABSOLUTE(非 0)与其他absolute 压过一切§3.3
WarperWAYPOINT:true引擎路径存在,但发行数据里一次没走到§5.2
WarperDUNGEON NAME 查不到之后怎么走未逆;不要假设回落§5.3

6.2 做地图前的检查

  1. 决定这张图是纯预设(两个 chunk 键写 0)、纯生成(无预设)、还是两者兼有;三种在原版分别有 44 / 80 / 62 个先例。
  2. 决定 GENERATION_TYPE:0 = 出口配对(看 [EXIT] 位置与 TYPE 是否能镜像配上),1 = 格子贴瓦(看四方向出口类型模式能不能落进 13 个池)。
  3. 至少一个 ENTRANCE_CHUNK:true 的类型,否则不生成。
  4. REQUIRESEXIT 缺省 true:悬空出口会从对应方向的 EXIT_CHUNK 方向池挑块封口;封不上时的行为没跟到底。
  5. MAX_APPEARANCE 缺省 10;一个类型的变体用满就从池里摘掉。
  6. 别指望同一个种子能复现原版的图(§7)。

6.3 做门前的检查

  1. 想同层落地写 LEVEL DELTA:0;不写就是往下一层。
  2. 回城类的门用 WARP NAME;此时 delta 无效。
  3. DUNGEON NAME 必须在 DUNGEONS/ 里能查到;大小写随意。
  4. 目标 dungeon 若是多层,去 STRATA.RULESET 逐层看对应的模板;层深 → stratum 的映射目前只有 FLOORS 这个推测。
  5. 不要用 WAYPOINT:true 仿传送阵。

7. 未解与尚未完全闭环事项清单

待解细节项当前分析现状
生成器的产出复现没把两个生成器移植出来跑过,也没拿同一个种子对过原版的图。还差:每一次 RNG 调用的次序逐次对齐(生成器 A 一次尝试里 1..3 次调用,顺序随分支变;流是 synced);出口配对的浮点精确相等-a == b)在 x87 → SSE2 上可能配不上,症状是“偶尔产出更小的图”而非崩溃,没有实测
四个贴瓦函数各对应格子的哪个角、四次邻居探测的偏移指向哪个邻居没有逐字节钉死;只确认四者结构相同、共用同一个池数组基址
出口类型 0/1/2 的含义“通 / 墙 / 墙的两种手性”是从占用真值表 + 命名推的,代码里没有名字;3/4/5 完全未知
生成器 A 的 REQUIRESEXIT 阶段封不上悬空出口时怎么办没跟到底
生成器 B 里「Failed to place MUSTPLACE:」在哪一相位起作用没逆;MUST_PLACE 只在生成器 A 的校验相位确认过
sub_6087A0 的函数大小ALLFUNCS 记 9,278 B,IDA get_func 给 8,689 B(差 589),多半是尾块,没查;证据里记 8,689,没有断言依赖这个数
目标层深 → 第几个 stratum没有在引擎里核过;FLOORS(全库 263 处)是最可能的键,但那是推测
WarperDUNGEON NAME 按名字查返回空之后 performWarp 怎么走没有追;只证明了那 6 个门永远走不到那一步
传送阵(Waygate)的真实触发链LAYOUTS/WAYGATE.LAYOUT 只有 2 Sound + 1 Layout Link Particle、没有 Warper;由引擎/UI 驱动,没有追

附录 A:核心符号与函数虚地址表(Torchlight2.exe, ImageBase 0x400000

功能虚地址
生成判定表(61 字节)sub_60C760
CLevelTemplateData 构造(缺省值)sub_60BBE0
RANDOMIZED 读入 / 无条件覆写0x605388 / 0x6053A2
生成器 A(出口配对式,GENERATION_TYPE 0sub_6087A0(预设播种 0x608849;目标数 0x608AD6;失败上限 0x608B00;主池挑类型 0x608B14;起扫位置 0x608C33;轴向抛硬币 0x608D78;开放度预算 0x608F5C
生成器 B(格子生长 + 贴瓦,GENERATION_TYPE 1sub_60A9A0(预设 0x60AA36;块数 0x60AD47 / 0x60AD55;挑格 0x60ADAD;八邻域 0x60ADE1 / 0x60ADE9
四个贴瓦函数sub_604270 / sub_604510 / sub_6047C0 / sub_604A70
模式号索引器:装载侧 / 使用侧sub_6004900x60073F)/ sub_6002800x604403
邻居格探测 / 角探测 / 收格子sub_600AE0 / sub_6015B0 / sub_603040
放置谓词:碰撞 / 出口有效性 / 落子sub_601310 / sub_600ED0matched=1 @0x6010AE;返回 0 @0x6012B1;配对距离 0x601258)/ sub_603D20
CChunkType ctor / CChunk 建于 / CChunkInstance ctor / CLevelLayout 建于、dtorsub_6023B0 / 0x607A12sub_604D20 内)/ sub_603150 / sub_604D20sub_602590
ENTRANCE_CHUNK → 种子池装载0x607A81
权重重置 / 变体摘除sub_600760 / sub_675850
vftable:CChunkType / CChunk / CChunkInstance / CLevelLayout0x2179B70 / 0x2179B30 / 0x2179BA0 / 0x2179B7C
CWarperDescriptor::CWarperDescriptor / CWarper::CWarper0x487210 / 0x615620xor ebx,ebx @0x61563A
performWarp 层深三分支0x43DD8E / 0x43DDA7 / 0x43DDADebp 最后写入 0x43DBEB;已知汇合点 0x43DBDF
WAYPOINT 路径点注册(发行数据未走到)sub_5C4E90
被误标的函数sub_6153D0(不在任何虚表;不是 CWarper::onActivate
浮点常量 0.5 / 100.0 / 50.0 / 1.5 / 0.1f / 1000.0 / −1000.0flt_213C538 / flt_2141458 / flt_2143D68 / flt_213C474 / flt_2140C6C / flt_2141438 / flt_214475C

附录 B:支撑源材料与真值账本映射

  • 真值账本 truth/ledger.jsonl
    • C-0165(关卡布局来源:sub_60C760 判定表、ctor 缺省、RANDOMIZED 死键、语料分类);
    • C-0169(两个生成器的算法、13 个模式池、语料坐标法对照、181 → 186 分母订正、墓碑);
    • C-0155(Warper 键 / 字段 / 缺省值双 oracle、目标层深三分支、语料普查);
    • C-0157(门 → 目标场景五跳解析、EchoPass 往返、三个“通顺但错”的 bug);
    • C-0163(6 个指向不存在 dungeon 的 Warper 是死内容、WAYPOINT=true 只有 3 个)。
  • 数据结构与静态配置(原版游戏分析/reversed_cpp/REGISTRY/level_generation_algorithms.json(C-0169 全表:数据模型、相位、谓词、字节锚点、浮点常量、墓碑)、level_generation_semantics.json(C-0165)、warper_semantics.json(C-0155)、warper_dangling_names.json(C-0163)、spawn_rules.json(Populate 双轨制,另文)。
  • 工具链tools/f2_level_generation.py(模板分类,--census / --selftest)、tools/f2_warpers.py(全语料 Warper 导出与缺省值补齐,--census / --layout)、tools/f2_warp_manifest.py(五跳解析 manifest)、tools/pe_bytes.py(按 VA 读发行 exe 字节的门)。