经验分摊、等级差修正与金币掉落公式

对象:Torchlight2.exe(32-bit,静态基址 0x400000
对应 backlog:HANDOFF_RE_BACKLOG.md 的 B-2 / C-0193
主入口:CUnit__awardExperience @ 0x52F970CLevel__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 的指令、常量和直接调用目标上。

经验奖励的计算顺序是:

  1. 读取 stat 68,把它夹在 [-100, +100]
  2. ceil(clampedStat * 0.01 * baseXP) 加到基础经验;
  3. 满足多人共享条件时,以“附近其他合格队员数 + 1”查询 XPSHARING,其返回值作为直接倍率
  4. 以“经验来源等级 − 接收者等级”查询 LEVEL_VERSUS_LEVEL_EXPERIENCE_MODIFIER,其返回值按百分数使用;
  5. +0x72C/+0x738 owned child/minion vector 转发;当前单位有 +0x71C owner 且不是已转发调用时,改派给 owner;否则在自身 +0x594 落账并对溢出做 INT32_MAX 饱和。

金币掉落则固定进行三次尝试。普通模式下每次独立执行名义 5% 的概率门:

text
rand_float_between(0.0, 2.0) < 0.1

成功后基础金币范围为:

text
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 加成与夹取

0x52F9CFpush 0x44 把 stat 68 送入 CUnit__getStat。随后两套 x87 比较/选择序列分别引用:

数据地址用途
0x2141458100.0f上界
0x2170214-100.0f下界
0x21426540.01f百分比换算

因此第一阶段为:

cpp
float pct = clamp(getStat(68, 7, 0), -100.0f, 100.0f);
int xp = baseXP + (int)ceil(pct * 0.01f * baseXP);

关键锚点:

VA指令/字节含义
0x52F9CF6A 44stat ID = 68
0x52F9DAD9 05 58 14 14 02加载 100.0f
0x52F9FBD9 05 14 02 17 02加载 -100.0f
0x52FA1BD8 0D 54 26 14 020.01f
0x52FA2BFF 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

cpp
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

等级差曲线在本地落账和远端队友派发两条路径上使用同一公式:

cpp
int levelDelta = source->levelAt0x110 - recipient->levelAt0x110;
xp = trunc(LEVEL_VERSUS_LEVEL_EXPERIENCE_MODIFIER(levelDelta) * 0.01f * xp);
路径等级差指令evaluate百分比换算
远端队伍成员0x52FC16 读 source、0x52FC1C 减 recipient0x52FC320x52FC370.01f
当前单位本地落账0x52FDC3 读 source、0x52FDC9 减 self0x52FDDF0x52FDE40.01f

注意两张曲线的单位不同:

曲线输入输出的使用方式
XPSHARING有效共享人数直接倍率
LEVEL_VERSUS_LEVEL_EXPERIENCE_MODIFIER来源等级 − 接收者等级百分数,再乘 0.01

重制时若把两张曲线都当百分数,XPSHARING 会被错误缩小 100 倍。

3.4 owned child/minion 转发与 owner 改派

经验函数中的相关字段为:

偏移十进制语义指令锚
+0x71C1820owner/master 指针0x52FD48
+0x72C1836owned child/minion 指针数组0x52FD1E
+0x7381848数组元素数0x52FD01

主函数先遍历 owned vector。对每个 child 都通过虚表 +0x1F0 再调用一次 awardExperience,尾部两个布尔参数为 (1, 1)

cpp
for (child : self->ownedChildren) {
    child->awardExperience(context, source, xp, true, true);
}

随后读取 self + 0x71C

  • owner 为空,或本次已经是 forwarded 调用:在 self 落账;
  • owner 非空且本次不是 forwarded:调用 owner 的同一虚函数,尾部参数为 (0, 1),然后直接退出当前调用。

伪代码如下:

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

asm
C7 86 94 05 00 00 FF FF FF 7F
mov dword ptr [esi+594h], 7FFFFFFFh

因此经验溢出采用 INT32_MAX 饱和,而不是有符号回绕。

4. 金币掉落公式

4.1 三次尝试的真实循环

循环计数不是 Hex-Rays 重构产物:

VA字节含义
0x5E8FACC7 44 24 14 03 00 00 00局部计数器初始化为 3
0x5E926A83 6C 24 14 01每轮减 1
0x5E926F0F 85 3F FD FF FF非零回跳 0x5E8FB4

概率门失败会从 0x5E8FDF 直接跳到 0x5E926A,成功生成完金币也到达相同的递减点。因此普通模式严格是三次机会,不是“成功一次就停”。

force_drop 为真时,0x5E8FB4 的分支跳过概率门,但不会跳过循环计数。只要后续金币对象创建没有失败,该模式每轮都会生成,总计三堆。

4.2 每次名义 5%

概率门的完整形状是:

cpp
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
  • 0x5E8FD10.1f 比较;
  • 未通过时 0x5E8FDF 跳到本轮递减点。

rand_float_between 实现为:

cpp
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 基础金额

金额的等级缩放有两个入口:

cpp
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 逆序压参后,实际调用为:

cpp
baseGold = rand_int_between_volatile(
    trunc(2.0f * scale),
    trunc(12.0f * scale)
);

整数 helper 的底层实现以 % (max-min+1) 取值,所以区间两端都包含。

4.4 Gold Find 两段加成

基础金额生成后,函数读取两个 stat 现场:

  1. 第一段固定读取 stat 28,对基础金额加成;
  2. 第二段按 owner/player 关系选 stat 51 或 stat 28,对第一段后的当前金额再次加成。

第二段分支不是“固定把 stat 28 算两次”:

  • owning player 就是当前单位,且它有第一个 owned child:使用 stat 51;
  • 其余路径:使用 stat 28。

每段加成都使用:

cpp
bonus = trunc(statValue * currentGold * 0.01f + 0.5f);
currentGold += bonus;

因为第二段以第一段之后的 currentGold 为基数,两段加成是复合的,不是都对原始基础金额做简单相加。

5. volatile RNG 与联机边界

两个随机 helper 都把同一个对象地址 0x361A8B0 放进 ecx

随机阶段helperseed 指令
是否掉落rand_float_between @ 0x6772000x6772BF: B9 B0 A8 61 03
金币金额rand_int_between_volatile @ 0x6773F00x6774AF: B9 B0 A8 61 03

IDA 中该对象已命名为 g_seed_volatile,且两条日志字符串也分别写着 Rand Between Seed VOLATILERand Integer Between Seed VOLATILE。更关键的是,即使不依赖字符串,机器码里的两个相同绝对地址已经把数据流钉住。

可安全下结论:

  • 金币概率和金额都不消费用于同步 gameplay 随机的那条 seed;
  • 重制若要求复盘/回滚确定性,不能无意中把这一流混入确定性仿真状态;
  • 若服务器是唯一执行掉落并把结果作为实体状态同步,volatile 本身不会造成双端结果分歧。

本轮没有继续追 CLevel__dropGold 的全量调用者及网络权威判定,所以不能写成“原版客户端必然各算各的并发生分歧”。

6. 重制实现建议

经验侧建议把三张概念拆开:

text
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. 自动验证

执行:

powershell
$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 五项,同时保留联机权威边界。