核心定位:本文讲的是「效果挂上去之后,玩家在状态栏里看到什么」这一段。 它与 CEffectManager 的分工很干净:管理器决定效果存不存在, 这条链路决定效果怎么显示、几个图标、图标上写什么数字。两套判定标准完全不同。

分析依据:基于 IDA 对 32 位可执行文件 Torchlight2.exe(基址 0x400000)的逐指令反汇编分析。文中所有 0x…… 均为绝对内存地址。

关联文档CEffectCAffixCEffectManager


0. 先纠正三个命名

这条链路的资料混乱程度超过前三篇,主要原因是**「CEffectDisplayMgr」这个类在 RTTI 里根本不存在**。 逐个查过 RTTI 类型描述符与 vtable 之后,真实的类只有下面四个:

真名RTTI 描述符vtable实例数职责
CUIEffectList0x289A85C0x219AB70 等 4 张单例,全局指针 g_pEffectDisplayMgr 0x3BAE908状态增益栏本体,持有全部显示分组
CUIEffectUpdating0x289A8780x219ABB4每个显示分组一个一个分组行:一个图标 + 一段文字 + 一组效果
CEffectGroupManager0x289A7F80x219AA80全局单例CGameData+0x4C词缀库,并持有上面那个 CUIEffectList
CEffectDisplayValues0x288A1600x2176844临时值对象(栈上构造)把一条或多条效果折算成「要显示的那几个数」

三条必须记住的纠正:

  1. g_pEffectDisplayMgr 指向的是 CUIEffectList,不是 CEffectGroupManagergetEffectDisplayMgr (0x7B2460) 只有两条指令(mov eax, g_pEffectDisplayMgr; retn), 而写入这个全局的唯一位置是 CUIEffectList__ctor (0x7B2C33)。 IDB 里旧的 CEffectDisplayMgr__ 前缀已统一改成 CUIEffectList__

  2. CEffectGroupManager 是全局单例,而且它的主业是词缀库。CGameData__ctor_loadAllData_STAGE0x64BCA9 构造它,紧接着 0x64BCC3 把它存进 CGameData+0x4C; 构造函数自己还把指针缓存到 dword_3BA14E0(取值函数 getEffectGroupManager 0x7ABC80)。 它的 7 个 growth 为 10 的向量就是词缀池的并行数组+0xA0 才是那个 CUIEffectList

  3. 0x7ACC50CEffectGroupManager 的析构函数,不是构造函数。 构造函数是 0x7ACB00。早期资料把这两个搞反了。

CAUTION

由 2 直接推出一条对 CAffix 参考手册 的更正: CEffectGroupMgr__rollAffixesForItem (0x7AC620) 的 this 不是单位指针,而是这个 CEffectGroupManager 单例。 调用点 CEquipment__applyRandomAffixes 0x404E4A0x404E63 的实际形态是:

text
call sub_6488C0            ; 取 CGameData
mov  eax, [eax+4Ch]        ; → CEffectGroupManager 单例
...
push esi                   ; esi = [item+0x1AC],单位指针是「第一个栈参」
mov  ecx, eax              ; this = 单例
call CEffectGroupMgr__rollAffixesForItem

所以「引擎里没有 CAffixMgr 这个类」这句话字面上仍然成立,但**「词缀池不是单例」的说法是错的**—— 词缀池确实是个全局单例,只不过它的类名叫 CEffectGroupManager


1. 整条链路

text
CEffect 被 linkEffect 挂上
  └─ CEffectMgr__linkEffect 阶段四 (0x7B1ED8)
       └─ getEffectDisplayMgr()                          0x7B2460   取 CUIEffectList 单例
            └─ CUIEffectList__addToMatchingGroup         0x7B46A0
                 ├─ 遍历 CUIEffectList+0x1C 的分组行
                 ├─ 行的宿主 (+0x7C) 必须等于 CEffectManager+0x58
                 ├─ CEffect__sameDisplayGroup            0x7B2470   ★ 分组键判定
                 │    命中 → 把效果的弱引用塞进行的 +0x84 向量
                 └─ 顺手回收行里已死的效果(见第 5 节)

显示数值:
CEffect ──CEffectDisplayValues_build 0x7A8DE0──▶ CEffectDisplayValues(单条)
       └──CEffectDisplayValues 累加器 0x7A87F0──▶ 同一分组多条效果合并

                        g_EffectDisplayKind_239 ─────┴──▶ 决定显示成数值 / 百分比 / 不显示

2. CEffectGroupManager(全局单例)

构造 CEffectGroupManager__ctor (0x7ACB00),析构 CEffectGroupManager__dtor (0x7ACC50)。

偏移量数据类型字段含义默认初始值
+0x00ptr虚函数表指针0x219AA80
+0x04intCRunicCore 基类字段0
+0x1Cptr按单位类型 id 索引的候选缓存红黑树根0
+0x30+0x3Cvector词缀记录(文件路径)空,growth 10
+0x40+0x4Cvector<CAffix*>词缀原型,数量取 +0x44空,growth 10
+0x50+0x5Cvector生成等级范围对 (min, max),交错存放空,growth 10
+0x60+0x6Cvector<int>WEIGHT空,growth 10
+0x70+0x7Cvector[UNITTYPES] id 表空,growth 10
+0x80+0x8Cvector[NOT_UNITTYPES] id 表空,growth 10
+0x90+0x9Cvector<int>DIFFICULTIES_ALLOWED空,growth 10
+0xA0ptrCUIEffectList*,构造时 alloc(88) 并就地构造0x7ACBE2

构造尾部还会 sub_7ABED0(this, L"MEDIA/AFFIXES.RAW") (0x7ACC21) 载入词缀索引缓存。 七个向量的消费端见 CAffix 参考手册 第 6 节。


3. CUIEffectList(增益栏本体)

alloc(88) = 88 字节 (0x58),构造 CUIEffectList__ctor (0x7B2B90),析构 CUIEffectList__dtor (0x7B2C90)。 它是多重继承:iListWindowControlleriTooltipiUIMenuListener 各占一个 vtable 槽。

偏移量数据类型字段含义默认初始值
+0x00ptr主 vtable0x219AB70
+0x04intCRunicCore 基类字段0
+0x08ptriListWindowController 子对象 vtable0x219AB7C
+0x0CptriTooltip 子对象 vtable0x219AB98
+0x10iTooltip 子对象的其余部分
+0x14ptriUIMenuListener 子对象 vtable0x219ABA8
+0x18ptr自身持有的多态对象(析构时虚析构 + 释放)0
+0x1C+0x28vector<CUIEffectUpdating*>显示分组行空,growth 25
+0x2C+0x38vector<CUIEffectUpdating*>回收行的空闲池acquireRow 从这里取、releaseRow 还回来空,growth 25
+0x3Cbyte布局脏标记;经 iTooltip 虚函数 CUIEffectList__iTooltip_isDirty (0x7B2C50) 对外暴露0
+0x40/+0x44ptr + cookietooltip 宿主窗口的弱引用;tick 里靠 CWidget_isVisible 校验0 / −1
+0x48/+0x4Cptr + cookie弱引用对,updateRowTimer 会顺带同步它指向控件的文本0 / −1
+0x50ptr当前 tooltip 指向的那一行0
+0x54float0.5 秒节流计时器tick 每帧减 dt,小于 0 时做一次全量刷新并重置为 0.50.0

析构函数把 g_pEffectDisplayMgr 置 0 (0x7B2D23),并用 vec_destroyAllElements 逐个销毁 +0x2C+0x1C 两个向量的元素。


4. CUIEffectUpdating(一个分组行)

alloc(0xC0) = 192 字节,工厂在 sub_7B3340 (0x7B3374 压入 0C0h), 构造 CUIEffectUpdating__ctor (0x7B3100),重置 sub_7B2AB0,析构 CUIEffectUpdating__dtor (0x7B31D0)。

偏移量数据类型字段含义默认初始值
+0x00ptr虚函数表指针0x219ABB4
+0x04intCRunicCore 基类字段0
+0x08byte本行需立即刷新的脏位;tick 检到就清零并调 updateRowTimer1
+0x0Cwstring 28B图标资源名,由 resolveIconAndBuildWidgets 三级回退解析而来
+0x28wstring 28B文本槽(buildRow_* 写入的标题/描述)
+0x44wstring 28B文本槽
+0x60wstring 28B当前显示文本缓存,与新算出的文本比对以决定是否重排
+0x7C/+0x80ptr + cookie本行归属的宿主单位,与 CEffectManager+0x58 比对0 / −1
+0x84+0x90vector本行收纳的效果,元素是 8 字节的 {CEffect*, cookie} 弱引用块空,growth 10
+0x94/+0x98ptr + cookie父 CEGUI 窗口;为空则整行被回收0 / −1
+0x9C/+0xA0ptr + cookie另一个 CEGUI 窗口;pruneRowsAndFlagDirtysub_71CBC0 校验其存活0 / −1
+0xA4ptr名为 ICON 的子控件0
+0xA8ptr名为 TIME 的子控件(倒计时文本)0
+0xACint图标的 intern 字符串 id(第三级回退用)−1
+0xB0/+0xB4u64来源技能的 UNIQUE_GUID(第一级回退用)−1 / −1
+0xB8/+0xBCu64可生成物的 GUID(第二级回退用)−1 / −1

NOTE

行里存的不是 CEffect* 裸指针,而是 8 字节的弱引用块addToMatchingGroupalloc(8) 建块,sub_4059A0 注册拿 cookie(0x7B47C6),效果销毁时块里的指针会自动失效。 这就是为什么效果被别处释放掉,增益栏不会立刻崩——但块本身仍需回收,见下一节。

+0x20 不是独立字段,而是 +0x0C 那个 wstring 的长度域。buildRow_*cmp dword ptr [ebx+20h], 00x7B3B6D)实际判的是「图标名解析出来了吗」—— 连同 +0xA4 非空,构成一行能否进入活动行向量的两个前置条件。

4.1 C++ 声明

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

cpp
// 弱引用块:引擎到处在用的 (指针, 注册号) 对
struct WeakRef {                            // sizeof == 8
    void* ptr;                              // +0x00
    int   cookie;                           // +0x04  初值 -1
};

// RTTI: CEffectGroupManager   vtable @ 0x219AA80   全局单例,存 CGameData+0x4C
struct CEffectGroupManager : CRunicCore {
    /* +0x08 */ int                  field_08;
    /* +0x0C */ int                  _gap0C;
    /* +0x10 */ int                  field_10[3];
    /* +0x1C */ void*                candidateCacheTree;   // 按单位类型 id 索引
    /* +0x20 */ int                  _gap20[4];
    /* +0x30 */ core_vector<void*>   affixRecords;         // 词缀记录(路径)
    /* +0x40 */ core_vector<CAffix*> affixPrototypes;      // 数量在 +0x44
    /* +0x50 */ core_vector<int>     spawnRangePairs;      // (min, max) 交错
    /* +0x60 */ core_vector<int>     weights;
    /* +0x70 */ core_vector<void*>   unitTypes;
    /* +0x80 */ core_vector<void*>   notUnitTypes;
    /* +0x90 */ core_vector<int>     difficultiesAllowed;
    /* +0xA0 */ CUIEffectList*       uiEffectList;         // 构造时 alloc(88) 就地构造
};

// RTTI: CUIEffectList   vtable @ 0x219AB70   单例,g_pEffectDisplayMgr @ 0x3BAE908
struct CUIEffectList : CRunicCore {
    /* +0x08 */ void**  vt_iListWindowController;
    /* +0x0C */ void**  vt_iTooltip;
    /* +0x10 */ int     iTooltip_rest;
    /* +0x14 */ void**  vt_iUIMenuListener;
    /* +0x18 */ void*   ownedObject;                        // 析构时虚析构 + 释放
    /* +0x1C */ core_vector<CUIEffectUpdating*> rows;        // 活动行,growth 25
    /* +0x2C */ core_vector<CUIEffectUpdating*> rowPool;     // 回收行的空闲池,growth 25
    /* +0x3C */ unsigned char layoutDirty;                   // iTooltip::isDirty 暴露的就是它
    /* +0x3D */ unsigned char _pad3D[3];
    /* +0x40 */ WeakRef tooltipWindow;                       // tooltip 宿主窗口
    /* +0x48 */ WeakRef auxWidget;                           // updateRowTimer 会同步它的文本
    /* +0x50 */ CUIEffectUpdating* tooltipRow;               // 当前 tooltip 指向的行
    /* +0x54 */ float   refreshTimer;                        // 0.5 秒节流
};

// RTTI: CUIEffectUpdating   vtable @ 0x219ABB4   每个显示分组一个
struct CUIEffectUpdating : CRunicCore {
    /* +0x08 */ unsigned char  needsRefresh;                // tick 检到就清零并刷新本行
    /* +0x09 */ unsigned char  _pad09[3];
    /* +0x0C */ wstring28      iconName;                    // 三级回退解析出来的图标资源名
    /* +0x28 */ wstring28      text1;
    /* +0x44 */ wstring28      text2;
    /* +0x60 */ wstring28      cachedDisplayText;           // 与新算出的文本比对
    /* +0x7C */ WeakRef        hostUnit;                    // == CEffectManager+0x58
    /* +0x84 */ core_vector<WeakRef*> effects;              // 每个元素是 alloc(8) 的弱引用块
    /* +0x94 */ WeakRef        parentWindow;                // 为空则整行被回收
    /* +0x9C */ WeakRef        auxWindow;
    /* +0xA4 */ void*          iconWidget;                  // 子控件 "ICON"
    /* +0xA8 */ void*          timeWidget;                  // 子控件 "TIME"
    /* +0xAC */ int            iconStringId;                // 回退三:intern id
    /* +0xB0 */ unsigned __int64 sourceSkillGuid;           // 回退一:来源技能 UNIQUE_GUID
    /* +0xB8 */ unsigned __int64 spawnableGuid;             // 回退二:可生成物 GUID
};

// RTTI: CEffectDisplayValues   vtable @ 0x2176844   临时值对象
struct CEffectDisplayValues : CRunicCore {
    /* +0x08 */ float duration;        // 来源效果的 DURATION
    /* +0x0C */ float valueMax;        // 主数值
    /* +0x10 */ float valueMin;        // 次数值
    /* +0x14 */ float slot2;           // 效果的槽 2
    /* +0x18 */ float slot3;
    /* +0x1C */ float slot4;
    /* +0x20 */ unsigned char hasDuration;
    /* +0x21 */ unsigned char _pad21[3];
    /* +0x24 */ int   typeId;          // 效果类型
    /* +0x28 */ CEffect* source;       // 贡献者(累加时是时长最长的那条)
};

static_assert(sizeof(WeakRef)              == 0x08, "");
static_assert(sizeof(CUIEffectList)        == 0x58, "CUIEffectList = 88 字节");
static_assert(sizeof(CUIEffectUpdating)    == 0xC0, "CUIEffectUpdating = 192 字节");
static_assert(offsetof(CEffectGroupManager, uiEffectList) == 0xA0, "");
static_assert(offsetof(CUIEffectUpdating, cachedDisplayText) == 0x60, "");
static_assert(offsetof(CUIEffectUpdating, effects) == 0x84, "");
static_assert(offsetof(CUIEffectUpdating, iconStringId) == 0xAC, "");

4.2 函数面

整簇 28 个函数已逐指令核实并命名。四张 vtable 的归属由构造函数 0x7B2BDD0x7B2BF0 的四条 mov 确定,析构 thunk 的 sub ecx, N 也与之一致:

子对象偏移接口vtable槽 0(析构)
+0x00CUIEffectList 本体0x219ABA8_2CUIEffectList__scalarDeletingDtor 0x7B3320
+0x08iListWindowController0x219AB98_1thunk sub ecx,8 0x7B2C70
+0x0CiTooltip0x219AB7C_0thunk sub ecx,0Ch 0x7B2C80
+0x14iUIMenuListener0x219AB70(无后缀)thunk sub ecx,14h 0x7B2C60

NOTE

MSVC 给 vtable 的 _0 / _1 / _2 后缀顺序与子对象偏移顺序不一致—— 无后缀的 ??_7CUIEffectList@@6B@ 实际是 +0x14 处那张。以 thunk 的调整量为准,别按名字猜。

行的生命周期(对象池)

函数地址作用
CUIEffectList__acquireRow0x7B3340空闲池非空就从池里取,否则 alloc(192) + 构造
CUIEffectList__releaseRow0x7B33E0解开三个子控件、reset 该行、从活动行摘掉、放回空闲池
CUIEffectUpdating__reset0x7B2AB0注销三组弱引用、清四个 wstring、五个 id 字段回 −1、脏位置 1
CUIEffectList__releaseRowsForWindow0x7B3710按父窗口批量回收

建行的三条分支——与 sameDisplayGroup 的三级回退一一对应:

函数地址触发条件额外从 dat 读的键
CUIEffectList__buildRow_skill0x7B3C60效果有来源技能,或 GUID2 解析为技能DISPLAYNAMEDESCRIPTION
CUIEffectList__buildRow_spawnable0x7B37D0GUID2 解析为可生成物DISPLAYNAME
CUIEffectList__buildRow_iconOnly0x7B3A20以上都不成立但 ICON id 有效

三者都走同一条尾巴:acquireRowCUIEffectUpdating__resolveIconAndBuildWidgets (0x7B2E10) → 成功则入活动行、失败则 releaseRow

逐帧与重建

函数地址作用
CUIEffectList__tick0x7B4C90每帧:syncDirtyListWindowspruneRowsAndFlagDirty → 刷新带脏位的行;refreshTimer 到点后做全量刷新与 tooltip 维护
CUIEffectList__syncDirtyListWindows0x7B34B0每帧扫全部列表窗,把脏的那些推去重建,见第 4.3 节
CUIEffectList__pruneRowsAndFlagDirty0x7B4980每帧行级回收:宿主/父窗口失效、行内效果全死 → 回收整行;块级回收后重算文本并标脏
CUIEffectList__updateRowTimer0x7B24E0刷新 TIME 控件;返回 0 表示该行已空
CUIEffectList__rebuildFromManager0x7B4030从一个 CEffectManager 全量重建,遍历三张表;收尾清掉 CEffectManager+0x42C
CUIEffectList__rebuildForWindow0x7B4E50解析出容器控件后转调上面那个
CUIEffectList__iListCtrl_loadLayoutAndRebuild0x7B5310载入 MEDIA/UI/PIECES/EFFECT.LAYOUT 再重建
CUIEffectList__iListCtrl_onWindowGone0x7B37A0窗口销毁时批量回收挂在它上面的行
CUIEffectList__iTooltip_build0x7B4EB0组装 tooltip:填 TITLE / DESCRIPTION / ICON / TIME 四个控件;永久效果的时长显示为 Always
CUIEffectList__iTooltip_isDirty0x7B2C50返回 +0x3Cmov al,[ecx+30h]ecx+0x0C 子对象)
CUIEffectUpdating__buildRowText0x7B42D0生成整行文本,见第 7.3 节

NOTE

CEffectMgr__addEffectsFromDatNode (0x7B2300) 地址落在这一簇里,但它其实是 CEffectManager 侧的 「从 dat 节点批量添加 [EFFECT]」入口(调 CDataGroup__collectChildBlocksNamedCEffectMgr__invalidateDescCache,被 sub_513A80 调用),与增益条分组逻辑无关。

地址更高处的 0x7B55F0 等函数读的是 STAT / DEFAULTVALUE / STACKABLE / REFRESHES 这类键, 属于另一个子系统,只是地址相邻,不属于本簇

4.3 脏标记是怎么变成一次重建的

CUIEffectList__syncDirtyListWindows (0x7B34B0) 是整条链路里最后一块拼图: 它把 CEffectManager+0x42C(描述缓存脏标记)翻译成一次真正的 UI 重建。 每帧由 tick 第一个调用,用两个文件级静态向量(惰性初始化 + atexit 析构,growth 均为 10)当暂存:

text
A.clear();  WidgetRegistry__collectAll(注册表, &A);  B.clear()
for w in A:
    lw = dynamic_cast<CWidgetListWindow*>(w)          // 从 CWidgetWrapper 向下转型,转不了就跳过
    if (!lw->window(+0x60)->isVisible()) { lw->needsRebuild = 1; continue }
    unit = CWidgetWrapper__resolveBoundUnit(lw)       // 0x71CBC0,解析该控件绑定的单位
    if (unit && unit->getEffectMgr() /*虚表槽 99*/) {
        mgr = unit->getEffectMgr()
        if (mgr->descDirty(+0x42C) || lw->needsRebuild) {     // 0x7B3640
            lw->needsRebuild = 0
            B.push_back(mgr)
            sub_71CC90(lw, 0);  sub_720DE0(lw, 1)             // 让该列表窗重建
        }
    } else {
        iListCtrl_onWindowGone(lw)                    // 绑定单位没有效果管理器 → 回收它的全部行
        lw->needsRebuild = 1
    }
for mgr in B: mgr->descDirty = 0                      // 0x7B36F4,统一清脏

两个要点:

  • CWidgetWrapper+0xDC 是「本控件需重建」标记,setter 就一句 mov [ecx+0DCh], eaxCWidgetWrapper__setNeedsRebuild 0x70F100)。控件被隐藏时、或绑定单位没有效果管理器时置 1, 重建完成时清 0。这就是「增益栏隐藏再显示会自己重建」的机制——隐藏期间攒下的变更不会丢。
  • CEffectManager+0x42C 有两处被清:本函数的 0x7B36F4,以及 CUIEffectList__rebuildFromManager 尾部的 0x7B41C0。前者是「批量收尾清」,后者是「谁重建谁清」。

于是 CEffectManager 第 4.6 节 那个悬着的问题有了答案: linkEffectinvalidateDescCache 只负责脏标记,把它变成重建动作的是这里; 至于 +0x430 / +0x484 那六个 wstring,它们的填充代码也已定位: CEffectMgr__buildDescCacheA (0x7B0720) 与 CEffectMgr__buildDescCacheB (0x7B1180), 按 基址 + 28 × 表索引 寻址、带惰性缓存门,唯一消费者是物品 tooltip 的 buildEffectDescForTooltip (0x57F780)。详见 CEffectManager 第 2 节 的字段表注解。


5. 分组键:sameDisplayGroup

CEffect__sameDisplayGroup (0x7B2470) 是 __stdcall,两个 CEffect*

text
if (a->ownerSkill && b->ownerSkill)
    return a->ownerSkill->UNIQUE_GUID == b->ownerSkill->UNIQUE_GUID;   // CSkill+0x1B8/+0x1BC

// 没有来源技能时,退到效果自己缓存的那份:
if ((a->GUID2_lo & a->GUID2_hi) != -1 && a->GUID2 == b->GUID2) return 1;   // +0x38/+0x3C
if (a->iconStringId != -1 && a->iconStringId == b->iconStringId) return 1; // +0x2C
return 0;

IMPORTANT

UI 分组键 = 来源技能的 UNIQUE_GUIDEXCLUSIVE 去重键 = (NAME, TYPE)。两套标准毫不相干。

直接后果:

  • 同名同类型但来自不同技能的两条效果,能同时存在(EXCLUSIVE 只在同一 (NAME, TYPE) 内起作用), 而且在增益栏上显示为两个独立图标
  • 同一技能产出的不同名效果,会被归进同一个图标,数值按第 6 节的规则合并。
  • 三条判据是依次回退的:有技能就只看技能 GUID;无技能才看 GUID2GUID2 也无效才看图标 id。 这意味着两条毫不相干的效果只要用了同一个 ICON,在没有来源技能时会被并成一组

第三条回退路径解释了一个常见现象:运行时代码直接构造的效果(CEffect__ctorRuntime,没有来源技能、 GUID2 为 −1/−1)如果共用图标,就会挤在同一个增益图标里。想让它们分开,给它们不同的 ICON


6. addToMatchingGroup 的双重职责

CUIEffectList__addToMatchingGroup(this, CEffect* newEff, CEffectManager* mgr) (0x7B46A0) 返回 char:1 表示已并入某个分组,0 表示没有匹配的分组。

它的循环干了两件事,读代码时容易只看见前一件:

职责一:并入分组。

text
if (!mgr) return 0;
for 每个分组行 row in this->rows:
    if (row->hostUnit != mgr->owner) continue;          // 0x7B4714,宿主必须一致
    for 每个弱引用块 blk in row->effects:
        if (!blk->ptr) continue;                        // 效果已失效
        if (还没匹配上 && sameDisplayGroup(newEff, blk->ptr)):
            newBlk = alloc(8); 注册弱引用; row->effects.push_back(newBlk)
            sub_7B24E0(row)                             // 刷新该行(需 row->+0x94 与 row->+0xA8 均非空)
            若 this->layoutDirty == 0:
                重新生成该行文本 (sub_7B42D0),与 row->cachedDisplayText 比对
                不同 → this->layoutDirty = 1
                       且 g_GameSingleton->+0x24A = 1   // 0x7B4849,全局 UI 刷新标志
            标记已匹配

职责二:顺手回收死效果。 同一个内层循环里,对每一个遍历到的弱引用块做存活判定:

text
if ( (时长已耗尽 && ACTIVATION != TRANSFER) || (flags & 0x20 /*墓碑*/) ):
    注销弱引用 (sub_405950),释放那 8 字节块,从 row->effects 里 swap-remove,--i

存活判定与 CEffectManager 用的是同一套(DURATION-900/-1000ACTIVATION == 2 视为永久, 否则比 DURATION 与已过时间),加上墓碑位 0x20

WARNING

这解释了 CEffectManager 第 4.4 节 那个「空转循环」为什么不是纯浪费—— 但也说明它确实是缺陷linkEffect 把这个函数调用「三表效果总数」次, 每次都会完整遍历所有分组行与行内所有效果。单位身上效果越多,每挂一条新效果的开销就越大, 而回收工作第一次调用就已经做完了。


7. 数值怎么算出来

7.1 单条效果:CEffectDisplayValues_build

CEffectDisplayValues_build(effect, out, mgr, levelOverride, useCached) (0x7A8DE0):

text
if (levelOverride != -1 && levelOverride != effect->LEVEL && !useCached)
    CEffect__setLevel(effect, levelOverride);      // 0x7A8E0F ← 技能面板「下一级」预览就靠这个
if (useCached) 取 effect->+0xD4(已算好的值)
else           按 MIN 与 MAX 各走一遍完整公式(与 rollAndCacheValue 同构)

两条容易踩的规则:

  • DISPLAYMAXMODIFIER(标志位 0x40000)的真实作用是「显示时把属性倍率强制成 1.0」0x7A8ED50x7A9005 两处 if (flags & 0x40000) statMul = 1.0)。 它不改变效果的实际数值,只让 tooltip 显示不含属性加成的裸值。
  • TYPE 为 6 / 7 / 123 / 124(四种回复类型)且时长有限时,显示值 = 值 × DURATION × 0.0160x7A9148)。也就是整段时长内的总量,而不是每帧量。 注意这与 CEffectManager 第 7 节 属性缓存里的 × 0.016(每帧量)不同: 同一个系数,一处乘了时长,一处没乘。

7.2 多条效果合并

同一分组内多条效果由累加器 0x7A87F0 汇总进一个 CEffectDisplayValues

text
out->valueMax += 本条的主数值
out->valueMin += 本条的次数值
out->slot2/slot3/slot4 += 本条对应的槽
out->duration = max(out->duration, 本条的 DURATION)      // 取最长
out->source   = 时长最长的那一条
out->typeId   = 最后一条的 TYPE

所以增益栏上一个图标显示的数字是该分组内所有效果的和,而倒计时取其中最长的时长

7.3 整行文本与倒计时

CUIEffectUpdating__buildRowText (0x7B42D0) 生成的不是一行而是多行

text
清空输出
收集本行所有还活着的效果(顺手回收失效的弱引用块)
while (还有未处理的效果) {
    取第一条的 (TYPE, DAMAGE_TYPE) 作为本批的键
    把所有同键的效果累加进一个 CEffectDisplayValues,并从待处理集里移除
    desc = CEffect__build_tooltip_desc(累加结果)
    输出非空则 输出 = 输出 + "
" + desc,否则 输出 = desc
}

也就是说同一个图标下,每个 (TYPE, DAMAGE_TYPE) 组合各占一行。 一个图标挂了 5 条效果、分属 3 种类型,tooltip 上就是 3 行。

倒计时由 CUIEffectList__updateRowTimer (0x7B24E0) 写进 TIME 控件:

text
maxRemain = 本行所有效果中 (DURATION − elapsed) 的最大值
            (DURATION 为 -900/-1000 或 ACTIVATION == TRANSFER 的记作 0)
若 maxRemain 仍为 0 且效果有所属词缀 → 退到 affix->+0xAC(词缀自己的 DURATION)
本行没有效果        → TIME 控件隐藏,返回 0(调用方据此回收整行)
maxRemain <= 0      → TIME 控件隐藏
否则 secs = round(maxRemain):
    secs <  60      → "<秒数><秒后缀>"
    secs <= 3600    → "<ceil(secs/60)><分后缀>"
    secs >  3600    → "<ceil(secs/3600)><时后缀>"
文本与控件当前内容不同才调 CWidgetWrapper__setText

三个单位后缀是从本地化字符串表惰性取的静态 wstring,用 atexit 注册析构。 分钟与小时都是向上取整,所以一个 61 秒的效果显示的是「2 分」而不是「1 分」。

7.4 g_EffectDisplayKind_239:显示成数值还是百分比

g_EffectDisplayKind_239 (0x28A1D80) 是又一张 239 项的表, 由 EffectsList_dat_load_239 在启动时按 EFFECTSLIST.DAT 每行的 TYPE填充 (0x401748 取值,默认实参是字符串 "VALUE"):

EFFECTSLIST.DATTYPE表值原版行数引擎实际行为
VALUE(或缺省、或任何写错的值)0166走数值格式化
PERCENT159走数值格式化——与 0 完全等价
NONE214跳过好/坏方向判定

TYPE:NONE 的 14 行都是「没有数值可显示」的效果,例如 43 KNOCK BACK EFFECT45 OPEN WAYPOINT PORTAL46 IDENTIFY57 TRANSFORM58 UNIT THEME。 读取点是 CEffect__build_tooltip_desc (0x7A71D4),判据是 == 2

WARNING

PERCENTVALUE 对引擎没有任何区别,百分号不是这一列画出来的。 按立即数字节扫描 0x4010000x2200000g_EffectDisplayKind_239 全程只出现 3 次: 两处写入(0x4017920x4017B7)加一处读取(0x7A71D4), 而那唯一的读取是 cmp …, 2——只区分「是不是 NONE」,从不区分 0 和 1。

真正的百分号来自 GOODDES / BADDES 等描述串里写死的字面 %。 实测:原版有 56 行标着 TYPE:Value,描述里却带 %33 PERCENT BLOCK CHANCE55 CRITICAL CHANCE64 STUN68 XP GAIN BONUS 等), 反过来 44 PERCENT ACTIVE DISTANCE BONUS 标着 Percent 而四个描述串全是 NA。 所以这一列在原版数据里更接近一份给人看的分类注记,改它不会改变任何显示结果。 完整 239 行的这一列取值见 239 个 TYPE 的数值槽对照表

CAUTION

这个 TYPEEFFECTSLIST.DAT 自己的列,和技能/词缀 dat 里 [EFFECT] 块的 TYPE 键是两个完全不同的东西。 前者取值只有 VALUE / PERCENT / NONE 三种,决定显示形态; 后者是效果类型名,决定效果做什么。两者同名不同义。

7.5 SHOW_IN_BUFFLIST

这是 [SKILL] 块的一个 <BOOL> 键,被两处以不同默认值分别读取

读取点默认实参落点
CSkill__ctorFromDat 0x6C2FC2当前值CSkill+0x59 的原值)写回 CSkill+0x59
sub_7B4030 0x7B422F1(true)只作为局部判定,不落字段

也就是说显示侧那次读取不看已加载的 CSkill+0x59,而是重新解析 dat 并默认为真。 两处的默认值不一致,是这条链路上最容易造成「配了却不生效/没配却显示」的地方。

实测用量<BOOL>SHOW_IN_BUFFLIST 的出现次数):

原版MOD 库
false952133
true015

原版只用它来隐藏,一次都没有显式写 true——因为显示侧的默认值本来就是真,写 true 是冗余的。 MOD 库里那 15 处 true 同样是冗余写法。


8. 速查表

text
类与实例
──────────────────────────────────────────────────────────────────────
CUIEffectList        88 字节   单例,g_pEffectDisplayMgr @0x3BAE908
                               ← 「CEffectDisplayMgr」这个名字实际指的就是它
CUIEffectUpdating    192 字节  每个显示分组一个(一个图标 = 一行)
CEffectGroupManager  —         全局单例 @CGameData+0x4C,主业是词缀库,+0xA0 挂 CUIEffectList
CEffectDisplayValues —         临时值对象,栈上构造,字段用到 +0x28

分组键(与 EXCLUSIVE 无关)
──────────────────────────────────────────────────────────────────────
1. 两条都有来源技能 → 比来源技能的 UNIQUE_GUID (CSkill+0x1B8)
2. 否则             → 比效果自己的 GUID2 (+0x38/+0x3C)
3. 都不行           → 比 ICON 的 intern id (+0x2C)     ← 共用图标会被并组

数值显示形态:EFFECTSLIST.DAT 每行的 TYPE 列
──────────────────────────────────────────────────────────────────────
VALUE (166 行) → 绝对数值    PERCENT (59 行) → 百分比    NONE (14 行) → 不显示
注意:这个 TYPE 与 [EFFECT] 块的 TYPE 键同名不同义

两条显示专用规则
──────────────────────────────────────────────────────────────────────
DISPLAYMAXMODIFIER (0x40000)  → 显示时属性倍率强制为 1.0,不影响实际数值
TYPE 6/7/123/124 + 有限时长   → 显示值 = 值 × DURATION × 0.016(整段总量)

一个分组内多条效果
──────────────────────────────────────────────────────────────────────
数值求和;时长取最长;source 记时长最长的那一条

9. 常见排错

Q1:两个本该合并的增益显示成了两个图标。 分组键是来源技能的 UNIQUE_GUID,不是名字。检查两条效果是不是来自不同技能。 同一技能的不同 [LEVELn] 共用同一个 UNIQUE_GUID,所以升级不会拆图标; 但复制技能文件时若忘了改 UNIQUE_GUID,两个技能会被错误地并成一组。

Q2:两个毫不相干的效果被塞进了同一个图标。 走到了第三条回退路径:都没有来源技能且 GUID2 无效时,只比 ICON 的 intern id。 给它们不同的 ICON 即可分开。

Q3:配了 EXCLUSIVE 却还是两个图标。EXCLUSIVE 管的是「效果能不能同时存在」,跟显示分组是两套判定。 效果真的被去重了的话图标自然只有一个;有两个图标说明两条效果都还在, 去查 (NAME, TYPE) 是否真的完全一致(见 CEffectManager 第 4.2 节)。

Q4:增益图标上的数字比预期大。 一个图标是一个分组,数字是该分组内所有效果的。分组内有几条,用第 5 节的规则数。

Q5:回复类效果的 tooltip 数字对不上。TYPE 为 6 / 7 / 123 / 124 且时长有限时,显示的是 值 × DURATION × 0.016,即整段时长的总量。

Q6:tooltip 数值不含我预期的属性加成。 检查是不是打了 DISPLAYMAXMODIFIER——它在显示路径上把属性倍率强制成 1.0。

Q7:TYPE:NONE 的效果为什么不显示数值? 那是 EFFECTSLIST.DAT 该行的 TYPE 列写了 NONE(原版 14 行如此), g_EffectDisplayKind_239 记为 2,build_tooltip_desc 据此跳过数值。 要改就得改 EFFECTSLIST.DAT 那一行——但不能改行序或行数(见 CEffect)。

Q8:单位身上效果一多,施法就卡。linkEffect 会把 addToMatchingGroup 调用「三表效果总数」次, 而每次调用都完整遍历所有分组行。这是引擎缺陷,不是数据问题(第 6 节)。