核心定位sub_5F6250CLevel 在布局内容实例化之后执行的“碰撞与通行性总烘焙”函数。它不是单纯生成碰撞,也不是 A* 寻路器;它把场景中的 CRoomPiece 碰撞网格汇总为 CCollisionList,构建 CQuadtreeCollision,配置 Ogre StaticGeometry,在 HASPATHING 开启时分配三层 0.4u 走格、导入 .MPP、叠加动态阻挡区域,最后让相关房间刷新路径缓存。

分析依据:通过本地 IDA MCP 对 32 位 Torchlight2.exe 进行只读反编译、交叉引用和关键指令复核。IDA 数据库为 E:/Torchlight 2/Torchlight2.exe.i64,映像基址 0x400000。本文所有 0x…… 均为绝对虚地址。

关联文档:数量烘焙后形成的零值走格计数如何进入怪物密度公式,参见 MONSTERS_PER_METER 完整参考;A* 如何消费走格,参见 TL2 走格寻路逆向分析


0. 核心结论速览

  1. sub_5F6250 位于 0x5F6250~0x5F98BB,函数体 0x366C 字节,即 13,932 字节;IDA 识别出 412 个基本块、圈复杂度 100,是一个被 STL、Ogre 数学类型和 SEH 清理代码放大的总控函数。

  2. 它只有一个调用者:sub_5F99100x5FAB62 调用。调用前性能标签是 BAKE TIME,调用后是 BAKE COLLISION TIME,直接证明它处在关卡生成的碰撞烘焙阶段。

  3. IDA 自动给出的 int __fastcall sub_5F6250(int, int, char) 是错误原型。汇编是 ECX=this、栈上压入一个布尔值、结尾 retn 4;正确近似签名应是:

    cpp
    void __thiscall CLevel::bakeCollisionAndPassability(bool has_pathing);
  4. has_pathing 来自 CLevelTemplateData + 0xD4 的配置键 HASPATHING,构造函数默认 true。原版显式写 false 的主要是 13 份 MAINMENUS 规则模板。

  5. HASPATHING:false 只跳过走格、passability 与路径缓存阶段;碰撞网格汇总、CCollisionListCQuadtreeCollision 和 Ogre StaticGeometry 仍会构建。

  6. CLevel + 0x5C 是新建的 CCollisionList*CLevel + 0x58 是由它烘焙出的 CQuadtreeCollision*。函数入口先析构旧对象再重建。

  7. 碰撞来源主要是每个布局根节点下 descriptor 为 Room PieceCRoomPiece。函数读取模型碰撞面,应用完整的缩放、旋转、平移矩阵,再汇入关卡级碰撞表;碰撞模型没有面时记录 Collision mesh ... has no faces!

  8. 走格范围按全局碰撞 AABB 对齐到 0.4 世界单位,因为换算使用 2.50.40000001。边界额外向外扩一格。

  9. 函数分配三张 width × height 字节网格:前两张初始化为 -1,第三张初始化为 0。随后 sub_5F36D0 按房间 .MPP 和动态区域写入这些网格。

  10. sub_5F36D0 同时累计 CLevel + 0x54 的零值格子数;该计数稍后被 MONSTERS_PER_METERPROPS_PER_METER 等人口密度计算共同使用。因此 sub_5F6250 是“布局走格”连接到“怪物数量”的关键桥梁。

  11. 动态 passability 阶段会重新收集一类特殊 Room Piece,把其碰撞 AABB 在 X/Z 各扩 0.4,Y 范围拉到 -1000~1000,合并为区域并重新栅格化。

  12. 末尾对 CLevel + 0x280 的房间列表执行两遍刷新:先设置/失效 pathing 状态并临时切换可见性,再调用 sub_622C20 重建动态注册。全局计数器最终输出 Rooms RepathedPathnodes CalculatedRooms PathCachedPathnodes Loaded


1. 函数身份、调用点与真实原型

1.1 基本信息

项目
函数地址0x5F6250
结束地址0x5F98BC 对齐前
大小0x366C / 13,932 字节
基本块412
圈复杂度100
局部栈空间0xCE8 字节,栈先对齐到 64 字节
直接调用者sub_5F9910,调用点 0x5FAB62
建议名称CLevel::bakeCollisionAndPassability

大栈帧主要来自几十个临时 Ogre::Vector3Ogre::Matrix4Ogre::AxisAlignedBoxstd::wstring 和临时 vector allocator,并不意味着 CLevel 本身有 3 KB 的单个算法状态。

1.2 调用时机

调用者 sub_5F9910 的相关顺序是:

cpp
sub_5F1C70(level, ...);                // 布局内容实例化/初步 bake
profile_mark(L"BAKE TIME");

level->bakeCollisionAndPassability(
    level_template->has_pathing
);

profile_mark(L"BAKE COLLISION TIME");

调用结束之后,sub_5F9910 才继续清理临时生成状态、处理 Exit 等后续关卡逻辑。由此可知 sub_5F6250 处理的是“所有房间已经存在,但游戏人口和最终运行逻辑尚未完全展开”的中间阶段。

1.3 为什么 IDA 的三参数原型是错的

调用点的真实指令:

assembly
mov  edx, [esp+...+var_1E8]  ; 低字节是 HASPATHING
push edx                     ; 唯一栈参数
mov  ecx, ebx                ; ECX = CLevel *this
call sub_5F6250

函数结尾:

assembly
mov esp, ebp
pop ebp
retn 4

retn 4 证明被调者只清理 4 字节栈参数。调用点恰好把同一个值留在 EDX 又压栈,Hex-Rays 因寄存器活跃性将它误认成第二个 fastcall 参数,进而显示:

cpp
int __fastcall sub_5F6250(int a1, int a2, char a3);

更合理的源级原型是:

cpp
void __thiscall CLevel::bakeCollisionAndPassability(bool has_pathing);

返回值也没有业务意义:尾部 EAX 只是临时 vector<AxisAlignedBox> allocator 的清理调用残留,唯一调用者完全忽略它。


2. HASPATHING 的来源与默认值

CLevelTemplateData 构造函数 sub_60BBE00x60BBE0)设置:

cpp
*(bool *)(this + 0xD4) = true;  // HASPATHING
*(bool *)(this + 0xD5) = true;  // HASAUTOMAP
*(bool *)(this + 0xD6) = true;  // 相邻的另一标志,本文未命名

模板读取函数 sub_604D200x604D20)明确把键映射到字段:

cpp
this->has_pathing = group.getBool(
    L"HASPATHING",
    this->has_pathing
);

this->has_automap = group.getBool(
    L"HASAUTOMAP",
    this->has_automap
);

调用者 sub_5F9910 分别取出:

cpp
bool has_pathing = *(bool *)(level_template + 0xD4);
bool has_automap = *(bool *)(level_template + 0xD5);

其中 has_automap 传给前面的 sub_5F1C70has_pathing 才传给 sub_5F6250。两者相邻且在反编译里共用被错误恢复的栈变量,阅读时很容易混淆。

原版数据中显式关闭 HASPATHING 的规则都在 MEDIA/LAYOUTS/MAINMENUS/,例如:

text
<BOOL>HASPATHING:false

包括 MAINMENU_TOWNRULES.TEMPLATEMAINMENU_SPLASHRULES.TEMPLATE、各幕主菜单背景规则等共 13 份。其余模板通常省略该键,继承默认 true

IMPORTANT

HASPATHING:false 不等于“整个场景没有碰撞”。函数在判断该参数之前已经完成关卡碰撞面汇总、四叉树碰撞和 StaticGeometry 构建;它关闭的是 2D passability/路径缓存一侧。


3. 总体阶段图

text
sub_5F9910:布局生成完毕


sub_5F6250

        ├─ ① 释放旧 CCollisionList / CQuadtreeCollision
        ├─ ② 新建 CCollisionList,遍历布局根对象
        │      ├─ 收集 CRoomPiece
        │      ├─ 变换碰撞网格
        │      ├─ 合并房间与全局 AABB
        │      └─ 合并特殊 CPropertyNode AABB
        ├─ ③ 收尾 CCollisionList:边界、面法线、索引
        ├─ ④ 配置并 build Ogre StaticGeometry
        ├─ ⑤ 创建 CQuadtreeCollision,插入全部碰撞面

        ├─ HASPATHING == false ───────────────────────┐
        │                                             │
        └─ HASPATHING == true                         │
               ├─ ⑥ 0.4u 对齐全局范围,分配三层网格 │
               ├─ ⑦ 按房间导入 .MPP 静态通行性       │
               ├─ ⑧ 收集动态碰撞 AABB并重新栅格化      │
               ├─ ⑨ 规范化/释放临时层                 │
               └─ ⑩ 失效并重建房间路径缓存             │

        ◄─────────────────────────────────────────────┘


释放临时 AABB vector,返回调用者

关键阶段地址:

地址范围阶段
0x5F627A~0x5F6469清理旧对象、创建 CCollisionList、读取 COMPRESS
0x5F646A~0x5F72A7遍历布局根对象,汇总房间碰撞与边界
0x5F72AD~0x5F73B7输出 Collision Assemble 统计
0x5F73BD~0x5F76D3收尾碰撞表并构建 Ogre StaticGeometry
0x5F76D4~0x5F7ACA创建 CQuadtreeCollision、插入碰撞面
0x5F7AEAHASPATHING && collision_list 总门
0x5F7AFB~0x5F7CF8对齐范围、分配并初始化三层走格
0x5F7CFD~0x5F855F静态 passability / .MPP 装配
0x5F85FF~0x5F904A动态 Room Piece AABB 装配
0x5F905C~0x5F94CECOMPRESS 分流、动态区域栅格化与网格规范化
0x5F94D6~0x5F9639房间 pathing 状态与路径缓存刷新
0x5F9646~0x5F9826输出路径统计计数器
0x5F982C~0x5F98B9进度回调、局部容器析构、返回

4. 阶段一:旧资源释放与统计器复位

函数入口先把四个全局统计值清零:

cpp
unk_335272C = 0; // Rooms Repathed
unk_3352730 = 0; // Pathnodes Calculated
unk_3352734 = 0; // Rooms PathCached
unk_3352738 = 0; // Pathnodes Loaded

名称由函数尾部日志一一对应,不是猜测。

随后清理关卡上一次烘焙留下的对象:

CLevel 偏移已确认类型入口行为
+0x5C / 92CCollisionList*调虚析构并交还对象分配器,置 NULL
+0x58 / 88CQuadtreeCollision*调虚析构并交还对象分配器,置 NULL

接着从引擎对象分配器申请 216 字节并调用 sub_468930

cpp
level->collision_list = new CCollisionList();

sub_468930 写入了明确的 RTTI/vtable:

cpp
*this = &CCollisionList::vftable;

因此 CLevel + 0x5C 的类型是直接证据,不是根据行为推断。

函数还读取一次名为 COMPRESS 的全局字符串选项。读取器 sub_6856E0 查询的是引擎注册的键值树;原版 MEDIA 中没有 <...>COMPRESS: 数据键。它更接近进程/工具模式选项,而不是普通 dungeon TEMPLATE 属性。

CLevel + 0x36E(878)若为真会强制把该模式视为开启;构造函数默认将它置 false,在当前关卡模块中没有找到其他直接写入点。


5. 阶段二:收集并变换 CRoomPiece 碰撞网格

5.1 外层对象

函数遍历 CLevel + 0x10 指向的对象指针表,数量在 CLevel + 0x14。每个非空元素看起来是一份已实例化布局/房间根对象。

对每个根对象,函数创建两个局部碰撞包围盒,初始为 Ogre infinite 状态,然后按 descriptor 查询其后代:

cpp
collectByDescriptor(root, L"Room Piece", room_pieces);

结果逐个从 CEditorBaseObject* RTTI 转为 CRoomPiece*

cpp
CRoomPiece* piece = dynamic_cast<CRoomPiece*>(object);

处理的必要条件包括:

  • dynamic cast 成功;
  • piece + 0x108 的资源句柄可解引用;
  • sub_622BE0(piece) 返回真。

sub_622BE0 只有 7 字节,等价于:

cpp
bool CRoomPiece_getFlag388(CRoomPiece* piece)
{
    return *(bool *)(piece + 0x184); // 十进制 388
}

该字段随后也由 sub_622DC0 设置,并与动态注册/路径缓存刷新相关。本文暂称 pathing_enabled,官方名称尚未恢复。

5.2 完整世界变换

对每个合格 CRoomPiece,函数构造:

text
M = Translation × Rotation × Scale

具体来源:

  • 缩放:CRoomPiece 的虚函数 +108
  • 旋转:其 Ogre SceneNode 虚函数 +188 返回 quaternion;
  • 平移:sub_61DCE0(piece, ..., true) 返回世界位置。

随后读取碰撞资源的局部最小/最大点,构造 AABB 的四个 XZ 角点,逐点乘 M,用变换后的点重新合并世界 AABB。这里不是只平移原始 AABB;缩放和旋转都参与。

5.3 碰撞面为空的保护

碰撞资源的 face 指针为空时,函数不会继续提交该网格,而是记录:

text
Collision mesh <资源名> has no faces!

之后恢复 CRoomPiece 原有可见/启用标志并继续下一个对象,不会让整个关卡烘焙失败。

5.4 静态/特殊房间分类

sub_621F600x621F60)递归沿编辑器对象的父层级查找 CRandomGroup,检查其内部状态、五个名称字段和 +602 标志。sub_5F6250 用它把 Room Piece 的碰撞范围分进两个局部 AABB 集合:

cpp
if (sub_621F60(piece, nullptr))
    merge(special_piece_bounds, transformed_bounds);
else
    merge(normal_piece_bounds, transformed_bounds);

同一判据又出现在后面的“动态 passability”阶段,因此它可以可靠地描述为“是否进入特殊/动态通行性处理”的判据;但五个名称 getter 的官方语义尚未全部恢复,本文不把它强行命名为单一 isDynamic 布尔字段。

5.5 CPropertyNode 对全局范围的影响

每个布局根对象处理完 Room Piece 后,还会查询:

cpp
collectByDescriptor(root, L"Property Node", property_nodes);

CPropertyNode + 0x104(数组下标 65)等于 19 时,函数调用其虚函数 +236 更新自身范围,再把 node + 0x114 附近的 AABB 合并到关卡总范围。

这意味着某些 Property Node 虽然没有三角形碰撞面,仍能扩张碰撞/passability 烘焙边界。

5.6 每房间和全局 AABB

每个外层布局根对象最终形成一份对齐后的 AABB,并追加到临时 vector<Ogre::AxisAlignedBox>。局部范围在 X/Z 上按 10 世界单位向外对齐:

cpp
aligned_min_x = floor((min_x - 0.2f) * 0.1f) * 10.0f;
aligned_min_z = floor((min_z - 0.2f) * 0.1f) * 10.0f;
aligned_max_x = ceil ((max_x + 0.2f) * 0.1f) * 10.0f;
aligned_max_z = ceil ((max_z + 0.2f) * 0.1f) * 10.0f;

总范围选用 normal 还是 special AABB 受 COMPRESS 模式影响:开启时选择 normal 集合,关闭时选择 special 集合。该分支的机械行为已确认,但“为什么压缩模式选择这一组”的资产管线语义仍需继续分析 COMPRESS 的上游启动方式。

阶段结束时输出:

text
Collision Assemble Time: <秒>
Collision Pieces :<数量>

Collision Pieces 是所有外层布局中收集到的 Room Piece 候选总数,而不一定等于成功提交碰撞面的数量。


6. 阶段三:CCollisionList 收尾

房间遍历结束后,对 CLevel + 0x5CCCollisionList 连续调用:

cpp
sub_469430(level->collision_list);
sub_469570(level->collision_list);

6.1 sub_469430:边界与面顶点缓存

该函数:

  • 遍历全部顶点,计算列表全局 min/max;
  • 清零两个派生计数;
  • 遍历全部三角面;
  • 按三角面三个顶点索引重新计算每面的局部 min/max。

因此它可以暂命名为:

cpp
CCollisionList::rebuildBoundsAndFaceExtents();

6.2 sub_469570:面法线

该函数对每个三角形执行:

cpp
Vector3 e1 = v2 - v1;
Vector3 e2 = v1 - v0;
Vector3 normal = normalize(cross(e1, e2));
store_normal(normal);

可暂命名为:

cpp
CCollisionList::rebuildFaceNormals();

当动态碰撞面在后续阶段追加到 CCollisionList 后,这两个函数还会再调用一次,说明列表允许先烘焙静态面、再增量追加特殊房间面。


7. 阶段四:Ogre StaticGeometry 构建

CLevel 持有至少八个 Ogre StaticGeometry*

偏移数量/用途
+0x228~+0x23C(552~572)连续 6 个 StaticGeometry 指针
+0x240(576)额外一组,visibility flags 最终设为 2
+0x244(580)额外一组,visibility flags 最终设为 4

函数对前六组逐一设置:

  • region origin;
  • region dimensions;
  • 自定义组标识;
  • setAllowPurge(true)

region origin 默认是 (0,0,0);若 CLevel + 0x24CCLevelTemplateData* 存在,则读取模板 +0xBEC(十进制 3052)处的 Vector3

region dimension 默认参数:

text
较小尺度 = 48
较大尺度 = 150

某个整数设置等于 1 时改为:

text
较小尺度 = 30
较大尺度 = 75

之后所有组调用虚函数 +52 执行 build。+0x240 组设置 visibility mask 2+0x244 组设置 mask 4;后者还额外关闭一个虚函数 +76 控制的标志,前六组和 +0x240 则把该标志打开。

阶段日志:

text
Geom Build Time: <秒>

NOTE

StaticGeometry 是渲染批处理结构,不等于 CCollisionList。这个函数同时构建两者,是因为它是关卡 bake 总控,不代表 TL2 用渲染几何直接做运行期碰撞。


8. 阶段五:创建 CQuadtreeCollision

8.1 类型与保存位置

函数通过引擎分配器申请 44 字节,调用 sub_5EFE100x5EFE10)。该构造函数明确写入:

cpp
*this = &CQuadtreeCollision::vftable;

结果保存到:

cpp
level->quadtree_collision = ...; // CLevel + 0x58

CQuadtreeCollision 构造函数还会把 X/Z 范围扩成正方形:取 abs(maxX-minX)abs(maxZ-minZ) 的较大者,把较短轴两边各扩一半差值。这样四叉树每层可以规则地四分。

8.2 “Smallest Face”参数

函数计算总碰撞 AABB 的对角线长度:

cpp
float scaled = bounds_diagonal.length() * 0.001f;
int smallest_face = 1;

if (scaled > 1.0f)
    smallest_face = (int)scaled;

这是正数向零截断,因此等价于:

cpp
smallest_face = max(1, floor(bounds_diagonal.length() * 0.001f));

该值传给 CQuadtreeCollision 构造函数,并输出:

text
Smallest Face :<值>

名称来自原版日志;它更可能是四叉树叶级/分割粒度参数,而不是场景中三角形几何面积的最小值。

8.3 插入全部碰撞面

CCollisionList + 0x94(148)是面数。函数逐面取得三角形索引/顶点并调用 sub_5EF920 插入四叉树。该 helper 会:

  • 判断三角形是否与当前节点范围相交;
  • 懒分配四个子节点;
  • 将面继续下发到相交子树;
  • 在达到范围/粒度条件时保存到当前节点。

阶段输出:

text
Collision Bake Time: <秒>
Collision Faces :<面数>
Quadtree Build Time: <秒>

Collision Bake Time 的起点取自函数入口附近,而不只是构造四叉树的局部时间;阅读性能日志时应以指令中的时间戳差为准,不要只按文字理解。


9. HASPATHING 总门

四叉树完成后,0x5F7AEA 才检查:

cpp
if (has_pathing && level->collision_list != nullptr) {
    // 所有走格、静态/动态 passability、路径缓存逻辑
}

这条门的两部分含义:

  • HASPATHING:false:跳过整个 2D 走格阶段;
  • 没有成功创建 CCollisionList:即使配置为 true 也不能继续。

主菜单背景设置 HASPATHING:false,正好符合“需要正常渲染和场景资源,但不需要玩家/怪物寻路”的使用场景。


10. 阶段六:0.4u 网格范围、尺寸与三层内存

10.1 全局边界对齐

总碰撞范围先在 X/Z 上扩 0.2,换算到每单位 2.5 格的坐标,向外取整,再额外加一格:

cpp
origin_x = (floor((min_x - 0.2f) * 2.5f) - 1.0f) * 0.4f;
origin_z = (floor((min_z - 0.2f) * 2.5f) - 1.0f) * 0.4f;

end_x = (ceil((max_x + 0.2f) * 2.5f) + 1.0f) * 0.4f;
end_z = (ceil((max_z + 0.2f) * 2.5f) + 1.0f) * 0.4f;

width  = int((end_x - origin_x) * 2.5f);
height = int((end_z - origin_z) * 2.5f);

保存位置:

CLevel 偏移类型含义
+0x2B0 / 688float网格 origin X
+0x2B4 / 692float网格 origin Z
+0x2B8 / 696float网格 end X
+0x2BC / 700float网格 end Z
+0x4C / 76uint32_twidth
+0x50 / 80uint32_theight

2.5 × 0.4 ≈ 1 是完整互逆关系;这里的 0.40000001 是单精度显示误差。

2026-09-07 的同轮 Frida 端到端样本还把公式从“静态反编译”提升为运行期复算:

Echo Pass / PASS_JT_A / Seeding: -2123059669 的同轮实测如下:

XZ
合并碰撞 AABB minimum-290.0-160.0
合并碰撞 AABB maximum-20.0200.0
公式 origin-290.8000183105469-160.8000030517578
公式 end-19.200000762939453200.8000030517578
公式尺寸679904
CLevel+0x4C/+0x50 活字段679904

逐轴代入可写成:

text
X: floor((-290 - 0.2) * 2.5) - 1 = -727  -> origin = -290.8
   ceil (( -20 + 0.2) * 2.5) + 1 =  -48  -> end    =  -19.2
   trunc((end - origin) * 2.5) = 679

Z: floor((-160 - 0.2) * 2.5) - 1 = -402  -> origin = -160.8
   ceil (( 200 + 0.2) * 2.5) + 1 =  502  -> end    =  200.8
   trunc((end - origin) * 2.5) = 904

原始 AABB 在 0x5F7AFBgetMinimum 前从 ECX 指向的对象读取;最终字段在同一次 sub_5F6250 返回时读取,六项完全一致。另一轮程序化关卡样本 [40, 80]..[280, 500] -> 604×1054 也满足同一公式,作为独立复核。

10.2 三层网格

函数分配三套“行指针数组 + 每行字节数组”:

CLevel 偏移初始值生命周期线索
+0x40 / 64-1主网格,后续把残留 0xFF 规范化为 1
+0x44 / 68-1临时/第二层,动态计算结束后释放
+0x48 / 720第三层,继续保留

初始化 helper sub_5DB5800x5DB580)的完整核心就是:

cpp
for (uint32_t x = 0; x < width; ++x) {
    for (uint32_t z = 0; z < height; ++z) {
        grid_40[x][z] = -1;
        grid_44[x][z] = -1;
        grid_48[x][z] = 0;
    }
}

字节级核对如下;三次 store 使用同一个 [row+z],只有网格字段与立即数不同:

asm
005DB596  8B 71 40 8B 34 96 C6 04 30 FF  ; +0x40[x][z] = -1
005DB5A0  8B 71 44 8B 34 96 C6 04 30 FF  ; +0x44[x][z] = -1
005DB5AA  8B 71 48 8B 34 96 C6 04 30 00  ; +0x48[x][z] = 0
005DB5B8  3B C6 72 DA                    ; z < +0x50
005DB5BD  3B 51 4C 72 CE                 ; x < +0x4C

在非 COMPRESS 的完整计算路径中,函数最后执行:

cpp
free_all_rows(grid_44);
grid_44 = nullptr;

for each cell in grid_40:
    if (grid_40[x][z] == 0xFF)
        grid_40[x][z] = 1;

因此 -1 在烘焙过程中是“尚未由房间定义”的哨兵;在主运行网格最终规范化时,它会变成正值 1,即阻挡。

末尾的释放与规范化也已逐条核字节:

asm
005F93E0  mov ecx, [ebx+44h]             ; 逐行 delete[]
...
005F93F5  mov eax, [ebx+44h]             ; delete[] 行指针数组
005F9401  89 7B 44                       ; +0x44 = nullptr

005F9417  8B 43 40                       ; 主网格 +0x40
005F941F  80 38 FF                       ; 精确比较 0xFF
005F9422  75 03                          ; 不是 0xFF 就保留
005F9424  C6 00 01                       ; 0xFF -> 1
005F9428  3B 4B 50 72 EA                 ; 完整内层高度
005F942E  3B 53 4C 72 DD                 ; 完整外层宽度

所以这里不是把所有非零格布尔化;01 以及占位节点留下的其他正值都保持原样, 仅残留哨兵 0xFF 被封口为阻挡 1


11. 阶段七:静态 passability 与 .MPP 装配

静态通行性装配主要调用 sub_5F36D00x5F36D0)。该函数会:

  1. 接收布局文件名并把扩展名替换成 .mpp
  2. 读取 MPP 字节格;
  3. 根据房间世界 AABB 与变换把局部格写入 CLevel 三层网格;
  4. 对零值格进行统计;
  5. 在某些标志组合下把没有缓存文件的区域交给运行期碰撞计算。

11.1 每房间路径

常规路径遍历前面保存的每房间 AABB,并取得相应布局根对象的文件名/世界位置:

cpp
for each generated_room:
    import_mpp_into_level_grid(
        room.layout_file,
        room_bounds,
        room_world_position,
        flags...
    );

当关卡模板某个分组数大于 1 时,代码还会依据模板的 cell 尺寸、room 坐标和边缘偏移构造四条窄 AABB,Y 范围固定为 -1000~1000。若这些窄盒不被已有区域包含,就加入待补算列表。这是瓦片接缝处的通行性补洞逻辑。

常量包括:

  • 接缝带宽/偏移:6.0
  • Y 范围:-1000.0~1000.0
  • 世界到走格换算:2.5

11.2 无逐房间范围时的回退

如果没有可用的逐房间 AABB,函数会选择:

  • 第一间房的布局文件与全局范围;或
  • 空文件名、全局碰撞列表范围和缺省位置。

仍然调用 sub_5F36D0,保证合法关卡不会因为缺少某个缓存范围而完全没有走格。

阶段输出:

text
Passability Assembly Time: <秒>

11.3 零值格计数

sub_5F36D0 在两条写入路径中都有:

cpp
if (written_grid_byte == 0)
    ++level->field_54;

因此 CLevel + 0x54 可严格命名为“零值布局格累计数”,而“可用/可走面积”是依据后续消费方式给出的高可信解释。

这一字段由后续人口填充函数传给 sub_600810,进入:

text
MONSTERS_PER_METER
CHAMPIONS_PER_METER
PROPS_PER_METER
NPCS_PER_METER
CREEPS_PER_METER
GOLD_PER_METER
...

详见 MONSTERS_PER_METER 完整参考


12. 阶段八:动态/特殊 Room Piece 通行区域

静态 .MPP 装配之后,函数再次遍历每份布局下的 Room Piece,但这次只处理同时满足:

cpp
piece->flag_388
&& sub_622BE0(piece)
&& sub_621F60(piece, nullptr)

的对象。对每个候选:

  1. 重新取得缩放、旋转、世界平移;
  2. 变换碰撞网格的 XZ 四角;
  3. 生成世界 AABB;
  4. X/Z 最小值各减 0.4,最大值各加 0.4
  5. Y 直接改为 -1000~1000
  6. 若没有被既有排除/覆盖 AABB 完全包含,则加入本房间动态范围;
  7. 合并同一房间所有候选为一份较大 AABB;
  8. 对齐到 0.4u 走格边界,加入动态区域 vector。

X/Z 扩一格的目的很明确:动态碰撞对象不能只封自己正下方的格,还需要给边缘留一个走格净空。

如果这一阶段向 CCollisionList 新增了三角面,函数比较前后 face count:

cpp
if (face_count_after != face_count_before) {
    collision_list->rebuildBoundsAndFaceExtents();
    collision_list->rebuildFaceNormals();

    for each newly_added_face:
        quadtree_collision->insert(face);
}

这样无需完全重建四叉树,只把新增面补进去。

阶段输出:

text
Dynamic Passability Assembly Time: <秒>

13. COMPRESS 分支

动态 AABB 收集结束后,函数再次读取 COMPRESS。机械行为如下:

COMPRESS 开启

  • 析构全局碰撞 AABB 和局部动态 AABB vector;
  • 跳过后面的逐动态区域 sub_5F36D0 计算;
  • 进入统一收尾。

COMPRESS 关闭

  • 遍历 CLevel + 0x198 指向的 AABB 数组,数量 CLevel + 0x1A4

  • 对每个有限 AABB 调用 sub_5F36D0,标志组合为运行期动态计算路径;

  • 累加每个区域的 XZ 面积:

    cpp
    area += (max_x - min_x) * (max_z - min_z);
  • 输出区域数、总面积和计算耗时;

  • 析构这些 AABB 并把数量清零;

  • 释放 CLevel + 0x44 的第二层网格;

  • 把主网格残留的 0xFF 转成 1

对应日志:

text
Dynamic Passability Regions: <数量>
Dynamic Passability Area: <XZ 面积整数化结果>
Dynamic Passability Calculation Time: <秒>
Passability Calculation Time: <秒>

从调用者集合和“查询全局注册键值树”的方式看,COMPRESS 高度疑似资产压缩/二进制缓存生成模式,而不是游戏规则键。但本文尚未沿 _WinMain 完整恢复它由命令行、GUTS UI 还是内部转换器设置;因此只记录已确认的分流行为。


14. 阶段九:房间 pathing 状态与缓存刷新

CLevel + 0x280(640)是需要刷新的一组 CRoomPiece*,数量在 +0x284(644);+0x290(656)有一张对应的字节标志表。

函数执行两轮遍历。

14.1 第一轮:设置 pathing 标志并触发可见性边沿

若 Room Piece 的资源或对象标志表明它走特殊碰撞路径,则新状态强制为 false;否则取 CLevel + 0x290 的对应字节。随后调用 sub_622DC0

sub_622DC0 的核心是:

cpp
piece->flag_388 = new_value;
if (rebuild_now)
    sub_622C20(piece);

接着函数保存 piece + 0x69 的当前可见/启用字节,调用虚函数 +56 先设为反值、再恢复原值:

cpp
bool old = piece->flag_105;
piece->setEnabled(!old);
piece->setEnabled(old);

这不是视觉闪烁,而是有意制造一次状态边沿,让依赖“启用状态改变”的注册逻辑失效旧缓存。

14.2 第二轮:sub_622C20

第二轮对每个非空 Room Piece 直接调用 sub_622C200x622C20)。该函数根据:

  • piece + 0x184 的 pathing 标志;
  • piece + 0x69 的启用/可见标志;
  • Ogre SceneNode::isVisible()
  • 碰撞资源是否存在;
  • piece + 0x1C5 是否已注册;

决定把对象加入或移出关卡的动态碰撞/路径结构。

启用且尚未注册时,它保存世界位置、建立变换矩阵并调用 sub_5F4730 加入;关闭且已经注册时调用 sub_5F4AE0 移除,然后恢复 SceneNode 位置。

所以第二轮不是 A* 搜索,而是让房间对象与新烘焙出的关卡碰撞/走格状态重新同步


15. 性能日志与四个全局路径统计器

函数会输出以下阶段日志:

日志文本表示的阶段
Collision Assemble Time:遍历 Room Piece、变换并汇总碰撞的耗时
Collision Pieces :收集到的 Room Piece 候选数量
Geom Build Time:Ogre StaticGeometry build 耗时
Smallest Face :CQuadtreeCollision 分割粒度参数
Collision Bake Time:从较早时间戳累计到四叉树创建后的耗时
Collision Faces :CCollisionList 三角面数量
Quadtree Build Time:向四叉树插入碰撞面的局部耗时
Passability Assembly Time:MPP/静态走格装配耗时
Dynamic Passability Assembly Time:特殊 Room Piece 范围收集和追加面耗时
Dynamic Passability Regions:运行期补算的 AABB 数量
Dynamic Passability Area:所有补算 AABB 的 XZ 面积和
Dynamic Passability Calculation Time:动态区域栅格化耗时
Passability Calculation Time:从 passability 阶段起始时间累计的总耗时

最后用分隔线输出四个全局计数器:

地址日志含义
0x335272CRooms Repathed :本轮重新计算路径的房间数
0x3352730Pathnodes Calculated :新计算的路径节点数
0x3352734Rooms PathCached :写入/生成路径缓存的房间数
0x3352738Pathnodes Loaded :从缓存读取的路径节点数

这些计数器在函数入口清零,但主要由下游 pathing/cache helper 增加,sub_5F6250 自身只是定义一次 bake 的统计窗口。


16. CLevel 关键字段图

下表只列本函数中能较高置信度命名的字段:

偏移形式建议名称证据/用途
+0x10指针表generated_rooms.data外层布局/房间根对象数组
+0x14uint32_tgenerated_rooms.size主要房间循环上界
+0x40uint8_t**passability_grid_primary初始 -1,最终残留 -1 转 1
+0x44uint8_t**passability_grid_secondary初始 -1,动态计算后释放
+0x48uint8_t**passability_grid_aux初始 0
+0x4Cuint32_tpassability_widthX 方向格数
+0x50uint32_tpassability_heightZ 方向格数
+0x54uint32_tzero_grid_cell_countsub_5F36D0 累计,人口密度使用
+0x58CQuadtreeCollision*quadtree_collisionsub_5EFE10 明确写入 vtable
+0x5CCCollisionList*collision_listsub_468930 明确写入 vtable
+0x198AxisAlignedBox*dynamic_regions.data逐区域 passability 补算
+0x1A4uint32_tdynamic_regions.size日志 Dynamic Passability Regions
+0x228StaticGeometry*[6]static_geometry_groups连续构建六组
+0x240StaticGeometry*static_geometry_mask_2visibility flags = 2
+0x244StaticGeometry*static_geometry_mask_4visibility flags = 4
+0x24CCLevelTemplateData*level_template_data读取 region origin 等规则
+0x280CRoomPiece**path_refresh_rooms.data末尾两轮刷新
+0x284uint32_tpath_refresh_rooms.size刷新循环上界
+0x290uint8_t*path_refresh_flags.data每房间 pathing 状态
+0x2B0floatgrid_origin_x0.4u 对齐范围
+0x2B4floatgrid_origin_z0.4u 对齐范围
+0x2B8floatgrid_end_x0.4u 对齐范围
+0x2BCfloatgrid_end_z0.4u 对齐范围
+0x2C0Ogre::Timerbake_timer所有阶段日志时间戳
+0x36Eboolforce_compress_mode(暂名)为真时强制 COMPRESS 分支

CAUTION

generated_roomsdynamic_regionspath_refresh_rooms 等是依据本函数行为给出的建议字段名,不是恢复出的 PDB 符号。尤其 CLevel + 0x198 的区域集合可能在其他函数中承担更宽的含义。


17. 高层重构伪代码

下面保留真实阶段和关键条件,省略 STL 扩容、SEH 析构状态和 Ogre 临时对象:

cpp
void CLevel::bakeCollisionAndPassability(bool has_pathing)
{
    resetPathBakeCounters();
    destroy(this->collision_list);
    destroy(this->quadtree_collision);

    this->collision_list = new CCollisionList();
    bool compress = globalOptionExists(L"COMPRESS")
                 || this->force_compress_mode;

    AxisAlignedBox total_bounds = Infinite;
    vector<AxisAlignedBox> per_room_bounds;
    uint32_t collision_piece_candidates = 0;

    for (LayoutRoot* room : this->generated_rooms) {
        if (!room) {
            per_room_bounds.push_back(Infinite);
            continue;
        }

        AxisAlignedBox normal_bounds = Infinite;
        AxisAlignedBox special_bounds = Infinite;

        vector<CEditorBaseObject*> pieces =
            room->collectByDescriptor(L"Room Piece");

        collision_piece_candidates += pieces.size();

        for (CEditorBaseObject* object : pieces) {
            CRoomPiece* piece = dynamic_cast<CRoomPiece*>(object);
            if (!piece || !piece->hasCollisionResource() || !piece->flag_388)
                continue;

            Matrix4 world = composeScaleRotationTranslation(piece);

            if (!piece->collisionMeshHasFaces()) {
                log(L"Collision mesh ... has no faces!");
                continue;
            }

            AxisAlignedBox world_bounds =
                transformCollisionBounds(piece, world);

            if (specialPassabilityPredicate(piece))
                special_bounds.merge(world_bounds);
            else
                normal_bounds.merge(world_bounds);

            if (pieceNeedsCollisionSubmission(piece))
                collision_list->appendTransformedMesh(piece, world);
        }

        for (CPropertyNode* node : room->collectPropertyNodes()) {
            if (node->type == 19) {
                node->updateBounds();
                total_bounds.merge(node->bounds);
            }
        }

        AxisAlignedBox selected = compress
            ? normal_bounds
            : special_bounds;

        total_bounds.merge(selected);
        per_room_bounds.push_back(alignToTenUnits(selected));
    }

    logCollisionAssemblyStats();

    collision_list->rebuildBoundsAndFaceExtents();
    collision_list->rebuildFaceNormals();

    configureAndBuildStaticGeometry();

    int leaf_size = max(
        1,
        int(total_bounds.diagonal().length() * 0.001f)
    );

    this->quadtree_collision = new CQuadtreeCollision(
        collision_list->bounds,
        leaf_size
    );

    for (Triangle face : collision_list->faces)
        quadtree_collision->insert(face);

    logCollisionAndQuadtreeStats();

    if (!has_pathing || !collision_list)
        return;

    allocateThreePassabilityGrids(
        alignToPointFourWithPadding(total_bounds)
    );
    initializeGrids(-1, -1, 0);

    for (LayoutRoot* room : generated_rooms)
        importRoomMppAndSeamRegions(room);

    logPassabilityAssemblyTime();

    uint32_t old_face_count = collision_list->face_count;
    vector<AxisAlignedBox> dynamic_bounds =
        collectSpecialRoomPieceBoundsExpandedByOneCell();

    if (collision_list->face_count != old_face_count) {
        collision_list->rebuildBoundsAndFaceExtents();
        collision_list->rebuildFaceNormals();

        for (uint32_t i = old_face_count;
             i < collision_list->face_count;
             ++i)
            quadtree_collision->insert(collision_list->faces[i]);
    }

    if (!globalOptionExists(L"COMPRESS")) {
        rasterizeDynamicRegions();
        freeSecondaryGrid();
        convertUndefinedPrimaryCellsToBlocked();
    }

    refreshRoomPathingStateTwice();
    logPathBakeCounters();
}

18. 与运行期寻路和人口生成的关系

sub_5F6250 本身不执行 A*,也不创建怪物。它为后两套系统准备输入:

对寻路

text
sub_5F6250
  分配/填充 CLevel 三层字节网格

CLevel::isWalkable / 路径上下文
  读取有符号字节判定格子是否阻挡

sub_464010 → sub_463EA0
  执行 8 邻域 A*

对怪物密度

text
sub_5F6250
  调 sub_5F36D0 导入房间 MPP
  对写入值为 0 的格子累计 CLevel+0x54

sub_5ED9B0
  把 CLevel+0x54 传给 sub_600810

MONSTERS_PER_METER_MIN/MAX
  零值格数 × 0.15384616 × 密度

这说明原版架构中的“怪物密度”不是从渲染场景 AABB 直接估面积,而是依赖本函数完成的 passability 网格装配结果。


19. 工程含义与移植注意事项

19.1 不要把渲染碰撞与走格合并成一张数据

原版同时维护:

  • 三角面碰撞列表 CCollisionList
  • 空间加速结构 CQuadtreeCollision
  • Ogre 渲染批处理 StaticGeometry
  • 三层字节 passability 网格;
  • 每房间动态注册与路径缓存。

Godot 移植可以换实现,但最好保留职责边界。把所有装饰模型直接变成 Godot 3D 碰撞体,会改变原版“MPP 决定可走性”的行为,并制造已有走格文档所述的幽灵墙差异。

19.2 HASPATHING:false 场景仍需要视觉 bake

主菜单就是现成例子:没有 A* 需求,但仍要构建 StaticGeometry 和场景碰撞相关资源。移植时不要把该键实现成“完全不处理场景”。

19.3 网格边界必须复制 padding

原版不是简单的:

cpp
floor(min * 2.5), ceil(max * 2.5)

它还先扩 0.2,再各向外加一格。少任何一层都会在房间边缘缩掉一圈,影响出口接缝、A* 吸附和 MONSTERS_PER_METER 的零值格计数。

19.4 动态对象按整列阻挡

动态 AABB 把 Y 改成 -1000~1000,说明 passability 计算有意将对象投影到 XZ 平面,忽略其实际高度。桥下可走、悬空装饰等情况不能仅凭三维 AABB 自动判断,必须服从原版分类与 MPP 数据。

19.5 缓存确定性

函数会统计 pathnodes 是“Calculated”还是“Loaded”。如果移植需要同种子地图重放,缓存键必须至少包含:

  • room/layout 身份;
  • 世界变换;
  • 0.4u 对齐边界;
  • MPP 数据版本;
  • 动态/特殊碰撞分类。

只按布局文件名缓存会把旋转、缩放或不同组合位置的房间错误复用。


20. 地址索引与建议命名

地址当前名称建议语义名
0x5F6250sub_5F6250CLevel::bakeCollisionAndPassability
0x5F9910sub_5F9910CLevel::generateFromTemplate(暂定)
0x468930sub_468930CCollisionList::CCollisionList
0x469430sub_469430CCollisionList::rebuildBoundsAndFaceExtents
0x469570sub_469570CCollisionList::rebuildFaceNormals
0x5EFE10sub_5EFE10CQuadtreeCollision::CQuadtreeCollision
0x5EF920sub_5EF920CQuadtreeCollision::insertTriangle
0x5DB580sub_5DB580CLevel::initializePassabilityGrids
0x5F36D0sub_5F36D0CLevel::importMppAndRasterizeRegion
0x5EF750sub_5EF750AxisAlignedBoxVector::pushBack
0x5EC180sub_5EC180AxisAlignedBoxVector::reserve
0x621F60sub_621F60CRoomPiece::usesSpecialPassabilityPath(暂定)
0x622BE0sub_622BE0CRoomPiece::getPathingEnabled(暂定)
0x622DC0sub_622DC0CRoomPiece::setPathingEnabled(暂定)
0x622C20sub_622C20CRoomPiece::syncDynamicCollisionRegistration

21. 已确认事实与推断边界

由 RTTI、指令或直接调用关系确认

  • 只有 sub_5F9910 调用 sub_5F6250
  • 正确调用约定是 thiscall,只有一个 4 字节栈参数。
  • 参数来自 HASPATHING,默认值为 true;HASAUTOMAP 是相邻但不同的字段。
  • CLevel + 0x5CCCollisionList*+0x58CQuadtreeCollision*
  • 函数遍历 descriptor Room PieceProperty Node,并使用 RTTI 转为 CRoomPiece / CPropertyNode
  • Room Piece 的缩放、旋转、平移都参与碰撞边界变换。
  • 0.4u 走格、2.5 逆换算、0.2 扩边和一格 padding。
  • 三张字节网格的分配位置与初始值 -1/-1/0
  • .MPP 导入 helper 对零值格累计 CLevel + 0x54
  • 动态范围 X/Z 扩 0.4,Y 拉到 ±1000。
  • COMPRESS 关闭时会逐动态区域补算、释放第二层网格,并把主网格残留 0xFF 改成 1。
  • 四个全局计数器与四条日志的准确对应。

高可信建议名,但不是官方符号

  • bakeCollisionAndPassability
  • zero_grid_cell_countpassability_grid_primary/secondary/aux
  • sub_621F60 是特殊/动态 passability 分类判据。
  • sub_622C20 是 Room Piece 动态碰撞/路径注册同步。

尚未完全恢复

  • sub_621F60 检查的五个 CRandomGroup 名称字段各自官方含义。
  • COMPRESS 的具体上游入口和 CLevel + 0x36E 在本模块外的写入机制。
  • 三层网格每一层的官方名称;目前只能根据 A* 消费、初始化、释放时机描述其角色。
  • 八组 StaticGeometry 各自对应的具体渲染类别。
  • 某些 Room Piece +0x191/+0x192 等碰撞分类字节的配置键名。

这些未知项不影响对函数主职责、阶段边界、网格尺寸、HASPATHING 门和碰撞对象类型的判断。