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。本轮真正补齐的四个函数是:

原地址建议名称核心职责置信度
0x452CE0NetUnitfxBlocked__applyShieldDamage处理 UNetUnitfxBlockedMsg,扣减/摧毁可破坏盾效果
0x523CF0CCharacter__rollMissileReflect汇总反射概率与比例,掷骰成功后返回反射倍率
0x7B67F0CStat__logValue按 bool/int/float 类型把 CStat 当前值写入 combatLog
0x7BFD10CTriggerableEffectAndAffixes__applyTo将 triggerable bundle 中的 affix 与 effect 派发到目标单位

这四个函数不是同一条“伤害公式”的连续阶段。它们只是都写入 combatLog.Log

  • 0x452CE0 属于网络消息消费侧
  • 0x523CF0 属于命中/投射物反射决策
  • 0x7B67F0CStat 调试记录,并非战斗数值结算;
  • 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 消息表项中:

text
MessageTableEntry<UNetUnitfxBlockedMsg, BlockedMessageHandler>

消息表位置是 0x2146E50,handler 指针位于 0x2146E58;包装器在 0x4569DDE8 FE C2 FF FF 调用 0x452CE0。这使“网络侧 blocked 事件”成为符号支撑的结论,而不是由日志文字猜出的名字。

3.2 消息字段

函数按以下固定偏移读取消息体:

消息偏移证据用途
+0x100x452D0B: 8B 4E 10查找 level/scene
+0x18,+0x1C8B 46 18 / 8B 56 1C第一组 64-bit 单位 GUID
+0x200x452EF0: DB 41 20作为整数伤害装入 x87,用于比较和扣盾
+0x28,+0x2C8B 56 28 / 8B 4E 2C第二组 64-bit 单位 GUID
+0x300x452F07: 80 79 30 00非零时强制进入破盾分支

这里只给两组 GUID 编号,不冒充已确认的“攻击者/受击者”字段名;函数内的使用关系支持受击对象与关联对象,但消息结构声明本身尚未恢复。

3.3 核心流程

函数先解析 level 与两个单位,然后从装备容器取槽位 2 的物品。它要求物品通过 unit-type 0x15 检查,并遍历其效果,寻找:

text
effect.type == 163

FULL_LOGIC 类型表把 163 命名为原版拼写 DESTRUCTABLE SHIELD。命中该效果后,slot 1 是当前盾值:

cpp
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.");
    }
}

破盾分支会执行模型/装备解绑、受击表现、HITShattered 标签以及清理;存活分支只更新效果 slot,并可发浮动 UI。

3.4 与 rollBlockParryShatter 的关系

0x452CE0CUnit__rollBlockParryShatter 会输出相同的两类字符串,但前者是 UNetUnitfxBlockedMsg接收/表现侧。这解释了为什么日志里看起来像同一件事,却存在两份外层函数:本地结算和网络重放/同步不能合并成一个钩点。

4. 0x523CF0:反射概率与反射比例是两列数据

4.1 输入汇总

函数维护两个浮点累计量,并先后调用:

text
0x523D3E  E8 CD A9 28 00  CEffectMgr__accumMissileReflect
0x523D57  E8 F4 46 2A 00  CInventory__accumMissileReflect

第一列是触发概率 chancePct,第二列是成功后的反射量 amountPct。这两个值不能合并成单一“反射百分比”。

4.2 掷骰与返回值

chancePct <= 0 直接返回 0。否则:

cpp
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;
}

关键证据:

  • 0x523D7Drand_float_between
  • 0x523D82 把随机值与 chancePct 比较;
  • 0x523DE8Reflected!
  • 0x523FF5 执行 fmul [0x2142654];该常量的原始位是 0x3C23D70A,即单精度 0.01

所以函数返回的是 amountPct / 100,不是布尔值,也不是原始百分数。

4.3 两个调用者

调用点所在路径返回值用途
0x5489BFCCharacter__resolveStrikeHit保存 x87 返回值并要求 >0 后继续反射分支
0x6E5882missile 碰撞/更新路径写入 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 写入:

text
C7 06 18 AC 19 02  mov dword ptr [esi], CStat::vftable

因此建议名称是 CStat__logValue。旧档案中的“效果调试转储”已纠正。

5.2 GUID 哨兵与类型分派

函数先检查 COMBAT LOG,再用以下三条指令判 GUID:

text
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读取格式化
0movzx 低字节sub_6863A0,已反编译为 true / false
1dwordintToWString
2floatsub_685FE0
其他不进入三条格式化支路保持空值文本

正常行在 0x7B698D 写入,形状是 <stat name> value: <formatted value>;无效 GUID 行在 0x7B6A00 写入。

这个函数属于调试/诊断面,不应作为伤害公式或 stat 更新钩点。若要拿原始 stat 值,应钩 sub_7B5F00 的返回或真正的写入路径,而不是解析这条日志文本。

6. 0x7BFD10CTriggerableEffectAndAffixes 双队列派发器

6.1 类身份与容器布局

相邻的构造/拷贝路径 0x7C05900x7C05B9 写入具名虚表:

text
C7 06 DC B0 19 02  mov dword ptr [esi], CTriggerableEffectAndAffixes::vftable

同一路径还显式构造 CSkillAffixDataCSkillEffectData,与 0x7BFD10 的两段循环完全对应:

对象偏移容器
+0x0C/+0x10/+0x14affix-data vector:pointer / size / capacity
+0x1C/+0x20/+0x24effect-data vector:pointer / size / capacity

6.2 第一段:实例化并挂接 affix

每个 affix 条目先做目标/关系过滤和 CUnit__hasUnitType 门控。通过后:

  1. 0x7BFFD1createAffixByNameAndLevel
  2. 0x7BFFE6CAffix__setSource
  3. 0x7C001FCAffix__setLevel
  4. 0x7C002E:写入传入位置/上下文;
  5. 0x7C0070:若条目时长大于 0,CAffix__setDuration
  6. 0x7C01D4:可选 combatLog,形状为 affix <name> -> <target>
  7. 0x7C0243CEquipment__attachAffix

函数在至少一条 affix 成功挂接时设置返回标志。

6.3 第二段:直接应用 effect

每个 effect-data 条目可含多个 CEffect。流程如下:

cpp
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 事件边界

建议拆成四类事件,而不是统一成一条字符串日志:

text
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 派发0x7BFFD10x7C02430x7C0504分别覆盖创建、挂接、直接 effect 应用结果

所有硬编码地址都应先校验对应机器码。跨 Steam/非 Steam build 时优先使用经唯一性验证的 AOB,而不是直接复用 VA。

8. 尚未钉住的边界

本轮明确保留三项未决,不把它们混进已证结论:

  1. UNetUnitfxBlockedMsg +0x30 的生产端尚未分析;目前只证明非零强制走破盾分支。
  2. 0x523CF0 的倍率进入 CCharacter__resolveStrikeHit 后,最终怎样影响反伤数值仍需沿大型命中函数继续追踪。
  3. CTriggerableEffectAndAffixes selector 0/1/2/4/5/6 的正式枚举名称尚未恢复。

这些开放项不影响 B-1 的目标:四个 combatLog 外层函数的身份、主流程、调用关系和关键字段已经闭合,并由发行 EXE 字节回归保护。

9. 验证结果

本报告配套门禁覆盖:

  • 四个函数的真实边界与逐指令可达性;
  • 全部关键机器码锚点;
  • E8 rel32 的真实调用目标;
  • UNetUnitfxBlockedMsg handler 表指针;
  • 反射换算常量 0.01f
  • CStatCTriggerableEffectAndAffixes 的具名虚表身份;
  • 一个故意改坏锚点的阴性对照。

当前结果:10 tests, OK