核心定位CEffectManager 是效果系统的挂载点与调度中心。CEffect 对象在被它接管之前不参与任何逻辑; 效果的叠加与去重、逐帧过期、属性汇总、UI 分组,全部在这个类里发生。

分析依据:基于 IDA 对 32 位可执行文件 Torchlight2.exe(基址 0x400000)的逐指令反汇编分析。文中所有 0x…… 均为绝对内存地址。 配置用量数据来自实机样本统计:原版 E:/Torchlight 2/MEDIA 的 16084 个 .DAT 文件与 MOD 库 D:/WizardProject/TL2 的 165753 个 .DAT 文件。

关联文档:被管理的对象参见 CEffect 参考手册CAffix 参考手册; 从技能开发视角的用法参见 TL2 技能设计指南


0. 样例速览

CEffectManager 是纯运行期对象,DAT 里没有与之对应的数据块。但有三类块是它的入口—— 它们各自触发管理器的哪个函数,是这一节要说清的事。下列样例均取自原版数据,内容原文照录(仅将缩进统一为 4 个空格)。

0.1 加与移除的完整一对:MEDIA/SKILLS/WARRIOR/CHARGE/CHARGE.DAT

这是「冲锋」技能 [LEVEL1] 的骨干部分,一份文件里就包含了词缀的挂载与卸载两端:

text
[LEVEL1]
    <FLOAT>MANACOSTOT:25
    <BOOL>CONTINUOUSLOOPING:true
    <INTEGER>MINIMUMTIMEMS:1000
    <INTEGER>DURATIONOVERRIDEMS:400
    <INTEGER>LEVEL_REQUIRED:25
    [EVENT_TRIGGER]
        <STRING>FILE:media/skills/warrior/charge/chargehit.layout
        <BOOL>ATTACHES:true
        <FLOAT>WEAPONDAMAGEPCT:100
        <BOOL>USEDPS:true
        <BOOL>CAN_CLONE:true
        [AFFIXES]
            <STRING>TARGET:SELF
            <STRING>AFFIX:CHARGE
        [/AFFIXES]
    [/EVENT_TRIGGER]
    [EVENT_UNITHIT]
        <BOOL>STATSHIDDEN:true
    [/EVENT_UNITHIT]
    [EVENT_UNITHIT]
        [AFFIXES]
            <STRING>TARGET:ENEMY
            <STRING>AFFIX:CHARGEEFFECT
        [/AFFIXES]
        [AFFIXES]
            <INTEGER>AFFIXLEVEL:25
            <STRING>TARGET:ENEMY
            <FLOAT>DURATION:0
            <STRING>AFFIX:CHARGEEFFECT_DAMAGE
        [/AFFIXES]
    [/EVENT_UNITHIT]
    [EVENT_END]
        [AFFIXESREMOVE]
            <STRING>AFFIX:CHARGE
        [/AFFIXESREMOVE]
    [/EVENT_END]
[/LEVEL1]

每个块最终落到管理器的哪个函数:

DAT 块TARGET落到哪个管理器调用的函数
[EVENT_TRIGGER] 里的 [AFFIXES]SELF施法者自己的CEffectMgr__addAffix (0x7AFFD0)
第二个 [EVENT_UNITHIT] 里的两个 [AFFIXES]ENEMY被命中单位的同上,每个被命中单位各调一次
[EVENT_END] 里的 [AFFIXESREMOVE]缺省施法者自己的按名移除,最终走 CAffix__detachFromHost (0x7A48C0)

四点值得注意:

  • TARGET 决定的是「哪一个管理器」,不是「哪一张表」。表由效果自身的 ACTIVATION 决定(第 3 节)。 TARGET 在原版 [AFFIXES] 块里写了 3540 次,MOD 库 120113 次——是这个块第二高频的键。
  • 同一个 [EVENT_*] 下可以并列多个 [AFFIXES],上例第二个 [EVENT_UNITHIT] 就有两个。 另外注意原文里并列了两个 [EVENT_UNITHIT]——第一个只写了 STATSHIDDEN:true,不带任何词缀。 它们各自独立调用一次 addAffix,而 addAffix 不做任何去重(第 6.3 节)。
  • CHARGE 这个词缀加在 EVENT_TRIGGER、删在 EVENT_END,构成一次施法期间的临时状态。 这是「限时增益」在引擎里的标准实现形态:不靠 DURATION 计时,而靠事件对称地加删。
  • 第二个 [AFFIXES] 写了 DURATION:0addAffix 的判据是 if (duration > 0)0 不满足, 所以这一项等于没写,词缀时长最终由 CAffix__deriveDurationFromEffects 从子效果推算(第 6.4 节)。

0.2 按名移除效果:MEDIA/SKILLS/PROCS/REMOVE_EFFECT_ON_HIT.DAT

一个完整的 22 行文件,作用就是命中时把某条效果摘掉:

text
[SKILL]
    <STRING>NAME:PROC REMOVE EFFECT
    <STRING>SKILL_TYPE:OFFENSIVE
    <STRING>ACTIVATION_TYPE:PROC
    <STRING>TARGET_ALIGNMENT:GOOD
    <BOOL>HIDDEN:true
    <BOOL>DONT_STOP_ON_DEATH:true
    <BOOL>CAN_BE_SILENCED:false
    <STRING>TARGET:NONE
    <INTEGER64>UNIQUE_GUID:-8370515247435035533
    [LEVEL1]
        [EVENT_START]
            <STRING>FILE:media/particles/monsters/cultist_hidden/hiddeneffect_explode.layout
            <BOOL>APPLYEFFECTS:true
            <BOOL>APPLYEFFECTSALWAYS:true
            <STRING>TARGET:FRIEND
            [EFFECTSREMOVE]
                <STRING>EFFECT:SET MESH INVISIBLE
            [/EFFECTSREMOVE]
        [/EVENT_START]
    [/LEVEL1]
[/SKILL]
  • EFFECT 的值按 CEffect+0x50大写规范化名称匹配,遍历管理器的三张表。
  • [EFFECTSREMOVE] 是极冷门的块:原版全库只有 5 处(MOD 库 1759 处)。 大部分「移除效果」的需求在原版里是用 [AFFIXESREMOVE](原版 867 处)解决的。
  • 运行期的等价入口是 CEffectMgr__removeEffectsByName (0x7AECA0),它在成功移除后会往 COMBAT LOG 打一行日志——配置排错时可以直接开这个开关观察(第 11 节 Q8)。

0.3 一次挂载的完整调用序列

把上面 [EVENT_TRIGGER] 那个 [AFFIXES] 展开成实际的函数调用链,便于对着下断点:

text
技能事件触发
 └─ CUnit__attachAffixByName            0x513C70
     ├─ if (!unit->+0x1B4)                          管理器不存在则惰性创建
     │     alloc(1240) → CEffectMgr__ctor           0x7B1C30
     └─ CEffectMgr__createAndAddAffixByName         0x7B00D0   ← 中间层,0x513D11 调
         ├─ createAffixByNameAndLevel               0x404200   ← 按名字取词缀原型在这里
         ├─ 难度门(与下面那道同款)                  不中 → 析构并释放,返回 0
         └─ CEffectMgr__addAffix(mgr, affix, level, source, dur, m1, m2)   0x7AFFD0
         ├─ 难度门: (1 << 当前难度) & affix->DIFFICULTIES_ALLOWED
         │     不中 → 析构并释放 affix,返回 0        ← 调用方不得再碰该指针
         ├─ CAffix__setDuration          0x7A3FF0    仅当 dur > 0
         ├─ CAffix__setLevel             0x7A4340    仅当 level != -1;内部算品质百分比
         │    └─ CAffix__applyQualityPercentToEffects  0x7A4180  改写子效果 MIN/MAX
         ├─ push_back(mgr->affixes, affix)           0x7B0095    无去重
         ├─ CAffix__setSource            0x7A4840
         ├─ CAffix__attachToMgr_applyEffects         0x7A49C0    ★ 真正施加的一步
         │    └─ 对每个子效果: setOwnerAffix → CEffectMgr_AddEffect  0x7A4FA9 → 0x7B21E0
         │         └─ 因 ownerAffix 已指向本管理器,走 relink 捷径进 linkEffect  0x7B1D70
         │              ├─ ① 旁路 → ② EXCLUSIVE 去重 → ③ 来源回填
         │              └─ ④ UI 分组 → ⑤ 按 ACTIVATION 入表 → ⑥ 描述缓存失效
         ├─ CAffix__deriveDurationFromEffects        0x7A4080
         └─ CEffectMgr__recomputeStats_239cache      0x7AFE00    立即全量重算属性

1. 运行架构与生命周期

1.1 先厘清三个容易混淆的对象

在 TL2 的逆向资料里,「效果管理器」这个说法被套用在三个完全不同的对象上,先分清楚:

对象RTTI / 地址实例数职责
CEffectManagerCEffectManager : CRunicCore,vtable 0x219AAE0每个单位、每件装备各一个本文主题:挂载、去重、过期、属性汇总
CUIEffectListvtable 0x219AB70,全局指针 g_pEffectDisplayMgr 0x3BAE908,取值函数 getEffectDisplayMgr 0x7B2460单例,88 字节UI 状态增益栏本体,持有全部显示分组
CEffectGroupManagervtable 0x219AA80,构造 0x7ACB00、析构 0x7ACC50全局单例,存 CGameData+0x4C词缀库;并在 +0xA0 持有上面那个 CUIEffectList

四点务必记住:

  1. CEffectManager 不是单例。 它按需创建在宿主对象的 +0x1B4 字段上 (CItem_AttachDynamicEffect 0x513BF7CUnit__attachAffixByName 0x513C8B 等处都能看到 cmp dword ptr [esi+1B4h], 0 的惰性判空)。
  2. IDB 里的 CEffectMgr__ 前缀是旧简写,RTTI 里的真名是 CEffectManager
  3. 不存在 CAffixMgr 这个类,但存在一个全局的词缀库单例,类名是 CEffectGroupManager。 挂在单位/装备身上的词缀实例进的是 CEffectManager 的词缀向量(第 6 节); 而「有哪些词缀可摇、各自的权重与等级范围」那份索引在 CEffectGroupManager 里。
  4. 早期资料把「CEffectDisplayMgr」当成一个类名 —— RTTI 里没有这个类,那个全局指针指向的是 CUIEffectList。 整条显示链路见 CEffectDisplayMgr 参考手册

对外的取用接口是虚函数表第 99 槽:CItem__getEffectMgr_vt99 0x514520CCharacter__getEffectMgr_vt99 0x51C960。 惰性创建的典型路径是 CBaseUnit__getOrCreateEffectMgr_linkEffect 0x513E90

text
if (!unit->+0x1B4) {
    mem = allocator->alloc(1240);          // 0x513EEB,1240 = 0x4D8 = sizeof(CEffectManager)
    unit->+0x1B4 = CEffectMgr__ctor(mem, unit);   // 0x7B1C30
}
return CEffectMgr__linkEffect(unit->+0x1B4, eff); // 0x7B1D70

1.2 生命周期总览

text
CEffect / CAffix 构造完成

  ├─ 效果路径: CEffectMgr__linkEffect (0x7B1D70)
  │    ├─ ① 旁路检查 (flags & 0x200000) → linkEffect_bypassTable (0x7B1BD0)
  │    ├─ ② 去重: 遍历三张表, 按 EXCLUSIVE + (NAME, TYPE) 让旧效果过期
  │    ├─ ③ 来源回填: mgr->+0x58 → effect->setSource
  │    ├─ ④ UI 分组: CUIEffectList__addToMatchingGroup (0x7B46A0)
  │    ├─ ⑤ 入表: 按 ACTIVATION 选表并 push_back
  │    └─ ⑥ 描述缓存失效

  ├─ 词缀路径: CEffectMgr__addAffix (0x7AFFD0)
  │    └─ 难度门 → 参数写入 → 入词缀表 → attachToMgr_applyEffects → recomputeStats

  ├─ 逐帧: CEffectMgr_expireEffects_perframe (0x7AF2A0)

  └─ 析构: CEffectMgr__dtor (0x7AFBD0) ← 虚表槽 0 的 CEffectMgr__scalarDeletingDtor (0x7B1D50)

2. 内存数据结构 (CEffectManager Memory Layout)

单个对象占用 1240 字节 (0x4D8)。字段图由构造函数 CEffectMgr__ctor (0x7B1C30) 与析构函数 CEffectMgr__dtor (0x7AFBD0) 交叉还原。

偏移量数据类型字段含义与描述写入来源默认初始值
+0x00ptr虚函数表指针构造函数0x219AAE0
+0x04intCRunicCore 基类内部字段0x4059180
+0x08+0x14vector<CAffix*>词缀列表CEffectMgr__addAffix 0x7B0095空,growth 1
+0x18+0x24vector<CEffect*>表 0:ACTIVATION = PASSIVElinkEffect 0x7B1F30空,growth 1
+0x28+0x34vector<CEffect*>表 1:ACTIVATION = DYNAMIC同上空,growth 1
+0x38+0x44vector<CEffect*>表 2:ACTIVATION = TRANSFER同上空,growth 1
+0x48+0x54vector<CEffect*>惰性旁路表linkEffect_bypassTable 0x7B1C16空,growth 10
+0x58ptr宿主对象(单位或装备)构造入参0
+0x5Cint宿主的弱引用 cookiesub_4059A0 0x7B1CC2−1
+0x60+0x41Bfloat[239]239 项属性缓存,下标即效果类型 IDCEffectMgr__recomputeStats_239cache 0x7AFFA5全 0
+0x41C+0x428vector<void*>当前生效的 UNITTHEME 去重表recomputeStats 0x7AFF0C空,growth 1
+0x42Cbyte描述文本缓存脏标记CEffectMgr__invalidateDescCache 0x7ADD95构造函数不写
+0x430 / +0x44C / +0x468wstring 28B ×3描述文本缓存 A 组,按表索引CEffectMgr__buildDescCacheA 0x7B074F空字符串
+0x484 / +0x4A0 / +0x4BCwstring 28B ×3描述文本缓存 B 组,按表索引CEffectMgr__buildDescCacheB 0x7B11AF空字符串

NOTE

两组共 6 个 wstring按表索引的惰性描述缓存(3 张表 × 2 种口味)。完整闭环:

环节函数 / 地址
构造eh vector constructor iterator 各批量构造 3 个(0x7B1CFC0x7B1D1E
写入 A 组CEffectMgr__buildDescCacheA (0x7B0720),寻址 0x7B074F
写入 B 组CEffectMgr__buildDescCacheB (0x7B1180),寻址 0x7B11AF
清空CEffectMgr__invalidateDescCache (0x7ADD60) 与 linkEffect 收尾段,同时置 +0x42C = 1
析构0x7AFCB20x7AFCCE

两个构建函数完全同构,寻址方式是 lea eax, ds:0[esi*8] / sub eax, esi / lea edi, [ecx+eax*4+基址], 即 基址 + 28 × 表索引esi*8 − esi = 7×,再 ×4 得 28×)。开头第一件事就是惰性缓存门:

text
if (cache[tableIdx] != L"") return cache[tableIdx];   // 0x7B076C / 0x7B11CC

所以 +0x42C 那个脏标记的真正作用是让下一次取描述时重建这六个串

唯一消费者buildEffectDescForTooltip (0x57F780)——物品/单位 tooltip 的效果文本行拼装器, 内含 CEGUI 颜色标记与 Damage 字样,被物品 tooltip 组装器 sub_587550 调用 9 次。 它在 0x57FCF1 取 A 组、0x57FCCB 取 B 组,选哪一组由自己的 arg_10 决定(0x57FCA1,为 0 走 A)。 两处的 this 都是先经虚表槽 99getEffectMgr[eax+18Ch])拿到的 CEffectManager—— 这也是判定这两个构建函数属于本类的依据。

需要强调的是:这是提示文本(Tooltip)缓存,不是 239 属性缓存。后者在 +0x60+0x41B,两者互不相干。

2.1 C++ 结构体声明

辅助类型 core_vector / wstring28 / CRunicCore 的定义与 CEffect 参考手册 中的完全一致。

cpp
// RTTI: CEffectManager : CRunicCore   vtable @ 0x219AAE0   sizeof == 0x4D8 (1240)

struct CEffectManager : CRunicCore {
    /* +0x08 */ core_vector<CAffix*>  affixes;        // addAffix 直接 push_back,无去重
    /* +0x18 */ core_vector<CEffect*> tablePassive;   // ACTIVATION == 0
    /* +0x28 */ core_vector<CEffect*> tableDynamic;   // ACTIVATION == 1
    /* +0x38 */ core_vector<CEffect*> tableTransfer;  // ACTIVATION == 2
    /* +0x48 */ core_vector<CEffect*> bypassTable;    // flags & 0x200000 的效果,growth 10
    /* +0x58 */ void*                 owner;          // 宿主单位或装备
    /* +0x5C */ int                   ownerCookie;    // 弱引用注册号,初值 -1
    /* +0x60 */ float                 statCache[239]; // 下标 = 效果类型 ID
    /* +0x41C*/ core_vector<void*>    activeUnitThemes;
    /* +0x42C*/ unsigned char         descDirty;
    /* +0x42D*/ unsigned char         _pad42D[3];   // 对齐填充:0x42D–0x42F 在扫描范围内命中数为 0
    /* +0x430*/ wstring28             descA[3];       // buildDescCacheA 惰性填充,按表索引
    /* +0x484*/ wstring28             descB[3];       // buildDescCacheB 惰性填充,按表索引
};

static_assert(sizeof(CEffectManager)                  == 0x4D8, "CEffectManager layout");
static_assert(offsetof(CEffectManager, tablePassive)  == 0x18, "");
static_assert(offsetof(CEffectManager, bypassTable)   == 0x48, "");
static_assert(offsetof(CEffectManager, statCache)     == 0x60, "");
static_assert(offsetof(CEffectManager, activeUnitThemes) == 0x41C, "");

2.2 方法原型

CEffectManager 是行为类,光有字段图不够用。下面按功能分组给出可直接用于 Hook 的声明: 全部是 __thiscallthisecx),返回类型与参数个数取自 IDA。

cpp
// ============ 构造 / 析构 ============
CEffectManager* __thiscall CEffectMgr__ctor(void* mem, void* owner);              // 0x7B1C30  mem 需 1240 字节
void            __thiscall CEffectMgr__dtor(CEffectManager*);                     // 0x7AFBD0
void*           __thiscall CEffectMgr__scalarDeletingDtor(CEffectManager*, char); // 0x7B1D50  虚表槽 0

// ============ 挂载 ============
CEffect* __thiscall CEffectMgr__linkEffect(CEffectManager*, CEffect*);            // 0x7B1D70  核心,见第 4 节
char     __thiscall CEffectMgr__linkEffect_bypassTable(CEffectManager*, CEffect*);// 0x7B1BD0  返回 1 = 新入表
CEffect* __thiscall CEffectMgr_AddEffect(CEffectManager*, void* source, CEffect*);// 0x7B21E0  见下注

// addAffix 有 6 个栈参,`retn 18h`。返回 0 表示被难度门拒绝,
// **此时传入的 affix 已被本函数析构并释放**,调用方不得再解引用。
CAffix*  __thiscall CEffectMgr__addAffix(CEffectManager*, CAffix*, int level,
                                         void* source, float duration,
                                         float mult0x0C, float field0x10);        // 0x7AFFD0

// ============ 卸载 ============
char __thiscall CEffectMgr__unlinkEffect(CEffectManager*, CEffect*,
                                         char destroy, char notify);              // 0x7AEBC0
char __thiscall CEffectMgr__removeEffectsByName(CEffectManager*, const wstring28*,
                                                char destroy);                    // 0x7AECA0
// 第 2 参只决定「要不要扫三张效果表」;**词缀那一遍无论如何都会跑**(见下注)
void __thiscall CEffectMgr__removeEffectsBySourceGuid(CEffectManager*, char alsoScanEffectTables,
                                                      __int64 sourceGuid);        // 0x7AF9B0
void __thiscall CEffectMgr__removeAffixesBySourceGuid(CEffectManager*, __int64 sourceGuid,
                                                      char destroy, int detachArg); // 0x7AF630
void __thiscall CEffectMgr_expireEffects_perframe(CEffectManager*);               // 0x7AF2A0

// ============ 属性与缓存 ============
void __thiscall CEffectMgr__recomputeStats_239cache(CEffectManager*);             // 0x7AFE00  全量重算
void __thiscall CEffectMgr__invalidateDescCache(CEffectManager*);                 // 0x7ADD60  仅标脏
cpp
// ============ 查询 ============
// 全部按 (类型 / 名称 / 指针) 线性查找,**只扫 PASSIVE 与 DYNAMIC 两张表**(见第 3.1 节)
CEffect* __thiscall CEffectMgr__findEffectByTypeAndName(CEffectManager*, int typeId,
                                                        const wstring28* name);           // 0x7ADEE0
char     __thiscall CEffectMgr__hasEffectWithName(CEffectManager*, const wstring28*);      // 0x7AE090
char     __thiscall CEffectMgr__hasEffectOfType(CEffectManager*, int typeId);              // 0x7AE100
char     __thiscall CEffectMgr__containsEffect(CEffectManager*, CEffect*);                 // 0x7AE1F0  按指针身份
CAffix*  __thiscall CEffectMgr__findAffixByName(CEffectManager*, const wstring28*);        // 0x7AE5F0  查词缀表

// 求值。damageType 传 7 表示「不按伤害类型过滤」(7 不是合法伤害类型,只有 0..6)
double   __thiscall CEffectMgr__evalEffectValue(CEffectManager*, int unused,
                                                CEffect*, char useCeil);                   // 0x7AE990
double   __thiscall CEffectMgr__sumStatOfType_filtered(CEffectManager*, int typeId,
                                                       int damageType, char onlyLocalSource,
                                                       char useCeil);                      // 0x7AEA40
void     __thiscall CEffectMgr__accumMissileReflect(CEffectManager*, float* pChancePct,
                                                    float* pAmountPct);                    // 0x7AE710

// ============ 批量操作 ============
// 把 tableIdx 指定的那一张表里 TYPE == typeId 的效果**拷贝**一份施加到 dstUnit
void     __thiscall CEffectMgr__transferEffectsOfTypeTo(CEffectManager*, void* dstUnit,
                                                        int tableIdx, int typeId,
                                                        float damageScale);                // 0x7AF0B0
void     __thiscall CEffectMgr__removeEnchantments_disenchant(CEffectManager*);            // 0x7AF480  洗练
void     __thiscall CEffectMgr__purgeUnsavableEffects(CEffectManager*);                    // 0x7AF950
void     __thiscall CEffectMgr__snapshotEffectPtrs(CEffectManager*,
                                                   core_vector<CEffect*>* out);            // 0x7AFDC0

CAUTION

0x7AF950 早期被命名为 hasSavableEffect这个名字是错的——它不做任何判定, 而是把三张表里所有没打 SAVE 位(flags & 0x10)的效果全部 expire,随后立刻调 expireEffects_perframe 回收。函数没有 retn imm(尾跳),也没有返回值。 已在 IDB 中改名为 CEffectMgr__purgeUnsavableEffects。详见第 9 节。

NOTE

CEffectMgr_AddEffect (0x7B21E0) 不总是拷贝。开头有一条捷径: if (eff->ownerAffix && eff->ownerAffix->host == this)——效果已经属于挂在本管理器上的词缀时, 直接对原对象linkEffect 并返回,不新建。只有其余情况才 alloc(304) + CEffect_CopyCtor

CEffectMgr__removeEffectsBySourceGuid (0x7AF9B0) 的职责比名字宽: 第 2 个参数为 0 时,三张效果表的扫描整段被跳过,一条效果都不会删; 而无论该参数取何值,函数总会CEffectMgr__removeAffixesBySourceGuid (0x7AF630) 对词缀向量按 GUID 匹配(传 −1/−1 表示全部),逐个 detachFromHost 后析构释放。 管理器析构正是用 (0, -1, -1) 这个组合——只杀词缀、不碰效果表。 词缀的 GUID 由 CAffix__getSourceGuid (0x7A44A0) 取自它第一条子效果GUID2+0x38); 没有子效果时返回 −1/−1。

关于 evalEffectValue 的第一个参数:它在整个函数体内没有任何读取点。 已逐指令确认——arg_ 只出现在 0x7AE991(读 arg_4)与 0x7AE9A9(读 arg_8), 此后 0x7AE9BE / 0x7AE9FF / 0x7AEA28arg_8 的栈槽当临时 float 反复复用。 retn 0Ch 说明确实收 3 个栈参,但第一个是死参。

2.3 常用偏移速记

Hook 时直接按偏移取字段的话:

cpp
#define MGR_AFFIXES(m)      (*(core_vector<CAffix*>*) ((char*)(m) + 0x08))
#define MGR_TABLE(m, act)   (*(core_vector<CEffect*>*)((char*)(m) + 0x18 + 0x10 * (act)))
#define MGR_BYPASS(m)       (*(core_vector<CEffect*>*)((char*)(m) + 0x48))
#define MGR_OWNER(m)        (*(void**)              ((char*)(m) + 0x58))
#define MGR_STAT(m, type)   (((float*)((char*)(m) + 0x60))[(type)])   // type ∈ [0, 239)
#define MGR_DESC_DIRTY(m)   (*(unsigned char*)      ((char*)(m) + 0x42C))

// 宿主对象上的管理器指针(CItem 与 CBaseUnit 同偏移)
#define UNIT_EFFECT_MGR(u)  (*(CEffectManager**)    ((char*)(u) + 0x1B4))

WARNING

MGR_TABLE(m, act)act 必须自行限制在 0–2。引擎自身的入表计算 (linkEffect 0x7B1F100x7B1F1E)也没有上界检查,传 3 会落到旁路表的数据指针上(第 3.2 节)。


IMPORTANT

statCache 的结尾 (0x60 + 239 × 4 = 0x41C) 与紧随其后的 activeUnitThemes 零间隙相邻。 这意味着 239 这个上限无法靠补丁扩容:数组一旦加长就会覆写后续全部字段,而且所有以 mgr[typeId + 24] 形式寻址的消费点都要同步改写。这是 EFFECTSLIST.DAT 必须恰好 239 行的第三个理由 (另两个见 CEffect 参考手册)。


3. 四张效果表

前三张按 CEffectACTIVATION+0x20)分流,第四张是旁路表。

偏移收纳条件参与逐帧过期参与属性缓存
表 0+0x18ACTIVATION = PASSIVE (0)
表 1+0x28ACTIVATION = DYNAMIC (1)
表 2+0x38ACTIVATION = TRANSFER (2)
旁路表+0x48flags & 0x200000

入表索引没有上界检查。 linkEffect 的计算是:

text
0x7B1F10    mov  eax, [ebx+20h]        ; ebx = 新效果,取 ACTIVATION
0x7B1F13    shl  eax, 4                ; × 16
0x7B1F1E    lea  esi, [eax + ebp + 18h]   ; ebp = mgr,得 mgr + 0x18 + 0x10 × ACTIVATION

ACTIVATION == 3 会落在 mgr+0x48,也就是旁路表的数据指针上。DAT 侧取不到 3 (枚举表只有 3 项,见 CEffect 参考手册),所以这是潜在而非当前可触发的越界; 自行 Hook CEffect__ctorRuntime 构造效果时需要自己卡住这个值。

3.1 谁扫三张表,谁只扫两张

这是 CEffectManager 里最容易踩的一处不一致:同一个类里的函数,有的遍历全部三张表,有的只遍历前两张。 判据在循环计数器的初值上——v = 3 还是 v = 2,或者 cmp ??, 2 的那条比较。逐个核实的结果:

3 张(含 TRANSFER2 张(跳过 TRANSFER
linkEffect 0x7B1D70findEffectByTypeAndName 0x7ADEE0
expireEffects_perframe 0x7AF2A0hasEffectWithName 0x7AE090
removeEffectsByName 0x7AECA0hasEffectOfType 0x7AE100
removeEffectsBySourceGuid 0x7AF9B0containsEffect 0x7AE1F0
purgeUnsavableEffects 0x7AF950sumStatOfType_filtered 0x7AEA40
removeEnchantments_disenchant 0x7AF480accumMissileReflect 0x7AE710
snapshotEffectPtrs 0x7AFDC0recomputeStats_239cache 0x7AFE00
dtor 0x7AFBD0

规律很清楚:「写」的操作扫全部三张,「读」的查询与统计只扫两张

三张表实际各装多少东西,可以从 ACTIVATION 的取值分布看出来(统计口径为 [EFFECT] 块,已排除 EFFECTSLIST.DAT):

ACTIVATION 取值落入的表原版MOD 库
DYNAMIC表 1 +0x286154137365
PASSIVE表 0 +0x18335857908
TRANSFER表 2 +0x382763875
缺省(走默认 DYNAMIC表 12013013
NORMAL / ALWAYS(非法值)表 1030
合计9989202191

NOTE

分母与本站另两篇一致(原版 9989 / MOD 库 202191),口径是「每个 [EFFECT] 开标签计一次」。 需要留意的是存在嵌套的 [EFFECT]:原版全库唯一一处在 MEDIA/SKILLS/MONSTERS/FIRSTHENCHMEN/MISSILEREFLECT.DAT(外层 PASSIVEMISSILE REFLECT 里套了一个 DYNAMICHENCHMENSPEEDUP),MOD 库另有 11 个文件共 16 个内层块。 用「遇到 [EFFECT] 就置标志、遇到 [/EFFECT] 就收块」的非重入解析器统计时, 这些内层块会被吞进外层,原版少数 1 个、MOD 库少数 12 个——上表已按重入口径修正。

WARNING

实际后果:ACTIVATION:TRANSFER 的效果能被挂上、能被逐帧过期、能被按名移除、能被析构, 但它查不到、统计不到、也不进 239 属性缓存hasEffectOfType 对它返回 0,sumStatOfType_filtered 不累加它,evalEffectValue 因为前置的 containsEffect 失败而直接返回 0.0

换句话说,TRANSFER 表是一块「只进不出的暂存区」,不要指望在它上面做任何查询。 实测 ACTIVATION:TRANSFER 的用量:原版 276 处、MOD 库 3875 处(分母见下), 不算罕见——写这类效果时必须知道它查不到也统计不到。

3.2 旁路表的用途

CEffectMgr__linkEffect_bypassTable (0x7B1BD0) 逻辑极简:

text
if (!eff) return 0;
线性搜 mgr+0x48;已存在 → return 0        // 按指针身份去重,不看 NAME / TYPE
eff->flags |= 0x200000;                   // 标记只在这里设置
push_back(mgr+0x48, eff); return 1;

linkEffect 的入口要求先有 0x200000 才会跳进旁路,而这个标记只在本函数设置——看似闭环。 破环的是第二个调用者 CSocketable__parkEffectsForWrongHost (0x584029),它直接调用本函数。

这套机制服务于宝石的双词缀设计

步骤函数行为
打标CSocketable__tagEffectsByHostItemType 0x57D840if / else if,Armor 优先:词缀的 [UNITTYPES] 含 Armor(13) → flags |= 0x100000否则含 Weapon(8) → flags |= 0x80000。两者皆适用时只打 0x100000
迁移CSocketable__parkEffectsForWrongHost宿主是 Weapon 而效果标了「护甲专属」→ 搬进旁路表;宿主是 Armor 而效果标了「武器专属」→ 同理

配对看着是反的,但这正是正确语义:标记记的是「本条效果适用于哪种宿主」, 迁移发生在宿主是另一种的时候——这就是同一颗宝石「镶武器给一组属性、镶护甲给另一组」的实现方式。

进了旁路表的效果永不参与 EXCLUSIVE 匹配,也不进属性缓存,只按指针身份去重。


4. 挂载契约:linkEffect 的六个阶段

CEffectMgr__linkEffect (0x7B1D70) 是整个效果系统里最关键的一个函数。叠加与去重规则全在这里。

4.1 阶段一:旁路

text
if (newEff->flags & 0x200000) return linkEffect_bypassTable(newEff);

4.2 阶段二:去重与独占

遍历全部三张表(表头步长 0x10),逐条比对:

text
if (!(newEff->flags & 4)) continue;             // ← EXCLUSIVE 只看新来的这一条
if (existing->NAME != newEff->NAME) continue;   // 两边都已 toUpper,大小写不敏感
if (existing->TYPE != newEff->TYPE) continue;   // NAME 与 TYPE 必须同时相同

// 存活判定 (0x7B1E20–0x7B1E5F)
if (   ( existing->DURATION == -900              // INSTANT
      || existing->DURATION == -1000             // ALWAYS
      || existing->ACTIVATION == 2               // TRANSFER 表永不 tick,elapsed 不推进
      || existing->DURATION > existing->elapsed )
    && !(existing->flags & 0x20) )               // 0x20 = 已作废墓碑位
{
    if (newEff->TYPE == 231 || newEff->TYPE == 234) newEff->setName(L"");
    existing->expire(mgr);                       // 0x7B1E90
}

WARNING

Hex-Rays 把 0x7B1E90 处的调用反编译成 CEffect__expire(v3)v3 = this = mgrthis 与参数颠倒了。 逆汇编 0x7B1E8Dmov ecx,[eax](取 existing)加上 0x7B1E8Fpush ebp(压入 mgr)才是真实调用形态。

三条结论:

  • 新效果没有 EXCLUSIVE → 无条件叠加,同名同类型可以堆任意多份。
  • 新效果有 EXCLUSIVE → 旧的全部打墓碑,新的照样入表。这是替换/刷新,不是拒绝
  • 任何情况下都不会拒绝新效果 —— 本函数没有返回失败的路径。

EXCLUSIVE 的默认值来自 CEffect__ctorFromDat:有限时长的 DAMAGE(52) 与 DAMAGE CHANCE(156) 自动为 true。 所以 DoT 天然独占;而运行时构造的效果(CEffect__ctorRuntime不会自动获得该位,除非创建方自己置位。 例如 225 PULL EFFECT0x559AA4 显式 or eax, 4,而 43 KNOCK BACK EFFECT 没有——这才是击退能无限叠加的真正原因。

CAUTION

早期资料中有一条「linkEffect 对 TYPE 43 跳过唯一性检查」的说法,是错的。 TYPE 43 跳过的是阶段四的 UI 分组扫描,阶段二的去重对它一样生效。

4.3 阶段三:来源回填

text
if (mgr->+0x58 && !newEff->sourceUnit) newEff->setSource(mgr->+0x58, 1);

即:效果自己没有来源单位时,用管理器的宿主补上。

4.4 阶段四:UI 分组

text
if (newEff->TYPE != 43) { ... }

循环体与循环变量完全无关edi / ebp 只当计数器,元素从未被取出),等价于:

text
if (三表总数 > 0 && g_pEffectDisplayMgr
    && g_pEffectDisplayMgr(CUIEffectList)->addToMatchingGroup(newEff, mgr)) descDirty = false;

循环在 addToMatchingGroup 首次返回 true 时 break(随后置 descDirty = false), 所以「重复执行三表效果总数次」是它一直返回 false 时的最坏情况,不是每次都发生。 但即便如此,这段循环的迭代次数与效果总数挂钩而与结果无关,仍是引擎自身的性能缺陷: 分组一直匹配不上时,单位身上挂的效果越多,每次挂载新效果的空转就越久。

4.5 阶段五:入表

text
idx = newEff->ACTIVATION;
vec = mgr + 0x18 + 0x10 * idx;
push_back(vec, newEff);
newEff->onLinked_spawnCountCompanion(mgr);      // 0x7A9F40

4.6 阶段六:描述缓存失效

descDirty 仍为真时,清空 +0x430/+0x44C/+0x468+0x484/+0x4A0/+0x4BC 共 6 个 wstring, 并置 +0x42C = 1。同构代码见 CEffectMgr__invalidateDescCache (0x7ADD60)。


5. 卸载、过期与所有权

5.1 逐帧过期

CEffectMgr_expireEffects_perframe (0x7AF2A0) 遍历三张表,对每条效果:

text
if (DURATION != -900 && 存活判定通过) continue;   // 保留
// 否则进入移除流程:
CEffect__notifyRemoved(eff);                       // 0x7AE660
if (eff->TYPE == 166) CUnit__removeTriggerableByName(eff->NAME);   // 0x513580
if (mgr->owner) owner->vt[90](eff);                // 虚表偏移 360
if (eff->ownerAffix != NULL) 仅从表中 swap-remove,不释放
else                        虚析构 + 释放,再 swap-remove
--i;                                               // 重新检查换上来的那一条

两点值得记住:

  • DURATION == -900INSTANT)的效果每帧都会被移除。 判定的第一步就把它排除在「保留」之外。
  • 移除采用 swap-remove:末位元素被换到当前位置,所以三张表内的顺序不稳定,不要依赖遍历次序。

调用方为 CMonster_tick_main (0x56B12B)、CEquipment__bakeDamageEffectsIntoTable (0x590C91)、 CEffectMgr__purgeUnsavableEffects (0x7AF9A4) 等 5 处。

5.2 三条卸载路径

函数地址匹配依据是否释放对象
CEffectMgr__unlinkEffect0x7AEBC0指针身份(先查旁路表,再查 ACTIVATION 对应的那张表)由第 3 参数决定
CEffectMgr__removeEffectsByName0x7AECA0NAME 全等,遍历三张表由第 3 参数决定
CEffectMgr__removeEffectsBySourceGuid0x7AF9B0GUID2+0x38)全等,或传 −1/−1 表示全部;跳过 TYPE 62。第 2 参为 0 时整段跳过效果表总是释放
CEffectMgr__removeAffixesBySourceGuid0x7AF630同上,但作用于词缀向量;由上一条无条件调用由第 3 参决定

removeEffectsByName 还带一个排错用的副作用:只要真的移除了东西,且设置项 COMBAT LOGg_SettingsIdx_COMBAT_LOG 0x28CFC08)大于 0,就会往战斗日志打一行 effect |c0088FF88<名字>|u removed from <单位名>0x7AEE520x7AEEAA)。 配效果配到怀疑人生时,这是引擎自带的观测点。

它的调用方几乎全是战斗逻辑:CCharacter__resolveStrikeHit(4 处)、CUnit__onDeath_vt120CUnit__executeInstantEffect(2 处)、CBaseUnit__modifyHealth 等 14 处。

5.3 所有权:谁负责释放

这是 Hook 这条链路时最容易搞错的地方,逐层写清:

场景释放者
直接 link 进来的效果,过期时管理器(虚析构 + 释放)
属于某个 CAffix 的效果,过期时管理器只摘表不释放,判据是 eff->+0xD0 != NULL
属于某个 CAffix 的效果,最终释放CAffix 的析构函数 0x7A5070:先 CAffix__detachFromHost 把效果从管理器表里摘掉,再 vec_destroyAllElements 逐个释放
管理器析构时的旁路表CEffectMgr__dtor 开头 0x7AFBF7lea ecx, [ebx+48h] + vec_destroyAllElements逐个虚析构并释放
管理器析构时的词缀紧接着 0x7AFC11removeEffectsBySourceGuid(0, -1, -1)——第 2 参为 0 跳过效果表,只走词缀那一遍:detachFromHost 后析构释放
管理器析构时的剩余效果最后三张表逐个虚析构 + 释放(此时词缀自己的效果已经在上一步被摘走了)

析构的实际顺序是:旁路表 → 词缀(连带摘走并释放它们的子效果)→ 三张表的残余 → 各向量缓冲区。 所以不会双重释放,但这也意味着:手工从表里摘一条属于词缀的效果并释放它,会让词缀持有悬垂指针。

vec_destroyAllElements (0x712F70) 的语义是「对每个元素调虚析构并交还分配器,清空 size; 第 2 参为真时再释放缓冲区本身」。


6. 词缀入口:addAffix

CEffectMgr__addAffix (0x7AFFD0) 是 __thiscallretn 18h(6 个栈参):

text
addAffix(CAffix* affix, int level, CUnit* source, float duration, float mult0x0C, float field0x10)

6.1 难度门会替调用方销毁对象

text
if (!affix) return 0;
if (!( (1 << g_pGameClient->+0x1B88) & affix->+0x8C )) {   // 0x7B0001
    affix->vt[0](0);            // MSVC scalar deleting dtor,flag = 0
    allocator->vt[1](affix);    // 释放
    return 0;
}

affix->+0x8C 就是 DAT 里的 DIFFICULTIES_ALLOWED(难度位掩码),g_pGameClient->+0x1B88 是当前难度序号。

CAUTION

被难度拒绝时,本函数会析构并释放你传进来的那个 affix 返回 0 就意味着对象已经不存在了——调用方既不能再解引用它,也不能自己 delete

6.2 四个参数的写入语义不对称

text
if (duration > 0) affix->setDuration(duration);              // 只在 > 0 时设置
if (level != -1)  affix->setLevel(level);                    // 参数是 unsigned int,不是 float
affix->setEffectMult0x0C_ifUnset(mult0x0C);                  // 只改还是哨兵 9999989.0 的子效果
affix->setEffectField0x10_all(field0x10);                    // 无条件覆写全部子效果

倒数第二个参数尊重子效果已设的值,最后一个不尊重。这两个函数分别是 CAffix__setEffectMult0x0C_ifUnset (0x7A4780) 与 CAffix__setEffectField0x10_all (0x7A47F0), 写的正是 CEffect+0x0C(即 SOAKSCALE,同时是暴击率乘数与护甲吸收乘数)和 +0x10

6.3 词缀入表没有任何去重

text
if (this[3] >= this[4]) vec_grow(this + 2);
*(this[2] + 4 * this[3]++) = affix;      // 0x7B0095

linkEffect 完全不同,这一层根本不查重复:同名词缀可以无限堆叠。 去重只发生在它的子效果进入 linkEffect 的那一层(按 EXCLUSIVE + (NAME, TYPE))。

6.4 收尾

text
affix->setSource(source);                  // 改 affix+0x14/+0x18 弱句柄,并递给全部子效果
affix->attachToMgr_applyEffects(mgr);      // ★ 真正施加效果的一步,0x7A49C0
affix->deriveDurationFromEffects();        // duration 仍 ≤ 0 时从子效果推算
CEffectMgr__recomputeStats_239cache(mgr);  // 立即重算属性缓存

按名查询词缀用 CEffectMgr__findAffixByName (0x7AE5F0),比对的是 CAffix+0x24NAME

相关 DAT 用量[AFFIXES] 容器块,原版 16084 个 .DAT / MOD 库 165753 个 .DAT):

原版MOD 库
AFFIX13149206355
AFFIXLEVEL4756119241
TARGET3540120113
DURATION2278768
TARGETTYPE1747388
ADDITIONALDESCRIPTION258217

[AFFIXESREMOVE] 块:AFFIX 原版 867 / MOD 库 42175,TARGET 559 / 16644,AFFIXLEVEL 547 / 12846。 [EFFECTSREMOVE] 块的 EFFECT:原版仅 5 处,MOD 库 1759 处。


7. 239 项属性缓存

CEffectMgr__recomputeStats_239cache (0x7AFE00) 是所有「被动加成到底生效没有」问题的落点。

text
清空 activeUnitThemes (+0x41C)
memset(mgr + 0x60, 0, 0x3BC);            // 0x3BC = 956 = 239 × 4

对 表0(PASSIVE) 与 表1(DYNAMIC) 的每条效果:      // ← 只有两张表
    if (!存活判定 || (flags & 0x20)) continue;
    if (eff->unitTheme) 去重后加入 activeUnitThemes
    v = eff->rolledValue;                                    // +0xD4
    if (DURATION 有限 && TYPE ∈ {6, 7, 123, 124}) v *= 0.016;  // 每帧折算
    mul = (eff->statModifyDef.size != 0 && eff->statSourceType >= 2)
            ? CEffect__evalStatModifier(...) : 1.0;
    statCache[eff->TYPE] += v * mul;

四条要点:

  1. TRANSFER 表不进属性缓存。 循环计数器初值为 2,只覆盖 +0x18+0x28 两张表。
  2. STATMODIFYNAME 想在这条路径上生效,STAT_SOURCE_TYPE 必须是 2 或 3ON UPDATE CASTER / ON UPDATE SELF),判据是 0x7AFF6E*(eff+300) >= 2u。 写了 STATMODIFYNAME 却配了 ON CAST CASTER(0) 或 ON CAST RECEIVER(1),属性倍率在这里不会被套用。
  3. 类型 6、7、123、124 在有限时长下按 × 0.016 折算(约合 1/62.5,即每帧量)。
  4. 缓存是全量重算,不是增量更新。 全 exe 有 27 个调用点,装备变更、词缀增删、逐帧 tick 都会触发。

8. 查询与批量操作接口

函数地址扫表用途与要点
findEffectByTypeAndName0x7ADEE02TYPENAME 同时匹配,返回首个命中
hasEffectWithName0x7AE0902NAME 存在性判定;不做存活/墓碑检查
hasEffectOfType0x7AE1002TYPE 存在性判定;同样不查存活
containsEffect0x7AE1F02指针身份查找,evalEffectValue 的前置门
findAffixByName0x7AE5F0词缀表比对 CAffix+0x24NAME
evalEffectValue0x7AE990求单条效果的当前值;第 1 个参数是死参
sumStatOfType_filtered0x7AEA402带伤害类型与来源过滤的求和,见下
accumMissileReflect0x7AE7102只看 TYPE 60 MISSILE REFLECT,见下
transferEffectsOfTypeTo0x7AF0B0指定 1 张把某类效果拷贝一份施加到另一个单位
removeEnchantments_disenchant0x7AF4803 + 词缀表洗练:删掉带 0x400 位的效果与附魔词缀
purgeUnsavableEffects0x7AF9503删掉所有没打 SAVE 的效果,见第 9 节
snapshotEffectPtrs0x7AFDC03把三张表的效果指针追加进传入的向量
invalidateDescCache0x7ADD60清空 6 个描述串并置 +0x42C = 1

8.1 sumStatOfType_filtered 的两道提前返回

签名是 (typeId, damageType, onlyLocalSource, useCeil)。函数体开头有两条捷径,都很容易咬人:

text
0x7AEA53   if (damageType == 7 && !onlyLocalSource) return statCache[typeId];
0x7AEA74   if (statCache[typeId] == 0.0)            return 0.0;
  • damageType == 7 是「不按伤害类型过滤」的哨兵。合法伤害类型只有 0..6PhysicalAll),7 越界,被借用作通配符。走这条路径时直接返回 239 缓存,根本不扫表。
  • 缓存为 0 就不扫表。这意味着:只要 statCache[typeId] 是 0,带过滤条件的查询也一律返回 0, 哪怕表里确实挂着该类型的效果。而缓存只统计 PASSIVEDYNAMIC 两张表—— 所以一条 TRANSFER 效果既不在缓存里,也无法通过这个函数被查到。

onlyLocalSource 为真时,还要求效果的来源单位非空且 CUnit__isLocallyAuthoritative 为真, 这是联机下的权威端过滤。

8.2 accumMissileReflect 的概率合成

只扫两张表、只看 TYPE 60MISSILE REFLECT)。它把两个百分比就地累加,公式并非简单相加:

text
p_old = *pChance / 100 ;  a_old = *pAmount / 100 ;  p_new = eff->rolledValue / 100
单条反射量 = (evalSlot(eff, 2) + evalSlot(eff, 3)) * 0.005      // (DMGPCTMIN + DMGPCTMAX) / 2 / 100

P = 1 - (1 - p_old) * (1 - p_new)                   // 按独立事件合成,永远不会超过 100%
A = (a_old * p_old + 单条反射量 * p_new) / P         // 按概率加权的均值

*pChance = P * 100 ;  *pAmount = A * 100

槽 2 / 槽 3 的别名来自 EFFECTSLIST.DAT 第 60 行的 VALUE3 / VALUE4 两列,即 DMGPCTMIN / DMGPCTMAX。 所以「反射伤害比例」取的是这两个值的算术平均,不是区间随机。

8.3 transferEffectsOfTypeTo 是拷贝不是搬迁

目标参数 dstUnit 不是另一个 CEffectManager,而是一个带虚表的宿主对象,通过两个虚函数沟通: vt+332 是门卫(返回 bool 决定这条能不能转),vt+336 才真正施加。

原效果留在原表,转过去的是一份新拷贝,且拷贝会被改写:

字段处理
ACTIVATION无条件置为 1 (DYNAMIC)0x7AF178
SAVE0x10清除——转过去的效果不进存档(0x7AF1AD
EXCLUSIVE0x4从原效果继承
LEVEL-1 → 0> 1000 → 0 的净化
槽 0 / 槽 1仅当 TYPE52 (DAMAGE)156 (DAMAGE CHANCE) 且拷贝不带 EXCLUSIVE 时,各乘以 damageScale 并重掷

施加完成后,本函数会把自己那份拷贝析构释放(目标侧自行再拷一份)。

调用方只有 4 处:CCharacter__resolveStrikeHit(2 处)与 CInvContainer__transferItemEffectsOfTypeTo(2 处)——后者是装备进出背包时把物品效果转移到角色身上的路径。

8.4 附魔标记 0x400

removeEnchantments_disenchant 的判据是 CEffect+0x280x400 位,含义是「这条效果是附魔师加的」。

  • 全 exe 唯一写入点CEquipment__markNewEffectsAsEnchanted (0x73E20E)。 它在附魔完成后用 snapshotEffectPtrs 再取一次快照,与附魔前的旧快照逐条比对,给新增的那些打上 0x400
  • 读取点:本函数 0x7AF54DCEquipment__bakeDamageEffectsIntoTable 0x590C28sub_7B0720 0x7B0815sub_7B1180 0x7B12BB
  • CAffix__isEnchantment (0x7A4470) 的定义是「没有子效果,首条子效果带 0x400」。

因此:掉落时摇出来的词缀永远没有这个位,只有附魔后处理会打。洗练之所以只删附魔、不动天生词缀,靠的就是它。


9. 与存档的关系

CEffectManager 自身不进入存档,进存档的是它管理的 CEffect 对象。

SAVE 键置的标志位 0x10 是筛选闸门。管理器侧的消费者是 CEffectMgr__purgeUnsavableEffects (0x7AF950), 它的行为不是判定而是清除

text
for 三张表的每一条效果:
    if (!(eff->flags & 0x10))     // 没有 SAVE 位
        eff->expire(mgr);          // 打墓碑位 0x20
CEffectMgr_expireEffects_perframe(mgr);    // 0x7AF9A4,尾跳,立刻回收

也就是说,存档前这一步会把所有没标 SAVE 的效果直接从单位身上删掉,而不是「跳过不写」。 两个调用方 sub_445520 (0x445679) 与 sub_5B83A0 (0x5B87DB) 都在存档/场景切换路径上。

反序列化时管理器是被重建的:CItemSaveState_read 读出一批 CEffect 之后, 由 CEquipment__loadFromSaveRecord (0x592109) 这类函数重新走挂载流程并触发 recomputeStats_239cache。详细的字节布局见 CEffect 参考手册


10. 速查表

text
对象         CEffectManager : CRunicCore   vtable 0x219AAE0   1240 字节 (0x4D8)
持有者       宿主对象的 +0x1B4,按需惰性创建;每单位/每装备各一个,不是单例
取用         虚表第 99 槽 (CItem 0x514520 / CCharacter 0x51C960)

四张表       +0x18 PASSIVE   +0x28 DYNAMIC   +0x38 TRANSFER   +0x48 旁路表
             索引 = mgr + 0x18 + 0x10 × ACTIVATION,无上界检查
             TRANSFER 表不进属性缓存;旁路表不参与去重也不进属性缓存

挂载         linkEffect 0x7B1D70
             EXCLUSIVE 只看新来者;命中就让旧的过期;新的永远入表,从不被拒
             去重键 = NAME + TYPE,两者必须同时相同

过期         expireEffects_perframe 0x7AF2A0,三张表全查
             DURATION == -900 (INSTANT) 每帧必被移除
             swap-remove,表内顺序不稳定
             eff->+0xD0 非空(属于词缀) → 只摘表不释放

词缀         addAffix 0x7AFFD0
             难度不匹配 → 替调用方析构并释放 affix,返回 0
             入表无去重,同名词缀可无限堆
             mult0x0C 尊重已设值;field0x10 无条件覆写

属性缓存     +0x60..+0x41B,float[239],下标 = 效果类型 ID
             只统计 PASSIVE 与 DYNAMIC 两张表
             STATMODIFY 需 STAT_SOURCE_TYPE >= 2 才参与
             与后续字段零间隙相邻,239 无法扩容

11. 常见排错与排坑指南

Q1:同名效果为什么无限叠加? 去重的前提是新来的那一条EXCLUSIVEflags & 4)。旧效果带不带无关紧要。 DAT 侧确认 EXCLUSIVE:true;运行时构造的效果则要创建方自己置位。

Q2:配了 EXCLUSIVE 还是叠加了? 去重键是 NAME + TYPE,两者必须同时相同。NAME 存的是两次大写后的结果, 但大写只处理字母——空格与下划线的差异照样让它们变成两条不同的效果。 另外检查效果是不是进了旁路表(flags & 0x200000),那里只按指针身份去重。

Q3:STATMODIFYNAME 配了却没有加成? 在属性缓存这条路径上,它需要 STAT_SOURCE_TYPEON UPDATE CASTERON UPDATE SELF。 配成 ON CAST CASTER(也包括拼错后静默落到 0 的情况)时倍率不会被套用。

Q4:ACTIVATION:TRANSFER 的被动加成为什么统计不到? 属性缓存只遍历 PASSIVEDYNAMIC 两张表,TRANSFER 表被排除在外。

Q5:为什么单位身上效果一多,施法就开始卡?linkEffect 阶段四的 UI 分组扫描是个空转循环,最坏情况下迭代次数等于三表效果总数(分组一直匹配不上时)。 这是引擎自身的缺陷,不是数据配置问题。

Q6:Hook 里手工移除了一条效果,之后崩溃在词缀析构上? 检查 eff->+0xD0。非空表示这条效果属于某个 CAffix,释放权在词缀手里; 正确做法是只从表里摘除,或者直接走 CAffix__detachFromHost

Q7:调用 addAffix 返回 0,之后 delete affix 崩了? 返回 0 意味着难度门拒绝,而它已经替你析构并释放了那个对象。返回 0 之后不要再碰该指针。

Q8:想确认某条效果到底有没有被挂上/移除? 打开游戏设置里的 COMBAT LOGremoveEffectsByName 会打印 effect <名字> removed from <单位>,这是引擎自带的观测点。