核心定位:本文讲的是「效果挂上去之后,玩家在状态栏里看到什么」这一段。 它与 CEffectManager 的分工很干净:管理器决定效果存不存在, 这条链路决定效果怎么显示、几个图标、图标上写什么数字。两套判定标准完全不同。
分析依据:基于 IDA 对 32 位可执行文件
Torchlight2.exe(基址0x400000)的逐指令反汇编分析。文中所有0x……均为绝对内存地址。关联文档:CEffect、CAffix、CEffectManager。
0. 先纠正三个命名
这条链路的资料混乱程度超过前三篇,主要原因是**「CEffectDisplayMgr」这个类在 RTTI 里根本不存在**。 逐个查过 RTTI 类型描述符与 vtable 之后,真实的类只有下面四个:
| 真名 | RTTI 描述符 | vtable | 实例数 | 职责 |
|---|---|---|---|---|
CUIEffectList | 0x289A85C | 0x219AB70 等 4 张 | 单例,全局指针 g_pEffectDisplayMgr 0x3BAE908 | 状态增益栏本体,持有全部显示分组 |
CUIEffectUpdating | 0x289A878 | 0x219ABB4 | 每个显示分组一个 | 一个分组行:一个图标 + 一段文字 + 一组效果 |
CEffectGroupManager | 0x289A7F8 | 0x219AA80 | 全局单例,CGameData+0x4C | 词缀库,并持有上面那个 CUIEffectList |
CEffectDisplayValues | 0x288A160 | 0x2176844 | 临时值对象(栈上构造) | 把一条或多条效果折算成「要显示的那几个数」 |
三条必须记住的纠正:
g_pEffectDisplayMgr指向的是CUIEffectList,不是CEffectGroupManager。getEffectDisplayMgr(0x7B2460) 只有两条指令(mov eax, g_pEffectDisplayMgr; retn), 而写入这个全局的唯一位置是CUIEffectList__ctor(0x7B2C33)。 IDB 里旧的CEffectDisplayMgr__前缀已统一改成CUIEffectList__。CEffectGroupManager是全局单例,而且它的主业是词缀库。CGameData__ctor_loadAllData_STAGE在0x64BCA9构造它,紧接着0x64BCC3把它存进CGameData+0x4C; 构造函数自己还把指针缓存到dword_3BA14E0(取值函数getEffectGroupManager0x7ABC80)。 它的 7 个 growth 为 10 的向量就是词缀池的并行数组,+0xA0才是那个CUIEffectList。0x7ACC50是CEffectGroupManager的析构函数,不是构造函数。 构造函数是0x7ACB00。早期资料把这两个搞反了。
CAUTION
由 2 直接推出一条对 CAffix 参考手册 的更正: CEffectGroupMgr__rollAffixesForItem (0x7AC620) 的 this 不是单位指针,而是这个 CEffectGroupManager 单例。 调用点 CEquipment__applyRandomAffixes 0x404E4A–0x404E63 的实际形态是:
call sub_6488C0 ; 取 CGameData
mov eax, [eax+4Ch] ; → CEffectGroupManager 单例
...
push esi ; esi = [item+0x1AC],单位指针是「第一个栈参」
mov ecx, eax ; this = 单例
call CEffectGroupMgr__rollAffixesForItem所以「引擎里没有 CAffixMgr 这个类」这句话字面上仍然成立,但**「词缀池不是单例」的说法是错的**—— 词缀池确实是个全局单例,只不过它的类名叫 CEffectGroupManager。
1. 整条链路
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)。
| 偏移量 | 数据类型 | 字段含义 | 默认初始值 |
|---|---|---|---|
+0x00 | ptr | 虚函数表指针 | 0x219AA80 |
+0x04 | int | CRunicCore 基类字段 | 0 |
+0x1C | ptr | 按单位类型 id 索引的候选缓存红黑树根 | 0 |
+0x30…+0x3C | vector | 词缀记录(文件路径) | 空,growth 10 |
+0x40…+0x4C | vector<CAffix*> | 词缀原型,数量取 +0x44 | 空,growth 10 |
+0x50…+0x5C | vector | 生成等级范围对 (min, max),交错存放 | 空,growth 10 |
+0x60…+0x6C | vector<int> | WEIGHT | 空,growth 10 |
+0x70…+0x7C | vector | [UNITTYPES] id 表 | 空,growth 10 |
+0x80…+0x8C | vector | [NOT_UNITTYPES] id 表 | 空,growth 10 |
+0x90…+0x9C | vector<int> | DIFFICULTIES_ALLOWED | 空,growth 10 |
+0xA0 | ptr | CUIEffectList*,构造时 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)。 它是多重继承:iListWindowController、iTooltip、iUIMenuListener 各占一个 vtable 槽。
| 偏移量 | 数据类型 | 字段含义 | 默认初始值 |
|---|---|---|---|
+0x00 | ptr | 主 vtable | 0x219AB70 |
+0x04 | int | CRunicCore 基类字段 | 0 |
+0x08 | ptr | iListWindowController 子对象 vtable | 0x219AB7C |
+0x0C | ptr | iTooltip 子对象 vtable | 0x219AB98 |
+0x10 | — | iTooltip 子对象的其余部分 | — |
+0x14 | ptr | iUIMenuListener 子对象 vtable | 0x219ABA8 |
+0x18 | ptr | 自身持有的多态对象(析构时虚析构 + 释放) | 0 |
+0x1C…+0x28 | vector<CUIEffectUpdating*> | 显示分组行 | 空,growth 25 |
+0x2C…+0x38 | vector<CUIEffectUpdating*> | 回收行的空闲池,acquireRow 从这里取、releaseRow 还回来 | 空,growth 25 |
+0x3C | byte | 布局脏标记;经 iTooltip 虚函数 CUIEffectList__iTooltip_isDirty (0x7B2C50) 对外暴露 | 0 |
+0x40/+0x44 | ptr + cookie | tooltip 宿主窗口的弱引用;tick 里靠 CWidget_isVisible 校验 | 0 / −1 |
+0x48/+0x4C | ptr + cookie | 弱引用对,updateRowTimer 会顺带同步它指向控件的文本 | 0 / −1 |
+0x50 | ptr | 当前 tooltip 指向的那一行 | 0 |
+0x54 | float | 0.5 秒节流计时器:tick 每帧减 dt,小于 0 时做一次全量刷新并重置为 0.5 | 0.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)。
| 偏移量 | 数据类型 | 字段含义 | 默认初始值 |
|---|---|---|---|
+0x00 | ptr | 虚函数表指针 | 0x219ABB4 |
+0x04 | int | CRunicCore 基类字段 | 0 |
+0x08 | byte | 本行需立即刷新的脏位;tick 检到就清零并调 updateRowTimer | 1 |
+0x0C | wstring 28B | 图标资源名,由 resolveIconAndBuildWidgets 三级回退解析而来 | 空 |
+0x28 | wstring 28B | 文本槽(buildRow_* 写入的标题/描述) | 空 |
+0x44 | wstring 28B | 文本槽 | 空 |
+0x60 | wstring 28B | 当前显示文本缓存,与新算出的文本比对以决定是否重排 | 空 |
+0x7C/+0x80 | ptr + cookie | 本行归属的宿主单位,与 CEffectManager+0x58 比对 | 0 / −1 |
+0x84…+0x90 | vector | 本行收纳的效果,元素是 8 字节的 {CEffect*, cookie} 弱引用块 | 空,growth 10 |
+0x94/+0x98 | ptr + cookie | 父 CEGUI 窗口;为空则整行被回收 | 0 / −1 |
+0x9C/+0xA0 | ptr + cookie | 另一个 CEGUI 窗口;pruneRowsAndFlagDirty 用 sub_71CBC0 校验其存活 | 0 / −1 |
+0xA4 | ptr | 名为 ICON 的子控件 | 0 |
+0xA8 | ptr | 名为 TIME 的子控件(倒计时文本) | 0 |
+0xAC | int | 图标的 intern 字符串 id(第三级回退用) | −1 |
+0xB0/+0xB4 | u64 | 来源技能的 UNIQUE_GUID(第一级回退用) | −1 / −1 |
+0xB8/+0xBC | u64 | 可生成物的 GUID(第二级回退用) | −1 / −1 |
NOTE
行里存的不是 CEffect* 裸指针,而是 8 字节的弱引用块:addToMatchingGroup 用 alloc(8) 建块,sub_4059A0 注册拿 cookie(0x7B47C6),效果销毁时块里的指针会自动失效。 这就是为什么效果被别处释放掉,增益栏不会立刻崩——但块本身仍需回收,见下一节。
+0x20 不是独立字段,而是 +0x0C 那个 wstring 的长度域。buildRow_* 里 cmp dword ptr [ebx+20h], 0(0x7B3B6D)实际判的是「图标名解析出来了吗」—— 连同 +0xA4 非空,构成一行能否进入活动行向量的两个前置条件。
4.1 C++ 声明
辅助类型 core_vector / wstring28 / CRunicCore 的定义与 CEffect 参考手册 中的完全一致。
// 弱引用块:引擎到处在用的 (指针, 注册号) 对
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 的归属由构造函数 0x7B2BDD–0x7B2BF0 的四条 mov 确定,析构 thunk 的 sub ecx, N 也与之一致:
| 子对象偏移 | 接口 | vtable | 槽 0(析构) |
|---|---|---|---|
+0x00 | CUIEffectList 本体 | 0x219ABA8(_2) | CUIEffectList__scalarDeletingDtor 0x7B3320 |
+0x08 | iListWindowController | 0x219AB98(_1) | thunk sub ecx,8 0x7B2C70 |
+0x0C | iTooltip | 0x219AB7C(_0) | thunk sub ecx,0Ch 0x7B2C80 |
+0x14 | iUIMenuListener | 0x219AB70(无后缀) | thunk sub ecx,14h 0x7B2C60 |
NOTE
MSVC 给 vtable 的 _0 / _1 / _2 后缀顺序与子对象偏移顺序不一致—— 无后缀的 ??_7CUIEffectList@@6B@ 实际是 +0x14 处那张。以 thunk 的调整量为准,别按名字猜。
行的生命周期(对象池)
| 函数 | 地址 | 作用 |
|---|---|---|
CUIEffectList__acquireRow | 0x7B3340 | 空闲池非空就从池里取,否则 alloc(192) + 构造 |
CUIEffectList__releaseRow | 0x7B33E0 | 解开三个子控件、reset 该行、从活动行摘掉、放回空闲池 |
CUIEffectUpdating__reset | 0x7B2AB0 | 注销三组弱引用、清四个 wstring、五个 id 字段回 −1、脏位置 1 |
CUIEffectList__releaseRowsForWindow | 0x7B3710 | 按父窗口批量回收 |
建行的三条分支——与 sameDisplayGroup 的三级回退一一对应:
| 函数 | 地址 | 触发条件 | 额外从 dat 读的键 |
|---|---|---|---|
CUIEffectList__buildRow_skill | 0x7B3C60 | 效果有来源技能,或 GUID2 解析为技能 | DISPLAYNAME、DESCRIPTION |
CUIEffectList__buildRow_spawnable | 0x7B37D0 | GUID2 解析为可生成物 | DISPLAYNAME |
CUIEffectList__buildRow_iconOnly | 0x7B3A20 | 以上都不成立但 ICON id 有效 | 无 |
三者都走同一条尾巴:acquireRow → CUIEffectUpdating__resolveIconAndBuildWidgets (0x7B2E10) → 成功则入活动行、失败则 releaseRow。
逐帧与重建
| 函数 | 地址 | 作用 |
|---|---|---|
CUIEffectList__tick | 0x7B4C90 | 每帧:syncDirtyListWindows → pruneRowsAndFlagDirty → 刷新带脏位的行;refreshTimer 到点后做全量刷新与 tooltip 维护 |
CUIEffectList__syncDirtyListWindows | 0x7B34B0 | 每帧扫全部列表窗,把脏的那些推去重建,见第 4.3 节 |
CUIEffectList__pruneRowsAndFlagDirty | 0x7B4980 | 每帧行级回收:宿主/父窗口失效、行内效果全死 → 回收整行;块级回收后重算文本并标脏 |
CUIEffectList__updateRowTimer | 0x7B24E0 | 刷新 TIME 控件;返回 0 表示该行已空 |
CUIEffectList__rebuildFromManager | 0x7B4030 | 从一个 CEffectManager 全量重建,遍历三张表;收尾清掉 CEffectManager+0x42C |
CUIEffectList__rebuildForWindow | 0x7B4E50 | 解析出容器控件后转调上面那个 |
CUIEffectList__iListCtrl_loadLayoutAndRebuild | 0x7B5310 | 载入 MEDIA/UI/PIECES/EFFECT.LAYOUT 再重建 |
CUIEffectList__iListCtrl_onWindowGone | 0x7B37A0 | 窗口销毁时批量回收挂在它上面的行 |
CUIEffectList__iTooltip_build | 0x7B4EB0 | 组装 tooltip:填 TITLE / DESCRIPTION / ICON / TIME 四个控件;永久效果的时长显示为 Always |
CUIEffectList__iTooltip_isDirty | 0x7B2C50 | 返回 +0x3C(mov al,[ecx+30h],ecx 是 +0x0C 子对象) |
CUIEffectUpdating__buildRowText | 0x7B42D0 | 生成整行文本,见第 7.3 节 |
NOTE
CEffectMgr__addEffectsFromDatNode (0x7B2300) 地址落在这一簇里,但它其实是 CEffectManager 侧的 「从 dat 节点批量添加 [EFFECT]」入口(调 CDataGroup__collectChildBlocksNamed 与 CEffectMgr__invalidateDescCache,被 sub_513A80 调用),与增益条分组逻辑无关。
地址更高处的 0x7B55F0 等函数读的是 STAT / DEFAULTVALUE / STACKABLE / REFRESHES 这类键, 属于另一个子系统,只是地址相邻,不属于本簇。
4.3 脏标记是怎么变成一次重建的
CUIEffectList__syncDirtyListWindows (0x7B34B0) 是整条链路里最后一块拼图: 它把 CEffectManager+0x42C(描述缓存脏标记)翻译成一次真正的 UI 重建。 每帧由 tick 第一个调用,用两个文件级静态向量(惰性初始化 + atexit 析构,growth 均为 10)当暂存:
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], eax(CWidgetWrapper__setNeedsRebuild0x70F100)。控件被隐藏时、或绑定单位没有效果管理器时置 1, 重建完成时清 0。这就是「增益栏隐藏再显示会自己重建」的机制——隐藏期间攒下的变更不会丢。CEffectManager+0x42C有两处被清:本函数的0x7B36F4,以及CUIEffectList__rebuildFromManager尾部的0x7B41C0。前者是「批量收尾清」,后者是「谁重建谁清」。
于是 CEffectManager 第 4.6 节 那个悬着的问题有了答案: linkEffect 与 invalidateDescCache 只负责置脏标记,把它变成重建动作的是这里; 至于 +0x430 / +0x484 那六个 wstring,它们的填充代码也已定位: CEffectMgr__buildDescCacheA (0x7B0720) 与 CEffectMgr__buildDescCacheB (0x7B1180), 按 基址 + 28 × 表索引 寻址、带惰性缓存门,唯一消费者是物品 tooltip 的 buildEffectDescForTooltip (0x57F780)。详见 CEffectManager 第 2 节 的字段表注解。
5. 分组键:sameDisplayGroup
CEffect__sameDisplayGroup (0x7B2470) 是 __stdcall,两个 CEffect*:
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_GUID;EXCLUSIVE 去重键 = (NAME, TYPE)。两套标准毫不相干。
直接后果:
- 同名同类型但来自不同技能的两条效果,能同时存在(
EXCLUSIVE只在同一(NAME, TYPE)内起作用), 而且在增益栏上显示为两个独立图标。 - 同一技能产出的不同名效果,会被归进同一个图标,数值按第 6 节的规则合并。
- 三条判据是依次回退的:有技能就只看技能 GUID;无技能才看
GUID2;GUID2也无效才看图标 id。 这意味着两条毫不相干的效果只要用了同一个ICON,在没有来源技能时会被并成一组。
第三条回退路径解释了一个常见现象:运行时代码直接构造的效果(CEffect__ctorRuntime,没有来源技能、 GUID2 为 −1/−1)如果共用图标,就会挤在同一个增益图标里。想让它们分开,给它们不同的 ICON。
6. addToMatchingGroup 的双重职责
CUIEffectList__addToMatchingGroup(this, CEffect* newEff, CEffectManager* mgr) (0x7B46A0) 返回 char:1 表示已并入某个分组,0 表示没有匹配的分组。
它的循环干了两件事,读代码时容易只看见前一件:
职责一:并入分组。
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 刷新标志
标记已匹配职责二:顺手回收死效果。 同一个内层循环里,对每一个遍历到的弱引用块做存活判定:
if ( (时长已耗尽 && ACTIVATION != TRANSFER) || (flags & 0x20 /*墓碑*/) ):
注销弱引用 (sub_405950),释放那 8 字节块,从 row->effects 里 swap-remove,--i存活判定与 CEffectManager 用的是同一套(DURATION 为 -900/-1000 或 ACTIVATION == 2 视为永久, 否则比 DURATION 与已过时间),加上墓碑位 0x20。
WARNING
这解释了 CEffectManager 第 4.4 节 那个「空转循环」为什么不是纯浪费—— 但也说明它确实是缺陷:linkEffect 把这个函数调用「三表效果总数」次, 每次都会完整遍历所有分组行与行内所有效果。单位身上效果越多,每挂一条新效果的开销就越大, 而回收工作第一次调用就已经做完了。
7. 数值怎么算出来
7.1 单条效果:CEffectDisplayValues_build
CEffectDisplayValues_build(effect, out, mgr, levelOverride, useCached) (0x7A8DE0):
if (levelOverride != -1 && levelOverride != effect->LEVEL && !useCached)
CEffect__setLevel(effect, levelOverride); // 0x7A8E0F ← 技能面板「下一级」预览就靠这个
if (useCached) 取 effect->+0xD4(已算好的值)
else 按 MIN 与 MAX 各走一遍完整公式(与 rollAndCacheValue 同构)两条容易踩的规则:
DISPLAYMAXMODIFIER(标志位0x40000)的真实作用是「显示时把属性倍率强制成 1.0」 (0x7A8ED5与0x7A9005两处if (flags & 0x40000) statMul = 1.0)。 它不改变效果的实际数值,只让 tooltip 显示不含属性加成的裸值。TYPE为 6 / 7 / 123 / 124(四种回复类型)且时长有限时,显示值 = 值 ×DURATION× 0.016 (0x7A9148)。也就是整段时长内的总量,而不是每帧量。 注意这与 CEffectManager 第 7 节 属性缓存里的× 0.016(每帧量)不同: 同一个系数,一处乘了时长,一处没乘。
7.2 多条效果合并
同一分组内多条效果由累加器 0x7A87F0 汇总进一个 CEffectDisplayValues:
out->valueMax += 本条的主数值
out->valueMin += 本条的次数值
out->slot2/slot3/slot4 += 本条对应的槽
out->duration = max(out->duration, 本条的 DURATION) // 取最长
out->source = 时长最长的那一条
out->typeId = 最后一条的 TYPE所以增益栏上一个图标显示的数字是该分组内所有效果的和,而倒计时取其中最长的时长。
7.3 整行文本与倒计时
CUIEffectUpdating__buildRowText (0x7B42D0) 生成的不是一行而是多行:
清空输出
收集本行所有还活着的效果(顺手回收失效的弱引用块)
while (还有未处理的效果) {
取第一条的 (TYPE, DAMAGE_TYPE) 作为本批的键
把所有同键的效果累加进一个 CEffectDisplayValues,并从待处理集里移除
desc = CEffect__build_tooltip_desc(累加结果)
输出非空则 输出 = 输出 + "
" + desc,否则 输出 = desc
}也就是说同一个图标下,每个 (TYPE, DAMAGE_TYPE) 组合各占一行。 一个图标挂了 5 条效果、分属 3 种类型,tooltip 上就是 3 行。
倒计时由 CUIEffectList__updateRowTimer (0x7B24E0) 写进 TIME 控件:
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.DAT 的 TYPE 列 | 表值 | 原版行数 | 引擎实际行为 |
|---|---|---|---|
VALUE(或缺省、或任何写错的值) | 0 | 166 | 走数值格式化 |
PERCENT | 1 | 59 | 走数值格式化——与 0 完全等价 |
NONE | 2 | 14 | 跳过好/坏方向判定 |
TYPE:NONE 的 14 行都是「没有数值可显示」的效果,例如 43 KNOCK BACK EFFECT、45 OPEN WAYPOINT PORTAL、46 IDENTIFY、57 TRANSFORM、58 UNIT THEME。 读取点是 CEffect__build_tooltip_desc (0x7A71D4),判据是 == 2。
WARNING
PERCENT 与 VALUE 对引擎没有任何区别,百分号不是这一列画出来的。 按立即数字节扫描 0x401000–0x2200000,g_EffectDisplayKind_239 全程只出现 3 次: 两处写入(0x401792、0x4017B7)加一处读取(0x7A71D4), 而那唯一的读取是 cmp …, 2——只区分「是不是 NONE」,从不区分 0 和 1。
真正的百分号来自 GOODDES / BADDES 等描述串里写死的字面 %。 实测:原版有 56 行标着 TYPE:Value,描述里却带 % (33 PERCENT BLOCK CHANCE、55 CRITICAL CHANCE、64 STUN、68 XP GAIN BONUS 等), 反过来 44 PERCENT ACTIVE DISTANCE BONUS 标着 Percent 而四个描述串全是 NA。 所以这一列在原版数据里更接近一份给人看的分类注记,改它不会改变任何显示结果。 完整 239 行的这一列取值见 239 个 TYPE 的数值槽对照表。
CAUTION
这个 TYPE 是 EFFECTSLIST.DAT 自己的列,和技能/词缀 dat 里 [EFFECT] 块的 TYPE 键是两个完全不同的东西。 前者取值只有 VALUE / PERCENT / NONE 三种,决定显示形态; 后者是效果类型名,决定效果做什么。两者同名不同义。
7.5 SHOW_IN_BUFFLIST
这是 [SKILL] 块的一个 <BOOL> 键,被两处以不同默认值分别读取:
| 读取点 | 默认实参 | 落点 |
|---|---|---|
CSkill__ctorFromDat 0x6C2FC2 | 当前值(CSkill+0x59 的原值) | 写回 CSkill+0x59 |
sub_7B4030 0x7B422F | 1(true) | 只作为局部判定,不落字段 |
也就是说显示侧那次读取不看已加载的 CSkill+0x59,而是重新解析 dat 并默认为真。 两处的默认值不一致,是这条链路上最容易造成「配了却不生效/没配却显示」的地方。
实测用量(<BOOL>SHOW_IN_BUFFLIST 的出现次数):
| 值 | 原版 | MOD 库 |
|---|---|---|
false | 95 | 2133 |
true | 0 | 15 |
原版只用它来隐藏,一次都没有显式写 true——因为显示侧的默认值本来就是真,写 true 是冗余的。 MOD 库里那 15 处 true 同样是冗余写法。
8. 速查表
类与实例
──────────────────────────────────────────────────────────────────────
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 节)。