核心定位:
sub_5F36D0是CLevel的“区域通行性装配器”。给它一个布局路径、一个世界空间 AABB、一个布局锚点和两个控制标志,它会优先装配同名.MPP;缓存不存在或过期时,则直接查询当前关卡的碰撞数据,按 0.4 世界单位逐格重算,并在允许的情况下写回新的.MPP。它既不是完整的 A* 寻路器,也不是关卡总碰撞烘焙器。上游总控是
sub_5F6250碰撞与通行性烘焙,下游走格消费者见 TL2 走格寻路逆向分析,零值格面积进入刷怪密度的链路见 MONSTERS_PER_METER 完整参考。分析依据:通过本地 IDA MCP 对 32 位
Torchlight2.exe进行只读反编译、交叉引用、调用现场和关键指令复核。IDA 数据库为E:/Torchlight 2/Torchlight2.exe.i64,映像基址0x400000。本文地址均为绝对虚地址。
0. 结论速览
sub_5F36D0位于0x5F36D0~0x5F472D,大小0x105E,即 4,190 字节;IDA 识别出 198 个基本块,圈复杂度 115。- 它只有一个调用者
sub_5F6250,但总共有四个调用点:0x5F7DAB、0x5F8487、0x5F8530、0x5F9211。 - 函数是标准 32 位
__thiscall:ECX=CLevel*,栈上有六个参数,结尾retn 18h。IDA 显示的七参数形式把this也算在函数签名中,本身没有调用约定错误。 - 推荐命名为
CLevel::loadOrBuildPassabilityRegion;更强调 MPP 时可命名为CLevel::importMppOrRasterizeRegion。 - 第一个参数是布局文件路径。函数把末尾六个字符
layout去掉,再追加mpp,因此FOO.LAYOUT对应FOO.MPP。 .MPP没有魔数、版本号或内嵌校验和。文件由 24 字节小端头部和grid_width × grid_height个单字节格组成。- 格值语义已经由读、写和查询三条路径共同确认:
0表示可走,1表示阻挡,0xFF表示无几何/未定义。 - 网格分辨率固定为
2.5 cells/u,即每格0.4世界单位;分类点位于格中心,因此坐标额外加0.2。 - 缓存未命中时,每个候选格先做
Y=-200 → +200的垂直碰撞检测;找到合法地面后,在命中点上方1.5u处向±X/±Z各探测0.3u,用于检查水平净空。 - 初始分类之后还有一个原地、级联的“封闭格修补”遍历:如果某个零值格在任意一条水平、垂直或对角轴的两端都被阻挡,就把它改为
1。 CLevel+0x54记录零值格数量,并最终进入MONSTERS_PER_METER、PROPS_PER_METER等面积密度公式。- 原版存在一个可观察细节:缓存未命中时,零值格在封闭格修补之前计数,修补把
0改成1后不会回退计数;下次从已写出的 MPP 加载时却只统计最终零值。首次重烘和随后加载的面积计数可能略有差异。
1. 函数画像与调用关系
| 项目 | 结果 |
|---|---|
| 函数地址 | 0x5F36D0 |
| 结束地址 | 0x5F472E(下一函数入口) |
| 机器码大小 | 0x105E / 4,190 bytes |
| 基本块 | 198 |
| 圈复杂度 | 115 |
| 显式局部栈空间 | 0x274 bytes |
| 调用者 | 仅 sub_5F6250 |
| 调用点 | 0x5F7DAB, 0x5F8487, 0x5F8530, 0x5F9211 |
| 栈清理 | retn 18h,被调函数清理 6 × 4 bytes |
入口与结尾的关键指令:
005F36D0 push 0FFFFFFFFh
005F36D2 push offset SEH_5F36D0
...
005F36E5 sub esp, 274h
...
005F36EE mov esi, [esp+28Ch+arg_0]
005F36F6 mov ebp, ecx ; EBP = CLevel *this
...
005F4725 add esp, 280h
005F472B retn 18h ; 6 个栈参数调用链可以概括为:
CLevel::bakeCollisionAndPassability sub_5F6250
├─ 为全关卡分配三层字节网格
├─ 遍历静态布局/房间范围
│ └─ loadOrBuildPassabilityRegion sub_5F36D0
├─ 必要时对全碰撞范围执行一次 fallback
│ └─ loadOrBuildPassabilityRegion sub_5F36D0
└─ 遍历动态 passability AABB
└─ loadOrBuildPassabilityRegion sub_5F36D0
├─ MPP 缓存有效:直接装配
└─ MPP 缓存无效:逐格碰撞分类并可选写回2. 纠正后的函数原型
Hex-Rays 当前给出的声明是:
void __thiscall sub_5F36D0(
DWORD* this,
int a2,
float* a3,
float* a4,
float* a5,
char a6,
char a7);结合 std::wstring 构造、Ogre Vector3 调用和四处压栈现场,建议恢复为:
void __thiscall CLevel::loadOrBuildPassabilityRegion(
const std::wstring& layout_path,
const Ogre::Vector3& bounds_min,
const Ogre::Vector3& bounds_max,
const Ogre::Vector3& world_ext,
bool clear_region,
bool claim_work_mask);参数含义如下:
| 参数 | ABI 位置 | 建议名称 | 含义 |
|---|---|---|---|
this | ECX | level | 当前 CLevel |
a2 | [esp+4] | layout_path | .LAYOUT 路径;空字符串表示只计算、不查写 MPP |
a3 | [esp+8] | bounds_min | 待装配/计算区域 AABB 最小点 |
a4 | [esp+C] | bounds_max | 待装配/计算区域 AABB 最大点 |
a5 | [esp+10h] | world_ext | 布局锚点;MPP 头保存它与 bounds_min 的 X/Z 差值 |
a6 | [esp+14h] | clear_region | 处理前是否把目标主网格区域清成可走 0 |
a7 | [esp+18h] | claim_work_mask | 是否通过第二层网格对区域去重/认领,主要用于动态范围 |
world_ext 不是 AABB 尺寸。写文件时可以直接看到:
header.world_ext_x = world_ext.x - bounds_min.x;
header.world_ext_z = world_ext.z - bounds_min.z;
header.bounds_x = bounds_max.x - bounds_min.x;
header.bounds_z = bounds_max.z - bounds_min.z;它使同一块局部 MPP 能在房间被放到不同世界坐标时重新定位,而不需要把绝对世界坐标写进缓存。
3. 总体控制流
layout_path 是否非空,且 CLevel+0x36D 是否允许 MPP I/O?
│
├─ 否:跳过缓存检查
│
└─ 是:把 *.LAYOUT 改为 *.MPP
│
├─ PackageMgr 查 VFS/包内文件记录
├─ 检查源 LAYOUT 和松散 MPP 是否存在
├─ 比较 MPP 与 LAYOUT 的修改时间
├─ 比较包记录 FILETIME
└─ 必要时计算松散 MPP 的 CRC32
│
├─ 缓存有效
│ ├─ 读取 24 字节头
│ ├─ 读取 width*height 字节体
│ └─ 装配到 CLevel 主走格
│
└─ 缓存无效
├─ 按 AABB 建临时字节格
├─ 逐格垂直落点检测
├─ 四方向水平净空检测
├─ 写入主走格并统计零值
├─ 封闭格修补
└─ 条件满足时写回 MPP函数始终在末尾释放临时字节缓冲并销毁路径字符串。缓存命中与重算不会改变调用者的控制流接口。
4. MPP 路径派生
路径派生位于 0x5F3748~0x5F37ED:
std::wstring mpp_path = layout_path;
mpp_path = mpp_path.substr(0, mpp_path.length() - 6);
mpp_path += L"mpp";例如:
MEDIA/LAYOUTS/VAULT/VAULT_TEMPLATE.LAYOUT
└─ 去掉 layout
MEDIA/LAYOUTS/VAULT/VAULT_TEMPLATE.mpp原版文件系统和包管理器对扩展名大小写不敏感,因此源资产里的 .MPP 与代码追加的 mpp 等价。
此处硬编码减去 6,没有先验证字符串确实以 layout 结尾。调用方只传布局路径,因而在可信输入下成立;移植实现最好显式检查扩展名,避免短字符串下溢或生成错误路径。
5. 缓存开关与输出覆盖路径
5.1 CLevel+0x36D
函数入口在 0x5F370C 检查:
cmp byte ptr [ebp+36Dh], 0
jz loc_5F3AA5CLevel 构造函数 sub_5F0020 在 0x5F04A0 把该字段初始化为 1。它控制本函数是否尝试读取和写回 MPP,建议暂命名为:
bool CLevel::mpp_cache_io_enabled; // +0x36D,默认 true关闭它不会关闭逐格计算;它只会令函数直接走重算路径,并禁止末尾写文件。
5.2 CLevel+0x468
CLevel+0x468 是一个 std::wstring,构造函数会正常初始化。非空时它作为 MPP 输出覆盖路径:
if (!level->mpp_output_override.empty())
mpp_path = level->mpp_output_override;函数还会把该覆盖路径的最后三个字符替换为 layout,以检查对应源布局是否存在。它主要服务于编辑器/离线烘焙场景,而不是普通运行期寻路。
6. MPP 缓存有效性判定
缓存判定横跨 0x5F37FB~0x5F3AA5,同时考虑 VFS、松散文件、时间戳和 CRC。
6.1 参与判定的辅助函数
| 地址 | 建议名称 | 已确认行为 |
|---|---|---|
0x68DD70 | PackageMgr::instanceForPath | 取得包管理器/挂载上下文 |
0x68DE30 | PackageMgr::findFile | 查找 .MPP 的 VFS 文件记录 |
0x67FBB0 | fileExists | 去掉开头的 / 或 \ 后调用 _waccess(path, 0) |
0x67FAA0 | isOlderOrMissing | first 不存在,或 first.mtime < second.mtime 时返回真 |
0x67FB10 | sameFileTimeAsPackageEntry | 用 FindFirstFileW 比较松散文件 FILETIME 与包记录 +8/+C |
0x694FA0 | crc32File | 4 KB 分块读文件,标准 CRC32:初值 0xFFFFFFFF、末尾按位取反 |
6.2 判定流程
伪代码近似为:
bool cache_valid = false;
bool layout_exists = false;
bool path_is_empty = layout_path.empty();
if (mpp_cache_io_enabled && !path_is_empty) {
mpp_path = replaceLayoutSuffix(layout_path, L"mpp");
PackageEntry* entry = PackageMgr::findFile(mpp_path);
cache_valid = entry != nullptr;
layout_exists = fileExists(layout_path);
if (!layout_exists && !mpp_output_override.empty()) {
auto override_layout = replaceMppSuffix(mpp_output_override, L"layout");
layout_exists = fileExists(override_layout);
}
bool loose_mpp_exists = fileExists(mpp_path);
if (cache_valid && loose_mpp_exists && layout_exists) {
if (!mpp_output_override.empty()) {
cache_valid = false;
} else if (isOlderOrMissing(mpp_path, layout_path)) {
cache_valid = false;
} else if (!sameFileTimeAsPackageEntry(entry, mpp_path)) {
cache_valid = false;
if (entry->crc32 != 0xFFFFFFFF)
cache_valid = crc32File(mpp_path) == entry->crc32;
}
} else if (layout_exists && !loose_mpp_exists) {
cache_valid = false;
}
if (developer_force_cache_flag)
cache_valid = true;
}这里的 developer_force_cache_flag 来自 sub_7DB930() 返回对象的 +0x40 字节。它的正式字段名尚未恢复,因此不应在移植代码里擅自解释成普通玩家设置。
6.3 为什么既比时间又比 CRC
包记录中保存了原文件的时间和可选 CRC。松散 MPP 与包内条目时间不同,并不必然表示内容不同,因此代码在包记录拥有有效 CRC 时再做一次内容校验。这样可以避免复制文件、解包或构建流程改变 FILETIME 后无谓重烘。
反过来,如果 .MPP 比源 .LAYOUT 更旧,函数直接判为过期,不需要 CRC 比较。
7. MPP 二进制格式
7.1 文件布局
本函数的读取顺序位于 0x5F3BF4~0x5F3C62,写入顺序位于 0x5F463B~0x5F46B6,两者完全对称:
| 偏移 | 大小 | 类型 | 建议字段名 | 写入表达式 |
|---|---|---|---|---|
0x00 | 4 | int32_le | grid_width | ceil(maxX)-floor(minX) 后的格宽 |
0x04 | 4 | int32_le | grid_height | ceil(maxZ)-floor(minZ) 后的格高 |
0x08 | 4 | float32_le | world_ext_x | world_ext.x - bounds_min.x |
0x0C | 4 | float32_le | world_ext_z | world_ext.z - bounds_min.z |
0x10 | 4 | float32_le | bounds_x | bounds_max.x - bounds_min.x |
0x14 | 4 | float32_le | bounds_z | bounds_max.z - bounds_min.z |
0x18 | W×H | uint8[] | cells | Z 行、X 列,索引 z * W + x |
因此文件大小必须满足:
file_size = 24 + grid_width * grid_height7.2 C 结构定义
#pragma pack(push, 1)
struct MppHeader {
int32_t grid_width;
int32_t grid_height;
float world_ext_x;
float world_ext_z;
float bounds_x;
float bounds_z;
};
#pragma pack(pop)
static_assert(sizeof(MppHeader) == 24);头部后直接跟随 grid_width * grid_height 个字节,没有额外长度、尾部、压缩块或对齐填充。
7.3 格值语义
| 字节 | 有符号解释 | 语义 | 代码证据 |
|---|---|---|---|
0x00 | 0 | 可走 | 写入主网格后累计 CLevel+0x54;寻路查询接受 |
0x01 | 1 | 阻挡 | 碰撞/净空失败和封闭格修补写入 |
0xFF | -1 | 无几何、未定义、区域外 | 垂直探测无命中时写入;缓存装配时不覆盖现有主网格 |
7.4 实际文件复核
本地真值样本 VAULT/VAULT_TEMPLATE.MPP 的前 24 字节解析为:
grid_width = 301
grid_height = 300
world_ext_x = 50.0
world_ext_z = 10.0
bounds_x = 120.0
bounds_z = 120.0
payload = 90,300 bytes = 301 × 300其格值分布为:
0x00 19,818 cells
0x01 2,612 cells
0xFF 67,870 cells文件总长 24 + 90,300 = 90,324 字节,与磁盘长度完全一致。
8. 世界坐标到走格坐标
8.1 全关卡网格基准
sub_5F6250 已在 CLevel 中建立全局走格范围:
CLevel 偏移 | 类型 | 含义 |
|---|---|---|
+0x40 | int8_t** | 主通行网格 |
+0x44 | int8_t** | 工作/认领网格 |
+0x48 | int8_t** | 第三辅助网格 |
+0x4C | int32_t | 全局格宽 |
+0x50 | int32_t | 全局格高 |
+0x54 | uint32_t | 累计零值格数量 |
+0x2B0 | float | 全局最小 X / grid_origin_x |
+0x2B4 | float | 全局最小 Z / grid_origin_z |
+0x2B8 | float | 全局最大 X |
+0x2BC | float | 全局最大 Z |
CLevel+0x5C 是 CCollisionList*。本函数只有在它存在时才执行真实的逐格碰撞分类。
8.2 区域格边界
无缓存时,AABB 映射公式是:
constexpr float CELLS_PER_UNIT = 2.5f;
constexpr float CELL_SIZE = 0.4f;
int start_x = floor((bounds_min.x - grid_origin_x) * 2.5f);
int start_z = floor((bounds_min.z - grid_origin_z) * 2.5f);
int end_x = ceil ((bounds_max.x - grid_origin_x) * 2.5f);
int end_z = ceil ((bounds_max.z - grid_origin_z) * 2.5f);
int width = end_x - start_x;
int height = end_z - start_z;缓存命中时,范围不再信任当前 AABB 的宽高,而是使用缓存头恢复:
float cached_min_x = world_ext.x - header.world_ext_x;
float cached_min_z = world_ext.z - header.world_ext_z;
start_x = floor((cached_min_x - grid_origin_x) * 2.5f);
start_z = floor((cached_min_z - grid_origin_z) * 2.5f);
end_x = start_x + header.grid_width;
end_z = start_z + header.grid_height;这就是 world_ext_x/z 存在的原因:它保存局部锚点到烘焙包围盒最小角的差值,使缓存能够跟随布局实例平移。
8.3 格中心
逐格重算时,采样位置是:
float world_x = grid_origin_x + grid_x * 0.4f + 0.2f;
float world_z = grid_origin_z + grid_z * 0.4f + 0.2f;0.2 正好是半格,说明分类发生在格中心而不是格角。
9. 缓存命中:装配 MPP
9.1 读取方式
sub_67E400 创建一个内存文件读取器,sub_67DFB0 连续读取六个 4 字节头字段,然后分配:
uint8_t* cached_cells = new uint8_t[header.grid_width * header.grid_height];
read(cached_cells, header.grid_width * header.grid_height);命中后更新两个全局调试计数器:
++g_rooms_path_cached; // 0x3352734
g_pathnodes_loaded += width * height; // 0x33527389.2 装配规则
双层循环以全局 X 为外层、Z 为内层;缓存体索引仍然是:
local_index = (global_z - start_z) * cached_width
+ (global_x - start_x);只有同时满足以下条件才装配:
- 全局 X/Z 位于关卡网格范围;
- 缓存字节不是
0xFF。
核心逻辑:
uint8_t value = cached_cells[local_index];
if (inside_level_grid && value != 0xFF) {
level->primary[global_x][global_z] = value;
if (value == 0)
++level->zero_grid_cell_count;
}0xFF 不覆盖主网格十分重要。多个布局/房间的 MPP 可以叠加到同一张全关卡网格,某块布局的“无几何”不能擦除另一块布局已经贡献的有效走格。
9.3 clear_region 的前置清理
如果 clear_region=true,装配/计算之前先执行:
for each cell in region:
if (inside_level_grid &&
(!claim_work_mask || work_mask[x][z] < 0))
primary[x][z] = 0;所以该标志不是“清成未知”,而是把目标范围预设为可走。后续缓存字节或碰撞分类再把其中一部分改回阻挡/无效。
10. 缓存未命中:逐格重算
缓存无效时,函数为区域分配 width × height 的临时字节数组。每个落在全关卡网格内、且未被工作网格排除的格都进入分类。
10.1 工作网格去重
当 claim_work_mask=true:
if (work_mask[x][z] > 0)
skip;
++g_pathnodes_calculated; // 0x3352730
work_mask[x][z] = 1;这用于动态 passability AABB 可能互相覆盖的阶段,使同一全局格在该轮最多计算一次。
claim_work_mask=false 时不检查、不写第二层网格,但仍递增 g_pathnodes_calculated。
这条分支的精确指令是:
005F3EC0 80 BC 24 A8 02 00 00 00 cmp byte ptr [esp+2A8h], 0
005F3EC8 74 10 jz 005F3EDA
005F3ECA 8B 43 44 mov eax, [ebx+44h]
005F3ECD 8B 0C B8 mov ecx, [eax+edi*4]
005F3ED0 80 3C 29 00 cmp byte ptr [ecx+ebp], 0
005F3ED4 0F 8F A2 04 00 00 jg 005F437C
005F3EDA FF 05 30 27 35 03 inc dword ptr [3352730h]
...
005F3EEA 8B 53 44 mov edx, [ebx+44h]
005F3EED 8B 04 BA mov eax, [edx+edi*4]
005F3EF0 C6 04 28 01 mov byte ptr [eax+ebp], 1jg 是有符号大于,所以严格条件是 int8(work_mask[x][z]) > 0。新一轮网格由 sub_5DB580 初始化:+0x40=-1、+0x44=-1、+0x48=0;其中第二层的独立证据为 0x5DB5A0: 8B 71 44 与 0x5DB5A6: C6 04 30 FF。因此正常生命周期是 -1(未认领)→ 1(已认领)。值 0 也不会跳过,而会在进入后改写成 1。
这说明 +0x44 不是另一套“可走/阻挡”答案,而是本轮工作的 visited/claim mask: 它只消除重叠动态 AABB 带来的重复碰撞查询。它不去重后文的 AABB 面积日志,也不改变 +0x40 主通行网格的三态语义。
10.2 初始值
函数先复制主网格当前值:
temp[cell] = primary[x][z];
if (temp[cell] == 0xFF)
temp[cell] = 0;只有当前值为 0 或 0xFF 的候选格会执行碰撞探测。原本已经为 1 的阻挡格保持阻挡,不会被本轮重新开放。
10.3 候选碰撞体预筛选
在发射射线前调用 sub_5F2C70。该函数会:
- 清空
CLevel+0x60一带的临时查询结构; - 使用
CQuadtreeCollision/CCollisionList对当前格附近范围做空间筛选; - 遍历已注册
CRoomPiece,要求启用、可见、允许 pathing、拥有碰撞资源且与查询 AABB 相交; - 把相交的动态房间件放入查询候选列表。
建议命名:
CLevel::prepareCollisionCandidates(query_min, query_max); // sub_5F2C70它不是最终射线求交,只负责缩小随后查询要检查的静态面片和动态房间件集合。
10.4 垂直落点检测
每格构造垂直线段:
Vector3 ray_from(world_x, -200.0f, world_z);
Vector3 ray_to (world_x, 200.0f, world_z);然后调用 sub_5E2150,并传入两处 0.1f 容差。该 helper 会在静态碰撞结构和已注册房间件中寻找最近命中,返回命中点、法线、碰撞分类和来源标志。
分类规则:
if (!raycastVertical(ray_from, ray_to, 0.1f, ...)) {
temp[cell] = 0xFF; // 没有地面/无几何
} else if (hit.y > 80.0f ||
hit.y < -80.0f ||
hit.kind == 100) {
temp[cell] = 1; // 超出有效层高或特殊阻挡类
} else {
temp[cell] = 0; // 暂定可走,继续净空检查
}hit.kind == 100 的正式枚举名尚未恢复。代码明确把它视为不可走碰撞类,但本文不把它武断命名为某种具体材质或单位类型。
10.5 四方向水平净空
合法落点上方 1.5u 处建立水平起点:
Vector3 origin(hit.x, hit.y + 1.5f, hit.z);然后依次向四个方向查询 0.3u:
origin → origin + Vector3(+0.3, 0, 0)
origin → origin + Vector3(-0.3, 0, 0)
origin → origin + Vector3(0, 0, +0.3)
origin → origin + Vector3(0, 0, -0.3)对应常量已经从数据段复核:
0x2142018 = +0.3000000119f
0x2171994 = -0.3000000119f任意一次 sub_5E1E90 命中且 hit.kind == 100,当前格就改为 1。这相当于在角色身体高度附近做一个十字形净空探测,避免只凭脚下有地面就把贴墙、细缝或特殊阻挡内部判为可走。
10.6 写入主网格与面积计数
分类结果立即写回:
primary[x][z] = temp[cell];
if (temp[cell] == 0)
++level->zero_grid_cell_count;这就是 CLevel+0x54 与 MPP 的直接连接点。
11. 封闭格修补:原地级联的 enclosure pass
只有缓存未命中时才执行 0x5F43A5~0x5F4598 的后处理。遍历顺序是 X 外层、Z 内层,而且直接修改主网格,因此较早格子的修改会影响后续格子,属于级联算法。
把“越界或值为 1”定义成 blocked;注意 0xFF 不算 blocked:
bool blocked(int x, int z) {
if (x < 0 || z < 0 || x >= grid_width || z >= grid_height)
return true;
return primary[x][z] == 1;
}对于每个当前为 0 的格:
if ((blocked(x - 1, z) && blocked(x + 1, z)) ||
(blocked(x, z - 1) && blocked(x, z + 1)) ||
(blocked(x - 1, z - 1) && blocked(x + 1, z + 1)) ||
(blocked(x + 1, z - 1) && blocked(x - 1, z + 1))) {
primary[x][z] = 1;
}四个条件分别检查:
- 左右两端都被堵;
- 上下两端都被堵;
- 左上与右下都被堵;
- 右上与左下都被堵。
任意一条轴两端同时被堵,这个零值格就被封墙。它会消除一格宽夹缝、孤立点和某些对角穿越情形。
11.1 0xFF 为什么按开放端处理
条件只把字节 1 视为墙;合法范围内的 0xFF 不满足 >0 检查,因此不会像墙一样夹死相邻零值格。这样布局边缘没有几何的区域不会自动形成一圈人工封闭墙。
11.2 计数时序缺陷
零值计数发生在 0x5F4369~0x5F436F,封闭格修补发生在后面的 0x5F43A5~0x5F4598。后处理没有执行:
--level->zero_grid_cell_count;因此本轮新烘焙时,面积计数包含随后被修成 1 的格;写出的 MPP 已经包含修补后的 1,下次缓存命中只会统计最终仍为 0 的格。
这会产生一个原版可观察差异:
第一次:重算 → 先数 0 → enclosure 把少量 0 改 1 → 人口面积略偏大
第二次:加载最终 MPP → 只数仍为 0 的格 → 人口面积略小差异大小取决于被 enclosure 修补的格数。做严格行为复刻时应保留;做确定性服务器或编辑器工具时,可以考虑把统计移动到后处理之后,但这会偏离原版。
12. MPP 写回
写回条件位于 0x5F459F~0x5F46E9:
if (level->mpp_cache_io_enabled &&
!cache_valid &&
!layout_path.empty() &&
layout_exists) {
writeMpp(...);
}如果 CLevel+0x468 的覆盖路径非空,写入该路径;否则写入由 layout_path 派生出的同名 .mpp。
写入次序严格为:
fwrite(&width, 4, 1, fp);
fwrite(&height, 4, 1, fp);
fwrite(&world_ext_minus_min_x, 4, 1, fp);
fwrite(&world_ext_minus_min_z, 4, 1, fp);
fwrite(&bounds_width_x, 4, 1, fp);
fwrite(&bounds_depth_z, 4, 1, fp);
fwrite(cells, width * height, 1, fp);关闭开发者强制标志时,写完会通知包管理器刷新/失效相应文件记录,使后续查询看到新文件。
12.1 文件格式的信任边界
本函数没有检查:
- 魔数;
- 格式版本;
width/height是否为正;width * height是否溢出;- 文件是否至少有
24 + width*height字节; wfopen_s是否成功后才调用fwrite。
sub_67DFB0 在读取越界时只累计内部缺失字节数,不会为目标字段自动提供可靠默认值。原版依赖包资产可信。面向 Mod 或网络上传内容的现代实现必须补上这些校验。
推荐最低验证:
if (file_size < 24) reject;
if (width <= 0 || height <= 0) reject;
if (uint64_t(width) * uint64_t(height) > MAX_GRID_CELLS) reject;
if (file_size != 24 + uint64_t(width) * uint64_t(height)) reject;
if (any cell not in {0, 1, 0xFF}) warn_or_reject;13. 四处调用模式
四个调用点均位于 sub_5F6250,真实标志组合由汇编压栈顺序和 retn 18h 共同确认:
| 调用点 | layout_path | AABB 来源 | clear_region | claim_work_mask | 作用 |
|---|---|---|---|---|---|
0x5F7DAB | 房间/布局路径 | 当前静态布局范围 | false | false | 直接叠加一块静态 MPP,不先清目标区 |
0x5F8487 | 房间/布局路径 | 替代静态范围 | true | false | 先把区域置零,再加载或重算 |
0x5F8530 | 当前布局路径或空串 | CCollisionList 总范围 | true | false | 无有效分块范围时的全碰撞区域 fallback |
0x5F9211 | 空字符串 | 动态 passability AABB | true | true | 不读写 MPP,使用工作网格去重后动态重算 |
第一处调用现场:
005F7D76 push 0 ; claim_work_mask = false
005F7D78 push 0 ; clear_region = false
...
005F7DA1 call getMinimum
005F7DA7 push eax ; bounds_min
005F7DA8 push esi ; layout_path
005F7DA9 mov ecx, ebx ; CLevel *this
005F7DAB call sub_5F36D0动态范围调用现场:
005F91E0 push 1 ; claim_work_mask = true
005F91E2 push 1 ; clear_region = true
...
005F9209 push eax ; bounds_min
005F920A push empty_wstring
005F920F mov ecx, ebx ; CLevel *this
005F9211 call sub_5F36D0layout_path 为空会在入口设置 path_is_empty,直接跳过所有缓存查写。因此动态阻挡区域永远不会生成匿名 MPP 文件。
13.1 四个调用点的同轮分布,以及“132 次”的更正
为避免把不同关卡/不同 seed 的日志拼成一组,运行时探针同时记录 sub_5F6250 的入口与返回, 只在一次完整烘焙结束后输出四点差值。探针保存在 原版游戏分析/runtime_probes/mpp_call_distribution.js。
旧交接表把“132 次调用”与“Regions=157 / Area=12961”列成同一局锚,但没有保存相同 sub_5F6250 函数边界内的四点计数。它们不能直接写成 132 = 1 + 157。定义域应分别为:
一次完整烘焙调用数
= 0x5F7DAB 次数
+ 0x5F8487 次数
+ 0x5F8530 次数
+ 非 infinite Dynamic AABB 数
0x5F9211 次数 = 非 infinite Dynamic AABB 数
Regions 日志 = Dynamic AABB vector 原始 size(包含 infinite 项)重放 Echo Pass / PASS_JT_A / Seeding: -2123059669 后,探针在同一个完整 bake 中同时读到 四点计数、AABB finite/infinite 分布和面积:
| 量 | 实测 |
|---|---|
0x5F7DAB | 1 |
0x5F8487 | 0 |
0x5F8530 | 0 |
0x5F9211 | 157 |
| 合计 | 158 |
| Dynamic AABB | 157 raw / 157 finite / 0 infinite |
| 面积 | 12961.0283203125 → 12961 |
这里精确满足 158 = 1 + 0 + 0 + 157 与 0x5F9211 次数 = finite AABB 数。因此旧的 “132 次”不是这组 157 / 12961 / 679×904 数据所属 bake 的完整调用数;它来自不同轮次、 不同钩子窗口,或是不完整的计数窗口。没有逐点原始记录,不能为 132 编造分布。本文把同 seed、 同函数边界重放得到的 158 次作为修正后的真值。
另一次程序化关卡完整 bake 得到 172 = 1 + 0 + 0 + 171、面积 14099.7275390625 → 14099,作为独立阳性对照;两轮都满足同一结构关系。
13.2 Dynamic Passability Regions / Area 的精确公式
动态区域容器是 CLevel 上的 std::vector<Ogre::AxisAlignedBox> 形状:
| 字段 | 指令 | 含义 |
|---|---|---|
+0x198 | 0x5F9190: 8B 83 98 01 00 00 | 0x20 字节 AABB 数组首址 |
+0x1A4 | 0x5F9164: 8B BB A4 01 00 00 | 原始元素数 |
步长 0x20 | 0x5F921A: 83 C6 20 | 下一个 AABB |
Regions 直接格式化保存下来的 +0x1A4 原始计数。证据是循环前 0x5F9164 把它读入 EDI,循环结束后 0x5F923B: 57 又把该值压给整数转字符串函数。 所以它不是可走格的 8 邻域连通块,也不是 sub_5F36D0 的调用次数。
每个元素先调用 Ogre::AxisAlignedBox::isInfinite();为真就在 0x5F91A1 跳到下一项。 对每个有限 AABB,代码计算:
Vector3 size = box.getMaximum() - box.getMinimum();
float_area += size.z * size.x; // Y 不参与
loadOrBuildPassabilityRegion(
L"", box.getMinimum(), box.getMaximum(), box.getMinimum(), true, true);乘法由 0x5F91CC 装入差向量的 Z 分量、0x5F91D3 乘 X 分量确认; 0x5F91E4 再把乘积加进浮点累计值。日志前 0x5F92A3 重新装入累计值, 0x5F92A7 调 __ftol2_sse 向零转成整数,随后 0x5F92B5 调整数格式化。因此:
Dynamic Passability Regions = vector.size()
Dynamic Passability Area = trunc(Σ有限AABB ((max.x-min.x) × (max.z-min.z)))Area 的单位是世界单位平方,但它是逐矩形面积和而不是矩形并集;AABB 重叠会重复计算。 +0x44 工作掩码会避免重叠格重复做碰撞分类,却不会回头扣减这个日志面积。 旧日志里的 157 / 12,961 正是这两个表达式的文本输出;它们与刷怪使用的 CLevel+0x54 × 0.16 不是同一个面积定义。
14. 全局计数器
sub_5F6250 在调用本函数前把四个计数器清零,本函数负责更新它们:
| 地址 | 日志标签 | 更新位置 | 精确含义 |
|---|---|---|---|
0x335272C | Rooms Repathed | 0x5F3D5B | 缓存未命中且 layout_path 非空的布局数 |
0x3352730 | Pathnodes Calculated | 0x5F3EDA | 实际进入重算分支的格数;工作网格已认领的格不重复计入 |
0x3352734 | Rooms PathCached | 0x5F3D2C | 成功采用 MPP 缓存的区域数 |
0x3352738 | Pathnodes Loaded | 0x5F3D32 | 从缓存分配/读取的 width × height 总格数,包含 0xFF |
注意 Pathnodes Loaded 不是“有效可走格数”,而是缓存体总字节数。
CLevel+0x54 则不是调试日志计数器,而是后续游戏逻辑使用的零值面积累计。
15. 与 MONSTERS_PER_METER 的连接
缓存命中和重算路径都存在:
if (cell == 0)
++level->zero_grid_cell_count; // CLevel+0x54后续怪物、道具等数量计算把该计数乘以 0.16:
0.4 × 0.4 = 0.16 world-unit² per cell因此面积链路是:
LAYOUT 碰撞范围
→ sub_5F36D0 的 MPP/逐格分类
→ 0 值格数量 CLevel+0x54
→ × 0.16 得到可走面积
→ × MONSTERS_PER_METER
→ 目标怪物数量这也解释了为什么 MPP 缺失、过期、错误或覆盖范围错位,会同时表现为:
- 玩家无法正常移动;
- 某些布局接缝变成幽灵墙;
- 怪物/道具密度异常;
- 第一次烘焙和再次加载的数量出现微小漂移。
16. 高层源码重建
下面省略 STL/SEH 清理和碰撞查询的输出参数,只保留控制语义:
void CLevel::loadOrBuildPassabilityRegion(
const std::wstring& layoutPath,
const Vector3& boundsMin,
const Vector3& boundsMax,
const Vector3& worldExt,
bool clearRegion,
bool claimWorkMask)
{
std::wstring mppPath = layoutPath;
bool cacheValid = false;
bool pathEmpty = false;
bool layoutExists = false;
PackageEntry* packageEntry = nullptr;
if (!mpp_cache_io_enabled || layoutPath.empty()) {
pathEmpty = true;
} else {
mppPath = replaceSuffix(layoutPath, 6, L"mpp");
packageEntry = PackageMgr::findFile(mppPath);
cacheValid = packageEntry != nullptr;
layoutExists = fileExists(layoutPath);
if (!layoutExists && !mpp_output_override.empty()) {
auto altLayout = replaceSuffix(mpp_output_override, 3, L"layout");
layoutExists = fileExists(altLayout);
}
bool looseMppExists = fileExists(mppPath);
cacheValid = validateMppCache(
cacheValid,
packageEntry,
looseMppExists,
layoutExists,
layoutPath,
mppPath,
mpp_output_override);
}
int startX = floor((boundsMin.x - grid_origin_x) * 2.5f);
int startZ = floor((boundsMin.z - grid_origin_z) * 2.5f);
int endX = ceil ((boundsMax.x - grid_origin_x) * 2.5f);
int endZ = ceil ((boundsMax.z - grid_origin_z) * 2.5f);
int width = endX - startX;
int height = endZ - startZ;
uint8_t* local = nullptr;
MppHeader header{};
if (cacheValid) {
readMpp(mppPath, &header, &local);
startX = floor((worldExt.x - header.world_ext_x - grid_origin_x) * 2.5f);
startZ = floor((worldExt.z - header.world_ext_z - grid_origin_z) * 2.5f);
width = header.grid_width;
height = header.grid_height;
endX = startX + width;
endZ = startZ + height;
++g_rooms_path_cached;
g_pathnodes_loaded += width * height;
} else {
if (!layoutPath.empty())
++g_rooms_repathed;
local = new uint8_t[width * height];
}
if (collision_list) {
if (clearRegion)
clearPrimaryRegion(startX, startZ, endX, endZ, claimWorkMask);
for (int x = startX; x < endX; ++x) {
for (int z = startZ; z < endZ; ++z) {
int li = (z - startZ) * width + (x - startX);
if (cacheValid) {
uint8_t value = local[li];
if (insideGlobalGrid(x, z) && value != 0xFF) {
primary[x][z] = value;
if (value == 0)
++zero_grid_cell_count;
}
continue;
}
if (!insideGlobalGrid(x, z)) {
local[li] = 0xFF;
continue;
}
if (claimWorkMask && work_mask[x][z] > 0)
continue;
++g_pathnodes_calculated;
if (claimWorkMask)
work_mask[x][z] = 1;
local[li] = primary[x][z];
if (local[li] == 0xFF)
local[li] = 0;
if (local[li] == 0) {
float wx = grid_origin_x + x * 0.4f + 0.2f;
float wz = grid_origin_z + z * 0.4f + 0.2f;
local[li] = classifyCell(wx, wz); // 0, 1, or 0xFF
}
primary[x][z] = local[li];
if (local[li] == 0)
++zero_grid_cell_count;
}
}
}
if (!cacheValid)
enclosurePass(primary, startX, startZ, endX, endZ, local);
if (mpp_cache_io_enabled && !cacheValid &&
!pathEmpty && layoutExists) {
writeMpp(mpp_output_override.empty() ? mppPath
: mpp_output_override,
width, height,
worldExt.x - boundsMin.x,
worldExt.z - boundsMin.z,
boundsMax.x - boundsMin.x,
boundsMax.z - boundsMin.z,
local);
refreshPackageRecord(mppPath);
}
delete[] local;
}单格分类可以单独恢复为:
uint8_t CLevel::classifyCell(float x, float z)
{
Vector3 down(x, -200.0f, z);
Vector3 up (x, 200.0f, z);
prepareCollisionCandidates(cellQueryMin(x, z), cellQueryMax(x, z));
Hit hit;
if (!raycastWide(down, up, 0.1f, 0.1f, &hit))
return 0xFF;
if (hit.position.y > 80.0f ||
hit.position.y < -80.0f ||
hit.kind == 100)
return 1;
Vector3 body = hit.position + Vector3(0, 1.5f, 0);
for (Vector3 d : {
Vector3(+0.3f, 0, 0),
Vector3(-0.3f, 0, 0),
Vector3(0, 0, +0.3f),
Vector3(0, 0, -0.3f) }) {
Hit side;
if (raycast(body, body + d, &side) && side.kind == 100)
return 1;
}
return 0;
}17. 移植建议
17.1 保留格式兼容层
若 Godot 迁移项目需要读取原版 MPP,应按以下顺序实现:
- 严格读取 24 字节小端头;
- 使用 64 位整数验证
width × height; - 确认文件长度精确匹配;
- 保持
z * width + x的存储顺序; - 保持
0/1/255三态,不要在导入时直接把255合并为墙; - 装配时让
255不覆盖其他布局已经写入的格。
17.2 保留坐标舍入顺序
原版使用单精度数据和 floor/ceil。以下两种写法在浮点边界上可能相差一格:
floor((x - origin) * 2.5f) // 原版顺序
floor(double(x - origin) / 0.4) // 不保证一致需要逐字节复刻 MPP 时,应保留原版 float32 运算和表达式顺序。
17.3 不要只用导航网格替代全部语义
Godot NavigationMesh 可以承担最终路径搜索,但原版 MPP 还承担:
- 多布局走格叠加;
0xFF不覆盖规则;- 动态范围去重重算;
- 可走面积统计;
- 刷怪/道具数量密度输入。
如果只生成一张导航网格而不保留等价面积和三态装配逻辑,移动可能正常,人口密度仍会偏离原版。
17.4 建议测试向量
至少覆盖:
bounds_min/max正好位于 0.4 边界;- 负世界坐标下的
floor; - 两块 MPP 重叠,其中一块对应格为
0xFF; - 源 LAYOUT 比 MPP 新;
- FILETIME 不同但 CRC 相同;
- 没有垂直碰撞的格;
- 地面命中 Y 恰好为
-80/+80; - 四方向任一方向命中碰撞类 100;
- enclosure pass 的级联顺序;
clear_region=true与工作网格认领组合;- 首次重烘和第二次缓存加载的
CLevel+0x54差异。
18. 建议命名表
| 地址/字段 | 当前名称 | 建议名称 | 置信度 |
|---|---|---|---|
0x5F36D0 | sub_5F36D0 | CLevel::loadOrBuildPassabilityRegion | 高 |
0x5F2C70 | sub_5F2C70 | CLevel::prepareCollisionCandidates | 中高 |
0x5E2150 | sub_5E2150 | CLevel::raycastCollisionWide | 中 |
0x5E1E90 | sub_5E1E90 | CLevel::raycastCollision | 中 |
0x67FBB0 | sub_67FBB0 | fileExists | 高 |
0x67FAA0 | sub_67FAA0 | isOlderOrMissing | 高 |
0x67FB10 | sub_67FB10 | sameFileTimeAsPackageEntry | 高 |
0x694FA0 | sub_694FA0 | crc32File | 高 |
CLevel+0x36D | 未命名字节 | mpp_cache_io_enabled | 中高 |
CLevel+0x468 | 未命名 wstring | mpp_output_override | 中高 |
CLevel+0x40 | 未命名 | primary_passability_grid | 高 |
CLevel+0x44 | 未命名 | passability_work_mask | 中高 |
CLevel+0x54 | 未命名 | zero_grid_cell_count | 高 |
19. 尚未恢复的语义边界
以下事实已由指令确认,但正式源码名仍未知:
- 碰撞结果
kind == 100的枚举名称; sub_7DB930()+0x40开发者强制缓存标志的正式名称;sub_5E2150两个0.1f参数在源码中的命名,可能是半径、容差或碰撞膨胀量;CLevel+0x468在编辑器 UI 中的公开字段名;- 四个调用分支对应的原始类/容器成员名称。
这些未知不影响 MPP 格式、坐标公式、缓存判定、格值分类和装配行为的复刻。报告中对它们使用描述性名称,并避免把推断写成已恢复的 RTTI/符号事实。
20. 地址索引
| 地址范围 | 功能 |
|---|---|
0x5F36D0~0x5F3742 | 入口、缓存 I/O 开关和空路径判断 |
0x5F3748~0x5F37ED | .LAYOUT → .mpp 路径派生 |
0x5F37FB~0x5F3AA5 | VFS、文件存在、时间戳与 CRC 有效性检查 |
0x5F3AAA~0x5F3BA6 | 当前 AABB 映射到全关卡格范围 |
0x5F3BAD~0x5F3D47 | 读取 MPP 头和字节体、恢复缓存格范围 |
0x5F3D55~0x5F3D79 | 缓存未命中计数与临时格分配 |
0x5F3DB0~0x5F3DF4 | clear_region 前置清零 |
0x5F3E08~0x5F3EBA | 双层格循环和缓存命中装配 |
0x5F3ED4~0x5F3F1B | 工作网格去重、初值继承 |
0x5F3F25~0x5F4068 | 格中心、查询范围和碰撞候选准备 |
0x5F40B8~0x5F4112 | 垂直落点检测及 Y/碰撞类检查 |
0x5F413B~0x5F433F | ±X/±Z 四向 0.3u 净空检测 |
0x5F4355~0x5F4398 | 无命中写 0xFF、主网格写入、零值计数 |
0x5F43A5~0x5F4598 | enclosure 封闭格修补 |
0x5F459F~0x5F46E9 | 条件写回 MPP、刷新包记录 |
0x5F46F2~0x5F472B | 临时缓冲、字符串和 SEH 清理 |
21. 最终定位
sub_5F36D0 最适合概括为:
“把一个布局或动态 AABB 的通行性装配进 CLevel 全局走格;
有可用 MPP 就平移复用,没有就用碰撞逐格烘焙并可选写回。”它同时连接了四套系统:
LAYOUT / MPP 文件缓存
│
▼
碰撞树与动态 Room Piece
│
▼
CLevel 三层 0.4u 通行网格
│
├─ A* / 移动查询
└─ 零值格面积 → MONSTERS_PER_METER因此,复刻 TL2 关卡行为时不能把它只理解成“读 MPP”。缓存命中路径负责多布局装配,缓存未命中路径本身就是原版运行时 MPP 烘焙内核的一部分。