核心定位sub_5F36D0CLevel 的“区域通行性装配器”。给它一个布局路径、一个世界空间 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. 结论速览

  1. sub_5F36D0 位于 0x5F36D0~0x5F472D,大小 0x105E,即 4,190 字节;IDA 识别出 198 个基本块,圈复杂度 115。
  2. 它只有一个调用者 sub_5F6250,但总共有四个调用点:0x5F7DAB0x5F84870x5F85300x5F9211
  3. 函数是标准 32 位 __thiscallECX=CLevel*,栈上有六个参数,结尾 retn 18h。IDA 显示的七参数形式把 this 也算在函数签名中,本身没有调用约定错误。
  4. 推荐命名为 CLevel::loadOrBuildPassabilityRegion;更强调 MPP 时可命名为 CLevel::importMppOrRasterizeRegion
  5. 第一个参数是布局文件路径。函数把末尾六个字符 layout 去掉,再追加 mpp,因此 FOO.LAYOUT 对应 FOO.MPP
  6. .MPP 没有魔数、版本号或内嵌校验和。文件由 24 字节小端头部grid_width × grid_height 个单字节格组成。
  7. 格值语义已经由读、写和查询三条路径共同确认:0 表示可走,1 表示阻挡,0xFF 表示无几何/未定义。
  8. 网格分辨率固定为 2.5 cells/u,即每格 0.4 世界单位;分类点位于格中心,因此坐标额外加 0.2
  9. 缓存未命中时,每个候选格先做 Y=-200 → +200 的垂直碰撞检测;找到合法地面后,在命中点上方 1.5u 处向 ±X/±Z 各探测 0.3u,用于检查水平净空。
  10. 初始分类之后还有一个原地、级联的“封闭格修补”遍历:如果某个零值格在任意一条水平、垂直或对角轴的两端都被阻挡,就把它改为 1
  11. CLevel+0x54 记录零值格数量,并最终进入 MONSTERS_PER_METERPROPS_PER_METER 等面积密度公式。
  12. 原版存在一个可观察细节:缓存未命中时,零值格在封闭格修补之前计数,修补把 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

入口与结尾的关键指令:

asm
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 个栈参数

调用链可以概括为:

text
CLevel::bakeCollisionAndPassability              sub_5F6250
  ├─ 为全关卡分配三层字节网格
  ├─ 遍历静态布局/房间范围
  │    └─ loadOrBuildPassabilityRegion           sub_5F36D0
  ├─ 必要时对全碰撞范围执行一次 fallback
  │    └─ loadOrBuildPassabilityRegion           sub_5F36D0
  └─ 遍历动态 passability AABB
       └─ loadOrBuildPassabilityRegion           sub_5F36D0
            ├─ MPP 缓存有效:直接装配
            └─ MPP 缓存无效:逐格碰撞分类并可选写回

2. 纠正后的函数原型

Hex-Rays 当前给出的声明是:

cpp
void __thiscall sub_5F36D0(
    DWORD* this,
    int a2,
    float* a3,
    float* a4,
    float* a5,
    char a6,
    char a7);

结合 std::wstring 构造、Ogre Vector3 调用和四处压栈现场,建议恢复为:

cpp
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 位置建议名称含义
thisECXlevel当前 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 尺寸。写文件时可以直接看到:

cpp
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. 总体控制流

text
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

cpp
std::wstring mpp_path = layout_path;
mpp_path = mpp_path.substr(0, mpp_path.length() - 6);
mpp_path += L"mpp";

例如:

text
MEDIA/LAYOUTS/VAULT/VAULT_TEMPLATE.LAYOUT
                              └─ 去掉 layout
MEDIA/LAYOUTS/VAULT/VAULT_TEMPLATE.mpp

原版文件系统和包管理器对扩展名大小写不敏感,因此源资产里的 .MPP 与代码追加的 mpp 等价。

此处硬编码减去 6,没有先验证字符串确实以 layout 结尾。调用方只传布局路径,因而在可信输入下成立;移植实现最好显式检查扩展名,避免短字符串下溢或生成错误路径。


5. 缓存开关与输出覆盖路径

5.1 CLevel+0x36D

函数入口在 0x5F370C 检查:

asm
cmp byte ptr [ebp+36Dh], 0
jz  loc_5F3AA5

CLevel 构造函数 sub_5F00200x5F04A0 把该字段初始化为 1。它控制本函数是否尝试读取和写回 MPP,建议暂命名为:

cpp
bool CLevel::mpp_cache_io_enabled; // +0x36D,默认 true

关闭它不会关闭逐格计算;它只会令函数直接走重算路径,并禁止末尾写文件。

5.2 CLevel+0x468

CLevel+0x468 是一个 std::wstring,构造函数会正常初始化。非空时它作为 MPP 输出覆盖路径:

cpp
if (!level->mpp_output_override.empty())
    mpp_path = level->mpp_output_override;

函数还会把该覆盖路径的最后三个字符替换为 layout,以检查对应源布局是否存在。它主要服务于编辑器/离线烘焙场景,而不是普通运行期寻路。


6. MPP 缓存有效性判定

缓存判定横跨 0x5F37FB~0x5F3AA5,同时考虑 VFS、松散文件、时间戳和 CRC。

6.1 参与判定的辅助函数

地址建议名称已确认行为
0x68DD70PackageMgr::instanceForPath取得包管理器/挂载上下文
0x68DE30PackageMgr::findFile查找 .MPP 的 VFS 文件记录
0x67FBB0fileExists去掉开头的 /\ 后调用 _waccess(path, 0)
0x67FAA0isOlderOrMissingfirst 不存在,或 first.mtime < second.mtime 时返回真
0x67FB10sameFileTimeAsPackageEntryFindFirstFileW 比较松散文件 FILETIME 与包记录 +8/+C
0x694FA0crc32File4 KB 分块读文件,标准 CRC32:初值 0xFFFFFFFF、末尾按位取反

6.2 判定流程

伪代码近似为:

cpp
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,两者完全对称:

偏移大小类型建议字段名写入表达式
0x004int32_legrid_widthceil(maxX)-floor(minX) 后的格宽
0x044int32_legrid_heightceil(maxZ)-floor(minZ) 后的格高
0x084float32_leworld_ext_xworld_ext.x - bounds_min.x
0x0C4float32_leworld_ext_zworld_ext.z - bounds_min.z
0x104float32_lebounds_xbounds_max.x - bounds_min.x
0x144float32_lebounds_zbounds_max.z - bounds_min.z
0x18W×Huint8[]cellsZ 行、X 列,索引 z * W + x

因此文件大小必须满足:

text
file_size = 24 + grid_width * grid_height

7.2 C 结构定义

cpp
#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 格值语义

字节有符号解释语义代码证据
0x000可走写入主网格后累计 CLevel+0x54;寻路查询接受
0x011阻挡碰撞/净空失败和封闭格修补写入
0xFF-1无几何、未定义、区域外垂直探测无命中时写入;缓存装配时不覆盖现有主网格

7.4 实际文件复核

本地真值样本 VAULT/VAULT_TEMPLATE.MPP 的前 24 字节解析为:

text
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

其格值分布为:

text
0x00  19,818 cells
0x01   2,612 cells
0xFF  67,870 cells

文件总长 24 + 90,300 = 90,324 字节,与磁盘长度完全一致。


8. 世界坐标到走格坐标

8.1 全关卡网格基准

sub_5F6250 已在 CLevel 中建立全局走格范围:

CLevel 偏移类型含义
+0x40int8_t**主通行网格
+0x44int8_t**工作/认领网格
+0x48int8_t**第三辅助网格
+0x4Cint32_t全局格宽
+0x50int32_t全局格高
+0x54uint32_t累计零值格数量
+0x2B0float全局最小 X / grid_origin_x
+0x2B4float全局最小 Z / grid_origin_z
+0x2B8float全局最大 X
+0x2BCfloat全局最大 Z

CLevel+0x5CCCollisionList*。本函数只有在它存在时才执行真实的逐格碰撞分类。

8.2 区域格边界

无缓存时,AABB 映射公式是:

cpp
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 的宽高,而是使用缓存头恢复:

cpp
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 格中心

逐格重算时,采样位置是:

cpp
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 字节头字段,然后分配:

cpp
uint8_t* cached_cells = new uint8_t[header.grid_width * header.grid_height];
read(cached_cells, header.grid_width * header.grid_height);

命中后更新两个全局调试计数器:

cpp
++g_rooms_path_cached;                       // 0x3352734
g_pathnodes_loaded += width * height;        // 0x3352738

9.2 装配规则

双层循环以全局 X 为外层、Z 为内层;缓存体索引仍然是:

cpp
local_index = (global_z - start_z) * cached_width
            + (global_x - start_x);

只有同时满足以下条件才装配:

  • 全局 X/Z 位于关卡网格范围;
  • 缓存字节不是 0xFF

核心逻辑:

cpp
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,装配/计算之前先执行:

cpp
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

cpp
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

这条分支的精确指令是:

asm
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], 1

jg有符号大于,所以严格条件是 int8(work_mask[x][z]) > 0。新一轮网格由 sub_5DB580 初始化:+0x40=-1+0x44=-1+0x48=0;其中第二层的独立证据为 0x5DB5A0: 8B 71 440x5DB5A6: C6 04 30 FF。因此正常生命周期是 -1(未认领)→ 1(已认领)。值 0 也不会跳过,而会在进入后改写成 1

这说明 +0x44 不是另一套“可走/阻挡”答案,而是本轮工作的 visited/claim mask: 它只消除重叠动态 AABB 带来的重复碰撞查询。它不去重后文的 AABB 面积日志,也不改变 +0x40 主通行网格的三态语义。

10.2 初始值

函数先复制主网格当前值:

cpp
temp[cell] = primary[x][z];
if (temp[cell] == 0xFF)
    temp[cell] = 0;

只有当前值为 00xFF 的候选格会执行碰撞探测。原本已经为 1 的阻挡格保持阻挡,不会被本轮重新开放。

10.3 候选碰撞体预筛选

在发射射线前调用 sub_5F2C70。该函数会:

  • 清空 CLevel+0x60 一带的临时查询结构;
  • 使用 CQuadtreeCollision/CCollisionList 对当前格附近范围做空间筛选;
  • 遍历已注册 CRoomPiece,要求启用、可见、允许 pathing、拥有碰撞资源且与查询 AABB 相交;
  • 把相交的动态房间件放入查询候选列表。

建议命名:

cpp
CLevel::prepareCollisionCandidates(query_min, query_max); // sub_5F2C70

它不是最终射线求交,只负责缩小随后查询要检查的静态面片和动态房间件集合。

10.4 垂直落点检测

每格构造垂直线段:

cpp
Vector3 ray_from(world_x, -200.0f, world_z);
Vector3 ray_to  (world_x,  200.0f, world_z);

然后调用 sub_5E2150,并传入两处 0.1f 容差。该 helper 会在静态碰撞结构和已注册房间件中寻找最近命中,返回命中点、法线、碰撞分类和来源标志。

分类规则:

cpp
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 处建立水平起点:

cpp
Vector3 origin(hit.x, hit.y + 1.5f, hit.z);

然后依次向四个方向查询 0.3u

cpp
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)

对应常量已经从数据段复核:

text
0x2142018 = +0.3000000119f
0x2171994 = -0.3000000119f

任意一次 sub_5E1E90 命中且 hit.kind == 100,当前格就改为 1。这相当于在角色身体高度附近做一个十字形净空探测,避免只凭脚下有地面就把贴墙、细缝或特殊阻挡内部判为可走。

10.6 写入主网格与面积计数

分类结果立即写回:

cpp
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:

cpp
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 的格:

cpp
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;
}

四个条件分别检查:

  1. 左右两端都被堵;
  2. 上下两端都被堵;
  3. 左上与右下都被堵;
  4. 右上与左下都被堵。

任意一条轴两端同时被堵,这个零值格就被封墙。它会消除一格宽夹缝、孤立点和某些对角穿越情形。

11.1 0xFF 为什么按开放端处理

条件只把字节 1 视为墙;合法范围内的 0xFF 不满足 >0 检查,因此不会像墙一样夹死相邻零值格。这样布局边缘没有几何的区域不会自动形成一圈人工封闭墙。

11.2 计数时序缺陷

零值计数发生在 0x5F4369~0x5F436F,封闭格修补发生在后面的 0x5F43A5~0x5F4598。后处理没有执行:

cpp
--level->zero_grid_cell_count;

因此本轮新烘焙时,面积计数包含随后被修成 1 的格;写出的 MPP 已经包含修补后的 1,下次缓存命中只会统计最终仍为 0 的格。

这会产生一个原版可观察差异:

text
第一次:重算 → 先数 0 → enclosure 把少量 0 改 1 → 人口面积略偏大
第二次:加载最终 MPP → 只数仍为 0 的格       → 人口面积略小

差异大小取决于被 enclosure 修补的格数。做严格行为复刻时应保留;做确定性服务器或编辑器工具时,可以考虑把统计移动到后处理之后,但这会偏离原版。


12. MPP 写回

写回条件位于 0x5F459F~0x5F46E9

cpp
if (level->mpp_cache_io_enabled &&
    !cache_valid &&
    !layout_path.empty() &&
    layout_exists) {
    writeMpp(...);
}

如果 CLevel+0x468 的覆盖路径非空,写入该路径;否则写入由 layout_path 派生出的同名 .mpp

写入次序严格为:

cpp
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 或网络上传内容的现代实现必须补上这些校验。

推荐最低验证:

cpp
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_pathAABB 来源clear_regionclaim_work_mask作用
0x5F7DAB房间/布局路径当前静态布局范围falsefalse直接叠加一块静态 MPP,不先清目标区
0x5F8487房间/布局路径替代静态范围truefalse先把区域置零,再加载或重算
0x5F8530当前布局路径或空串CCollisionList 总范围truefalse无有效分块范围时的全碰撞区域 fallback
0x5F9211空字符串动态 passability AABBtruetrue不读写 MPP,使用工作网格去重后动态重算

第一处调用现场:

asm
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

动态范围调用现场:

asm
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_5F36D0

layout_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。定义域应分别为:

text
一次完整烘焙调用数
  = 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 分布和面积:

实测
0x5F7DAB1
0x5F84870
0x5F85300
0x5F9211157
合计158
Dynamic AABB157 raw / 157 finite / 0 infinite
面积12961.0283203125 → 12961

这里精确满足 158 = 1 + 0 + 0 + 1570x5F9211 次数 = 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> 形状:

字段指令含义
+0x1980x5F9190: 8B 83 98 01 00 000x20 字节 AABB 数组首址
+0x1A40x5F9164: 8B BB A4 01 00 00原始元素数
步长 0x200x5F921A: 83 C6 20下一个 AABB

Regions 直接格式化保存下来的 +0x1A4 原始计数。证据是循环前 0x5F9164 把它读入 EDI,循环结束后 0x5F923B: 57 又把该值压给整数转字符串函数。 所以它不是可走格的 8 邻域连通块,也不是 sub_5F36D0 的调用次数。

每个元素先调用 Ogre::AxisAlignedBox::isInfinite();为真就在 0x5F91A1 跳到下一项。 对每个有限 AABB,代码计算:

cpp
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 调整数格式化。因此:

text
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 在调用本函数前把四个计数器清零,本函数负责更新它们:

地址日志标签更新位置精确含义
0x335272CRooms Repathed0x5F3D5B缓存未命中且 layout_path 非空的布局数
0x3352730Pathnodes Calculated0x5F3EDA实际进入重算分支的格数;工作网格已认领的格不重复计入
0x3352734Rooms PathCached0x5F3D2C成功采用 MPP 缓存的区域数
0x3352738Pathnodes Loaded0x5F3D32从缓存分配/读取的 width × height 总格数,包含 0xFF

注意 Pathnodes Loaded 不是“有效可走格数”,而是缓存体总字节数。

CLevel+0x54 则不是调试日志计数器,而是后续游戏逻辑使用的零值面积累计。


15. 与 MONSTERS_PER_METER 的连接

缓存命中和重算路径都存在:

cpp
if (cell == 0)
    ++level->zero_grid_cell_count; // CLevel+0x54

后续怪物、道具等数量计算把该计数乘以 0.16

text
0.4 × 0.4 = 0.16 world-unit² per cell

因此面积链路是:

text
LAYOUT 碰撞范围
  → sub_5F36D0 的 MPP/逐格分类
  → 0 值格数量 CLevel+0x54
  → × 0.16 得到可走面积
  → × MONSTERS_PER_METER
  → 目标怪物数量

这也解释了为什么 MPP 缺失、过期、错误或覆盖范围错位,会同时表现为:

  • 玩家无法正常移动;
  • 某些布局接缝变成幽灵墙;
  • 怪物/道具密度异常;
  • 第一次烘焙和再次加载的数量出现微小漂移。

16. 高层源码重建

下面省略 STL/SEH 清理和碰撞查询的输出参数,只保留控制语义:

cpp
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;
}

单格分类可以单独恢复为:

cpp
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,应按以下顺序实现:

  1. 严格读取 24 字节小端头;
  2. 使用 64 位整数验证 width × height
  3. 确认文件长度精确匹配;
  4. 保持 z * width + x 的存储顺序;
  5. 保持 0/1/255 三态,不要在导入时直接把 255 合并为墙;
  6. 装配时让 255 不覆盖其他布局已经写入的格。

17.2 保留坐标舍入顺序

原版使用单精度数据和 floor/ceil。以下两种写法在浮点边界上可能相差一格:

cpp
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. 建议命名表

地址/字段当前名称建议名称置信度
0x5F36D0sub_5F36D0CLevel::loadOrBuildPassabilityRegion
0x5F2C70sub_5F2C70CLevel::prepareCollisionCandidates中高
0x5E2150sub_5E2150CLevel::raycastCollisionWide
0x5E1E90sub_5E1E90CLevel::raycastCollision
0x67FBB0sub_67FBB0fileExists
0x67FAA0sub_67FAA0isOlderOrMissing
0x67FB10sub_67FB10sameFileTimeAsPackageEntry
0x694FA0sub_694FA0crc32File
CLevel+0x36D未命名字节mpp_cache_io_enabled中高
CLevel+0x468未命名 wstringmpp_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~0x5F3AA5VFS、文件存在、时间戳与 CRC 有效性检查
0x5F3AAA~0x5F3BA6当前 AABB 映射到全关卡格范围
0x5F3BAD~0x5F3D47读取 MPP 头和字节体、恢复缓存格范围
0x5F3D55~0x5F3D79缓存未命中计数与临时格分配
0x5F3DB0~0x5F3DF4clear_region 前置清零
0x5F3E08~0x5F3EBA双层格循环和缓存命中装配
0x5F3ED4~0x5F3F1B工作网格去重、初值继承
0x5F3F25~0x5F4068格中心、查询范围和碰撞候选准备
0x5F40B8~0x5F4112垂直落点检测及 Y/碰撞类检查
0x5F413B~0x5F433F±X/±Z 四向 0.3u 净空检测
0x5F4355~0x5F4398无命中写 0xFF、主网格写入、零值计数
0x5F43A5~0x5F4598enclosure 封闭格修补
0x5F459F~0x5F46E9条件写回 MPP、刷新包记录
0x5F46F2~0x5F472B临时缓冲、字符串和 SEH 清理

21. 最终定位

sub_5F36D0 最适合概括为:

text
“把一个布局或动态 AABB 的通行性装配进 CLevel 全局走格;
 有可用 MPP 就平移复用,没有就用碰撞逐格烘焙并可选写回。”

它同时连接了四套系统:

text
LAYOUT / MPP 文件缓存


碰撞树与动态 Room Piece


CLevel 三层 0.4u 通行网格

          ├─ A* / 移动查询
          └─ 零值格面积 → MONSTERS_PER_METER

因此,复刻 TL2 关卡行为时不能把它只理解成“读 MPP”。缓存命中路径负责多布局装配,缓存未命中路径本身就是原版运行时 MPP 烘焙内核的一部分。