NOTE
逆向方法与数据支撑:本文基于 IDA 对 32 位客户端 Torchlight2.exe(ImageBase 0x400000,文内所有 sub_XXXXXX 与 0xXXXXXX 均为绝对虚地址)执行逐指令反汇编与控制流分析。关键数字采用三类互相独立的 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 键的模板一定会跑生成器。两个生成器不是一个算法的两种参数,是两套完全不同的算法。传送门方面,Warper 的 LEVEL DELTA 缺省是 1 而不是 0,管着全语料 87% 的门;写成 0 不崩不报错,只是绝大多数门原地不动。另有 6 个指向不存在 dungeon 的门是死内容而非数据缺陷,3 个 WAYPOINT=true 的门恰好全在其中,意味着引擎那条“踩 WAYPOINT 门注册路径点”的路径在发行数据里一次都没被走到。
0. 核心结论速览
- 关卡布局有两个来源,判定表在
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 键就一定会跑生成器;关掉它的唯一写法是把MINCHUNKS或MAXCHUNKS写成 0。 RANDOMIZED是死键:0x605388 mov [esi+60h], al存回getBool("RANDOMIZED", …)的结果,紧接着0x6053A2 C6 46 60 01无条件写成 1。181 个模板全部显式写了这个键,全都无效。- 全语料分母是 186 不是 181:早期按文件名后缀
RULES.TEMPLATE过滤漏掉 5 个.TEMPLATE,其中 3 个真的被 DUNGEONS 引用(DRAGON_SPECTRAL / TESTESTHERIANPUZZLE / QA_TEST,各 2 处)。订正后:纯预设 44 / 纯生成 80 / 两者兼有 62 / 空池 0;预设式装配(接缝检查)只对 106 个模板成立,142 个模板的图里含生成产物。 - 两个生成器是两套算法:
sub_6087A0(GENERATION_TYPE 0,8,689 B)是出口配对式 —— 从ENTRANCE_CHUNK起,每次挑一个新块,在已放置块上找出口类型相等且格偏移镜像相反的空出口接上去;sub_60A9A0(GENERATION_TYPE 1,4,598 B)是格子 blob 生长 + 对偶格贴瓦 —— 先用rand_int_between_syncedSeed(0,7)的八邻域随机走长出一片格子,再逐格调四个近乎同构的贴瓦函数。两者只共用守卫、三个放置谓词和MIN/MAXCHUNKS,没有一个共同相位。 - 生成器 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。 - 两个生成器吃的语料本来就不是同一批: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 用的。 Warperdescriptor(code 41)7 个字段的缺省值由两个互相独立的 oracle 对账、逐个相同:游戏 exe 的CWarper::CWarper@0x615620逐条反汇编,与 EditorGuts 的 descriptor 克隆表快照。★★★LEVEL DELTA缺省是 1,语料 6,686 / 7,716(87%) 个门没写这个键。- 目标层深三条分支(
performWarp,ebp=0已用支配性证明):LEVEL ABSOLUTE != 0⇒ 取绝对值 [0x43DD8E];否则WARP NAME != ""⇒ 层深钉成 0、delta 不参与 [0x43DDA7];否则+= LEVEL DELTA[0x43DDAD]。WARP OUT OF DUNGEON不是第四条机制 —— 引擎把 delta 改写成-1 - 当前层深、absolute 清 0,再走同一条链。 - 门 → 目标场景是五跳解析:
DUNGEON NAME(大小写不敏感)→DUNGEONS/*.DAT→ 逐STRATUM的RULESET→[LAYOUT]预设 → 变体目录。act1_town ⇄ EchoPass这对原版真实的门两端都通,EchoPass 是单块关卡。目标层深 → 第几个 stratum 的映射没有在引擎里核过,FLOORS是最可能的键但那是推测。 - 6 个指向不存在 dungeon 的 Warper 是死内容(
TOWN ×3 / MAIN / HatchCrypt / A1-WarbeastLair):它们所在的 prop 布局在全语料 25,255 个文件里 0 处引用,两条独立查法(文件全名带扩展名、WaygatePortal单位 GUID7663373534943777246)都为 0,且配了阳性对照。7,716 个 Warper 里只有 3 个WAYPOINT=true,正是这里 3 个死掉的WAYGATEPORTAL。 - 三块墓碑:① 「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 回落主城”。 - 没有做产出复现:两个生成器的算法结构与常量已逆,但没把它们移植出来跑过,也没拿同一个种子对过原版的图。要到那一步还差 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 | +44 | 0 | xor ebx,ebx ⇒ 0 |
RANDOMIZEAFTERLOAD | +97(C-0165 的记法) | false | — |
REQUIRESEXIT | +97(C-0169 的记法,见 §2.1) | true | — |
MINCHUNKS | +216 | 2 | mov ebp,2 ⇒ 2 |
MAXCHUNKS | +220 | 3 | C7 86 DC…03 ⇒ 3 |
| 预设条数 | +324(vector 的 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 键,就一定满足第三个条件,一定会跑生成器。想让一张图只走预设装配,唯一写法是把 MINCHUNKS 或 MAXCHUNKS 写成 0 —— 语料里所有纯预设模板 44/44 都同时把两个键写成了 0,这是原版美术实际在用的开关。
1.2 全语料分类
把上面的判定原样搬到全语料上跑(tools/f2_level_generation.py),每个模板落到四类之一:
| 分类 | 含义 | 早期计数(分母 181) | 订正后(分母 186) |
|---|---|---|---|
| 纯预设 | 有 [LAYOUT] 预设,且 chunk 键写 0 关掉了生成器 | 43 | 44 |
| 纯生成 | 没有预设,布局全靠生成器 | 79 | 80 |
| 两者兼有 | 有预设,同时生成器也会跑 | 59 | 62 |
| 空池 | 没有预设、且 GENERATION_TYPE 落到分派表之外 | 0 | 0 |
分类器能产出 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 是死键
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] 块:
| 偏移 | 字段 | 备注 |
|---|---|---|
+8 | bool ENTRANCE_CHUNK | ctor 置 0 |
+9 | bool EXIT_CHUNK | ctor 置 0 |
+10 | bool “已分类”哨兵 | 装载时每个类型只分类一次,重复出现直接跳过 |
+11 | bool MUST_PLACE | ctor 置 0 |
+12 | int WIDTH | 装载缺省 1 |
+16 | int HEIGHT | 装载缺省 1 |
+20 | int MAX_APPEARANCE | 装载缺省 10 |
+24 | wstring NAME | |
+68 | wstring FOLDER | 缺尾部斜杠时补上 |
+96/+100/+104/+108 | vector<Ogre::Vector3> 出口位置 | data/count/cap/hint=10 |
+112/+116/+120/+124 | vector<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 里与生成相关的字段:
| 偏移 | 内容 |
|---|---|
+44 | GENERATION_TYPE |
+48/52/56 | vector<CChunkType*> 全部类型 |
+64/68/72 | vector<CChunk*> 全部变体 |
+80/84/88 | vector<CChunkType*> MUST_PLACE 名单 |
+96 | bool(INCLUSIVE_FILES 多于 1) |
+97 | bool REQUIRESEXIT(C-0169;⚠ C-0165 在同一偏移记的是 RANDOMIZEAFTERLOAD,见 §1.1) |
+216 / +220 | MINCHUNKS / MAXCHUNKS |
+240 | TILEBASIS(缺省 4.0) |
+244 / +248 | CHUNKWIDTHBASIS / CHUNKHEIGHTBASIS(25.0 / 25.0) |
+304/308/312 | 布局池 vector<CLevelLayout*> |
+320/324/328 | 预设 vector |
+336 | WeightedRandom* 布局池(指针,不是内嵌) |
+340/344/348 | vector<WeightedRandom*> —— 每个 chunk 类型一个“变体池” |
+356 | WeightedRandom(内嵌)主循环用的 chunk 类型池 —— 既非入口也非出口的普通类型全进这里 |
+424 | WeightedRandom(内嵌)种子池 —— 只装 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 是条目数(+336 的 pickRandomLayout 与 +452 守卫都读它)。
守卫的真实含义:两个生成器开头那个 if (!*(this+452)) return 0 不是独立开关 —— +452 就是种子池的 count,所以它的意思是“没有入口块就不生成”。字节锚点 0x607A81(29 字节)钉住了装载侧的路径:cmp [ebp+0Ah],0(已分类哨兵)→ cmp [ebp+8],0(ENTRANCE_CHUNK)→ lea ecx,[esi+1A8h](种子池 +424)。
2.2 两个生成器对照
| 维度 | 生成器 A:sub_6087A0 | 生成器 B:sub_60A9A0 |
|---|---|---|
GENERATION_TYPE | 0(缺省) | 1 |
| 函数大小 | 8,689 B(IDA get_func;ALLFUNCS 记 9,278 B,差 589,多半是尾块,没查) | 4,598 B |
| 形状 | 出口配对式:从入口块起,每次选一个新块,找一个已放置块上类型相同且方向相对的空出口接上去 | 格子 blob 生长 + 对偶格贴瓦:先随机长出一片格子,再逐格按四邻居占用贴瓦 |
| 目标块数 | rand_int_between_syncedSeed(MINCHUNKS, MAXCHUNKS) @0x608AD6 | rand_int_between_syncedSeed(MINCHUNKS, MAXCHUNKS) @0x60AD55 |
| 失败上限 | 放置循环 while (fails < 1000) @0x608B00;外层第二重 1000 上限 | 同样 1000 次 |
| 核心随机 | rand_int_between_syncedSeed(0, 已放置数−1) 决定从哪个块开始扫;rand_float(0,100) < 50 决定先试 X 还是 Z | rand_int_between_syncedSeed(0, 7) @0x60ADE9 挑八邻域方向 |
| 读哪些池 | 320/324/328/336/340/348/356/424/452/492/560/628/696/764/832/900/968/1920/1988 | 320/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)/TILEBASIS;rand_int_between_syncedSeed(0, 已放置数−1)(0x608C33)决定从哪个已放置块开始扫(环形回绕);对每个已放置块的每个出口:已占用则跳过、出口类型必须相等、偏移必须镜像相反;rand_float(0,100) < 50(0x608D78)决定先试 X 还是先试 Z;候选位置 = 已放置块位置 ± 一个 chunk;if (sub_601310(...) || !sub_600ED0(...)) 拒绝,否则 sub_603D20(...) 落子 |
| 4 | 开放度预算 | 0x608F5C:n = 新类型的出口数; if (n <= 2) 收下; else { budget += n−2; if (budget <= 目标数) 收下; else 拒绝 }。★★ 预算是无条件写回的(89 44 24 68),拒绝这一手时并不回滚 ⇒ 一次失败的尝试会永久吃掉预算。这不是推的语义,是写回指令落在两条分支的公共路径上。收下之后 ++budget。外层还有第二重 1000 上限(整体重试),失败则释放全部已放置块重来 |
| 5 | REQUIRESEXIT 收尾 | 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 字节 CLevelLayout,addEntry(池下标, 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 = 1(0x6010AE —— 伪代码读不出来,是从汇编拿的);探针 = E 的世界位置沿出口方向外推 1.5×TILEBASIS(flt_213C474,四个方向各用一次);以半展 CHUNKWIDTHBASIS·TILEBASIS − 0.1(flt_2140C6C)扫已放置块;探针落进某块 P 内 ⇒ matched = 0,然后在 P 上找一个类型相同、且 |F.world − 探针| < 2·TILEBASIS(0x601258)的出口 F,找到才接受这个出口;探针什么都没碰到且 matched 仍为 1 ⇒ 也接受。扫完全部已放置块都没接受 ⇒ xor al,al(0x6012B1)返回 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 | 守卫与播种 | 同样先读 +452;rand_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 / 604A70 → sub_600280 | 四次 sub_600AE0 探测邻居格是否存在 → 模式号 → 从 pool[模式号] pick | lea ecx,[esi+ecx*4+40Ch] @0x604403 |
两处 disp32 都是 0x40C,逐字节可查 —— 两侧必须用同一套编号才能配上。
内部自洽:1036 + 68×13 = 1920,而 +1920 是另一条线上早就认出来的字段(挑方向用的临时池)。基址、步长、条数分别从两处 lea 与 sub_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 的全语料分布:
TYPE 值 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| 出现次数(全部模板) | 331 | 197 | 187 | 14 | 32 | 26 |
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 | 字节 | 含义 |
|---|---|---|---|
| 模式池基址(装载侧) | 0x60073F | 8D 8C 90 0C 04 00 00 | lea ecx,[eax+edx*4+40Ch],sub_600490 按出口类型登记 |
| 模式池基址(生成器 B) | 0x604403 | 8D 8C 8E 0C 04 00 00 | lea ecx,[esi+ecx*4+40Ch],同一基址、使用侧 |
| 占用 → 模式号 序言 | 0x600280 | 80 7C 24 04 00 74 1D 8A 44 24 08 8A 4C 24 10 8A | sub_600280 四个 char 形参;80 7C 24 04 00 = cmp byte [esp+4],0 |
ENTRANCE_CHUNK → 种子池 | 0x607A81 | 80 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_CHUNK → lea ecx,[esi+1A8h](+424) |
| 生成器 B 八邻域 | 0x60ADE1 | 6A 07 6A 00 89 44 24 68 E8 72 C1 | push 7 / push 0 / call rand_int_between_syncedSeed |
| 生成器 B 块数 | 0x60AD47 | 8B 96 DC 00 00 00 8B 86 D8 00 00 00 52 50 E8 | mov edx,[esi+0DCh] MAXCHUNKS、mov eax,[esi+0D8h] MINCHUNKS → rand_int(MIN,MAX) |
| 生成器 A 失败上限 | 0x608B00 | 81 FF E8 03 00 00 0F 8D | cmp edi,3E8h = 1000 |
| 生成器 A 开放度预算 | 0x608F5C | 8B 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 轴向抛硬币 | 0x608D78 | D9 05 58 14 14 02 D9 5C 24 04 D9 1C 24 E8 E6 E0 06 00 D8 1D 68 | rand_float_between_syncedSeed(0,100) < 50 |
| 出口配对距离 | 0x601258 | FF 15 24 78 11 02 D9 44 24 24 DC C0 DE D9 DF E0 F6 C4 41 74 | |Δ| < 2 * TILEBASIS;DC C0 = fadd st,st 把 TILEBASIS 翻倍 |
| 常量符号 | VA | 值 | 用途 |
|---|---|---|---|
flt_213C538 | 0x213C538 | 0.5 | 半个 chunk(出口格坐标换算、AABB 半展) |
flt_2141458 | 0x2141458 | 100.0 | 生成器 A 轴向抛硬币的上界 |
flt_2143D68 | 0x2143D68 | 50.0 | 同上的阈值 —— 先 X 还是先 Z 各一半 |
flt_213C474 | 0x213C474 | 1.5 | sub_600ED0 出口探针外推 1.5×TILEBASIS |
flt_2140C6C | 0x2140C6C | 0.10000000149011612 | 扫邻居时 AABB 半展要减掉的 0.1(float,不是双精度) |
flt_2141438 | 0x2141438 | 1000.0 | sub_601310 的 AABB +Y 上界 |
flt_214475C | 0x214475C | −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 | +0x100 | 1 | ★★★ 不是 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,必须一致才采用:
- 游戏 exe 的
CWarper::CWarper@0x615620逐条反汇编:分配0x150,xor ebx,ebx@0x61563A先证ebx = 0,再逐条读 store; - EditorGuts 的 descriptor 克隆表快照(
descriptor_defaults.jsonl的Warper行)。
两份是不同二进制、不同取法,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=8 且 warp_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 全语料普查
| 项 | 数值 |
|---|---|
普查的 .LAYOUT | 2,740 |
Warper / Teleport | 7,716 / 74 |
| 层深分支:delta / out-of-dungeon / by-warp-name / absolute | 7,688 / 18 / 9 / 1 |
| 目标反查:resolved | 5,184 |
目标反查:无 DUNGEON NAME | 2,284 |
| 目标反查:歧义 | 237(全是 Catacombs 两个 DAT) |
| 目标反查:未知(指向不存在的 dungeon) | 6(§5) |
目标反查:LASTDUNGEON 魔法名 | 5 |
普查口径里的一个坑 —— Group CHOICE:CHOICE=Weight 的裁剪在导出侧只作用在 renderable 列表上,而 Warper 不 renderable —— 直接遍历全部节点会把三选一的变体当成三个真门(1X1_CLIFF_CONCAVE_S1E1_BB_A 就是 GenericCaves / TheBurialChambers / NGBearCave)。为此把被裁掉的根提到模块级 _CHOICE_DROPPED_ROOTS(键是布局文件的 normcase 绝对路径,存的是装配后带前缀的 ID —— CHOICE 块跑在 Layout Link 内联之后)。
3.5 两块墓碑与两把作废的尺子
| 曾经的说法 | 实际 | 判别依据 |
|---|---|---|
sub_6153D0 是 CWarper::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 道门的解析结果
| 状态 | 数量 | 含义 |
|---|---|---|
scene | 5 | 目标是单块关卡且变体已转换 |
needs-assembly | 4 | 目标预设不止一块,需要拼装 |
not-converted | 6 | 目标解析到了变体目录,但那张图没转 |
no-presets | 2 | 目标模板没有 [LAYOUT] 预设(走生成器,§1) |
4.4 三个“语义通顺、能跑、但是错”的 bug
| # | 错法 | 症状 | 修法 |
|---|---|---|---|
| ① | 空序列上的 all() 恒真 | 0 个预设的模板被判成“单块”,再因为类型列表为空被贴上 multi-chunk-type —— 两个 bug 叠出一个通顺的错答案;multi-chunk-type 与 no-chunk-type 是两件事 | 分开两个标签 |
| ② | 拿后面的 stratum 冒充第一层 | IceDeepCaverns 的 stratum 0 没预设、stratum 1 是 boss 房;把 strata 揉在一起判,boss 房就成了“这张图” | 逐 stratum 解析,顶层取 stratum 0 |
| ③ | 变体目录后缀写死 _A | ACT1_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 逐个看,不能把所有层揉在一起。 - 变体目录不一定叫
_A:ACT1_PASS2 / PASS3就是_B。写工具的人按_A硬编会把整批图报成“不存在”。 - 目标层深和 stratum 编号之间的对应目前没有引擎级证据,
FLOORS只是最可能的键。
5. 六个指向不存在 dungeon 的 Warper 是死内容
5.1 判据
6 个门的 DUNGEON NAME 在 DUNGEONS/ 里查不到:TOWN ×3 / MAIN / HatchCrypt / A1-WarbeastLair。它们是死内容,不是数据缺陷,判据是:
- 它们所在的 prop 布局在全语料 25,255 个
.LAYOUT/.DAT/.TEMPLATE里 0 处引用; - 两条独立查法:文件全名带扩展名、以及
WaygatePortal单位 GUID7663373534943777246,两条都是 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.TEMPLATE 写 RANDOMIZED:false | 被 0x6053A2 无条件覆盖成 1,任何值都无效 | §1.4 |
RULES.TEMPLATE 不写 MINCHUNKS / MAXCHUNKS | 缺省 2 / 3 ⇒ 一定跑生成器 | §1.1 |
GENERATION_TYPE 写 0/1 之外的值 | 静默不生成;若又无预设则落空池 | §1.1 |
GENERATION_TYPE 1 的模板里 [EXIT] TYPE 写 3/4/5 | 该 CHUNKTYPE 一个模式池都进不去,不报错 | §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 NAME 与 LEVEL DELTA | 层深钉成 0,delta 不参与 | §3.3 |
Warper 同时写 LEVEL ABSOLUTE(非 0)与其他 | absolute 压过一切 | §3.3 |
Warper 的 WAYPOINT:true | 引擎路径存在,但发行数据里一次没走到 | §5.2 |
Warper 的 DUNGEON NAME 查不到 | 之后怎么走未逆;不要假设回落 | §5.3 |
6.2 做地图前的检查
- 决定这张图是纯预设(两个 chunk 键写 0)、纯生成(无预设)、还是两者兼有;三种在原版分别有 44 / 80 / 62 个先例。
- 决定
GENERATION_TYPE:0 = 出口配对(看[EXIT]位置与TYPE是否能镜像配上),1 = 格子贴瓦(看四方向出口类型模式能不能落进 13 个池)。 - 至少一个
ENTRANCE_CHUNK:true的类型,否则不生成。 REQUIRESEXIT缺省 true:悬空出口会从对应方向的EXIT_CHUNK方向池挑块封口;封不上时的行为没跟到底。MAX_APPEARANCE缺省 10;一个类型的变体用满就从池里摘掉。- 别指望同一个种子能复现原版的图(§7)。
6.3 做门前的检查
- 想同层落地写
LEVEL DELTA:0;不写就是往下一层。 - 回城类的门用
WARP NAME;此时 delta 无效。 DUNGEON NAME必须在DUNGEONS/里能查到;大小写随意。- 目标 dungeon 若是多层,去
STRATA.RULESET逐层看对应的模板;层深 → stratum 的映射目前只有FLOORS这个推测。 - 不要用
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 处)是最可能的键,但那是推测 |
Warper 的 DUNGEON 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 0) | sub_6087A0(预设播种 0x608849;目标数 0x608AD6;失败上限 0x608B00;主池挑类型 0x608B14;起扫位置 0x608C33;轴向抛硬币 0x608D78;开放度预算 0x608F5C) |
生成器 B(格子生长 + 贴瓦,GENERATION_TYPE 1) | sub_60A9A0(预设 0x60AA36;块数 0x60AD47 / 0x60AD55;挑格 0x60ADAD;八邻域 0x60ADE1 / 0x60ADE9) |
| 四个贴瓦函数 | sub_604270 / sub_604510 / sub_6047C0 / sub_604A70 |
| 模式号索引器:装载侧 / 使用侧 | sub_600490(0x60073F)/ sub_600280(0x604403) |
| 邻居格探测 / 角探测 / 收格子 | sub_600AE0 / sub_6015B0 / sub_603040 |
| 放置谓词:碰撞 / 出口有效性 / 落子 | sub_601310 / sub_600ED0(matched=1 @0x6010AE;返回 0 @0x6012B1;配对距离 0x601258)/ sub_603D20 |
CChunkType ctor / CChunk 建于 / CChunkInstance ctor / CLevelLayout 建于、dtor | sub_6023B0 / 0x607A12(sub_604D20 内)/ sub_603150 / sub_604D20、sub_602590 |
ENTRANCE_CHUNK → 种子池装载 | 0x607A81 |
| 权重重置 / 变体摘除 | sub_600760 / sub_675850 |
vftable:CChunkType / CChunk / CChunkInstance / CLevelLayout | 0x2179B70 / 0x2179B30 / 0x2179BA0 / 0x2179B7C |
CWarperDescriptor::CWarperDescriptor / CWarper::CWarper | 0x487210 / 0x615620(xor ebx,ebx @0x61563A) |
performWarp 层深三分支 | 0x43DD8E / 0x43DDA7 / 0x43DDAD(ebp 最后写入 0x43DBEB;已知汇合点 0x43DBDF) |
WAYPOINT 路径点注册(发行数据未走到) | sub_5C4E90 |
| 被误标的函数 | sub_6153D0(不在任何虚表;不是 CWarper::onActivate) |
| 浮点常量 0.5 / 100.0 / 50.0 / 1.5 / 0.1f / 1000.0 / −1000.0 | flt_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 个)。
- C-0165(关卡布局来源:
- 数据结构与静态配置(
原版游戏分析/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 字节的门)。