CombatLog 外层函数收口:联机格挡、投射物反射、CStat 日志与 Triggerable Effect/Affix
对象:
Torchlight2.exe(32-bit,静态基址0x400000)
对应 backlog:HANDOFF_RE_BACKLOG.md的 B-1
机器可读证据:原版游戏分析/reversed_cpp/REGISTRY/combat_outer_functions.json
回归测试:python -m unittest tests.truth.test_combat_outer_functions
1. 结论
B-1 已收口。旧清单把 sub_52CAF0 列为尚未分析,但它早已在 C-0194 完成,正式名称是 CUnit__applyNetworkHealthDelta。本轮真正补齐的四个函数是:
| 原地址 | 建议名称 | 核心职责 | 置信度 |
|---|---|---|---|
0x452CE0 | NetUnitfxBlocked__applyShieldDamage | 处理 UNetUnitfxBlockedMsg,扣减/摧毁可破坏盾效果 | 高 |
0x523CF0 | CCharacter__rollMissileReflect | 汇总反射概率与比例,掷骰成功后返回反射倍率 | 高 |
0x7B67F0 | CStat__logValue | 按 bool/int/float 类型把 CStat 当前值写入 combatLog | 高 |
0x7BFD10 | CTriggerableEffectAndAffixes__applyTo | 将 triggerable bundle 中的 affix 与 effect 派发到目标单位 | 高 |
这四个函数不是同一条“伤害公式”的连续阶段。它们只是都写入 combatLog.Log:
0x452CE0属于网络消息消费侧;0x523CF0属于命中/投射物反射决策;0x7B67F0是CStat 调试记录,并非战斗数值结算;0x7BFD10是触发式效果和词缀派发器。
因此,combatLog 写入点地图应理解为“事件观察入口”,不能把所有调用者硬拼成一条流水线。
2. 清单纠错与函数边界
当前 IDA 边界为:
| 函数 | 区间(尾地址不含) | 大小 |
|---|---|---|
0x452CE0 | [0x452CE0, 0x453422) | 1858 B (0x742) |
0x523CF0 | [0x523CF0, 0x524027) | 823 B (0x337) |
0x7B67F0 | [0x7B67F0, 0x7B6A1C) | 556 B (0x22C) |
0x7BFD10 | [0x7BFD10, 0x7C054B) | 2107 B (0x83B) |
旧 G_effects_affixes_stats.funcs.json 曾把前三个相关大小记为 2045、647、2192 B。那是旧边界快照,本轮已按当前 IDA 边界并用本地 x86 指令长度解码器逐条走到尾地址。
3. 0x452CE0:网络 blocked 消息与可破坏盾
3.1 身份不是推测
0x452CE0 只有一个调用者:0x4569D0 的 24 字节包装器。更关键的是,包装器地址 0x4569D0 存在于如下具名 RTTI 消息表项中:
MessageTableEntry<UNetUnitfxBlockedMsg, BlockedMessageHandler>消息表位置是 0x2146E50,handler 指针位于 0x2146E58;包装器在 0x4569DD 用 E8 FE C2 FF FF 调用 0x452CE0。这使“网络侧 blocked 事件”成为符号支撑的结论,而不是由日志文字猜出的名字。
3.2 消息字段
函数按以下固定偏移读取消息体:
| 消息偏移 | 证据 | 用途 |
|---|---|---|
+0x10 | 0x452D0B: 8B 4E 10 | 查找 level/scene |
+0x18,+0x1C | 8B 46 18 / 8B 56 1C | 第一组 64-bit 单位 GUID |
+0x20 | 0x452EF0: DB 41 20 | 作为整数伤害装入 x87,用于比较和扣盾 |
+0x28,+0x2C | 8B 56 28 / 8B 4E 2C | 第二组 64-bit 单位 GUID |
+0x30 | 0x452F07: 80 79 30 00 | 非零时强制进入破盾分支 |
这里只给两组 GUID 编号,不冒充已确认的“攻击者/受击者”字段名;函数内的使用关系支持受击对象与关联对象,但消息结构声明本身尚未恢复。
3.3 核心流程
函数先解析 level 与两个单位,然后从装备容器取槽位 2 的物品。它要求物品通过 unit-type 0x15 检查,并遍历其效果,寻找:
effect.type == 163FULL_LOGIC 类型表把 163 命名为原版拼写 DESTRUCTABLE SHIELD。命中该效果后,slot 1 是当前盾值:
void applyBlockedMessage(NetUnitfxBlockedMsg& msg) {
Unit* unit = level.findUnit(msg.guidA);
Unit* other = level.findUnit(msg.guidB);
Item* shield = unit->inventory->getAtEquipLocation(2);
Effect* e = findActiveEffect(shield, 163); // DESTRUCTABLE SHIELD
float shield_hp = e->evalSlot(1);
int damage = msg.damage_at_0x20;
if ((double)damage > shield_hp || msg.force_break_at_0x30 != 0) {
combatLog(unit->name + "(" + damage + ") Damage Blocked! Shield destroyed!");
msg.damage_at_0x20 = 0;
e->setSlotAndReroll(1, 0.0f);
detachAndDestroyShield();
play("HIT");
play("Shattered");
} else {
e->setSlotAndReroll(1, shield_hp - damage);
combatLog(unit->name + "(" + damage + ") Damage Blocked! "
+ (shield_hp - damage) + " shield hp remaining.");
}
}破盾分支会执行模型/装备解绑、受击表现、HIT 与 Shattered 标签以及清理;存活分支只更新效果 slot,并可发浮动 UI。
3.4 与 rollBlockParryShatter 的关系
0x452CE0 和 CUnit__rollBlockParryShatter 会输出相同的两类字符串,但前者是 UNetUnitfxBlockedMsg 的接收/表现侧。这解释了为什么日志里看起来像同一件事,却存在两份外层函数:本地结算和网络重放/同步不能合并成一个钩点。
4. 0x523CF0:反射概率与反射比例是两列数据
4.1 输入汇总
函数维护两个浮点累计量,并先后调用:
0x523D3E E8 CD A9 28 00 CEffectMgr__accumMissileReflect
0x523D57 E8 F4 46 2A 00 CInventory__accumMissileReflect第一列是触发概率 chancePct,第二列是成功后的反射量 amountPct。这两个值不能合并成单一“反射百分比”。
4.2 掷骰与返回值
chancePct <= 0 直接返回 0。否则:
float CCharacter::rollMissileReflect(/* context */) {
float chancePct = 0.0f;
float amountPct = 0.0f;
effectMgr.accumMissileReflect(&chancePct, &amountPct);
inventory.accumMissileReflect(&chancePct, &amountPct);
if (chancePct <= 0.0f)
return 0.0f;
if (rand_float_between(0.0f, 100.0f) > chancePct)
return 0.0f;
combatLog(name + " Reflected!");
showReflectedTextAndEffects();
return amountPct * 0.01f;
}关键证据:
0x523D7D调rand_float_between;0x523D82把随机值与chancePct比较;0x523DE8写Reflected!;0x523FF5执行fmul [0x2142654];该常量的原始位是0x3C23D70A,即单精度0.01。
所以函数返回的是 amountPct / 100,不是布尔值,也不是原始百分数。
4.3 两个调用者
| 调用点 | 所在路径 | 返回值用途 |
|---|---|---|
0x5489BF | CCharacter__resolveStrikeHit | 保存 x87 返回值并要求 >0 后继续反射分支 |
0x6E5882 | missile 碰撞/更新路径 | 写入 missile +0x300 与 +0x2E4,并把 +0x354 设为 3 |
第二个调用者直接证明这不是泛化的“受到攻击时随机特效”;返回倍率会成为投射物后续状态的一部分。第一条调用路径仍需继续追到最终反伤公式,才能确认 strike 分支如何消费倍率。
5. 0x7B67F0:这是 CStat 日志,不是 effect dump
5.1 类身份交叉证据
调用链 sub_7BD620 → sub_7BBBE0 → 0x7B67F0 中,sub_7BBBE0 会调用具名函数 CStatRegistry__findDef。若对象尚不存在,它调用 sub_7B6A20 构造对象;该构造器在 0x7B6A4C 写入:
C7 06 18 AC 19 02 mov dword ptr [esi], CStat::vftable因此建议名称是 CStat__logValue。旧档案中的“效果调试转储”已纠正。
5.2 GUID 哨兵与类型分派
函数先检查 COMBAT LOG,再用以下三条指令判 GUID:
0x7B6829 8B 4E 60 mov ecx,[esi+60h]
0x7B682C 23 4E 64 and ecx,[esi+64h]
0x7B682F 83 F9 FF cmp ecx,FFFFFFFFh若 (guid0 & guid1) == 0xFFFFFFFF,函数记录 INVALID_GUID。否则读取 this+0x90 的值种类:
| kind | 读取 | 格式化 |
|---|---|---|
0 | movzx 低字节 | sub_6863A0,已反编译为 true / false |
1 | dword | intToWString |
2 | float | sub_685FE0 |
| 其他 | 不进入三条格式化支路 | 保持空值文本 |
正常行在 0x7B698D 写入,形状是 <stat name> value: <formatted value>;无效 GUID 行在 0x7B6A00 写入。
这个函数属于调试/诊断面,不应作为伤害公式或 stat 更新钩点。若要拿原始 stat 值,应钩 sub_7B5F00 的返回或真正的写入路径,而不是解析这条日志文本。
6. 0x7BFD10:CTriggerableEffectAndAffixes 双队列派发器
6.1 类身份与容器布局
相邻的构造/拷贝路径 0x7C0590 在 0x7C05B9 写入具名虚表:
C7 06 DC B0 19 02 mov dword ptr [esi], CTriggerableEffectAndAffixes::vftable同一路径还显式构造 CSkillAffixData 与 CSkillEffectData,与 0x7BFD10 的两段循环完全对应:
| 对象偏移 | 容器 |
|---|---|
+0x0C/+0x10/+0x14 | affix-data vector:pointer / size / capacity |
+0x1C/+0x20/+0x24 | effect-data vector:pointer / size / capacity |
6.2 第一段:实例化并挂接 affix
每个 affix 条目先做目标/关系过滤和 CUnit__hasUnitType 门控。通过后:
0x7BFFD1:createAffixByNameAndLevel;0x7BFFE6:CAffix__setSource;0x7C001F:CAffix__setLevel;0x7C002E:写入传入位置/上下文;0x7C0070:若条目时长大于 0,CAffix__setDuration;0x7C01D4:可选 combatLog,形状为affix <name> -> <target>;0x7C0243:CEquipment__attachAffix。
函数在至少一条 affix 成功挂接时设置返回标志。
6.3 第二段:直接应用 effect
每个 effect-data 条目可含多个 CEffect。流程如下:
for (effectData : effectVector) {
if (!target->hasUnitType(effectData.requiredUnitType))
continue;
if (!bypassTargetSelector && !relationMatches(effectData.selector, ...))
continue;
for (Effect* e : effectData.effects) {
if (position)
e->position = *position;
e->setSource(source, true);
bool temporarilyFilledLevel = (e->level == 0);
if (temporarilyFilledLevel) {
e->level = (level >= 0 && level <= 1000) ? level : 0;
e->rollAndCacheValue(0, 0);
}
if (target->vfunc_0x150(context, context, e, true))
appliedAny = true;
if (temporarilyFilledLevel) {
e->level = 0;
e->rollAndCacheValue(0, 0);
}
}
}
return appliedAny;0x7C04A4..0x7C04C5 的无分支位运算把负数或大于 1000 的 level 变为 0;0x7C04F5 从目标单位虚表 +0x150 读取应用入口,0x7C0504 调用后以 AL 判断成功。临时补 level 的效果会在 0x7C0537 恢复为 0 并重新 roll/cache。
6.4 目标 selector
两段循环都处理 selector 0/1/2/4/5/6,比较传入的目标、source/owner 和单位 +0x71C 指向的关系对象;参数 a5 非零时会绕过 selector 过滤。
正式枚举标签尚未从 RTTI 或字符串表恢复,所以本报告不把这些数字强行命名为 SELF、OWNER、PET 等。重制实现可以先按现有比较关系复刻,但公开 API 最好等枚举名补齐后再定名。
7. 对重制与钩子的直接建议
7.1 事件边界
建议拆成四类事件,而不是统一成一条字符串日志:
ShieldBlocked/ShieldDestroyed
MissileReflectRolled(chancePct, amountPct, succeeded)
StatDebugValue(statGuid, kind, rawValue)
TriggerableBundleApplied(source, target, affix/effect, applied)这能保留原版日志已经丢掉的原始值,也能明确区分网络表现、随机判定、调试转储与效果派发。
7.2 推荐钩点
| 需求 | 推荐点 | 原因 |
|---|---|---|
| 观察网络破盾 | 0x452CE0 入口及 0x452EF0/0x45321A | 可同时拿消息字段、旧盾值和分支结果 |
| 观察反射掷骰 | 0x523D7D 前后与 0x523FF5 | 能分开记录 chance、roll、amount 与最终倍率 |
| 观察 stat 原值 | sub_7B5F00 返回 | 0x7B67F0 已把值格式化为文本 |
| 观察 effect/affix 派发 | 0x7BFFD1、0x7C0243、0x7C0504 | 分别覆盖创建、挂接、直接 effect 应用结果 |
所有硬编码地址都应先校验对应机器码。跨 Steam/非 Steam build 时优先使用经唯一性验证的 AOB,而不是直接复用 VA。
8. 尚未钉住的边界
本轮明确保留三项未决,不把它们混进已证结论:
UNetUnitfxBlockedMsg +0x30的生产端尚未分析;目前只证明非零强制走破盾分支。0x523CF0的倍率进入CCharacter__resolveStrikeHit后,最终怎样影响反伤数值仍需沿大型命中函数继续追踪。CTriggerableEffectAndAffixesselector0/1/2/4/5/6的正式枚举名称尚未恢复。
这些开放项不影响 B-1 的目标:四个 combatLog 外层函数的身份、主流程、调用关系和关键字段已经闭合,并由发行 EXE 字节回归保护。
9. 验证结果
本报告配套门禁覆盖:
- 四个函数的真实边界与逐指令可达性;
- 全部关键机器码锚点;
E8 rel32的真实调用目标;UNetUnitfxBlockedMsghandler 表指针;- 反射换算常量
0.01f; CStat与CTriggerableEffectAndAffixes的具名虚表身份;- 一个故意改坏锚点的阴性对照。
当前结果:10 tests, OK。