NOTE
逆向方法与数据支撑:本文基于对 32 位客户端 Torchlight2.exe(ImageBase 0x400000,文内所有 sub_XXXXXX 与 0xXXXXXX 均为绝对虚地址)寻路模块 0x462F60–0x464400 的反编译体阅读,并对 10 处关键常量与指令做了 PE 字节级回读(每步代价、结点大小、提前收工阈值、迭代上限字段、吸附半径、哨兵、后继槽数)。该模块共 24 个函数,本文只读了其中 8 个。配套数据来自对七张发行地图 .mpp 走格的离线连通性普查,以及一次复刻实现侧的幽灵墙普查与实跑对照。本文严格收录已收敛至真值账本(Ledger C-0173 / C-0174)的结论;凡源材料标注为「没读 / 未确定 / 移植选择」的边界项,一律如实标注,不作补推。
核心工程结论:原版寻路是一台 8 邻域 A*,但它的代价模型与启发函数都不是教科书那套——每步代价恒 1.0(斜向也是 1.0),而启发是欧氏距离的平方。g 的单位是格、h 的单位是格²,启发严重不可采纳,行为偏贪心最佳优先,给不出最短路。「怪为什么绕远、贴墙拐」是算法本身,不是怪物 AI 笨。另外两条同样要紧:「能不能站」与「能不能规划着走过去」是两套判定(前者单点无半径,后者有净空要求,一格宽的缝隙规划不过去);TL2 没有 3D 碰撞体,装饰物不挡人,挡不挡只看 .mpp 那一格。
0. 核心结论速览
- 模块与入口:寻路模块位于
0x462F60–0x464400,共 24 个函数(读了 8 个)。移动侧sub_5425A0调sub_464010(unit+112 /*世界X*/, unit+120 /*世界Z*/, goalX, goalZ)进入。 - 8 邻域是数出来的,不是猜的:
sub_463940里落在sub_4637B0上的E8 rel32调用正好 8 个;整段.text上落在sub_4637B0的调用也正好这 8 个,而_realfuncs.json记该函数只有 1 个调用者——两个数必须相等,互为校验。结点里也正好留了 8 个后继槽(cmp ecx, 8@0x463881)。 - ★★★ 每步代价恒 1.0,斜向也是 1.0(
fadd dword ptr [0x213C5DC],该四字节现读 = float 1.0)⇒ 度量是切比雪夫;启发 h = (dx)² + (dz)²,是距离的平方,单位是格²。量纲不同、启发不可采纳 ⇒ 原版给不出最短路。佐证不是推的:sub_463650专门用来把改小了的 g 回传给已闭合结点——只有启发不可采纳、闭表可能出错时才需要这一步。 - 起点与目标各一次 ±16 格吸附:目标
sub_463270扫[cx−16, cx+16] × [cz−16, cz+16]这个 33×33 方块,把每个空格换算回世界坐标求欧氏距离取最小,哨兵 9999.0,一个都没有就原样返回;起点sub_4633F0同样 ±16,但候选格另要求周围净空。16 格 = 6.4 世界单位。 - A 内核形态*:结点
calloc(1, 0x40)= 64 字节;开表ctx+12、闭表ctx+16都是单链表,sub_463220按 f 升序插入、主循环每轮摘表头,等价于优先队列;但按 index 找结点是线性扫链(O(n)),这正是它需要迭代上限的原因。 - 迭代上限与提前收工:
if (i > *(DWORD*)(ctx+68)) break;,超了就拿ctx+8(至今 h 最小的结点)当结果并把ctx+66清 0(=「不是完整路径」)——上限的值没读。目标被吸过时,只要至今最好的结点 h < 2.0(格²,≈1.41 格,阈值现读自[0x2143F64])就当场收工,同样标记不完整。 - ★★ 「能不能站」与「能不能走过去」是两套判定:
CLevel::isWalkable(sub_5DB690)单点查表、无半径,管「能不能站」;寻路的sub_463940走正交方向时要求目标格在自己那一行有一个空的横向邻居、斜向要求两个正交邻格都空、起点吸附也要求净空,管「能不能规划着走过去」。⇒ 一格宽(0.4u)的缝隙规划不过去,做地图通道至少留两格。 - 两层格网与阻挡极性:
ctx+48永远查,ctx+52只在ctx+65为假时查,两层判定同形(哪层是静态地形、哪层是动态占位没定)。阻挡 ⟺ 有符号 byte > 0;255 读成 −1 = 可走——与isWalkable的setle(有符号 ≤ 0 = 可走)同一个方向。 - 发行地图的走格不是一整块:七张验收图里分量数从 3 到 67,主分量占比从 39.7%(
echopass_jd)到 92.1%(sundered_zone);四张 Echo Pass 变体里反复出现一模一样的 13,465 / 8,322 格分量,那是共用瓦片的真实地形结构,不是导出错误。引擎没有「最大连通分量」这个概念,吸不到就失败。 - ★★★ 幽灵墙:七张图 4,943 个按名字判成 OBSTACLE 的道具里 639 个(13%)坐在
.mpp说可走的格子上;echopass_jd上一块石头(rock_06)让玩家卡死 6,000 帧。.mpp是对的——TL2 没有 3D 碰撞体,那块石头在原版里根本不挡人。⇒ TL2 里装饰物不挡人,挡不挡只看.mpp;反过来,玩家「穿过」石头是原版行为,不是 bug。 - 明确留着的未知:
ctx+72(净空/拐角规则总开关)谁写、何时开没查,所有净空结论都挂在它为真这个前提上;两层格网各是什么没定;迭代上限的值没读;引擎有没有视线平滑(string pulling)未确定——24 个函数只读了 8 个,读过的里面没有,这不等于没有。
1. 模块位置、入口与函数清单
寻路模块是 .text 里一段连续的区间:0x462F60–0x464400,ALLFUNCS/_realfuncs.json 落在该区间的条目共 24 个。本文读了 8 个,职责如下:
| 函数 | 体积(字节) | 职责 |
|---|---|---|
sub_464010 | 656 | 世界坐标 → 格坐标、起终点吸附、派发搜索 |
sub_463270 | 371 | 目标吸附:±16 格方块内取世界欧氏最近的空格 |
sub_4633F0 | 604 | 起点吸附:同样 ±16,候选格另要求周围有净空 |
sub_463EA0 | 360 | A* 主循环 |
sub_463940 | 1367 | 邻域展开,正好 8 次 call sub_4637B0 |
sub_4637B0 | 387 | 松弛一个邻居:g = parent.g + 1.0,h = 距离²,f = g + h |
sub_463220 | 65 | 开表按 f 升序插入 |
sub_463650 | 266 | 把改小了的 g 回传给已闭合结点 |
没读的 16 个:sub_462F60 / sub_463030 / sub_4630A0 / sub_463130 / … / sub_4642A0 / sub_464360 等。后文凡是「引擎有没有 X」的问题,都要先记住这一半没读。
入口:移动侧 sub_5425A0 调 sub_464010(unit+112, unit+120, goalX, goalZ),其中 +112 / +120 是单位结构体里的世界 X / Z。
搜索方向:sub_464010 调的是 sub_463EA0(goalX, goalZ, startX, startZ, 1)——根结点是起点、终止条件是走到终点;最后那个 1 让它把 parent 链就地反转,结果读出来是「起点 → 终点」。起终点落在同一格时不跑搜索,直接置 ctx+67 = 1(=「已经到了」)。
对 MOD 作者的意义:寻路是引擎里一段独立的、不读 DAT 的代码。你在单位 DAT 里改任何键都不会改变它怎么找路——能改的只有地图那一侧:.mpp 里每一格是不是可走。
2. 走格取值:格子换算、两层格网、阻挡极性
2.1 世界坐标到格坐标
cell = floor((world − origin) × (1.0 / cellSize))寻路上下文(下文写作 ctx)里与格网相关的字段:
ctx 偏移 | 含义 |
|---|---|
+32 | Z 方向格数 |
+36 | X 方向格数 |
+40 | cellSize(一格 = 0.4 世界单位) |
+48 | 第一层格网(永远查) |
+52 | 第二层格网(仅 ctx+65 为假时查) |
+56 | origin X |
+60 | origin Z |
2.2 两层格网
ctx+48 永远查;ctx+52 只在 ctx+65 为假时查,两层的判定完全同形。
WARNING
哪层是静态地形、哪层是动态占位,本文没定。不要按名字猜。ctx+65 由谁置位也没查。
2.3 阻挡极性:有符号 byte > 0
阻挡 ⟺ 有符号 byte > 0。这与 CLevel::isWalkable(sub_5DB690)里的 setle(有符号 ≤ 0 = 可走)是同一个方向:255 在两处都读成 −1 = 可走。
这一条与走格导出工具 tools/f2_walkgrid.py 的口径 walk = (cells == 0) 互相对得上——发行 .mpp 里的值只有 {0, 1, 255},而 255 在装载后不会以 255 的形态进关卡。两条独立推导互相对上了。
对 MOD 作者的意义:走格里「可走」不是一个值而是一个区间(有符号 ≤ 0)。255 不是「最硬的墙」,是「图外 / 本瓦片不定义此格」= 可走。写工具改 .mpp 时别把 255 当阻挡。
3. 起点与目标的吸附:±16 格
3.1 目标吸附 sub_463270
目标格越界或被挡时,扫 [cx−16, cx+16] × [cz−16, cz+16] 这个 33×33 方块,把每个空格换算回世界坐标求欧氏距离,取最小。哨兵是 9999.0;一个空格都没有就原样返回。命中时把 ctx+64 置 1(=「目标被挪过」)。
字节层:lea ebx, [esi − 0x10] @ 0x4632DD、lea eax, [esi + 0x10] @ 0x4632E4;哨兵 float 9999.0 在 0x214882C。
3.2 起点吸附 sub_4633F0
同样的 ±16 方块、同样的欧氏最近,但候选格额外要求周围有净空(见 §5)。
3.3 这意味着什么
「点在墙上、角色走到墙边」不是表现层做的,是规划器自己吸的。
对 MOD 作者的意义:
- 16 格 = 6.4 世界单位。目标离最近可走格超过 16 格,寻路直接失败;单位不会「尽力走近一点」。
- 吸附取的是世界欧氏最近,不是「一圈圈往外找第一个命中」——斜角方向上两种口径会差一格。
- 目标一旦被吸过(
ctx+64),后面 §4.5 的提前收工就会生效:单位可能在离吸附目标 ≈1.41 格处停下。
4. A* 内核:结点、开闭表、代价、启发
4.1 结点:64 字节
主循环用 calloc(1, 0x40) 分配结点(push 0x40 @ 0x463ED7),布局按 DWORD 下标:
| 下标 | 字节偏移 | 含义 |
|---|---|---|
[0] | +0 | f |
[1] | +4 | h |
[2] | +8 | g |
[3] | +12 | x |
[4] | +16 | z |
[5] | +20 | index = x + z × width |
[6] | +24 | parent |
[7..14] | +28..+56 | 最多 8 个后继回指针(cmp ecx, 8 @ 0x463881) |
[15] | +60 | 链表 next |
4.2 开表与闭表:单链表 + 线性扫
开表 ctx+12、闭表 ctx+16,都是单链表(next 在结点 +60)。sub_463220(65 字节)按 f 升序插入,主循环每轮摘表头 ⇒ 等价于优先队列。
但按 index 找结点是线性扫链(while (node[5] != idx)),O(n)。这正是它需要迭代上限的原因。
4.3 ★★★ 代价恒 1.0,启发是距离的平方
sub_4637B0 松弛一个邻居:
g = parent.g + 1.0 // fadd dword ptr [0x213C5DC],该四字节现读 = float 1.0
h = (dx)² + (dz)² // 单位是格²
f = g + h两点要分开说:
- 每步代价恒 1.0,斜向也是 1.0。 度量是切比雪夫,不是欧几里得。斜着走一格与直着走一格同价。
- h 是欧氏距离的平方。 g 的单位是格,h 的单位是格²。量纲不同,h 相对 g 严重不可采纳(远处 h 大得离谱,近处 h 又小得几乎不起作用)。行为偏贪心最佳优先,给不出最短路。
佐证不是推的:sub_463650(266 字节)专门用来「把改小了的 g 回传给已闭合的结点」。教科书 A* 在启发可采纳时闭合结点的 g 就是最终值,永远不需要回传;只有启发不可采纳、闭表可能出错时才需要这一步。引擎写了这一步,说明它自己知道。
对 MOD 作者的意义:怪绕远、贴墙拐、明明有近路却走远路——这是算法本身的性质,不是「怪物 AI 笨」,也不是你 DAT 里哪个半径写错了。任何 DAT 键都改不了它。
4.4 迭代上限与部分路径
if (i > *(DWORD*)(ctx+68)) break; // cmp ebp, [esi+0x44] @ 0x463F8A超了就拿 ctx+8(至今 h 最小的结点)当结果,并把 ctx+66 清 0(=「这不是完整路径」)。
WARNING
迭代上限 ctx+68 的值没读。 要读它需要活进程或找到写入点,本文两者都没做。
对 MOD 作者的意义:超大开阔图上远距离点击,会看到单位走一半停下——那是迭代上限到了,引擎交了一条部分路径。
4.5 提前收工:h < 2.0
目标被吸过(ctx+64)时,只要至今最好的结点 h < 2.0(格²,即 ≈1.41 格)就当场收工,同样把 ctx+66 清 0。阈值 2.0 现读自 [0x2143F64](fld dword ptr [0x2143F64] @ 0x463F76)。
注意条件:只有目标被吸过才启用。目标本来就落在可走格上时,搜索要一直跑到终点。
5. 八邻域是怎么数出来的,以及净空规则
5.1 数的是字节,不是注释
sub_463940(1367 字节)负责邻域展开。里面落在 sub_4637B0 上的 E8 rel32 调用正好 8 个。三条彼此独立的数对上了:
sub_463940内 8 个call sub_4637B0;- 整段
.text上落在sub_4637B0的调用也正好这 8 个; _realfuncs.json记sub_4637B0只有 1 个调用者 ⇒ 前两个数必须相等。
结点布局里 [7..14] 正好 8 个后继槽(cmp ecx, 8 @ 0x463881),与之自洽。
NOTE
勘误:此前的记录曾把「八邻域」写成「移植选择,引擎用几邻域没查」。那是错的——结论当时就是 8,错的是理由。按错的理由,下一步会去问「要不要改成四邻域」这种根本不该问的问题。现已按字节数出来,此处留碑。
5.2 净空规则:斜向、正交、起点
| 情形 | 要求 |
|---|---|
| 斜向移动 | 两个正交邻格都空(= 拐角规则,不能贴着墙角切过去) |
| 正交移动 | 目标格在自己那一行要有一个空的横向邻居 |
起点吸附(sub_4633F0) | 候选落脚格周围要有净空 |
正交移动那条是关键:它意味着一格宽(0.4u)的缝隙,规划器不肯走。缝里每一格 isWalkable 都说可走,但寻路要求旁边再有一格空的,缝里没有 ⇒ 规划不过去。
CAUTION
以上净空规则全部挂在 ctx+72 为真这个前提上。ctx+72 是净空/拐角规则的总开关,谁写的、什么时候开,本文没查。
5.3 ★★ 「能不能站」与「能不能走过去」是两套判定
这一条最容易被混成一条,所以单独说:
| 判定 | 函数 | 口径 |
|---|---|---|
| 能不能站 | CLevel::isWalkable(sub_5DB690) | 单点查表,有符号 ≤ 0 = 可走,没有身体半径 |
| 能不能规划着走过去 | 寻路 sub_463940 / sub_4633F0 | 有净空要求(§5.2),寻路是有身体宽度的 |
两条不互相推翻。「单位可以站在一格宽的缝里」与「寻路不会把单位规划进一格宽的缝」同时成立。把一个单位直接放进缝里,它能站;让它自己走进去,它不走。
对 MOD 作者的意义:
- 做地图时通道至少留两格(0.8u)。一格宽的缝在
.mpp里刷成可走也没用,单位规划不过去。 - 摆装饰物、刷走格时按「走过去」那套留余量,而不是按「能站」那套。
6. 字节锚点表
下表列出本文所有关键结论的 PE 字节级锚点。每一条都可以在 Torchlight2.exe 里按虚地址直接核对。
| 锚点 | 虚地址 | 字节 | 长度 | 说明 |
|---|---|---|---|---|
step_cost_fadd | 0x4637BF | D8 05 DC C5 13 02 | 6 | sub_4637B0:fadd dword ptr [0x213C5DC]——每步代价的加数 |
step_cost_value | 0x213C5DC | 00 00 80 3F | 4 | 上一条指向的四字节 = float 1.0 |
node_is_64_bytes | 0x463ED7 | 6A 40 | 2 | sub_463EA0:push 0x40——calloc 的结点大小 |
early_accept_fld | 0x463F76 | D9 05 64 3F 14 02 | 6 | sub_463EA0:fld dword ptr [0x2143F64] |
early_accept_value | 0x2143F64 | 00 00 00 40 | 4 | 上一条指向的四字节 = float 2.0(单位是格²) |
iteration_cap_field | 0x463F8A | 3B 6E 44 | 3 | sub_463EA0:cmp ebp, [esi+0x44]——0x44 = 68 = 迭代上限字段 |
snap_radius_minus_16 | 0x4632DD | 8D 5E F0 | 3 | sub_463270:lea ebx, [esi − 0x10] |
snap_radius_plus_16 | 0x4632E4 | 8D 46 10 | 3 | sub_463270:lea eax, [esi + 0x10] |
snap_sentinel_value | 0x214882C | 00 3C 1C 46 | 4 | sub_463270 的哨兵 = float 9999.0 |
eight_child_slots | 0x463881 | 83 F9 08 | 3 | sub_4637B0:cmp ecx, 8——结点最多 8 个后继 |
7. 发行地图走格的连通分量普查
用 tools/f2_walkgrid_reach.py 离线读七张验收图导出的走格(四邻域洪泛),分量阈值 200 格(≥ 200 算「主要分量」,否则算碎屑)。「最近可走格」一列以复刻实现侧的合成出生点 (132.0, 44.0) 为基准。
| 场景 | 格网 | 可走格 | 分量数 | 主要分量(格) | 主分量占比 | 碎屑(块 / 格) | 最近可走格落在主分量上? |
|---|---|---|---|---|---|---|---|
act1_town | 301 × 350 | 20,003 | 9 | 16,572 / 3,166 | 82.8% | 7 / 265 | 是 |
clockwork_boss | 401 × 226 | 18,310 | 3 | 15,715 / 2,594 | 85.8% | 1 / 1 | 否(落在 2,594) |
echopass_jd | 601 × 1050 | 86,611 | 29 | 34,413 / 28,058 / 13,465 / 8,322 / 880 / 555 / 430 | 39.7% | 22 / 488 | 否(落在 28,058) |
echopass_jt | 650 × 900 | 80,496 | 29 | 56,734 / 13,467 / 8,356 / 898 / 429 | 70.5% | 24 / 612 | 是 |
echopass_lm | 925 × 900 | 87,941 | 23 | 64,156 / 13,465 / 8,322 / 430 / 411 / 358 / 281 | 73.0% | 16 / 518 | 是 |
echopass_pb | 625 × 1125 | 99,834 | 28 | 53,709 / 23,451 / 13,465 / 8,322 / 430 | 53.8% | 23 / 457 | 否(落在 129 格的碎屑上) |
sundered_zone | 1328 × 1352 | 506,886 | 67 | 466,800 / 37,023 / 2,148 / 330 | 92.1% | 63 / 585 | 是 |
七张里 3 张的「最近可走格」不在主分量上。echopass_pb 最极端:主分量 53,709 格,最近可走格却落在一块 129 格(0.1%)的碎屑上。
NOTE
四张 Echo Pass 图里反复出现一模一样的 13,465 / 8,322——四个变体共用大部分瓦片,那是真实地形结构(被水 / 崖隔开的区域),不是导出错误。普查工具只报分布,不裁决哪些该连通。
对 MOD 作者的意义:
- 引擎没有「最大连通分量」这个概念。目标吸附只在 ±16 格里找最近的空格,不管那格跟起点连不连通;吸不到就失败。
- 一张图上「可走」不等于「能走到」。拼装地图时被水、崖隔开的可走区域彼此不通,这是正常结构;但如果你把出生点、传送门或必经 NPC 放进了一块碎屑分量,从表面上看走格是绿的,实际谁也走不进去。
8. 幽灵墙:TL2 里装饰物不挡人
8.1 现象
复刻实现侧在 echopass_jd(seed 1234)上做 6,000 帧实跑:玩家一步没走到怪跟前,hits = 0、kills = 0,解卡计数 forced = 849,位置在 x = 45.3 与 45.7 之间来回跳——差值正好一个解卡步长 UNSTICK_STEP = 0.4。
8.2 定位链
- 解卡计数器只说「推了 849 次」,不说被谁挡 ⇒ 加了一个 collider 点名器(
VANILLA_NAVDEBUG=1),把接触点的 collider 名字打出来。 - 点名结果:
col_Room_Piece0_clone723428_85,法线 (0.96, 0.00, 0.29)——一面朝 +X 的竖直墙。 - 地面高度剖面(前方每 0.4u 打一条竖直射线):0.00 / 0.00 / 2.00 / 1.72 / 0.79 / 0.00 …⇒ 前方 0.8–1.6u 有个 2u 高的坎,爬台阶逻辑正确拒绝(
MAX_STEP= 0.6)。 - 把走格与几何并排打在同一批格子上:走格那一整行全是可走,而几何有一条 1.1–4.0u 的抬高带。
- 走格对齐校验:单位落在可走格 32/35(91%),转置对照 0% ⇒ 走格没错位,是几何多出来一条带。
- 顺着
.tscn查那个节点的 mesh:res://meshes/props/z1tundra_props/rock_06.res——一块石头。
8.3 定性
复刻实现的 tl2_terrain_collision.gd 按资源名字分类:rock 在 OBSTACLE_KEYS 里 ⇒ 给它挂了实心碰撞体。而发行 .mpp 说那一格可走。
.mpp 是对的。 TL2 没有 3D 碰撞体,能不能站只由 CLevel::isWalkable(sub_5DB690)单点查表说了算。那块石头在原版里根本不挡人。墙是复刻侧造的。
既有的「保守解卡」只推 XZ、保留旧 Y,把身体往石头里塞,下一帧物理原样推回来 ⇒ 849 次空转、净位移 0。
8.4 这不是个案:七张图的普查
tools/f2_phantom_walls.py 从 .tscn 读每个 MeshInstance3D 的 mesh 路径,按分类器源码里解析出来的关键词表判层,再问走格那一格可不可走。
| 场景 | 节点数 | OBSTACLE | GROUND | 不建碰撞 | 幽灵墙 | 占 OBSTACLE 比 |
|---|---|---|---|---|---|---|
act1_town | 1,800 | 123 | 116 | 1,561 | 36 | 29.3% |
clockwork_boss | 706 | 169 | 63 | 474 | 4 | 2.4% |
echopass_jd | 4,184 | 327 | 445 | 3,412 | 75 | 22.9% |
echopass_jt | 5,598 | 470 | 442 | 4,686 | 115 | 24.5% |
echopass_lm | 4,524 | 240 | 457 | 3,827 | 53 | 22.1% |
echopass_pb | 6,473 | 514 | 530 | 5,429 | 112 | 21.8% |
sundered_zone | 21,012 | 3,100 | 5,024 | 12,888 | 244 | 7.9% |
| 合计 | — | 4,943 | — | — | 639 | 13% |
echopass_jd 上的几个例子:
| 节点 | mesh | XZ |
|---|---|---|
Room_Piece0_clone12282319_12 | props/Z1Tundra_props/rock_05.res | (38.2, 68.9) |
Room_Piece0_clone12093446_34 | props/z1tundra_props/rock_06.res | (37.9, 70.6) |
Room_Piece0_clone12282230_61 | props/Z1Tundra_props/rock_05.res | (54.9, 67.6) |
Room_Piece0_clone12227765_65 | props/z1tundra_props/rock_06.res | (75.9, 25.7) |
Room_Piece3_clone12281951_75 | props/Z1Tundra_props/rock_01.res | (69.7, 48.8) |
WARNING
639 是下界。 普查只看节点原点落在哪一格,不算 mesh 的实际 footprint;原点在不可走格上、身体探进可走格的道具不会被数进来。
普查不裁决。 走格是二维的:原点落在可走格上,也可能那格的真实落脚面在另一个高度(桥下 / 桥上)。工具只报重合。不过 clockwork_boss 只有 2.4%、sundered_zone 7.9%,而四张 Echo Pass 都在 22–25%——这个分布本身说明它不是均匀噪声。
8.5 两次走错路(勘误)
记下来是因为两次都长得很像对的。
错一:以为那是桥面。 挡路节点挂在 Variations/Wood_Bridge 组下,高度剖面量到 1.1–4.0u 的抬高带,于是判定「那是桥面,走格标它可走是因为该走在上面」,照这条改了三版解卡(采 Y → 沿前缘取最高 → XZ+Y 一起挪),而爬升计数 climb 一直是 0.00 / 0.05,一次都没够到——采 Y 的射线只查 GROUND 层,石头在 OBSTACLE 层,射线直接穿过去了。真正的错在更前面:「它被判成 GROUND」这个判断来自一条没设 collision_mask 的射线(默认全层)。「射线打到了它」与「它在 GROUND 层」是两码事。
错二:普查工具第一版报了零。 第一版 f2_phantom_walls.py 在七张图上一律报 OBSTACLE = 0。根因:节点行末尾带 groups=["tl2_room_piece"],里面有个 ],正则在第一个 ] 处截断 ⇒ 带 groups 的房间瓦片全被漏掉,匹配到的 810 个全是阴影贴片(mesh 名含 shadow,被 NOCOLLIDE 吃掉)⇒ 全判 none。自检只测了分类那一半(五条合成输入,全绿),没测取数那一半。修法是加一条端到端对照:实测顶死玩家的那个节点(echopass_jd / Room_Piece0_clone723428_85)必须出现在幽灵墙名单里,否则整份数据判为不可信并抛错。修之前用临时脚本量出的「132 个 OBSTACLE、44 个幽灵墙(33%)」三个数全错(那个脚本跳过了 NOCOLLIDE / FOLIAGE,把名叫 Rock_* 的阴影贴片当成了石头),已作废。
8.6 对 MOD 作者的意义
- ★★★ TL2 里装饰物不挡人。 挡不挡只看
.mpp那一格。摆一块石头不会自动挡住玩家;要挡,就得在走格里把那些格子刷成阻挡。反过来:玩家「穿过」石头是原版行为,不是 bug。 - ★ 反方向同样成立:
.mpp刷了阻挡而地面上什么都没有,玩家就会撞上看不见的墙。这种空气墙事故有过实测记录,方向与幽灵墙正好相反。 - ★ 做重制 / 移植的人:凡是「按资源名字给碰撞体分类」的管线,都要拿
.mpp对一遍。七张图上 13% 的 OBSTACLE 道具站在可走格上,而只要一个就能让玩家卡死。
9. 复刻实现侧的口径对照
这一节给做重制或移植的人看。复刻实现用的是 Godot 4.7 内置的 AStarGrid2D,不是逐步复刻——邻域与拐角规则与引擎一致,代价模型与启发函数刻意不一致。逐条对照:
| 项 | 引擎 | 复刻实现 | 后果 | 为什么没对齐 |
|---|---|---|---|---|
| 八邻域 | 8(字节数出来的) | 8 | 一致 | — |
| 拐角规则 | 斜向要求两个正交邻格都空 | DIAGONAL_MODE_ONLY_IF_NO_OBSTACLES | 一致 | — |
| 目标吸附半径 | 16 格(方块内取欧氏最近) | 16 格(一圈圈往外取第一个命中,即切比雪夫最近) | 斜角上会差一格 | — |
| 起终点同格 | 不搜索 | 不搜索 | 一致 | — |
| 代价 + 启发 | 每步 1.0(切比雪夫)+ h = 距离²(不可采纳、偏贪心) | OCTILE 启发 + √2 斜向代价(可采纳、最优) | ★ 复刻的路比原版更短更直。要复刻手感,这是第一个该动的地方 | AStarGrid2D 不暴露自定义启发;要复刻就得自己写一遍 A* |
| 正交方向的净空要求 | 目标格那一行要有一个空的横向邻居 | 没有 | 复刻能钻原版钻不过去的一格宽缝 | AStarGrid2D 没有这个钩子 |
| 迭代上限 | 有(ctx+68),超了交部分路径并标记不完整 | 没有 | 最坏情况会有一次大搜索的帧尖峰 | 改用连通分量预检挡掉「目标根本不在这块地上」 |
| 第二层格网 | ctx+52,受 ctx+65 门控 | 只有一层 | 动态占位(如果那层是它的话)不影响路径 | 哪层是什么还没定 |
| 视线平滑(string pulling) | 未确定——24 个函数只读了 8 个,读过的里面没有 | 有,把能直连的拐点删掉 | 复刻的路更少拐点 | 别把「没找到」写成「没有」 |
| 最大连通分量 | 没有这个概念(吸不到就失败) | 有,只用在出生点 | 出生点保证落在能走遍全图的那块地上 | 原版出生点是 authored 的、不需要吸;复刻拼装整关用的是合成出生点 |
NOTE
吸附半径从 12 改到 16 是对齐口径,不是修现象:改完四张实测图的 hits / kills / repaths / forced 一个数都没变——目标离最近可走格从来没超过 12 环。如实记下来,免得日后把它当成一次有效修复。
幽灵墙的处置:被卡住的那几帧把 OBSTACLE 层从玩家身体的 collision_mask 里摘掉,挪开一个身位或 60 帧安全阀到点就恢复;只对拼装整关开。根治(建碰撞体时按 footprint 跳过坐在可走格上的 OBSTACLE 道具)没做。实跑对照(VANILLA_PLAYTEST=6000,3 杀,seed 1234):
| 场景 | 修前 | 修后 |
|---|---|---|
echopass_jd | timeout / hits 0 / kills 0 / forced 849 | ok / hits 45 / kills 3 / forced 1(穿模只进了 1 次、4 帧) |
echopass_lm | ok / hits 36 / repaths 308 | ok / hits 36 / repaths 131 |
echopass_pb | ok / hits 43 / repaths 222 | ok / hits 43 / repaths 40 |
echopass_jt | died / hits 6 | died / hits 2(hits >= 1 仍成立,余量变薄) |
repaths 普遍下降是因为测试光标改成只挑同一连通分量里的怪,路径不再每帧作废;jt 命中下降是同一改动的代价——光标换了目标,路上就被打死了。
CAUTION
「与原版等价」并未验证。 总纲对寻路的判据是「可达性 + 到达 tick 数与原版一致,逐格路径不判」,而这需要原版的路径 / 时序 trace 当 oracle,目前没有。上面量到的全部是复刻侧自己的行为。别把「门是绿的」读成「与原版等价已验证」。
10. 未解与未确定事项
| 事项 | 现状 |
|---|---|
ctx+72(净空 / 拐角规则总开关)由谁写入、何时开启 | 没查。§5 所有净空结论都挂在它为真这个前提上 |
两层格网 ctx+48 / ctx+52 各是什么;ctx+65 由谁置位 | 没定。不要按名字猜 |
迭代上限 ctx+68 的值 | 没读。需要活进程或找到写入点 |
| 引擎有没有视线平滑(string pulling) | 未确定。模块 24 个函数只读了 8 个,读过的里面没有——不等于没有 |
| 没读的 16 个函数各做什么 | sub_462F60 / sub_463030 / sub_4630A0 / sub_463130 / … / sub_4642A0 / sub_464360 等,未读 |
| 幽灵墙的根治 | 建碰撞体时按 footprint 跳过坐在可走格上的 OBSTACLE 道具——没做 |
| 639 这个数 | 是下界(只看原点)。按 footprint 算会更多 |
| 反方向普查 | .mpp 说不可走、而复刻侧没有碰撞体的格子有多少(「穿墙」那一侧)——没查 |
| 解卡参数 6 帧 / 0.4u、安全阀 60 帧、退出条件「挪开一个身位」 | 没有 oracle,是移植选择 |
| 复刻侧怪节点 Y 与玩家物理体 Y 差 6–9u | 原因仍未查 |
附录 A:核心符号与虚地址表(Torchlight2.exe,ImageBase 0x400000)
| 功能 | 虚地址 / 符号 |
|---|---|
| 寻路模块区间 | 0x462F60–0x464400(24 个函数) |
| 移动侧入口 | sub_5425A0 → sub_464010(unit+112, unit+120, goalX, goalZ) |
| 世界 → 格 + 吸附 + 派发 | sub_464010(656 字节) |
| 目标吸附(±16,欧氏最近) | sub_463270(371 字节;lea @ 0x4632DD / 0x4632E4;哨兵 0x214882C) |
| 起点吸附(±16 + 净空) | sub_4633F0(604 字节) |
| A* 主循环 | sub_463EA0(360 字节;push 0x40 @ 0x463ED7;fld [0x2143F64] @ 0x463F76;cmp ebp, [esi+0x44] @ 0x463F8A) |
| 邻域展开(8 次 call) | sub_463940(1367 字节) |
| 松弛邻居(g + 1.0,h = 距离²) | sub_4637B0(387 字节;fadd [0x213C5DC] @ 0x4637BF;cmp ecx, 8 @ 0x463881) |
| 开表按 f 升序插入 | sub_463220(65 字节) |
| g 回传闭合结点 | sub_463650(266 字节) |
| 每步代价常量 float 1.0 | 0x213C5DC |
| 提前收工阈值 float 2.0 | 0x2143F64 |
| 吸附哨兵 float 9999.0 | 0x214882C |
CLevel::isWalkable(单点查表,setle) | sub_5DB690 |
附录 B:支撑源材料与真值账本映射
- 真值账本
truth/ledger.jsonl:- C-0173(原版走格寻路:8 邻域 A*、每步代价恒 1.0、启发是距离的平方;两套判定;复刻侧口径差异);
- C-0174(幽灵墙:按名字给道具加碰撞体,而 TL2 没有 3D 碰撞;4,943 / 639 普查)。
- 数据结构与静态配置(
原版游戏分析/reversed_cpp/REGISTRY/):walkgrid_pathfinding.json、phantom_walls.json。 - 工具链:
tools/f2_walkgrid.py(走格导出)、tools/f2_walkgrid_reach.py(连通分量普查)、tools/f2_phantom_walls.py(幽灵墙普查,带端到端阳性对照)。