本组文档:计算公式 · 110 项字段表 · IDA 交叉核查 · 移植状态与缺口 · 玩家属性与加点

结论与覆盖范围

本文整理原版计算机制及静态证据。后续 Godot 接入情况见移植状态与缺口公式已定位不等于完整实战已经复原

原版 MEDIA/UI/MENUS/INGAMEMENUS/JOURNAL.LAYOUT 引用了 110 个不同的静态属性名。 它们来自 286 项枚举,统一进入 0x7B7430。字段名与枚举序号从原版静态初始化函数 0x10CC5F0 重新读取,不是按名字相似性猜索引。

证据类别字段数本轮确认到哪里
静态公式确认64输入、分支、显示取整/类型及相关叶函数
保存状态读取确认22CPlayer 字段偏移、时间换算/格式化;尚非全部累计事件写入链
原版隐藏占位8原版 LAYOUT 祖先行 VISIBLE:false,原版跳转表走默认零值
武器伤害调用及原生函数体16左右手、分量、伤害类型参数与完整函数体;未宣称已压平为无遗漏的 Godot 实现

完整逐项表:详细数值字段总表。 机器可读证据:原版游戏分析/reversed_cpp/REGISTRY/journal_stat_formulas.json。 该 JSON 保存字段的枚举、控件 ID、原文格式、隐藏祖先、跳转目标、公式、依赖函数和证据等级。

输入为 IDA 13337E:\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
力量 STRU+0x57869 / 710x51B210
敏捷 DEXU+0x57470 / 720x51B1B0
专注 MAGICU+0x5803 / 180x51B0C0
体力 DEFENSEU+0x57C2 / 170x51B040

正常原版效果下:

text
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
BaseCritclamp(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 - ...

retainedCoefficientclamp((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)。

text
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 指令验证):

text
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. 射程、弧度、溅射与双持门控

武器射程显示弧度显示溅射显示
空手/盾000
Rifle(type 116)53030%
Cannon(type 188)65550%
其他武器下式截断 WEAPONARC截断 SECONDARY_DMG_PCT×100

普通武器射程:

text
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)

有库存管理器时:

text
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:

text
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)

text
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

text
Pnew = 1 - (1-P)*(1-q)
Anew = (A*P + r*q) / Pnew

UI 两行分别显示 100*Pnew100*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。时间项分别使用秒字段或毫秒字段:

text
总游玩时间 = 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_FLOATLODWORD(v241) 是统一输出 union 的反编译表现,很多历史项实际是整数, 不能照变量表把它们都保存为 float。

原版浮点格式化 0x687320 还会按小数位数做 floor(x*10^n+0.5)/10^n,整数时省去小数。 大多数浮点读数 n=1,攻击间隔 n=2;不能用 Godot 当前统一的“最多两位小数”替代全部字段。 逐项 JSON 已为每个字段标注显示格式。

10. 对当前移植的影响与实施顺序

以下是实现的依赖顺序;部分显示投影已落地,当前完成项及边界见移植状态

  1. 拆出“内部有效值”和“原版显示投影”:修正暴击/闪避/暴伤、EQUIPPED 等字段的独立取整规则。
  2. 建立按伤害类型分桶的效果读口,再接物理/元素护甲、抗性百分比和伤害加成。
  3. 保存左右手武器的完整伤害通道与攻击定义;按原生包装器接 attack period、DPS、暴击伤害。
  4. 按 TYPE 60 效果实例做反射聚合,不用单个 stat 总和替代。
  5. 单独补历史计数的更新/存档生命周期;保留原版隐藏的8行,不造数据。

仍需后续验证:复杂武器分支的逐样本运行对拍、历史计数的所有写入事件。 攻击定义到 period 的数据来源已在后续 wiki/IDA 交叉核查中补齐; 不能把“有原版函数体”说成“已经完成移植”。

复现

powershell
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 -v

build/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 仓库目录。原版静态证据、移植进度与尚待验证项分别标明,不把参考算例当成原版运行对拍。