分析对象:
Torchlight2.exe(32-bit,静态基址0x400000)
核心函数:CUnit__awardExperience(0x52F970)、CLevel__dropGold(0x5E8F70)
核心定位:界定杀怪经验加成、近邻组队分摊、等级差衰减曲线,以及场景金币掉落的三次判定公式与随机数流。
1. 结论速览
经逆向分析与指令级回验,经验与金币结算中的各项未决参数均已与原版客户端的机器指令、数据段常量及底层调用点完成精确闭环。
经验奖励的计算顺序为:
- 读取角色 stat 68(经验加成百分比),将其值限制在
[-100, +100]区间; - 基础经验增加
ceil(clampedStat * 0.01 * baseXP); - 满足多人组队共享条件时,以「空间有效近邻队友数 + 1」查询
XPSHARING曲线,其输出作为直接乘法倍率; - 以「怪物等级 − 角色等级」为键查询
LEVEL_VERSUS_LEVEL_EXPERIENCE_MODIFIER,其输出作为百分比系数; - 向
+0x72C/+0x738的 owned child/minion 列表转发;若当前单位持有+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 为辅助表达空间近邻判定的分析命名,未写入原始符号表。
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虚函数过滤判据的具体业务语义; - 多人联机环境下
dropGold在客户端与主机之间的权威判定拓扑。
上述未决项不影响核心数值逻辑:两张经验曲线的输入与单位换算、owned/owner 转发机制、stat 68 钳位截断、volatile RNG 流以及三次 5% 独立掉落循环均已彻底闭合。
8. 自动化验证覆盖
配套自动化测试用例覆盖以下关键项:
- 关键反编译指令在可执行文件中的实际机器码与控制流;
- 钳位上下界常量(
100.0f、-100.0f)与各浮点缩放倍率; XPSHARING有效人数统计、浮点与整数 volatile RNG 的底层调用目标;- 等级差减法方向、owned/owner 字段偏移及防递归转发标志;
- 金币掉落三次循环的初始化、递减控制与回边跳转。