结论与覆盖范围
本文整理原版计算机制及静态证据。后续 Godot 接入情况见移植状态与缺口;公式已定位不等于完整实战已经复原。
原版 MEDIA/UI/MENUS/INGAMEMENUS/JOURNAL.LAYOUT 引用了 110 个不同的静态属性名。 它们来自 286 项枚举,统一进入 0x7B7430。字段名与枚举序号从原版静态初始化函数 0x10CC5F0 重新读取,不是按名字相似性猜索引。
| 证据类别 | 字段数 | 本轮确认到哪里 |
|---|---|---|
| 静态公式确认 | 64 | 输入、分支、显示取整/类型及相关叶函数 |
| 保存状态读取确认 | 22 | CPlayer 字段偏移、时间换算/格式化;尚非全部累计事件写入链 |
| 原版隐藏占位 | 8 | 原版 LAYOUT 祖先行 VISIBLE:false,原版跳转表走默认零值 |
| 武器伤害调用及原生函数体 | 16 | 左右手、分量、伤害类型参数与完整函数体;未宣称已压平为无遗漏的 Godot 实现 |
完整逐项表:详细数值字段总表。 机器可读证据:原版游戏分析/reversed_cpp/REGISTRY/journal_stat_formulas.json。 该 JSON 保存字段的枚举、控件 ID、原文格式、隐藏祖先、跳转目标、公式、依赖函数和证据等级。
输入为 IDA 13337 的 E:\Torchlight 2\Torchlight2.exe.i64;重新核对了实际输入文件路径, 并记录磁盘 EXE SHA256。没有 attach 原版游戏、改 IDB、改 DLL/PAK、改存档。 这份结论是静态二进制取证,不是已经完成原版/Godot 同状态运行对拍。
1. 先统一符号:getStat 不等于装备属性
S(t,d=7):CUnit::getStat(t, damageType=d, onlyLocalSource=false),入口 0x518850。G(name,x):按原版名字查询曲线,在 x 处求值。T(x):向零截断;C(x):ceil;F(x):落成 float32。p:原版 float 常量0.0099999998f,不是任意换成数学上精确的 0.01。- 伤害类型:0 Physical、1 Magical、2 Fire、3 Ice、4 Electric、5 Poison、6 All; 7 是“不筛伤害类型”的查询哨兵,不是 All。
S 合并角色效果管理器和库存/装备效果。0x7AEA40 在 d=7 且不筛来源时直接读 239 项缓存; 按具体伤害类型查询才走带筛选的效果列表。装备之外的被动/临时效果可能同时参与。 因此,详细页名为 CHARACTER ... EQUIPPED 的项目,不能解释成“只遍历穿戴装备”。
这也是现有移植的数据缺口:只保留 effectId -> 总值 的字典,无法恢复 S(25,Fire)、 S(25,All)、S(26,Physical) 等不同桶。应保留效果的伤害类型及来源,再生成缓存。
2. 四主属性:基础、效果项与最终值不是同一个数
| 主属性 | 基础字段 | flat / pct effect | 有效值 getter |
|---|---|---|---|
| 力量 STR | U+0x578 | 69 / 71 | 0x51B210 |
| 敏捷 DEX | U+0x574 | 70 / 72 | 0x51B1B0 |
| 专注 MAGIC | U+0x580 | 3 / 18 | 0x51B0C0 |
| 体力 DEFENSE | U+0x57C | 2 / 17 | 0x51B040 |
正常原版效果下:
effective = base + ceil(base * S(pct) * p) + ceil(S(flat))
BASE = base
EQUIPPED = truncate(S(flat))
PERCENT BONUS = S(pct) # 浮点显示,默认 1 位小数例如 flat=2.5,最终属性会加 3,但“EQUIPPED”显示项截成 2;不能让四个显示项机械相加后 强行等于 effective。体力 getter 另读 S(17,6),但原版效果加载器把 type 17 强制为 Magical=1, 因此该额外桶在原版正常数据里为 0;此前 effect-semantics 的相关证据与本轮相符。
3. 暴击、闪避、失误与格挡:必须额外执行显示取整
以玩家为对象:
| 详细页字段 | 原版显示公式 | 关键地址 |
|---|---|---|
| 暴击率 | min(100, ceil(BaseCrit + float(S55))) | 0x524030 |
| BaseCrit | clamp(G(CRIT_CHANCE,effectiveDEX),0,100) | 0x5213E0 |
| 闪避率 | ceil(float(clamp(G(DODGE_CHANCE,effectiveDEX)+S178,0,75))) | 0x520F10;0x7B8FE3 |
| 暴击伤害加成 | ceil(float(clamp(G(CRIT_BONUS,effectiveSTR)+S89+50,0,500))) | 0x521020;0x7B9FC8 |
| 失误几率 | truncate(100 - getFumbleChance()) | 0x51AF30;0x7B96B9 |
| 失误伤害惩罚 | truncate((1-retainedCoefficient)*100) | 0x520E00;0x7B96DD |
| 格挡率 | 无左手盾=0;有盾为 truncate(min(75,S62+S33+BlockFromVit)) | 0x524090 |
getFumbleChance() 返回的是 clamp(100-(G(FUMBLE_CHANCE,0)-ceil(S179)),0,100)。 它的名字与 UI 的“失误几率”方向相反,所以外层还要 100 - ...。
retainedCoefficient 为 clamp((G(FUMBLE_DAMAGE,effectiveDEX)+ceil(S180))*p,0,1), 沿原版中间 float 落点执行。它表示失误后保留的伤害比例,不是惩罚比例。
BlockFromVit = clamp(G(BLOCK_CHANCE,effectiveDEFENSE),0,100)。有格挡词缀但没盾, 玩家最终格挡仍为 0;盾由真实库存位置 2 检查,不按人物贴图是否看起来拿盾判断。
重要:有些显示 getter 没有再补下限。例如暴击率是在已夹过的 BaseCrit 上加 S55 后 ceil, 只追加 100 上限;不能自行把负修正后的结果再统一 clamp 到 0。
4. 左右手攻速、DPS、暴击伤害
原版左右手对应:left → 库存位置 2(0x518830),right → 位置 1(0x518810)。
m = float(1 + S(22) * p)
t = WeaponPeriod(item)
AttackPeriod = t != 0 ? 1 / (m / t) : m
AverageDamage = float((Dmin + Dmax) * 0.5)
DPS = AttackPeriod != 0 ? ceil(float(AverageDamage / AttackPeriod)) : 0
CritDamage = truncate(float(Dmax) * (1 + CritDamageBonus(true) * p))- 攻速列实际上是 seconds/attack,原版 TEXT 就写着
<STAT1> seconds,保留 2 位小数。 正常情形等价于t / m,但原版 t=0 的分支也必须保留。 - WeaponPeriod 0x576F30:优先 item[183] 的攻击定义,缺失才用 item[182], 返回该定义
float(+0x78) * float(+0x74);不是随手取任意 idle/attack 动画长度。 后续结合旧 wiki 与 0x58F0C0 → 0x5182E0 已闭合字段来源: +0x74=DEVIATION_FROM_30FPS,+0x78=float(SPEED*p);详见IDA 交叉核查报告。 - DPS 不额外乘暴击期望、处决期望或敌方护甲;它是原版面板值,不是实战平均输出预测。
- 暴击伤害使用该手最大伤害,最后截断;不能用 DPS 或最小/最大平均值代替。
- 武器不存在时按手伤害/DPS 返回 0;攻击间隔字段例外:角色存在时走 LABEL_584,返回 m。 原版没有在这些包装器里新增一个“最短攻击间隔”常量。
原有反编译注释的陷阱
当前 IDB 曾把 0x52E9D0 注释成“远程伤害”、0x52DA30 注释成“近战伤害”。 这会误导这里的实现。详细页同一把武器的 DPS 同时调用两者,前者内部使用 CEquipment::sumDamageMaxOfType,后者使用 sumDamageMinOfType;两者内部都含近战/远程武器分支。 因此这里应按 Dmax / Dmin 理解,而不是按 melee/ranged 二选一。 本轮把纠正证据写入报告,未擅自改写旧 IDB 注释。
统一调用参数(从 UI 的实际 call 指令验证):
Dmin: 0x52DA30(weaponKind=0, component, flag=false, hand, damageType)
Dmax: 0x52E9D0(weaponKind=0, component, flag=false, hand, damageType)
component: 0=全部, 1=物理部分, 2=元素部分
hand: 1=left, 2=right总最小/最大供 DPS;“物理最小/最大”传 component=1;各手 FIRE/ICE/ELECTRIC/POISON BONUS 传 component=2、对应元素类型,而且调用 Dmax。后者是绝对伤害值,不是百分比。
完整函数体已保存。它们按类型分别合并武器基础/附加伤害、Strength、Focus、 S15/S16、S88、武器分类 S99/S102/S103、S25 的分桶百分比、S0/S1 和 S10 平加。 各阶段可能分别取整,All 桶还可能参与多个元素通道。左右手条件并非可以盲目折叠成同一段; 将这些函数压成 武器伤害×(1+全部加成) 仍会漏掉分量门控和取整差异。
5. 射程、弧度、溅射与双持门控
| 武器 | 射程显示 | 弧度显示 | 溅射显示 |
|---|---|---|---|
| 空手/盾 | 0 | 0 | 0 |
| Rifle(type 116) | 5 | 30 | 30% |
| Cannon(type 188) | 6 | 55 | 50% |
| 其他武器 | 下式 | 截断 WEAPONARC | 截断 SECONDARY_DMG_PCT×100 |
普通武器射程:
attackDefinition.range
+ (RangedWeapon ? S78 : 0)
+ float(U+0x94) * REACH_BONUS
+ 0.2左手读 item[183],右手读 item[182]。主 CPlayer vtable=0x2177804,slot+0x78 指向 0x464F90,读取 U+0x94。另一个因子 U+0x638 已在 0x56D380 的加载器确认是 REACH_BONUS(缺省 0.75),不是 COLLISION_RADIUS。
处决率 0x5212E0 使用专注曲线加 S153,夹在 0..100 后截断;但外层 enum 207 还要求双手 都是 Weapon,且右手若属于 MELEE/PISTOL/WAND/DAGGER,左手也必须符合每一项相应类型检查。 所以不能只因专注>0就显示非零处决率。DUAL WIELD BONUS 只检查双手 Weapon 后返回 T(S88), 门控比 EXECUTE CHANCE 更弱。
6. 物理护甲、元素护甲与伤害抗性百分比
物理护甲(0x521660 → 0x5246F0)
有库存管理器时:
b = unit.baseArmor + inventory.armorSum
raw = b + ceil(b*p*(S23(Physical)+S23(All)+owner.S97))
+ ceil(S8(Physical)) + ceil(S8(All))
AC = max(0, raw + ceil(raw*p*G(PLAYER_DEFENSE_ARMORBONUS,effectiveDEF)) - ceil(S66))owner.S97 只在存在 root owner 时加入。原版 raw getter 没库存管理器时直接返回 0。
元素护甲(0x520A40)
对 d=Fire/Ice/Electric/Poison:
x = float(baseResistance[d] + S(intrinsic[d]))
x = float(x * (1 + p*(S23(d)+S23(All))))
x = float(x + S8(d) + S8(All) + S(defenseType[d]))
x = float(x + int(ceil(x*p*G(PLAYER_DEFENSE_ARMORBONUS,effectiveDEF))))
return ceil(max(0, float(x - int(ceil(S66)))))intrinsic 映射不是顺序编号:Fire→219,Ice→218,Electric→220,Poison→221。 defenseType 映射为 Fire→37、Ice→38、Electric→39、Poison→40。 S8 平加不吃前面的 S23 百分比,但吃后面的体力曲线;顺序不可交换。
抗性百分比(0x51AD60)
x = ceil(S26(d) + S26(All))
玩家:x < -75 时返回 75,否则返回 -max(x,-100)即读的是受到伤害百分比修正并反号,不是 armor/(armor+等级常数)。 增伤会表现为负抗性;不能把所有显示值都 clamp 成 0..75。
RESISTANCE SLOW %=ceil(S140),RESISTANCE IMMOBILIZE %=ceil(S197),显示本身不追加统一上限。
7. 反射:不是普通加法,也不是 DPS
0x7AE710 按原版效果顺序折叠 TYPE 60,并通过角色、库存、有效槽位与装备/嵌入物遍历累加。 设现有概率为 P,已有反射量为 A,新效果触发概率为 q, r=(DMGPCTMIN+DMGPCTMAX)/200:
Pnew = 1 - (1-P)*(1-q)
Anew = (A*P + r*q) / PnewUI 两行分别显示 100*Pnew 和 100*Anew。后者虽然静态属性名是 CHARACTER MISSILE REFLECT DPS,LAYOUT 原文却为 <STAT1>%,它的确是反射量百分比。
两条 20% 概率、各反射 50% 伤害的普通数学例子,聚合概率为 36%,聚合反射量约 55.56%; 不能写成“概率40%、伤害100%”,也不能直接认为仍是50%。 这个例子说明算法结构,不冒充原版浮点逐位运行对拍。原版折叠体也没有给 Pnew=0 的分母加保护, 且概率有界的说法只对合法输入 q∈[0,1] 成立。
8. 其余加成、恢复与历史统计
- 攻击/施法速度加成分别 T(S22)、T(S29)。
- Magic Find、Fishing Luck、Gold Drop Bonus 分别 ceil(S27)、ceil(S84)、ceil(S28)。
- 五种 Damage Bonus % 是
ceil(S25(d)) + ceil(S25(All)),这里没有加入力量/专注曲线。 - MISSILE RANGE BONUS 是 S78,浮点显示,不自带百分号含义。
- HP REGEN 显示
T(HP_RECHARGE_RATE+S7)+T(S124);MANA REGEN 显示 S6+S123, MANA REGEN BASE 单独取 MANA_RECHARGE_RATE。相应全局字段由 0x641160 确认。 原版文本为/sec;不要再无条件把已处理的效果缓存乘除 tick rate。 - PLAYER AVG ITEM LEVEL 是 BODY 中有物品、可装备且非隐藏槽的等级均值;空格不进入分母。
- Difficulty 来自当前客户端难度枚举的本地化名称;NG+ 字符串来自玩家保存的周目数。
22 个历史字段中,死亡、怪物击杀、金币收集、脚步、任务等直接读取 CPlayer 的专用计数。 最高伤害读取已保存整数后 abs32。时间项分别使用秒字段或毫秒字段:
总游玩时间 = formatTime(Player+0x360 的 float 秒)
本级时间 = formatTime(Player+0x9BC 的 int 毫秒 * 0.001)
平均每级 = formatTime(((总毫秒[+0x96C]-本级毫秒[+0x9BC]) / (level-1)) * 0.001)平均项先做整数除法,排除当前未完成等级;level≤1 显示 00:00:00。 本轮尚未逐个重建这些字段的累计事件和存档字段写入,因此不能用当前金币、当前背包或近期战斗日志代填。
8 个原版隐藏零值
枚举 73..80(累计/平均承伤与伤害、本级伤害相关)都跳到 0x7BB27F 默认出口。 跳转指令 0x7B74F0、跳转表 0x7BB2EC 的原始项已核对,不只是反编译没显示 case。 对应 LAYOUT 行祖先 VISIBLE:false,所以它们不该成为“必须补齐原版功能”的任务。
9. 为什么不能只信伪代码或函数注释
本二进制里部分 x87 返回值被 Hex-Rays 错误追踪:
sub_41C8C0实际是ceil(float);伪代码若显示它被调用后又(int)旧变量, 不意味着 ceil 被丢弃。DPS 的 0x7BA4F4、闪避的 0x7B8FE3、暴伤加成的 0x7B9FC8 已按汇编确认。- 暴击伤害包装器伪代码重复使用旧 st0 变量,看起来像“最大伤害×最大伤害”; 0x7BA780..0x7BA793 实际是暴伤 getter 的返回值乘最大伤害,再加最大伤害。
COERCE_FLOAT和LODWORD(v241)是统一输出 union 的反编译表现,很多历史项实际是整数, 不能照变量表把它们都保存为 float。
原版浮点格式化 0x687320 还会按小数位数做 floor(x*10^n+0.5)/10^n,整数时省去小数。 大多数浮点读数 n=1,攻击间隔 n=2;不能用 Godot 当前统一的“最多两位小数”替代全部字段。 逐项 JSON 已为每个字段标注显示格式。
10. 对当前移植的影响与实施顺序
以下是实现的依赖顺序;部分显示投影已落地,当前完成项及边界见移植状态:
- 拆出“内部有效值”和“原版显示投影”:修正暴击/闪避/暴伤、EQUIPPED 等字段的独立取整规则。
- 建立按伤害类型分桶的效果读口,再接物理/元素护甲、抗性百分比和伤害加成。
- 保存左右手武器的完整伤害通道与攻击定义;按原生包装器接 attack period、DPS、暴击伤害。
- 按 TYPE 60 效果实例做反射聚合,不用单个 stat 总和替代。
- 单独补历史计数的更新/存档生命周期;保留原版隐藏的8行,不造数据。
仍需后续验证:复杂武器分支的逐样本运行对拍、历史计数的所有写入事件。 攻击定义到 period 的数据来源已在后续 wiki/IDA 交叉核查中补齐; 不能把“有原版函数体”说成“已经完成移植”。
复现
python -X utf8 tools/research_journal_stats.py --all
python -X utf8 tools/journal_stat_formulas.py
python -X utf8 -m unittest tests.test_journal_stat_research -vbuild/research_journal/ 保存本轮新抓的 C 伪代码、关键函数完整汇编、跳转表、玩家 vtable、 源文件校验值和 EXE 身份。抓取器会读取 IDA 的完整截断输出附件,不把前 1023 字符误当成完整函数。
本轮 6 项证据完整性测试通过:全字段覆盖、源布局匹配、默认跳转与隐藏标志、 x87 取整汇编锚点、原版显示单位、函数证据校验值。它们不是原版运行时数值对拍测试。 未提交 Git;未触碰运行中编辑器、游戏状态或默认存档。
来源与使用范围
本页同步自 Torchlight2-Remaster-Project 的 docs/devlog/2026-09-11-journal-stat-formulas.md。文中的 tools/、core_sim/、torchlight-2-poc/ 和 build/ 路径及复现命令,均属于重制项目,不是 Wiki 仓库目录。原版静态证据、移植进度与尚待验证项分别标明,不把参考算例当成原版运行对拍。