核心定位MONSTERS_PER_METER_MINMONSTERS_PER_METER_MAX 控制程序化地图希望创建的怪物数量。它们不是“每个房间固定生成多少只怪物”,也不是简单地乘地图宽度或路径长度;引擎先统计布局占用网格中值为 0 的格子数,再乘固定换算系数 0.15384616,最后在密度区间内进行同步随机并向上取整。

分析依据:通过本地 IDA MCP 对 32 位 Torchlight2.exe 进行只读反编译分析。IDA 数据库为 E:/Torchlight 2/Torchlight2.exe.i64,映像基址为 0x400000。文中的 0x…… 均为该数据库中的绝对地址。

数据依据:配置样例来自原版 E:/Torchlight 2/MEDIA/DUNGEONS/*.DAT。函数名形如 sub_600810 是尚未写回 IDA 数据库的临时识别名;本文只记录建议语义,没有改名或修改 IDB。


0. 结论速览

怪物数量的核心计算位于 sub_6008100x600810)。在没有正数绝对数量覆盖时,它的等价伪代码是:

cpp
float effective_cells = level_zero_grid_cells * 0.15384616f;

float low  = effective_cells * min(MONSTERS_PER_METER_MIN,
                                   MONSTERS_PER_METER_MAX);
float high = effective_cells * max(MONSTERS_PER_METER_MIN,
                                   MONSTERS_PER_METER_MAX);

int wanted_monsters = ceil(
    rand_float_between_synced_seed(low, high)
);

完整优先级如下:

text
MONSTER_MIN > 0 或 MONSTER_MAX > 0?
    ├─ 是:在两个绝对值之间做同步整数随机,直接返回
    └─ 否:MONSTERS_PER_METER_MIN 与 MAX 是否都 > 0?
             ├─ 是:地图格数 × 0.15384616 × 密度,
             │      同步浮点随机后 ceil
             └─ 否:返回 0

因此最需要记住的是:

  • 配置文件里没有被运行期读取的裸键 MONSTERS_PER_METER,实际键是带 _MIN_MAX 的一对。
  • 绝对数量键使用单数:MONSTER_MINMONSTER_MAX
  • 绝对数量一侧只要大于 0,整条 per-meter 分支就被覆盖。
  • per-meter 分支要求 MIN、MAX 两者都大于 0;只写一侧或另一侧为 0 会得到 0
  • 两组上下界都会自动排序,MIN/MAX 写反不会造成负区间。
  • per-meter 结果使用 ceil,任意大于 0 的随机结果至少生成目标数 1
  • 随机数来自 synchronized seed,同一种子下应保持确定性。

1. 配置出现在哪里

这些键位于地下城定义的 [STRATA*] 块中,而不是地图 .LAYOUT 文件本身。RULESET 决定地图拼接规则,密度键决定地图生成完成后要向可用区域中投放多少内容。

1.1 原版样例:普通墓穴层

MEDIA/DUNGEONS/CATACOMBS.DAT 的第一层:

text
[STRATA0]
    <STRING>RULESET:media/layouts/catacomb/defiledcrypt_rules.template
    <INTEGER>FLOORS:1
    <STRING>MONSTERSPAWNCLASS:CATACOMBS_UNDEAD
    <FLOAT>MONSTERS_PER_METER_MIN:0.01
    <FLOAT>MONSTERS_PER_METER_MAX:0.01
    <STRING>CHAMPIONSPAWNCLASS:CHAMPION_CATACOMBS
    <STRING>PROPSPAWNCLASS:NONE
    <STRING>NPCSPAWNCLASS:NPCS_DUNGEON_ENCHANTERS_AND_MIMICS
    <FLOAT>NPCS_MIN:1
    <FLOAT>NPCS_MAX:2
[/STRATA0]

这里怪物密度上下界相等,所以怪物目标数本身没有密度随机波动;但换算后的浮点值仍会经过 ceil

1.2 同一地下城的高密度层

同一文件的 STRATA1 使用更高且带区间的密度:

text
[STRATA1]
    <STRING>RULESET:media/layouts/catacomb/defiledcryptbossroom_rules.template
    <STRING>MONSTERSPAWNCLASS:CATACOMBS_UNDEAD
    <FLOAT>MONSTERS_PER_METER_MIN:0.014
    <FLOAT>MONSTERS_PER_METER_MAX:0.015
    <STRING>CHAMPIONSPAWNCLASS:CHAMPION_CATACOMBS
    <STRING>PROPSPAWNCLASS:NONE
    <FLOAT>NPCS_MIN:0
    <FLOAT>NPCS_MAX:0
[/STRATA1]

这说明密度属于每个 strata 的参数,不必对整个 dungeon 固定。

1.3 低密度样例:Echo Pass

MEDIA/DUNGEONS/ECHOPASS.DAT

text
<FLOAT>GOLD_PER_METER_MIN:0
<FLOAT>GOLD_PER_METER_MAX:0
<STRING>MONSTERSPAWNCLASS:ECHOPASS
<FLOAT>MONSTERS_PER_METER_MIN:0.003
<FLOAT>MONSTERS_PER_METER_MAX:0.003
<STRING>CHAMPIONSPAWNCLASS:NONE
<FLOAT>CHAMPIONS_MIN:0
<FLOAT>CHAMPIONS_MAX:0
<STRING>PROPSPAWNCLASS:NONE

0.003 明显低于构造函数默认的 0.01~0.0125,所以该区域有意降低常规怪物密度。


2. 字符串键表与初始化

IDA 字符串搜索只发现以下两个直接相关的宽字符串:

地址字符串
0x213F65CMONSTERS_PER_METER_MAX
0x213F68CMONSTERS_PER_METER_MIN

每个字符串都有大量交叉引用,但其中大多数只是不同编译单元生成的静态 std::wstring 初始化/析构副本,并不代表有几十套独立算法。

运行期加载器使用的字符串对象位于:

地址含义
0x3350910第一类 PER_METER_MIN 字符串对象,即 MONSTERS_PER_METER_MIN
0x335092C第一类 PER_METER_MAX 字符串对象,即 MONSTERS_PER_METER_MAX

每个 32 位 std::wstring 对象占 28 字节,一对 MIN/MAX 共 56 字节。加载器按类别递增索引,因此同一段连续键表同时服务怪物、精英、道具、NPC 等生成类别。

运行期加载器真正引用的这份数组由 sub_18D11800x18D1180)初始化。 sub_1081C20 初始化的是 0x28CF208 的另一编译单元副本;那份没人消费,不能拿它证明 运行期槽位。活数组的键表顺序为:

类别索引Per-meter MINPer-meter MAX
0MONSTERS_PER_METER_MINMONSTERS_PER_METER_MAX
1MONSTERS_SEC_PER_METER_MINMONSTERS_SEC_PER_METER_MAX
2CHAMPIONS_PER_METER_MINCHAMPIONS_PER_METER_MAX
3PROPS_PER_METER_MINPROPS_PER_METER_MAX
4NPCS_PER_METER_MINNPCS_PER_METER_MAX
5CREEPS_PER_METER_MINCREEPS_PER_METER_MAX
6GOLD_PER_METER_MINGOLD_PER_METER_MAX
7FISH_PER_METER_MINFISH_PER_METER_MAX
8RARE_FISH_PER_METER_MINRARE_FISH_PER_METER_MAX
9FORMATIONS_PER_METER_MINFORMATIONS_PER_METER_MAX
10~12空字符串空字符串

加载循环固定运行 13 次。最后三项的键名为空,表现为保留槽位;在没有其他代码直接写入时,它们沿用构造函数默认值 0

这份映射现在有三段互相咬合的字节证据,而不是只靠表的排列猜测:

  1. sub_18D1180 对每项都先 push <UTF-16 literal>,再以 mov ecx, 0x3350910 + 0x1C*i 指定目标 std::wstring;20 个非空项逐个对应上表的索引 0~9。
  2. sub_5D7C200x5D8206/0x5D822B 用同一运行偏移索引 0x335092C;MIN 路径 在 0x5D820F: 83 C0 E4 回退 0x1C。循环尾 0x5D82A2 每次令键偏移 +=0x38,同时 0x5D82A7 令字段指针 +=8
  3. 数量消费者 sub_600810 用同一个 idx 读取 CLevelTemplateData+0xB1C+8*idx 的 per-meter Vector2(绝对数量槽在 +0xB84+8*idx)。

因此槽 1/4/5/6 分别是 Secondary Monsters、NPCs、Creeps、Gold;槽 7/8/9 分别是 Fish、Rare Fish、Formations。这个结论来自活数组初始化、加载循环和消费端三方对齐。

2.1 绝对数量键表

绝对数量键由 sub_18D13D00x18D13D0)初始化:

类别索引绝对 MIN绝对 MAX
0MONSTER_MINMONSTER_MAX
1MONSTER_SEC_MINMONSTER_SEC_MAX
2CHAMPIONS_MINCHAMPIONS_MAX
3PROPS_MINPPROPS_MAX
4NPCS_MINNPCS_MAX
5CREEPS_MINCREEPS_MAX
6GOLD_MINGOLD_MAX
7FISH_MINFISH_MAX
8RARE_FISH_MINRARE_FISH_MAX
9~12空字符串空字符串

WARNING

道具绝对上限在原版可执行文件中确实拼成了 PPROPS_MAX。如果 MOD 写更自然的 PROPS_MAX,这个版本的加载器不会通过该键表读到它。需要使用绝对道具上限时,应按二进制实际字符串写 PPROPS_MAX


3. CLevelTemplateData 的读取与字段布局

3.1 加载函数

sub_5D7C200x5D7C20)负责取得一份规则数据并填充 CLevelTemplateData。该函数会:

  1. 根据 dungeon/strata 选择 RULESET;相关路径缺失时能看到回退字符串 media/layouts/crypt/rules.dat
  2. 通过 sub_60BBE0 构造 CLevelTemplateData
  3. 循环 13 个生成类别,读取 spawn class、启用标志、per-meter 区间和绝对数量区间。
  4. 把结果保存在模板对象中,供之后的地图人口填充阶段使用。

核心读取逻辑可整理为:

cpp
for (int category = 0; category < 13; ++category) {
    rules->spawn_class[category] = group.getString(
        spawn_class_key[category],
        rules->spawn_class[category]
    );

    rules->enabled[category] = group.getBool(
        enabled_key[category],
        rules->enabled[category]
    );

    rules->per_meter[category].min = group.getFloat(
        per_meter_min_key[category],
        rules->per_meter[category].min
    );
    rules->per_meter[category].max = group.getFloat(
        per_meter_max_key[category],
        rules->per_meter[category].max
    );

    rules->absolute[category].min = group.getFloat(
        absolute_min_key[category],
        rules->absolute[category].min
    );
    rules->absolute[category].max = group.getFloat(
        absolute_max_key[category],
        rules->absolute[category].max
    );
}

这里 getFloat(key, current_value) 的第二个参数是缺省值;所以省略配置键不会自动变成任意随机值,而是保留构造函数预置值。

3.2 相关字段

CLevelTemplateData 对象起始地址为基准:

偏移类型含义
+0x834(十进制 2100)std::wstring[13]每类生成内容使用的 spawn class 名称
+0xB0C(十进制 2828)bool[13]每类启用/控制标志区域
+0xB1C + category*8floatPER_METER_MIN
+0xB20 + category*8floatPER_METER_MAX
+0xB84 + category*8float绝对 MIN
+0xB88 + category*8float绝对 MAX

这两个数组是并排的 float 对,而不是两个指针数组:

cpp
struct RangeF {
    float min;
    float max;
};

struct CLevelTemplateData_Partial {
    // ...
    RangeF per_meter[13];  // +0xB1C
    RangeF absolute[13];   // +0xB84
    // ...
};

13 * 8 = 104 = 0x68,恰好也是 0xB84 - 0xB1C,验证了这两张表连续排列。


4. 构造函数默认值

CLevelTemplateData 构造函数为 sub_60BBE00x60BBE0)。在 DAT 没写对应键时,per-meter 默认值如下:

索引类别默认 MIN默认 MAX
0Monsters0.010.0125
1Monsters Secondary00
2Champions0.00020.0002
3Props0.0050.0075
4NPCs00
5Creeps00
6Gold0.00120.0017
7Fish00
8Rare Fish00
9Formations0.0010.002
10保留槽位00
11保留槽位00
12保留槽位00

所有类别的绝对 MIN/MAX 默认都是 0,因此默认走 per-meter 分支。

NOTE

原版怪物默认值不是 0.01~0.01,而是 0.01~0.0125。许多具体 dungeon 会在自己的 [STRATA] 中覆写为 0.01~0.01,两者不要混为一谈。


5. 核心数量函数 sub_600810

5.1 建议签名

根据三个参数的用途,可将 0x600810 暂时标注为:

cpp
int __thiscall CLevelTemplateData_calcDesiredCount(
    CLevelTemplateData* this,
    int category,
    uint32_t zero_grid_cell_count
);

第三个参数来自 CLevel + 0x54。它在构造函数中被清零,在布局网格导入时累计,随后同一个值被传给怪物、次级怪物、精英、道具、NPC、creep 和金币等类别。

5.2 完整等价伪代码

cpp
int CLevelTemplateData_calcDesiredCount(
    CLevelTemplateData* rules,
    int category,
    uint32_t zero_grid_cell_count)
{
    float absolute_min = rules->absolute[category].min;
    float absolute_max = rules->absolute[category].max;

    // 绝对数量拥有最高优先级;只要任意一侧为正即进入。
    if (absolute_min > 0.0f || absolute_max > 0.0f) {
        int low  = (int)min(absolute_min, absolute_max);
        int high = (int)max(absolute_min, absolute_max);

        return rand_int_between_synced_seed(low, high);
    }

    float density_min = rules->per_meter[category].min;
    float density_max = rules->per_meter[category].max;

    // 与绝对分支不同:密度上下界必须同时为正。
    if (density_min > 0.0f && density_max > 0.0f) {
        float low_density  = min(density_min, density_max);
        float high_density = max(density_min, density_max);

        float effective_measure =
            (float)zero_grid_cell_count * 0.15384616f;

        float sampled = rand_float_between_synced_seed(
            effective_measure * low_density,
            effective_measure * high_density
        );

        return (int)ceil(sampled);
    }

    return 0;
}

5.3 关键指令地址

地址行为
0x600816读取并比较绝对 MIN(+0xB84
0x600833读取并比较绝对 MAX(+0xB88
0x600845比较 per-meter MIN(+0xB1C
0x60085E比较 per-meter MAX(+0xB20
0x600898地图格数乘 0.15384616
0x6008BF调用同步种子的浮点区间随机函数
0x6008C7调用 ceil
0x600937绝对数量分支调用同步种子的整数区间随机函数

5.4 绝对数量分支的类型细节

绝对 MIN/MAX 在对象中仍以 float 保存,但传给整数随机函数前会被转换为整数。因此不应依赖 MONSTER_MIN:1.5 之类的小数语义;配置绝对数量时应使用整数值,即使 DAT 标签写成 <FLOAT>


6. “每米”的地图尺度从哪里来

持有该输入的对象是 CLevelsub_5F00200x5F0020)中明确设置 CLevel::vftable,并把 this[21],即 CLevel + 0x54,初始化为 0

布局导入函数 sub_5F36D00x5F36D0)会打开与 layout 同名的 .MPP,根据布局边界建立二维字节网格。它把世界坐标乘 2.5 后做 floor/ceil,说明网格精度为每个世界单位 2.5 格。处理每个覆盖格时能看到两处相同累计:

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

对应的反编译上下文包括:

cpp
if (!v46[v45])
    ++v43[21];

// 另一条布局写入路径:
if (!v61[v45])
    ++v43[21];

因此可以严格确认:

  • CLevel + 0x54 是布局二维网格中 零值格子的累计数量
  • 该值在地图人口填充函数中作为第三参数传给所有 per-meter 数量计算。
  • 核心函数再把它乘 0.15384616f

从周围的边界、覆盖和占用判断看,零值格子高度疑似代表可用/未阻塞区域,因此本文使用“可用网格规模”作为便于理解的称呼。不过,当前反编译中没有恢复出这个字段的官方 C++ 名称,也没有直接字符串把 0 枚举命名为 WALKABLE。严谨表述仍应是“零值布局格子数”,不应武断称为导航网格面积或路径总长度。

6.1 固定系数 0.15384616

该常量位于 flt_2179B4C,数值近似 2/13。可确认的是它把零值格子计数转换为密度计算使用的有效尺度;尚不能仅凭该常量证明单位就是现实意义上的一米。

建议复刻时保留二进制中的单精度常量:

cpp
constexpr float kGridCellsToDensityMeasure = 0.15384616f;

不要擅自改成 0.4(虽然坐标网格步长看似为 1 / 2.5),也不要在要求原版兼容时先替换为双精度分数。原版的最终 ceil 会让边界附近的微小浮点差异变成整整一个生成目标的差别。


7. 目标数如何变成实际怪物

地图人口填充主函数是 sub_5ED9B00x5ED9B0)。它对同一个 CLevel + 0x54 连续调用 sub_600810

cpp
wanted_creeps     = calcDesiredCount(rules, 5, level->field_54);
wanted_npcs       = calcDesiredCount(rules, 4, level->field_54);
wanted_monsters   = calcDesiredCount(rules, 0, level->field_54);
wanted_monsters_2 = calcDesiredCount(rules, 1, level->field_54);
wanted_props      = calcDesiredCount(rules, 3, level->field_54);
wanted_champions  = calcDesiredCount(rules, 2, level->field_54);
wanted_gold       = calcDesiredCount(rules, 6, level->field_54);

程序中的日志字符串进一步确认了部分局部变量的含义:

text
Monsters Created Wanting to Create:
Props Created Wanting to Create:
Champs Created Wanting to Create:

对普通怪物,后续流程可简化为:

cpp
int created = 0;
int failed_attempts = 0;

while (created < wanted_monsters) {
    Region* region = weighted_regions.pick(synced_rng);
    const wchar_t* spawn_class = rules->spawn_class[MONSTERS];

    int spawned = place_monster_group(
        region->bounds,
        spawn_class,
        /* 其他等级、主题和生成参数 */
    );

    if (spawned <= 0) {
        ++failed_attempts;

        if (failed_attempts > 10) {
            // 防止没有合法落点时无限循环。
            spawned = 1;
            failed_attempts = 0;
        }
    }

    created += spawned;
}

实际怪物/怪物组放置 helper 是 sub_5E98A00x5E98A0)。由此可见:

  • MONSTERS_PER_METER_* 计算的是 希望创建的数量预算,不是直接决定每次调用创建几只。
  • MONSTERSPAWNCLASS 决定从哪个怪物集合取内容。
  • 放置区域通过权重选择,仍会受到区域包围盒、碰撞、可用落点等条件影响。
  • helper 一次可能返回大于 1 的已创建数量,因此一个“目标单位”不必等于一次函数调用。
  • 连续失败超过 10 次时,主循环把进度人为增加 1;这会使实际创建数少于目标数,但避免死循环。

7.1 次级怪物密度

类别 1 使用独立的一组:

text
MONSTERS_SEC_PER_METER_MIN
MONSTERS_SEC_PER_METER_MAX
MONSTER_SEC_MIN
MONSTER_SEC_MAX

它与普通怪物走同一个数量算法,但在后续独立循环中使用第二套 spawn class/生成通道。默认密度为 0,必须由数据显式启用。


8. 数值示例

以下例子只演示 sub_600810 的目标数量计算,不包含落点失败造成的实际数量减少。

8.1 固定密度

假设地图累计 1000 个零值格子,并使用墓穴常见配置:

text
MONSTERS_PER_METER_MIN = 0.01
MONSTERS_PER_METER_MAX = 0.01

计算:

text
有效尺度 = 1000 × 0.15384616 = 153.84616
区间下限 = 153.84616 × 0.01 = 1.5384616
区间上限 = 153.84616 × 0.01 = 1.5384616
目标数量 = ceil(1.5384616) = 2

8.2 默认密度区间

同样是 1000 个零值格子,使用构造函数默认值 0.01~0.0125

text
随机区间 = [1.5384616, 1.923077]
ceil 后目标数量恒为 2

虽然原始区间有随机性,但整个区间都落在 (1, 2] 内,取整后随机性消失。调密度时必须以 ceil 后的离散区间 检查实际效果。

8.3 跨越整数边界的随机区间

假设 2000 个零值格子,密度仍为 0.01~0.0125

text
有效尺度 = 307.69232
随机区间 = [3.0769232, 3.846154]
ceil 后目标数量恒为 4

若区间改为 0.008~0.015

text
随机区间 = [2.4615386, 4.6153848]
ceil 后可能得到 3、4 或 5

8.4 绝对数量覆盖

text
<FLOAT>MONSTER_MIN:5
<FLOAT>MONSTER_MAX:8
<FLOAT>MONSTERS_PER_METER_MIN:0.01
<FLOAT>MONSTERS_PER_METER_MAX:0.02

结果只会在整数 5~8 之间同步随机,地图格数和两个 per-meter 值都不会参与。

如果只写:

text
<FLOAT>MONSTER_MIN:5
<FLOAT>MONSTER_MAX:0

仍会进入绝对分支。函数会排序成 0~5,因此可能返回 0;“只写正数 MIN 就等于至少生成 MIN”这一理解是错误的。


9. 配置边界与常见陷阱

9.1 只配置一个 per-meter 边界会关闭生成

以下配置返回 0

text
<FLOAT>MONSTERS_PER_METER_MIN:0.01
<FLOAT>MONSTERS_PER_METER_MAX:0

per-meter 判断使用逻辑与:

cpp
min > 0.0f && max > 0.0f

因此要启用密度生成,两个键都应给正数。

9.2 MIN/MAX 写反会被修正

text
<FLOAT>MONSTERS_PER_METER_MIN:0.02
<FLOAT>MONSTERS_PER_METER_MAX:0.01

函数会使用 0.01~0.02。虽然不会崩溃,仍建议按名称正确排序,避免误导工具和维护者。

9.3 负数不表示特殊自动值

绝对分支要求任意一侧 > 0;per-meter 分支要求两侧 > 0。负值不会产生“反向密度”,通常只会让该分支不成立或与另一侧正数一起进入绝对分支后形成包含负数的随机区间。不要把 -1 当作这里的通用缺省标记。

9.4 目标数不保证等于落地数

密度公式只输出目标预算。地图没有足够合法落点时,放置 helper 可反复返回 0,重试保护最终跳过部分预算。所以验证 MOD 时应同时查看:

  • 计算出的目标数;
  • 实际创建成功数;
  • 区域/碰撞条件是否允许落点;
  • spawn class 是否为空、为 NONE 或没有有效候选。

9.5 小数变化可能完全看不出效果

由于最后使用 ceil,同一地图尺度下,一段连续密度区间可能全部映射到同一个整数。例如前文 1000 格时,0.01~0.0125 始终得到 2。调参时应先算出整数边界,而不是只比较小数相差百分之多少。


10. 复刻建议

如果要在 Godot 迁移管线中重现原版人口密度,建议把“数量预算”和“实际放置”拆为两步:

gdscript
const GRID_TO_DENSITY_MEASURE := 0.15384616

func calculate_desired_count(
        zero_grid_cells: int,
        per_meter_min: float,
        per_meter_max: float,
        absolute_min: float,
        absolute_max: float,
        rng: RandomNumberGenerator) -> int:
    if absolute_min > 0.0 or absolute_max > 0.0:
        var low := int(min(absolute_min, absolute_max))
        var high := int(max(absolute_min, absolute_max))
        return rng.randi_range(low, high)

    if per_meter_min <= 0.0 or per_meter_max <= 0.0:
        return 0

    var low_density := min(per_meter_min, per_meter_max)
    var high_density := max(per_meter_min, per_meter_max)
    var measure := zero_grid_cells * GRID_TO_DENSITY_MEASURE
    return ceili(rng.randf_range(
        measure * low_density,
        measure * high_density
    ))

要达到原版确定性,仅复制公式还不够:

  1. 必须以与原版相同的方式构建布局字节网格并统计零值格子。
  2. 随机数调用顺序必须一致;共享种子但调用顺序不同,结果仍会偏离。
  3. 应先计算所有类别的预算,再按原版次序选择区域和生成内容。
  4. 需要复刻“失败超过 10 次后推进预算”的保护,否则无合法落点的地图可能卡死。
  5. 若只追求视觉接近而非种子级一致,可以保留公式,但使用 Godot 自己的确定性 RNG 与导航落点系统。

11. 完整调用链

text
地下城 DAT 的 [STRATA]

    ├─ RULESET
    ├─ MONSTERSPAWNCLASS
    ├─ MONSTERS_PER_METER_MIN/MAX
    └─ MONSTER_MIN/MAX


sub_5D7C20 @ 0x5D7C20
读取并缓存 CLevelTemplateData

            ├──────────────────────────────────────┐
            │                                      │
            ▼                                      ▼
sub_5F36D0 @ 0x5F36D0                    sub_600810 @ 0x600810
加载 LAYOUT/MPP 网格                    读取模板中的密度/绝对区间
统计零值格子到 CLevel+0x54              计算各类别目标数量
            │                                      │
            └──────────────────┬───────────────────┘

                    sub_5ED9B0 @ 0x5ED9B0
                    地图人口填充与分类循环


                    sub_5E98A0 @ 0x5E98A0
                    在加权区域内实际放置怪物组

规则数据的上游选择函数还有:

地址作用
0x5D8420从加权规则候选中选择一项,再调用 sub_5D7C20
0x5D98F0加载索引规则,并在配置了 DUNGEON 链接时继续跟随
0x5F9910把规则对象保存到 CLevel + 0x24C(十进制 588),进入布局生成流程

sub_5ED9B0 正是从 CLevel + 0x24C 取得 CLevelTemplateData*,同时从 CLevel + 0x54 取得零值网格计数。


12. 地址索引与建议命名

地址当前名称建议语义名
0x5D7C20sub_5D7C20loadLevelTemplateDataFromRules
0x5E98A0sub_5E98A0placeMonsterGroupInRegion
0x5ED9B0sub_5ED9B0populateGeneratedLevel
0x5F0020sub_5F0020CLevel::CLevel
0x5F36D0sub_5F36D0CLevel::importLayoutGridAndCountZeroCells
0x5F9910sub_5F9910CLevel::generateFromTemplate(暂定)
0x600810sub_600810CLevelTemplateData::calcDesiredCount
0x60BBE0sub_60BBE0CLevelTemplateData::CLevelTemplateData
0x1081C20sub_1081C20initPerMeterKeyTable
0x18D13D0sub_18D13D0initAbsoluteCountKeyTable

以下字段名同样是建议名,不是恢复出的官方符号:

对象与偏移建议字段名
CLevel + 0x54zero_grid_cell_count
CLevel + 0x24Clevel_template_data
CLevelTemplateData + 0xB1Cper_meter_ranges[13]
CLevelTemplateData + 0xB84absolute_ranges[13]

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

已由反编译直接确认

  • 字符串键名、键表顺序及 PPROPS_MAX 拼写。
  • CLevelTemplateData 两张区间表的偏移、步长与 13 次加载循环。
  • 各类别构造函数默认值。
  • 绝对数量优先级、per-meter 两侧必须为正、上下界排序。
  • 固定乘数 0.15384616f、同步浮点随机及 ceil
  • CLevel + 0x54 在布局导入时按零值字节格累计。
  • 同一计数被传给怪物、精英、道具、NPC、creep、金币等类别。
  • 普通怪物目标数进入实际放置循环,连续失败超过 10 次会跳过一份预算。

高可信解释,但没有官方符号名

  • 零值格子代表可用/未阻塞的布局区域。
  • 0.15384616 是从布局格数到“per meter”有效尺度的内部单位换算。
  • 类别 1 是普通怪物之外的第二生成通道。

尚不能仅凭当前证据断言

  • CLevel + 0x54 的原始 C++ 字段名。
  • 零值格子的官方枚举名称是否确实叫 walkable。
  • 0.15384616 的设计来源为何是近似 2/13,以及这里的 meter 是否与 Ogre 世界单位严格一一对应。
  • 同步 RNG 的完整全局调用序列;若追求跨引擎逐种子完全一致,还需要继续逆向 RNG 状态与所有中间调用。

14. 最小配置模板

按地图规模自动计算普通怪物数量:

text
[STRATA0]
    <STRING>RULESET:media/layouts/your_zone/your_rules.template
    <STRING>MONSTERSPAWNCLASS:YOUR_MONSTER_SET
    <FLOAT>MONSTERS_PER_METER_MIN:0.01
    <FLOAT>MONSTERS_PER_METER_MAX:0.0125
[/STRATA0]

固定数量区间并覆盖密度:

text
[STRATA0]
    <STRING>RULESET:media/layouts/your_zone/your_rules.template
    <STRING>MONSTERSPAWNCLASS:YOUR_MONSTER_SET
    <FLOAT>MONSTER_MIN:8
    <FLOAT>MONSTER_MAX:12
[/STRATA0]

明确关闭普通怪物自动生成:

text
[STRATA0]
    <STRING>MONSTERSPAWNCLASS:NONE
    <FLOAT>MONSTERS_PER_METER_MIN:0
    <FLOAT>MONSTERS_PER_METER_MAX:0
    <FLOAT>MONSTER_MIN:0
    <FLOAT>MONSTER_MAX:0
[/STRATA0]

最后一种写法同时清零两条数量路径,也避免继承 CLevelTemplateData 的默认怪物密度。