核心定位:
sub_5F6250是CLevel在布局内容实例化之后执行的“碰撞与通行性总烘焙”函数。它不是单纯生成碰撞,也不是 A* 寻路器;它把场景中的CRoomPiece碰撞网格汇总为CCollisionList,构建CQuadtreeCollision,配置 OgreStaticGeometry,在HASPATHING开启时分配三层 0.4u 走格、导入.MPP、叠加动态阻挡区域,最后让相关房间刷新路径缓存。分析依据:通过本地 IDA MCP 对 32 位
Torchlight2.exe进行只读反编译、交叉引用和关键指令复核。IDA 数据库为E:/Torchlight 2/Torchlight2.exe.i64,映像基址0x400000。本文所有0x……均为绝对虚地址。关联文档:数量烘焙后形成的零值走格计数如何进入怪物密度公式,参见 MONSTERS_PER_METER 完整参考;A* 如何消费走格,参见 TL2 走格寻路逆向分析。
0. 核心结论速览
sub_5F6250位于0x5F6250~0x5F98BB,函数体0x366C字节,即 13,932 字节;IDA 识别出 412 个基本块、圈复杂度 100,是一个被 STL、Ogre 数学类型和 SEH 清理代码放大的总控函数。它只有一个调用者:
sub_5F9910在0x5FAB62调用。调用前性能标签是BAKE TIME,调用后是BAKE COLLISION TIME,直接证明它处在关卡生成的碰撞烘焙阶段。IDA 自动给出的
int __fastcall sub_5F6250(int, int, char)是错误原型。汇编是ECX=this、栈上压入一个布尔值、结尾retn 4;正确近似签名应是:cppvoid __thiscall CLevel::bakeCollisionAndPassability(bool has_pathing);has_pathing来自CLevelTemplateData + 0xD4的配置键HASPATHING,构造函数默认true。原版显式写false的主要是 13 份MAINMENUS规则模板。HASPATHING:false只跳过走格、passability 与路径缓存阶段;碰撞网格汇总、CCollisionList、CQuadtreeCollision和 OgreStaticGeometry仍会构建。CLevel + 0x5C是新建的CCollisionList*,CLevel + 0x58是由它烘焙出的CQuadtreeCollision*。函数入口先析构旧对象再重建。碰撞来源主要是每个布局根节点下 descriptor 为
Room Piece的CRoomPiece。函数读取模型碰撞面,应用完整的缩放、旋转、平移矩阵,再汇入关卡级碰撞表;碰撞模型没有面时记录Collision mesh ... has no faces!。走格范围按全局碰撞 AABB 对齐到 0.4 世界单位,因为换算使用
2.5与0.40000001。边界额外向外扩一格。函数分配三张
width × height字节网格:前两张初始化为-1,第三张初始化为0。随后sub_5F36D0按房间.MPP和动态区域写入这些网格。sub_5F36D0同时累计CLevel + 0x54的零值格子数;该计数稍后被MONSTERS_PER_METER、PROPS_PER_METER等人口密度计算共同使用。因此sub_5F6250是“布局走格”连接到“怪物数量”的关键桥梁。动态 passability 阶段会重新收集一类特殊
Room Piece,把其碰撞 AABB 在 X/Z 各扩0.4,Y 范围拉到-1000~1000,合并为区域并重新栅格化。末尾对
CLevel + 0x280的房间列表执行两遍刷新:先设置/失效 pathing 状态并临时切换可见性,再调用sub_622C20重建动态注册。全局计数器最终输出Rooms Repathed、Pathnodes Calculated、Rooms PathCached、Pathnodes Loaded。
1. 函数身份、调用点与真实原型
1.1 基本信息
| 项目 | 值 |
|---|---|
| 函数地址 | 0x5F6250 |
| 结束地址 | 0x5F98BC 对齐前 |
| 大小 | 0x366C / 13,932 字节 |
| 基本块 | 412 |
| 圈复杂度 | 100 |
| 局部栈空间 | 0xCE8 字节,栈先对齐到 64 字节 |
| 直接调用者 | 仅 sub_5F9910,调用点 0x5FAB62 |
| 建议名称 | CLevel::bakeCollisionAndPassability |
大栈帧主要来自几十个临时 Ogre::Vector3、Ogre::Matrix4、Ogre::AxisAlignedBox、std::wstring 和临时 vector allocator,并不意味着 CLevel 本身有 3 KB 的单个算法状态。
1.2 调用时机
调用者 sub_5F9910 的相关顺序是:
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 的三参数原型是错的
调用点的真实指令:
mov edx, [esp+...+var_1E8] ; 低字节是 HASPATHING
push edx ; 唯一栈参数
mov ecx, ebx ; ECX = CLevel *this
call sub_5F6250函数结尾:
mov esp, ebp
pop ebp
retn 4retn 4 证明被调者只清理 4 字节栈参数。调用点恰好把同一个值留在 EDX 又压栈,Hex-Rays 因寄存器活跃性将它误认成第二个 fastcall 参数,进而显示:
int __fastcall sub_5F6250(int a1, int a2, char a3);更合理的源级原型是:
void __thiscall CLevel::bakeCollisionAndPassability(bool has_pathing);返回值也没有业务意义:尾部 EAX 只是临时 vector<AxisAlignedBox> allocator 的清理调用残留,唯一调用者完全忽略它。
2. HASPATHING 的来源与默认值
CLevelTemplateData 构造函数 sub_60BBE0(0x60BBE0)设置:
*(bool *)(this + 0xD4) = true; // HASPATHING
*(bool *)(this + 0xD5) = true; // HASAUTOMAP
*(bool *)(this + 0xD6) = true; // 相邻的另一标志,本文未命名模板读取函数 sub_604D20(0x604D20)明确把键映射到字段:
this->has_pathing = group.getBool(
L"HASPATHING",
this->has_pathing
);
this->has_automap = group.getBool(
L"HASAUTOMAP",
this->has_automap
);调用者 sub_5F9910 分别取出:
bool has_pathing = *(bool *)(level_template + 0xD4);
bool has_automap = *(bool *)(level_template + 0xD5);其中 has_automap 传给前面的 sub_5F1C70;has_pathing 才传给 sub_5F6250。两者相邻且在反编译里共用被错误恢复的栈变量,阅读时很容易混淆。
原版数据中显式关闭 HASPATHING 的规则都在 MEDIA/LAYOUTS/MAINMENUS/,例如:
<BOOL>HASPATHING:false包括 MAINMENU_TOWNRULES.TEMPLATE、MAINMENU_SPLASHRULES.TEMPLATE、各幕主菜单背景规则等共 13 份。其余模板通常省略该键,继承默认 true。
IMPORTANT
HASPATHING:false 不等于“整个场景没有碰撞”。函数在判断该参数之前已经完成关卡碰撞面汇总、四叉树碰撞和 StaticGeometry 构建;它关闭的是 2D passability/路径缓存一侧。
3. 总体阶段图
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、插入碰撞面 |
0x5F7AEA | HASPATHING && collision_list 总门 |
0x5F7AFB~0x5F7CF8 | 对齐范围、分配并初始化三层走格 |
0x5F7CFD~0x5F855F | 静态 passability / .MPP 装配 |
0x5F85FF~0x5F904A | 动态 Room Piece AABB 装配 |
0x5F905C~0x5F94CE | COMPRESS 分流、动态区域栅格化与网格规范化 |
0x5F94D6~0x5F9639 | 房间 pathing 状态与路径缓存刷新 |
0x5F9646~0x5F9826 | 输出路径统计计数器 |
0x5F982C~0x5F98B9 | 进度回调、局部容器析构、返回 |
4. 阶段一:旧资源释放与统计器复位
函数入口先把四个全局统计值清零:
unk_335272C = 0; // Rooms Repathed
unk_3352730 = 0; // Pathnodes Calculated
unk_3352734 = 0; // Rooms PathCached
unk_3352738 = 0; // Pathnodes Loaded名称由函数尾部日志一一对应,不是猜测。
随后清理关卡上一次烘焙留下的对象:
CLevel 偏移 | 已确认类型 | 入口行为 |
|---|---|---|
+0x5C / 92 | CCollisionList* | 调虚析构并交还对象分配器,置 NULL |
+0x58 / 88 | CQuadtreeCollision* | 调虚析构并交还对象分配器,置 NULL |
接着从引擎对象分配器申请 216 字节并调用 sub_468930:
level->collision_list = new CCollisionList();sub_468930 写入了明确的 RTTI/vtable:
*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 查询其后代:
collectByDescriptor(root, L"Room Piece", room_pieces);结果逐个从 CEditorBaseObject* RTTI 转为 CRoomPiece*:
CRoomPiece* piece = dynamic_cast<CRoomPiece*>(object);处理的必要条件包括:
- dynamic cast 成功;
piece + 0x108的资源句柄可解引用;sub_622BE0(piece)返回真。
sub_622BE0 只有 7 字节,等价于:
bool CRoomPiece_getFlag388(CRoomPiece* piece)
{
return *(bool *)(piece + 0x184); // 十进制 388
}该字段随后也由 sub_622DC0 设置,并与动态注册/路径缓存刷新相关。本文暂称 pathing_enabled,官方名称尚未恢复。
5.2 完整世界变换
对每个合格 CRoomPiece,函数构造:
M = Translation × Rotation × Scale具体来源:
- 缩放:
CRoomPiece的虚函数+108; - 旋转:其 Ogre
SceneNode虚函数+188返回 quaternion; - 平移:
sub_61DCE0(piece, ..., true)返回世界位置。
随后读取碰撞资源的局部最小/最大点,构造 AABB 的四个 XZ 角点,逐点乘 M,用变换后的点重新合并世界 AABB。这里不是只平移原始 AABB;缩放和旋转都参与。
5.3 碰撞面为空的保护
碰撞资源的 face 指针为空时,函数不会继续提交该网格,而是记录:
Collision mesh <资源名> has no faces!之后恢复 CRoomPiece 原有可见/启用标志并继续下一个对象,不会让整个关卡烘焙失败。
5.4 静态/特殊房间分类
sub_621F60(0x621F60)递归沿编辑器对象的父层级查找 CRandomGroup,检查其内部状态、五个名称字段和 +602 标志。sub_5F6250 用它把 Room Piece 的碰撞范围分进两个局部 AABB 集合:
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 后,还会查询:
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 世界单位向外对齐:
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 的上游启动方式。
阶段结束时输出:
Collision Assemble Time: <秒>
Collision Pieces :<数量>Collision Pieces 是所有外层布局中收集到的 Room Piece 候选总数,而不一定等于成功提交碰撞面的数量。
6. 阶段三:CCollisionList 收尾
房间遍历结束后,对 CLevel + 0x5C 的 CCollisionList 连续调用:
sub_469430(level->collision_list);
sub_469570(level->collision_list);6.1 sub_469430:边界与面顶点缓存
该函数:
- 遍历全部顶点,计算列表全局 min/max;
- 清零两个派生计数;
- 遍历全部三角面;
- 按三角面三个顶点索引重新计算每面的局部 min/max。
因此它可以暂命名为:
CCollisionList::rebuildBoundsAndFaceExtents();6.2 sub_469570:面法线
该函数对每个三角形执行:
Vector3 e1 = v2 - v1;
Vector3 e2 = v1 - v0;
Vector3 normal = normalize(cross(e1, e2));
store_normal(normal);可暂命名为:
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 + 0x24C 的 CLevelTemplateData* 存在,则读取模板 +0xBEC(十进制 3052)处的 Vector3。
region dimension 默认参数:
较小尺度 = 48
较大尺度 = 150某个整数设置等于 1 时改为:
较小尺度 = 30
较大尺度 = 75之后所有组调用虚函数 +52 执行 build。+0x240 组设置 visibility mask 2,+0x244 组设置 mask 4;后者还额外关闭一个虚函数 +76 控制的标志,前六组和 +0x240 则把该标志打开。
阶段日志:
Geom Build Time: <秒>NOTE
StaticGeometry 是渲染批处理结构,不等于 CCollisionList。这个函数同时构建两者,是因为它是关卡 bake 总控,不代表 TL2 用渲染几何直接做运行期碰撞。
8. 阶段五:创建 CQuadtreeCollision
8.1 类型与保存位置
函数通过引擎分配器申请 44 字节,调用 sub_5EFE10(0x5EFE10)。该构造函数明确写入:
*this = &CQuadtreeCollision::vftable;结果保存到:
level->quadtree_collision = ...; // CLevel + 0x58CQuadtreeCollision 构造函数还会把 X/Z 范围扩成正方形:取 abs(maxX-minX) 与 abs(maxZ-minZ) 的较大者,把较短轴两边各扩一半差值。这样四叉树每层可以规则地四分。
8.2 “Smallest Face”参数
函数计算总碰撞 AABB 的对角线长度:
float scaled = bounds_diagonal.length() * 0.001f;
int smallest_face = 1;
if (scaled > 1.0f)
smallest_face = (int)scaled;这是正数向零截断,因此等价于:
smallest_face = max(1, floor(bounds_diagonal.length() * 0.001f));该值传给 CQuadtreeCollision 构造函数,并输出:
Smallest Face :<值>名称来自原版日志;它更可能是四叉树叶级/分割粒度参数,而不是场景中三角形几何面积的最小值。
8.3 插入全部碰撞面
CCollisionList + 0x94(148)是面数。函数逐面取得三角形索引/顶点并调用 sub_5EF920 插入四叉树。该 helper 会:
- 判断三角形是否与当前节点范围相交;
- 懒分配四个子节点;
- 将面继续下发到相交子树;
- 在达到范围/粒度条件时保存到当前节点。
阶段输出:
Collision Bake Time: <秒>
Collision Faces :<面数>
Quadtree Build Time: <秒>Collision Bake Time 的起点取自函数入口附近,而不只是构造四叉树的局部时间;阅读性能日志时应以指令中的时间戳差为准,不要只按文字理解。
9. HASPATHING 总门
四叉树完成后,0x5F7AEA 才检查:
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 格的坐标,向外取整,再额外加一格:
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 / 688 | float | 网格 origin X |
+0x2B4 / 692 | float | 网格 origin Z |
+0x2B8 / 696 | float | 网格 end X |
+0x2BC / 700 | float | 网格 end Z |
+0x4C / 76 | uint32_t | width |
+0x50 / 80 | uint32_t | height |
2.5 × 0.4 ≈ 1 是完整互逆关系;这里的 0.40000001 是单精度显示误差。
2026-09-07 的同轮 Frida 端到端样本还把公式从“静态反编译”提升为运行期复算:
Echo Pass / PASS_JT_A / Seeding: -2123059669 的同轮实测如下:
| 量 | X | Z |
|---|---|---|
| 合并碰撞 AABB minimum | -290.0 | -160.0 |
| 合并碰撞 AABB maximum | -20.0 | 200.0 |
| 公式 origin | -290.8000183105469 | -160.8000030517578 |
| 公式 end | -19.200000762939453 | 200.8000030517578 |
| 公式尺寸 | 679 | 904 |
CLevel+0x4C/+0x50 活字段 | 679 | 904 |
逐轴代入可写成:
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 在 0x5F7AFB 调 getMinimum 前从 ECX 指向的对象读取;最终字段在同一次 sub_5F6250 返回时读取,六项完全一致。另一轮程序化关卡样本 [40, 80]..[280, 500] -> 604×1054 也满足同一公式,作为独立复核。
10.2 三层网格
函数分配三套“行指针数组 + 每行字节数组”:
CLevel 偏移 | 初始值 | 生命周期线索 |
|---|---|---|
+0x40 / 64 | -1 | 主网格,后续把残留 0xFF 规范化为 1 |
+0x44 / 68 | -1 | 临时/第二层,动态计算结束后释放 |
+0x48 / 72 | 0 | 第三层,继续保留 |
初始化 helper sub_5DB580(0x5DB580)的完整核心就是:
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],只有网格字段与立即数不同:
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 的完整计算路径中,函数最后执行:
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,即阻挡。
末尾的释放与规范化也已逐条核字节:
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 ; 完整外层宽度所以这里不是把所有非零格布尔化;0、1 以及占位节点留下的其他正值都保持原样, 仅残留哨兵 0xFF 被封口为阻挡 1。
11. 阶段七:静态 passability 与 .MPP 装配
静态通行性装配主要调用 sub_5F36D0(0x5F36D0)。该函数会:
- 接收布局文件名并把扩展名替换成
.mpp; - 读取 MPP 字节格;
- 根据房间世界 AABB 与变换把局部格写入
CLevel三层网格; - 对零值格进行统计;
- 在某些标志组合下把没有缓存文件的区域交给运行期碰撞计算。
11.1 每房间路径
常规路径遍历前面保存的每房间 AABB,并取得相应布局根对象的文件名/世界位置:
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,保证合法关卡不会因为缺少某个缓存范围而完全没有走格。
阶段输出:
Passability Assembly Time: <秒>11.3 零值格计数
sub_5F36D0 在两条写入路径中都有:
if (written_grid_byte == 0)
++level->field_54;因此 CLevel + 0x54 可严格命名为“零值布局格累计数”,而“可用/可走面积”是依据后续消费方式给出的高可信解释。
这一字段由后续人口填充函数传给 sub_600810,进入:
MONSTERS_PER_METER
CHAMPIONS_PER_METER
PROPS_PER_METER
NPCS_PER_METER
CREEPS_PER_METER
GOLD_PER_METER
...12. 阶段八:动态/特殊 Room Piece 通行区域
静态 .MPP 装配之后,函数再次遍历每份布局下的 Room Piece,但这次只处理同时满足:
piece->flag_388
&& sub_622BE0(piece)
&& sub_621F60(piece, nullptr)的对象。对每个候选:
- 重新取得缩放、旋转、世界平移;
- 变换碰撞网格的 XZ 四角;
- 生成世界 AABB;
- X/Z 最小值各减
0.4,最大值各加0.4; - Y 直接改为
-1000~1000; - 若没有被既有排除/覆盖 AABB 完全包含,则加入本房间动态范围;
- 合并同一房间所有候选为一份较大 AABB;
- 对齐到 0.4u 走格边界,加入动态区域 vector。
X/Z 扩一格的目的很明确:动态碰撞对象不能只封自己正下方的格,还需要给边缘留一个走格净空。
如果这一阶段向 CCollisionList 新增了三角面,函数比较前后 face count:
if (face_count_after != face_count_before) {
collision_list->rebuildBoundsAndFaceExtents();
collision_list->rebuildFaceNormals();
for each newly_added_face:
quadtree_collision->insert(face);
}这样无需完全重建四叉树,只把新增面补进去。
阶段输出:
Dynamic Passability Assembly Time: <秒>13. COMPRESS 分支
动态 AABB 收集结束后,函数再次读取 COMPRESS。机械行为如下:
COMPRESS 开启
- 析构全局碰撞 AABB 和局部动态 AABB vector;
- 跳过后面的逐动态区域
sub_5F36D0计算; - 进入统一收尾。
COMPRESS 关闭
遍历
CLevel + 0x198指向的 AABB 数组,数量CLevel + 0x1A4;对每个有限 AABB 调用
sub_5F36D0,标志组合为运行期动态计算路径;累加每个区域的 XZ 面积:
cpparea += (max_x - min_x) * (max_z - min_z);输出区域数、总面积和计算耗时;
析构这些 AABB 并把数量清零;
释放
CLevel + 0x44的第二层网格;把主网格残留的
0xFF转成1。
对应日志:
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 的核心是:
piece->flag_388 = new_value;
if (rebuild_now)
sub_622C20(piece);接着函数保存 piece + 0x69 的当前可见/启用字节,调用虚函数 +56 先设为反值、再恢复原值:
bool old = piece->flag_105;
piece->setEnabled(!old);
piece->setEnabled(old);这不是视觉闪烁,而是有意制造一次状态边沿,让依赖“启用状态改变”的注册逻辑失效旧缓存。
14.2 第二轮:sub_622C20
第二轮对每个非空 Room Piece 直接调用 sub_622C20(0x622C20)。该函数根据:
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 阶段起始时间累计的总耗时 |
最后用分隔线输出四个全局计数器:
| 地址 | 日志 | 含义 |
|---|---|---|
0x335272C | Rooms Repathed : | 本轮重新计算路径的房间数 |
0x3352730 | Pathnodes Calculated : | 新计算的路径节点数 |
0x3352734 | Rooms PathCached : | 写入/生成路径缓存的房间数 |
0x3352738 | Pathnodes Loaded : | 从缓存读取的路径节点数 |
这些计数器在函数入口清零,但主要由下游 pathing/cache helper 增加,sub_5F6250 自身只是定义一次 bake 的统计窗口。
16. CLevel 关键字段图
下表只列本函数中能较高置信度命名的字段:
| 偏移 | 形式 | 建议名称 | 证据/用途 |
|---|---|---|---|
+0x10 | 指针表 | generated_rooms.data | 外层布局/房间根对象数组 |
+0x14 | uint32_t | generated_rooms.size | 主要房间循环上界 |
+0x40 | uint8_t** | passability_grid_primary | 初始 -1,最终残留 -1 转 1 |
+0x44 | uint8_t** | passability_grid_secondary | 初始 -1,动态计算后释放 |
+0x48 | uint8_t** | passability_grid_aux | 初始 0 |
+0x4C | uint32_t | passability_width | X 方向格数 |
+0x50 | uint32_t | passability_height | Z 方向格数 |
+0x54 | uint32_t | zero_grid_cell_count | sub_5F36D0 累计,人口密度使用 |
+0x58 | CQuadtreeCollision* | quadtree_collision | sub_5EFE10 明确写入 vtable |
+0x5C | CCollisionList* | collision_list | sub_468930 明确写入 vtable |
+0x198 | AxisAlignedBox* | dynamic_regions.data | 逐区域 passability 补算 |
+0x1A4 | uint32_t | dynamic_regions.size | 日志 Dynamic Passability Regions |
+0x228 | StaticGeometry*[6] | static_geometry_groups | 连续构建六组 |
+0x240 | StaticGeometry* | static_geometry_mask_2 | visibility flags = 2 |
+0x244 | StaticGeometry* | static_geometry_mask_4 | visibility flags = 4 |
+0x24C | CLevelTemplateData* | level_template_data | 读取 region origin 等规则 |
+0x280 | CRoomPiece** | path_refresh_rooms.data | 末尾两轮刷新 |
+0x284 | uint32_t | path_refresh_rooms.size | 刷新循环上界 |
+0x290 | uint8_t* | path_refresh_flags.data | 每房间 pathing 状态 |
+0x2B0 | float | grid_origin_x | 0.4u 对齐范围 |
+0x2B4 | float | grid_origin_z | 0.4u 对齐范围 |
+0x2B8 | float | grid_end_x | 0.4u 对齐范围 |
+0x2BC | float | grid_end_z | 0.4u 对齐范围 |
+0x2C0 | Ogre::Timer | bake_timer | 所有阶段日志时间戳 |
+0x36E | bool | force_compress_mode(暂名) | 为真时强制 COMPRESS 分支 |
CAUTION
generated_rooms、dynamic_regions、path_refresh_rooms 等是依据本函数行为给出的建议字段名,不是恢复出的 PDB 符号。尤其 CLevel + 0x198 的区域集合可能在其他函数中承担更宽的含义。
17. 高层重构伪代码
下面保留真实阶段和关键条件,省略 STL 扩容、SEH 析构状态和 Ogre 临时对象:
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*,也不创建怪物。它为后两套系统准备输入:
对寻路
sub_5F6250
分配/填充 CLevel 三层字节网格
↓
CLevel::isWalkable / 路径上下文
读取有符号字节判定格子是否阻挡
↓
sub_464010 → sub_463EA0
执行 8 邻域 A*对怪物密度
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
原版不是简单的:
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. 地址索引与建议命名
| 地址 | 当前名称 | 建议语义名 |
|---|---|---|
0x5F6250 | sub_5F6250 | CLevel::bakeCollisionAndPassability |
0x5F9910 | sub_5F9910 | CLevel::generateFromTemplate(暂定) |
0x468930 | sub_468930 | CCollisionList::CCollisionList |
0x469430 | sub_469430 | CCollisionList::rebuildBoundsAndFaceExtents |
0x469570 | sub_469570 | CCollisionList::rebuildFaceNormals |
0x5EFE10 | sub_5EFE10 | CQuadtreeCollision::CQuadtreeCollision |
0x5EF920 | sub_5EF920 | CQuadtreeCollision::insertTriangle |
0x5DB580 | sub_5DB580 | CLevel::initializePassabilityGrids |
0x5F36D0 | sub_5F36D0 | CLevel::importMppAndRasterizeRegion |
0x5EF750 | sub_5EF750 | AxisAlignedBoxVector::pushBack |
0x5EC180 | sub_5EC180 | AxisAlignedBoxVector::reserve |
0x621F60 | sub_621F60 | CRoomPiece::usesSpecialPassabilityPath(暂定) |
0x622BE0 | sub_622BE0 | CRoomPiece::getPathingEnabled(暂定) |
0x622DC0 | sub_622DC0 | CRoomPiece::setPathingEnabled(暂定) |
0x622C20 | sub_622C20 | CRoomPiece::syncDynamicCollisionRegistration |
21. 已确认事实与推断边界
由 RTTI、指令或直接调用关系确认
- 只有
sub_5F9910调用sub_5F6250。 - 正确调用约定是
thiscall,只有一个 4 字节栈参数。 - 参数来自
HASPATHING,默认值为 true;HASAUTOMAP是相邻但不同的字段。 CLevel + 0x5C是CCollisionList*,+0x58是CQuadtreeCollision*。- 函数遍历 descriptor
Room Piece与Property 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_count、passability_grid_primary/secondary/aux。sub_621F60是特殊/动态 passability 分类判据。sub_622C20是 Room Piece 动态碰撞/路径注册同步。
尚未完全恢复
sub_621F60检查的五个 CRandomGroup 名称字段各自官方含义。COMPRESS的具体上游入口和CLevel + 0x36E在本模块外的写入机制。- 三层网格每一层的官方名称;目前只能根据 A* 消费、初始化、释放时机描述其角色。
- 八组
StaticGeometry各自对应的具体渲染类别。 - 某些 Room Piece
+0x191/+0x192等碰撞分类字节的配置键名。
这些未知项不影响对函数主职责、阶段边界、网格尺寸、HASPATHING 门和碰撞对象类型的判断。