NOTE

逆向方法与数据支撑:本文基于对 32 位客户端 Torchlight2.exe(ImageBase 0x400000,文内所有 sub_XXXXXX0xXXXXXX 均为绝对虚地址)寻路模块 0x462F600x464400 的反编译体阅读,并对 10 处关键常量与指令做了 PE 字节级回读(每步代价、结点大小、提前收工阈值、迭代上限字段、吸附半径、哨兵、后继槽数)。该模块共 24 个函数,本文只读了其中 8 个。配套数据来自对七张发行地图 .mpp 走格的离线连通性普查,以及一次复刻实现侧的幽灵墙普查与实跑对照。本文严格收录已收敛至真值账本(Ledger C-0173 / C-0174)的结论;凡源材料标注为「没读 / 未确定 / 移植选择」的边界项,一律如实标注,不作补推。

核心工程结论:原版寻路是一台 8 邻域 A*,但它的代价模型与启发函数都不是教科书那套——每步代价恒 1.0(斜向也是 1.0),而启发是欧氏距离的平方。g 的单位是格、h 的单位是格²,启发严重不可采纳,行为偏贪心最佳优先,给不出最短路。「怪为什么绕远、贴墙拐」是算法本身,不是怪物 AI 笨。另外两条同样要紧:「能不能站」与「能不能规划着走过去」是两套判定(前者单点无半径,后者有净空要求,一格宽的缝隙规划不过去);TL2 没有 3D 碰撞体,装饰物不挡人,挡不挡只看 .mpp 那一格


0. 核心结论速览

  1. 模块与入口:寻路模块位于 0x462F600x464400,共 24 个函数(读了 8 个)。移动侧 sub_5425A0sub_464010(unit+112 /*世界X*/, unit+120 /*世界Z*/, goalX, goalZ) 进入。
  2. 8 邻域是数出来的,不是猜的sub_463940 里落在 sub_4637B0 上的 E8 rel32 调用正好 8 个;整段 .text 上落在 sub_4637B0 的调用也正好这 8 个,而 _realfuncs.json 记该函数只有 1 个调用者——两个数必须相等,互为校验。结点里也正好留了 8 个后继槽(cmp ecx, 8 @ 0x463881)。
  3. ★★★ 每步代价恒 1.0,斜向也是 1.0fadd dword ptr [0x213C5DC],该四字节现读 = float 1.0)⇒ 度量是切比雪夫;启发 h = (dx)² + (dz)²,是距离的平方,单位是格²。量纲不同、启发不可采纳 ⇒ 原版给不出最短路。佐证不是推的:sub_463650 专门用来把改小了的 g 回传给已闭合结点——只有启发不可采纳、闭表可能出错时才需要这一步。
  4. 起点与目标各一次 ±16 格吸附:目标 sub_463270[cx−16, cx+16] × [cz−16, cz+16] 这个 33×33 方块,把每个空格换算回世界坐标求欧氏距离取最小,哨兵 9999.0,一个都没有就原样返回;起点 sub_4633F0 同样 ±16,但候选格另要求周围净空。16 格 = 6.4 世界单位。
  5. A 内核形态*:结点 calloc(1, 0x40) = 64 字节;开表 ctx+12、闭表 ctx+16 都是单链表,sub_463220 按 f 升序插入、主循环每轮摘表头,等价于优先队列;但按 index 找结点是线性扫链(O(n)),这正是它需要迭代上限的原因。
  6. 迭代上限与提前收工if (i > *(DWORD*)(ctx+68)) break;,超了就拿 ctx+8(至今 h 最小的结点)当结果并把 ctx+66 清 0(=「不是完整路径」)——上限的值没读。目标被吸过时,只要至今最好的结点 h < 2.0(格²,≈1.41 格,阈值现读自 [0x2143F64])就当场收工,同样标记不完整。
  7. ★★ 「能不能站」与「能不能走过去」是两套判定CLevel::isWalkablesub_5DB690)单点查表、无半径,管「能不能站」;寻路的 sub_463940 走正交方向时要求目标格在自己那一行有一个空的横向邻居、斜向要求两个正交邻格都空、起点吸附也要求净空,管「能不能规划着走过去」。⇒ 一格宽(0.4u)的缝隙规划不过去,做地图通道至少留两格。
  8. 两层格网与阻挡极性ctx+48 永远查,ctx+52 只在 ctx+65 为假时查,两层判定同形(哪层是静态地形、哪层是动态占位没定)。阻挡 ⟺ 有符号 byte > 0;255 读成 −1 = 可走——与 isWalkablesetle(有符号 ≤ 0 = 可走)同一个方向。
  9. 发行地图的走格不是一整块:七张验收图里分量数从 3 到 67,主分量占比从 39.7%(echopass_jd)到 92.1%(sundered_zone);四张 Echo Pass 变体里反复出现一模一样的 13,465 / 8,322 格分量,那是共用瓦片的真实地形结构,不是导出错误。引擎没有「最大连通分量」这个概念,吸不到就失败。
  10. ★★★ 幽灵墙:七张图 4,943 个按名字判成 OBSTACLE 的道具里 639 个(13%)坐在 .mpp 说可走的格子上echopass_jd一块石头rock_06)让玩家卡死 6,000 帧。.mpp 是对的——TL2 没有 3D 碰撞体,那块石头在原版里根本不挡人。⇒ TL2 里装饰物不挡人,挡不挡只看 .mpp;反过来,玩家「穿过」石头是原版行为,不是 bug。
  11. 明确留着的未知ctx+72(净空/拐角规则总开关)谁写、何时开没查,所有净空结论都挂在它为真这个前提上;两层格网各是什么没定;迭代上限的值没读;引擎有没有视线平滑(string pulling)未确定——24 个函数只读了 8 个,读过的里面没有,这不等于没有

1. 模块位置、入口与函数清单

寻路模块是 .text 里一段连续的区间:0x462F600x464400ALLFUNCS/_realfuncs.json 落在该区间的条目共 24 个。本文读了 8 个,职责如下:

函数体积(字节)职责
sub_464010656世界坐标 → 格坐标、起终点吸附、派发搜索
sub_463270371目标吸附:±16 格方块内取世界欧氏最近的空格
sub_4633F0604起点吸附:同样 ±16,候选格另要求周围有净空
sub_463EA0360A* 主循环
sub_4639401367邻域展开,正好 8 次 call sub_4637B0
sub_4637B0387松弛一个邻居:g = parent.g + 1.0,h = 距离²,f = g + h
sub_46322065开表按 f 升序插入
sub_463650266把改小了的 g 回传给已闭合结点

没读的 16 个sub_462F60 / sub_463030 / sub_4630A0 / sub_463130 / … / sub_4642A0 / sub_464360 等。后文凡是「引擎有没有 X」的问题,都要先记住这一半没读。

入口:移动侧 sub_5425A0sub_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 偏移含义
+32Z 方向格数
+36X 方向格数
+40cellSize(一格 = 0.4 世界单位)
+48第一层格网(永远查)
+52第二层格网(仅 ctx+65 为假时查)
+56origin X
+60origin Z

2.2 两层格网

ctx+48 永远查;ctx+52 只在 ctx+65 为假时查,两层的判定完全同形。

WARNING

哪层是静态地形、哪层是动态占位,本文没定。不要按名字猜。ctx+65 由谁置位也没查。

2.3 阻挡极性:有符号 byte > 0

阻挡 ⟺ 有符号 byte > 0。这与 CLevel::isWalkablesub_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] @ 0x4632DDlea 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]+0f
[1]+4h
[2]+8g
[3]+12x
[4]+16z
[5]+20index = x + z × width
[6]+24parent
[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. 每步代价恒 1.0,斜向也是 1.0。 度量是切比雪夫,不是欧几里得。斜着走一格与直着走一格同价。
  2. 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.jsonsub_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::isWalkablesub_5DB690单点查表,有符号 ≤ 0 = 可走,没有身体半径
能不能规划着走过去寻路 sub_463940 / sub_4633F0有净空要求(§5.2),寻路是有身体宽度的

两条不互相推翻。「单位可以站在一格宽的缝里」与「寻路不会把单位规划进一格宽的缝」同时成立。把一个单位直接放进缝里,它能站;让它自己走进去,它不走。

对 MOD 作者的意义

  • 做地图时通道至少留两格(0.8u)。一格宽的缝在 .mpp 里刷成可走也没用,单位规划不过去。
  • 摆装饰物、刷走格时按「走过去」那套留余量,而不是按「能站」那套。

6. 字节锚点表

下表列出本文所有关键结论的 PE 字节级锚点。每一条都可以在 Torchlight2.exe 里按虚地址直接核对。

锚点虚地址字节长度说明
step_cost_fadd0x4637BFD8 05 DC C5 13 026sub_4637B0fadd dword ptr [0x213C5DC]——每步代价的加数
step_cost_value0x213C5DC00 00 80 3F4上一条指向的四字节 = float 1.0
node_is_64_bytes0x463ED76A 402sub_463EA0push 0x40——calloc 的结点大小
early_accept_fld0x463F76D9 05 64 3F 14 026sub_463EA0fld dword ptr [0x2143F64]
early_accept_value0x2143F6400 00 00 404上一条指向的四字节 = float 2.0(单位是格²)
iteration_cap_field0x463F8A3B 6E 443sub_463EA0cmp ebp, [esi+0x44]——0x44 = 68 = 迭代上限字段
snap_radius_minus_160x4632DD8D 5E F03sub_463270lea ebx, [esi − 0x10]
snap_radius_plus_160x4632E48D 46 103sub_463270lea eax, [esi + 0x10]
snap_sentinel_value0x214882C00 3C 1C 464sub_463270 的哨兵 = float 9999.0
eight_child_slots0x46388183 F9 083sub_4637B0cmp ecx, 8——结点最多 8 个后继

7. 发行地图走格的连通分量普查

tools/f2_walkgrid_reach.py 离线读七张验收图导出的走格(四邻域洪泛),分量阈值 200 格(≥ 200 算「主要分量」,否则算碎屑)。「最近可走格」一列以复刻实现侧的合成出生点 (132.0, 44.0) 为基准。

场景格网可走格分量数主要分量(格)主分量占比碎屑(块 / 格)最近可走格落在主分量上?
act1_town301 × 35020,003916,572 / 3,16682.8%7 / 265
clockwork_boss401 × 22618,310315,715 / 2,59485.8%1 / 1(落在 2,594)
echopass_jd601 × 105086,6112934,413 / 28,058 / 13,465 / 8,322 / 880 / 555 / 43039.7%22 / 488(落在 28,058)
echopass_jt650 × 90080,4962956,734 / 13,467 / 8,356 / 898 / 42970.5%24 / 612
echopass_lm925 × 90087,9412364,156 / 13,465 / 8,322 / 430 / 411 / 358 / 28173.0%16 / 518
echopass_pb625 × 112599,8342853,709 / 23,451 / 13,465 / 8,322 / 43053.8%23 / 457(落在 129 格的碎屑上)
sundered_zone1328 × 1352506,88667466,800 / 37,023 / 2,148 / 33092.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 定位链

  1. 解卡计数器只说「推了 849 次」,不说被谁挡 ⇒ 加了一个 collider 点名器(VANILLA_NAVDEBUG=1),把接触点的 collider 名字打出来。
  2. 点名结果:col_Room_Piece0_clone723428_85,法线 (0.96, 0.00, 0.29)——一面朝 +X 的竖直墙
  3. 地面高度剖面(前方每 0.4u 打一条竖直射线):0.00 / 0.00 / 2.00 / 1.72 / 0.79 / 0.00 …⇒ 前方 0.8–1.6u 有个 2u 高的坎,爬台阶逻辑正确拒绝(MAX_STEP = 0.6)。
  4. 把走格与几何并排打在同一批格子上:走格那一整行全是可走,而几何有一条 1.1–4.0u 的抬高带。
  5. 走格对齐校验:单位落在可走格 32/35(91%),转置对照 0% ⇒ 走格没错位,是几何多出来一条带
  6. 顺着 .tscn 查那个节点的 mesh:res://meshes/props/z1tundra_props/rock_06.res——一块石头

8.3 定性

复刻实现的 tl2_terrain_collision.gd 按资源名字分类:rockOBSTACLE_KEYS 里 ⇒ 给它挂了实心碰撞体。而发行 .mpp 说那一格可走

.mpp 是对的。 TL2 没有 3D 碰撞体,能不能站只由 CLevel::isWalkablesub_5DB690)单点查表说了算。那块石头在原版里根本不挡人。墙是复刻侧造的。

既有的「保守解卡」只推 XZ、保留旧 Y,把身体往石头里塞,下一帧物理原样推回来 ⇒ 849 次空转、净位移 0。

8.4 这不是个案:七张图的普查

tools/f2_phantom_walls.py.tscn 读每个 MeshInstance3D 的 mesh 路径,按分类器源码里解析出来的关键词表判层,再问走格那一格可不可走。

场景节点数OBSTACLEGROUND不建碰撞幽灵墙占 OBSTACLE 比
act1_town1,8001231161,5613629.3%
clockwork_boss7061696347442.4%
echopass_jd4,1843274453,4127522.9%
echopass_jt5,5984704424,68611524.5%
echopass_lm4,5242404573,8275322.1%
echopass_pb6,4735145305,42911221.8%
sundered_zone21,0123,1005,02412,8882447.9%
合计4,94363913%

echopass_jd 上的几个例子:

节点meshXZ
Room_Piece0_clone12282319_12props/Z1Tundra_props/rock_05.res(38.2, 68.9)
Room_Piece0_clone12093446_34props/z1tundra_props/rock_06.res(37.9, 70.6)
Room_Piece0_clone12282230_61props/Z1Tundra_props/rock_05.res(54.9, 67.6)
Room_Piece0_clone12227765_65props/z1tundra_props/rock_06.res(75.9, 25.7)
Room_Piece3_clone12281951_75props/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_jdtimeout / hits 0 / kills 0 / forced 849ok / hits 45 / kills 3 / forced 1(穿模只进了 1 次、4 帧)
echopass_lmok / hits 36 / repaths 308ok / hits 36 / repaths 131
echopass_pbok / hits 43 / repaths 222ok / hits 43 / repaths 40
echopass_jtdied / hits 6died / hits 2hits >= 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

功能虚地址 / 符号
寻路模块区间0x462F600x464400(24 个函数)
移动侧入口sub_5425A0sub_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 @ 0x463ED7fld [0x2143F64] @ 0x463F76cmp ebp, [esi+0x44] @ 0x463F8A
邻域展开(8 次 call)sub_463940(1367 字节)
松弛邻居(g + 1.0,h = 距离²)sub_4637B0(387 字节;fadd [0x213C5DC] @ 0x4637BFcmp ecx, 8 @ 0x463881
开表按 f 升序插入sub_463220(65 字节)
g 回传闭合结点sub_463650(266 字节)
每步代价常量 float 1.00x213C5DC
提前收工阈值 float 2.00x2143F64
吸附哨兵 float 9999.00x214882C
CLevel::isWalkable(单点查表,setlesub_5DB690

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

  • 真值账本 truth/ledger.jsonl
    • C-0173(原版走格寻路:8 邻域 A*、每步代价恒 1.0、启发是距离的平方;两套判定;复刻侧口径差异);
    • C-0174(幽灵墙:按名字给道具加碰撞体,而 TL2 没有 3D 碰撞;4,943 / 639 普查)。
  • 数据结构与静态配置(原版游戏分析/reversed_cpp/REGISTRY/walkgrid_pathfinding.jsonphantom_walls.json
  • 工具链tools/f2_walkgrid.py(走格导出)、tools/f2_walkgrid_reach.py(连通分量普查)、tools/f2_phantom_walls.py(幽灵墙普查,带端到端阳性对照)。