经验分摊、等级差修正与金币掉落公式
对象:
Torchlight2.exe(32-bit,静态基址0x400000)
对应 backlog:HANDOFF_RE_BACKLOG.md的 B-2 / C-0193
主入口:CUnit__awardExperience@0x52F970、CLevel__dropGold@0x5E8F70
机器可读证据:原版游戏分析/reversed_cpp/REGISTRY/xp_and_gold_award.json
回归测试:python -m unittest tests.truth.test_xp_and_gold_award
1. 结论
B-2 的五个 NOT_PINNED 项已经全部落到原版 EXE 的指令、常量和直接调用目标上。
经验奖励的计算顺序是:
- 读取 stat 68,把它夹在
[-100, +100]; - 以
ceil(clampedStat * 0.01 * baseXP)加到基础经验; - 满足多人共享条件时,以“附近其他合格队员数 + 1”查询
XPSHARING,其返回值作为直接倍率; - 以“经验来源等级 − 接收者等级”查询
LEVEL_VERSUS_LEVEL_EXPERIENCE_MODIFIER,其返回值按百分数使用; - 向
+0x72C/+0x738owned child/minion vector 转发;当前单位有+0x71Cowner 且不是已转发调用时,改派给 owner;否则在自身+0x594落账并对溢出做INT32_MAX饱和。
金币掉落则固定进行三次尝试。普通模式下每次独立执行名义 5% 的概率门:
rand_float_between(0.0, 2.0) < 0.1成功后基础金币范围为:
scale = 1 + level * 0.25 // 有 source 路径
scale = 1 + level * 0.125 // fallback owner 路径
baseGold = rand_int_between_volatile(trunc(2*scale), trunc(12*scale))概率掷骰和金额掷骰都直接消费 g_seed_volatile。这证明金币不是从同步 gameplay RNG 流取样;但它本身不能证明客户端一定各自生成不同金币,后者还取决于 dropGold 是否只在权威端执行。
2. 函数边界与角色
| 函数 | 区间(尾地址不含) | 角色 |
|---|---|---|
CUnit__awardExperience | [0x52F970, 0x52FF09) | 经验加成、队伍分摊、等级差修正、子单位转发和最终落账 |
CLevel__dropGold | [0x5E8F70, 0x5E928A) | 三次掉落尝试、金币基础金额、Gold Find 加成和金币单位生成 |
countNearbyPartyRecipients(本报告名) | [0x521BD0, 0x521C99) | 统计共享范围内的其他合格队伍成员 |
rand_float_between | [0x677200, 0x6772EA) | 使用 volatile seed 生成区间浮点数 |
rand_int_between_volatile | [0x6773F0, 0x6774CF) | 使用 volatile seed 生成闭区间整数 |
countNearbyPartyRecipients 是便于重制实现理解的语义名,未写回 IDA。
3. 经验公式
3.1 stat 68 加成与夹取
0x52F9CF 的 push 0x44 把 stat 68 送入 CUnit__getStat。随后两套 x87 比较/选择序列分别引用:
| 数据地址 | 值 | 用途 |
|---|---|---|
0x2141458 | 100.0f | 上界 |
0x2170214 | -100.0f | 下界 |
0x2142654 | 0.01f | 百分比换算 |
因此第一阶段为:
float pct = clamp(getStat(68, 7, 0), -100.0f, 100.0f);
int xp = baseXP + (int)ceil(pct * 0.01f * baseXP);关键锚点:
| VA | 指令/字节 | 含义 |
|---|---|---|
0x52F9CF | 6A 44 | stat ID = 68 |
0x52F9DA | D9 05 58 14 14 02 | 加载 100.0f |
0x52F9FB | D9 05 14 02 17 02 | 加载 -100.0f |
0x52FA1B | D8 0D 54 26 14 02 | 乘 0.01f |
0x52FA2B | FF 15 0C 6B 11 02 | 调用 ceil |
直接结果是:这一阶段最低可把经验降到 0%,最高只能提高到 200%。例如 stat 68 写成 +200 与 +100 在这里效果相同;写成 -200 与 -100 也相同。
这里仅钉住了 stat ID,未用本轮控制流强行为它补正式数据表枚举名。
3.2 XPSHARING 的输入不是全队人数
共享分支先调用 0x521BD0。该辅助函数遍历 party 链表,并对候选成员执行三层过滤:
- 排除当前本地玩家自己;
- 排除
vtable + 0x1A4判定为真的成员; - 只保留与本地玩家距离小于
CGlobals + 0xE4阈值的成员。
每通过一个候选,0x521C88: inc ebx。主函数只在计数大于 0 时启用共享曲线,然后在 0x52FADF 给计数加 1.0f:
int nearbyOthers = countNearbyPartyRecipients(self);
if (nearbyOthers > 0) {
float recipients = float(nearbyOthers) + 1.0f;
xp = trunc(XPSHARING(recipients) * xp);
}这个 +1 代表当前经验接收者自身。因此曲线的横轴是本次有效共享人数,而不是队伍名单的总人数。
0x52FAEF 直接把曲线结果乘经验整数;这个位置没有 0.01f。所以 XPSHARING 返回的是直接倍率,例如曲线值 0.75 就是 75%,不是 0.75%。
3.3 LEVEL_VERSUS_LEVEL_EXPERIENCE_MODIFIER
等级差曲线在本地落账和远端队友派发两条路径上使用同一公式:
int levelDelta = source->levelAt0x110 - recipient->levelAt0x110;
xp = trunc(LEVEL_VERSUS_LEVEL_EXPERIENCE_MODIFIER(levelDelta) * 0.01f * xp);| 路径 | 等级差指令 | evaluate | 百分比换算 |
|---|---|---|---|
| 远端队伍成员 | 0x52FC16 读 source、0x52FC1C 减 recipient | 0x52FC32 | 0x52FC37 乘 0.01f |
| 当前单位本地落账 | 0x52FDC3 读 source、0x52FDC9 减 self | 0x52FDDF | 0x52FDE4 乘 0.01f |
注意两张曲线的单位不同:
| 曲线 | 输入 | 输出的使用方式 |
|---|---|---|
XPSHARING | 有效共享人数 | 直接倍率 |
LEVEL_VERSUS_LEVEL_EXPERIENCE_MODIFIER | 来源等级 − 接收者等级 | 百分数,再乘 0.01 |
重制时若把两张曲线都当百分数,XPSHARING 会被错误缩小 100 倍。
3.4 owned child/minion 转发与 owner 改派
经验函数中的相关字段为:
| 偏移 | 十进制 | 语义 | 指令锚 |
|---|---|---|---|
+0x71C | 1820 | owner/master 指针 | 0x52FD48 |
+0x72C | 1836 | owned child/minion 指针数组 | 0x52FD1E |
+0x738 | 1848 | 数组元素数 | 0x52FD01 |
主函数先遍历 owned vector。对每个 child 都通过虚表 +0x1F0 再调用一次 awardExperience,尾部两个布尔参数为 (1, 1):
for (child : self->ownedChildren) {
child->awardExperience(context, source, xp, true, true);
}随后读取 self + 0x71C:
- owner 为空,或本次已经是 forwarded 调用:在
self落账; - owner 非空且本次不是 forwarded:调用 owner 的同一虚函数,尾部参数为
(0, 1),然后直接退出当前调用。
伪代码如下:
if (self->owner != nullptr && !alreadyForwarded) {
self->owner->awardExperience(context, source, xp, false, true);
return;
}
applyLevelModifierAndCreditSelf();字段语义不是只靠这一处猜的:0x526530 也递归遍历 +0x72C/+0x738,检查 child 的 +0x71C,并对 player owner 的第一个 child 做特殊保留;已有 PET_VELOCITY_RE/REPORT.md 则独立确认 +0x71C 是可沿链上溯的 owner/master 指针。
因此,整个数组应称为 owned child/minion vector。只有第一个元素在部分 player 逻辑里具有主宠特殊性,不能把数组中的每个元素都解释成“主宠”。
3.5 最终经验存储
经验落账字段是 CUnit + 0x594。溢出路径在 0x52FE11 执行:
C7 86 94 05 00 00 FF FF FF 7F
mov dword ptr [esi+594h], 7FFFFFFFh因此经验溢出采用 INT32_MAX 饱和,而不是有符号回绕。
4. 金币掉落公式
4.1 三次尝试的真实循环
循环计数不是 Hex-Rays 重构产物:
| VA | 字节 | 含义 |
|---|---|---|
0x5E8FAC | C7 44 24 14 03 00 00 00 | 局部计数器初始化为 3 |
0x5E926A | 83 6C 24 14 01 | 每轮减 1 |
0x5E926F | 0F 85 3F FD FF FF | 非零回跳 0x5E8FB4 |
概率门失败会从 0x5E8FDF 直接跳到 0x5E926A,成功生成完金币也到达相同的递减点。因此普通模式严格是三次机会,不是“成功一次就停”。
当 force_drop 为真时,0x5E8FB4 的分支跳过概率门,但不会跳过循环计数。只要后续金币对象创建没有失败,该模式每轮都会生成,总计三堆。
4.2 每次名义 5%
概率门的完整形状是:
if (!force_drop && rand_float_between(0.0f, 2.0f) >= 0.1f)
continue;证据为:
0x5E8FBA加载2.0f;0x5E8FC7执行fldz,提供0.0f;0x5E8FCC直接调用rand_float_between@0x677200;0x5E8FD1与0.1f比较;- 未通过时
0x5E8FDF跳到本轮递减点。
rand_float_between 实现为:
return min + (max - min) * unitFloat(g_seed_volatile);底层 unitFloat @ 0x676C30 把随机 mantissa 拼成 [1,2) 的 double,再减 1.0,所以 u ∈ [0,1),最终区间为 [0,2)。阈值所占区间比例为 0.1 / 2.0 = 0.05。
“5%”是游戏设计层面的名义概率;有限状态 PRNG 与 IEEE-754 离散取值下,不把它表述成无限精度概率论意义上的严格实数测度。
若把三轮视为独立同分布抽样,可推导:
| 结果 | 概率 |
|---|---|
| 0 堆 | 0.95³ = 85.7375% |
| 至少 1 堆 | 1 - 0.95³ = 14.2625% |
| 恰好 1 堆 | 3 × 0.05 × 0.95² = 13.5375% |
| 恰好 2 堆 | 3 × 0.05² × 0.95 = 0.7125% |
| 恰好 3 堆 | 0.05³ = 0.0125% |
这张表是从已钉公式推导的统计解释,不是 EXE 中另存的一组常量。
4.3 基础金额
金额的等级缩放有两个入口:
if (source != nullptr)
scale = 1.0f + sourceLevel * 0.25f;
else if (owner != nullptr)
scale = 1.0f + ownerLevel * 0.125f;
else
scale = 1.0f;随后 x87 栈先计算 12 * scale,转换成整数并压栈,再把 scale 自加得到 2 * scale,转换并压栈。按 cdecl 逆序压参后,实际调用为:
baseGold = rand_int_between_volatile(
trunc(2.0f * scale),
trunc(12.0f * scale)
);整数 helper 的底层实现以 % (max-min+1) 取值,所以区间两端都包含。
4.4 Gold Find 两段加成
基础金额生成后,函数读取两个 stat 现场:
- 第一段固定读取 stat 28,对基础金额加成;
- 第二段按 owner/player 关系选 stat 51 或 stat 28,对第一段后的当前金额再次加成。
第二段分支不是“固定把 stat 28 算两次”:
- owning player 就是当前单位,且它有第一个 owned child:使用 stat 51;
- 其余路径:使用 stat 28。
每段加成都使用:
bonus = trunc(statValue * currentGold * 0.01f + 0.5f);
currentGold += bonus;因为第二段以第一段之后的 currentGold 为基数,两段加成是复合的,不是都对原始基础金额做简单相加。
5. volatile RNG 与联机边界
两个随机 helper 都把同一个对象地址 0x361A8B0 放进 ecx:
| 随机阶段 | helper | seed 指令 |
|---|---|---|
| 是否掉落 | rand_float_between @ 0x677200 | 0x6772BF: B9 B0 A8 61 03 |
| 金币金额 | rand_int_between_volatile @ 0x6773F0 | 0x6774AF: B9 B0 A8 61 03 |
IDA 中该对象已命名为 g_seed_volatile,且两条日志字符串也分别写着 Rand Between Seed VOLATILE 与 Rand Integer Between Seed VOLATILE。更关键的是,即使不依赖字符串,机器码里的两个相同绝对地址已经把数据流钉住。
可安全下结论:
- 金币概率和金额都不消费用于同步 gameplay 随机的那条 seed;
- 重制若要求复盘/回滚确定性,不能无意中把这一流混入确定性仿真状态;
- 若服务器是唯一执行掉落并把结果作为实体状态同步,volatile 本身不会造成双端结果分歧。
本轮没有继续追 CLevel__dropGold 的全量调用者及网络权威判定,所以不能写成“原版客户端必然各算各的并发生分歧”。
6. 重制实现建议
经验侧建议把三张概念拆开:
base XP
-> clamped stat-68 bonus
-> nearby-recipient XPSHARING multiplier
-> source-minus-recipient level modifier percentage
-> owned-unit forwarding / owner redirect
-> saturating credit不要把 owned vector 强类型成“宠物数组”;它还可能承载召唤物或其他被拥有单位。alreadyForwarded 必须随 owner 改派正确切换,否则会出现 owner 链重复派发或递归。
金币侧建议保留三次 Bernoulli 尝试,而不是把它合并成一次 14.2625% 掷骰。合并会改变一次掉两堆或三堆的尾部事件,也会改变随机数消费次数,进而破坏与原版的序列兼容。
若重制采用确定性联机/回滚架构,建议明确建模两类 RNG:
- deterministic gameplay RNG:进入存档、快照或回滚状态;
- presentation/volatile 或 authority-only RNG:只由权威端消费,并同步最终结果。
7. 仍保留的边界
- stat 68 的正式枚举名尚未由本轮数据表证据确认;
- party 候选的
vtable + 0x1A4排除方法尚无正式名称; - 金币掉落的联机权威调用拓扑没有纳入 B-2。
这些边界不影响 B-2 五项验收:两张经验曲线的输入与单位、owned/owner 转发、stat 68 夹取、volatile RNG,以及三次名义 5% 循环均已闭合。
8. 自动验证
执行:
$env:PYTHONUTF8='1'
python -m unittest tests.truth.test_xp_and_gold_award -v强制门会验证:
- 所有登记字节仍位于原版函数的真实指令边界;
- 两个夹取常量和所有金币倍率常量的实际 float 值;
XPSHARING计数 helper、浮点 RNG、volatile 整数 RNG 的直接 call 目标;- 等级差的减法方向、owned/owner 三个字段偏移与转发标志;
- 三次循环的初始化、递减和回边;
- 两个 RNG helper 都使用
0x361A8B0; - 机器可读登记已移除旧
NOT_PINNED五项,同时保留联机权威边界。