分析对象:Torchlight2.exe(32-bit,静态基址 0x400000)
核心函数:CUnit__awardExperience(0x52F970)、CLevel__dropGold(0x5E8F70)
核心定位:界定杀怪经验加成、近邻组队分摊、等级差衰减曲线,以及场景金币掉落的三次判定公式与随机数流。

1. 结论速览 ​

经逆向分析与指令级回验,经验与金币结算中的各项未决参数均已与原版客户端的机器指令、数据段常量及底层调用点完成精确闭环。

经验奖励的计算顺序为:

  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 列表转发;若当前单位持有 +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 为辅助表达空间近邻判定的分析命名,未写入原始符号表。

3. 经验公式 ​

3.1 stat 68 加成与夹取 ​

0x52F9CF 的 push 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 02乘 0.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 减 recipient0x52FC320x52FC37 乘 0.01f
当前单位本地落账0x52FDC3 读 source、0x52FDC9 减 self0x52FDDF0x52FDE4 乘 0.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;
  • 0x5E8FD1 与 0.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 VOLATILE 与 Rand 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 虚函数过滤判据的具体业务语义;
  • 多人联机环境下 dropGold 在客户端与主机之间的权威判定拓扑。

上述未决项不影响核心数值逻辑:两张经验曲线的输入与单位换算、owned/owner 转发机制、stat 68 钳位截断、volatile RNG 流以及三次 5% 独立掉落循环均已彻底闭合。

8. 自动化验证覆盖 ​

配套自动化测试用例覆盖以下关键项:

  • 关键反编译指令在可执行文件中的实际机器码与控制流;
  • 钳位上下界常量(100.0f、-100.0f)与各浮点缩放倍率;
  • XPSHARING 有效人数统计、浮点与整数 volatile RNG 的底层调用目标;
  • 等级差减法方向、owned/owner 字段偏移及防递归转发标志;
  • 金币掉落三次循环的初始化、递减控制与回边跳转。