核心定位:
CEffect是游戏运行期间「一条正在生效的效果实例」在内存中的对象表达。 在 DAT 文件中每声明一个[EFFECT]数据块,在资源加载或技能施放时便会对应构造出一个CEffect对象。本文档为该底层类的权威技术参考手册,涵盖字段结构、配置键表、数值计算及存档序列化格式。分析依据:基于 IDA 对 32 位可执行文件
Torchlight2.exe(基址0x400000)的逐指令反汇编分析。文中所有0x……均为绝对内存地址。 配置用量数据来自实机样本统计:覆盖原版E:/Torchlight 2/MEDIA中 16084 个.DAT文件,以及 MOD 库D:/WizardProject/TL2中 165753 个.DAT文件,按「包含该键的[EFFECT]块数量」进行统计(EFFECTSLIST.DAT中的 239 行类型注册不计入效果块,已排除)。关联文档:TL2 技能设计指南 从技能开发的视角串联了
[EVENT_*]/[LEVELn]/[EFFECT]/[AFFIXES]的层级关系。本文档不重复设计方法论,专精于CEffect单一类的底层解剖。词缀容器侧的机制参见 CAffix 参考手册,挂载与调度侧参见 CEffectManager 参考手册,状态栏显示侧参见 CEffectDisplayMgr 参考手册。
0. 配置样例速览
在展开底层解剖之前,先给出三个取自原版数据的真实 [EFFECT] 块(内容原文照录,仅将缩进统一为 4 个空格),以及两份可直接套用的骨架。三个样例分别覆盖了光环、持续伤害与属性缩放这三类最典型的配置形态。
0.1 光环型:MEDIA/AFFIXES/SKILLS/EMBERMAGE_FLAMEAURA_DEF.DAT
[EFFECT]
<STRING>NAME:FLAMEAURA_DEF
<STRING>ACTIVATION:DYNAMIC
<STRING>TYPE:PERCENT DAMAGE TAKEN
<STRING>DAMAGE_TYPE:ALL
<BOOL>NOGRAPH:true
<STRING>UNITTHEME:flameaura
<BOOL>SAVE:true
<BOOL>EXCLUSIVE:true
<STRING>ICON:skillicon_flameaura
<BOOL>MODIFIERISBONUS:true
<FLOAT>MIN:-5
<FLOAT>MAX:-5
[/EFFECT]逐条解读:
- 未声明
DURATION—— 字符串为空,被替换为ALWAYS并写入-1000.0;同时标志位0x1不会置位。这是「永久生效」的标准写法。 TYPE:PERCENT DAMAGE TAKEN对应EFFECTSLIST.DAT的第 26 行。该行的GRAPH1/GRAPH2两列均为空,因此即使不写NOGRAPH:true也不会绑定任何曲线(原理见第 6 节)。此处的NOGRAPH:true属于冗余但无害的保险写法。MIN与MAX同为-5,区间随机退化为定值-5,数值完全可控。EXCLUSIVE:true配合非空的NAME,构成NAME+TYPE的去重键:同一单位上重复施放只刷新、不叠加。SAVE:true使该效果进入存档;UNITTHEME/ICON/MODIFIERISBONUS分别负责单位主题贴图、状态栏图标与 UI 加成归类。
0.2 持续伤害(DoT)型:MEDIA/AFFIXES/SKILLS/SPELL_POISONCLOUD.DAT
[EFFECT]
<STRING>ACTIVATION:PASSIVE
<STRING>DURATION:6
<STRING>TYPE:DAMAGE
<STRING>DAMAGE_TYPE:POISON
<BOOL>SAVE:true
<FLOAT>MIN:25
<FLOAT>MAX:25
<FLOAT>MINFORCE:0
<FLOAT>MAXFORCE:0
[/EFFECT]逐条解读:
TYPE:DAMAGE对应EFFECTSLIST.DAT的第 52 行(即0x34)。配合有限时长DURATION:6,恰好命中0x7AB94A的预置条件,EXCLUSIVE的默认值被抬升为true。该 DoT 天然独占,重复施加只刷新时长。若需要可叠加,必须显式补写<BOOL>EXCLUSIVE:false。- 第 52 行的
GRAPH1/GRAPH2均为DAMAGE_MONSTER,因此槽 0 与槽 1 会被绑定曲线。计算走分支一:先在MIN~MAX之间掷值(此处两端同为 25,结果恒为 25),再当作百分比去乘曲线求值结果——即「曲线值的 25%」,而不是「每跳 25 点伤害」。 MINFORCE/MAXFORCE是槽 2 / 槽 3 的别名,来自第 52 行的VALUE3/VALUE4两列(别名机制见第 5 节)。槽 2–4 在全部 239 个类型中都不存在默认曲线,故恒走区间随机分支。- 未声明
NAME—— 名称为空串。所有空名效果在去重判定中会被视为彼此同名,这一点在与EXCLUSIVE联用时需格外留意。 ACTIVATION:PASSIVE使+0x20为 0,进而触发STAT_SOURCE_TYPE的兜底逻辑,将+0x12C置为 3(ON UPDATE SELF)。
0.3 属性缩放型:MEDIA/AFFIXES/SKILLS/PHALANX.DAT
[EFFECT]
<BOOL>SAVE:true
<STRING>NAME:PHALANX PERCENT SPEED
<STRING>ACTIVATION:PASSIVE
<STRING>TYPE:PERCENT SPEED
<STRING>STATMODIFYNAME:DEFENSE,MELEE
<STRING>STATMODIFYPERCENT:50,50
<STRING>DURATION:0
<FLOAT>MIN:50
<FLOAT>MAX:50
[/EFFECT]逐条解读:
STATMODIFYNAME与STATMODIFYPERCENT以英文逗号分列,按下标一一配对,最终形成DEFENSE × 0.5与MELEE × 0.5两组属性倍率。DURATION:0是一处容易误读的写法:字符串非空,故标志位0x1会置位;但解析出的数值0不满足「大于 0」的条件,最终仍落回-1000.0(ALWAYS)。也就是说,其生效结果与不写DURATION相同,唯独标志位0x1的状态相反——而该位正是词缀系统推导自身时长的依据。TYPE:PERCENT SPEED为第 21 行,无默认曲线,走区间随机分支;MIN与MAX同为 50,结果恒为 50。
0.4 可直接套用的骨架
最小可用骨架(仅保留必要键):
[EFFECT]
<STRING>TYPE:PERCENT SPEED
<STRING>NAME:MY_EFFECT_SPEED
<STRING>ACTIVATION:PASSIVE
<STRING>DURATION:ALWAYS
<FLOAT>MIN:10
<FLOAT>MAX:10
[/EFFECT]完整骨架(涵盖各功能分区,右侧 ← 为注解,并非 DAT 语法,实际使用时请删除):
[EFFECT]
# ---- 身份:决定「这是什么效果」----
<STRING>TYPE:DAMAGE ← 必填。拼错静默变 193 (NONE),整条效果失效
<STRING>NAME:MYMOD_BURN ← 去重键的一半,建议加 MOD 前缀避免撞名
<STRING>ACTIVATION:DYNAMIC ← PASSIVE / DYNAMIC / TRANSFER,拼错静默变 DYNAMIC
<STRING>DAMAGE_TYPE:FIRE ← 缺省 Physical;拼错则变 Magical,两者结果不同
# ---- 时长:最高频的失效原因 ----
<STRING>DURATION:6 ← INSTANT / ALWAYS / 正数秒;必须大于 0 才被视为限时
# ---- 数值 ----
<BOOL>NOGRAPH:true ← 类型自带默认曲线时,需显式声明才能启用区间随机
<FLOAT>MIN:20 ← 槽 0,别名查 EFFECTSLIST 对应行的 VALUE1 列
<FLOAT>MAX:40 ← 槽 1,别名查 VALUE2 列
# <STRING>GRAPHOVERRIDE:MY_CURVE ← 一旦声明,将覆盖全部 5 个槽的曲线
# ---- 叠加与持久化 ----
<BOOL>EXCLUSIVE:true ← 有限时长的 DAMAGE 默认即为 true,其余类型默认 false
<BOOL>SAVE:true ← 未声明则该效果不写入存档
# ---- 表现 ----
<STRING>ICON:myicon_burn
<STRING>PARTICLE_FX:MY_BURN_FX
<STRING>UNITTHEME:burning
[/EFFECT]1. 运行架构与生命周期
一个 CEffect 对象在孤立状态下不执行任何逻辑。它本质上是一组属性数据外加一个已缓存的计算数值,必须被挂载至某个 CEffectManager(效果管理器)中,才会接收每帧的 tick 更新、显示在 UI 状态增益栏中,并被持久化序列化至存档。未完成挂载的 CEffect 仅仅是一块静态内存数据。
.DAT 中的 [EFFECT] 数据块
└─ CEffect__ctorFromDat (0x7AAA30) 读取 24 个配置键与 5 个数值槽,计算并缓存最终数值
└─ CEffectManager::linkEffect (0x7B1D70) 根据 ACTIVATION 属性分流挂入三张管理表之一
├─ 逐帧更新 (tick): 比较 DURATION 与已流逝时间 elapsed (+0x18) 判定生命周期
├─ 过期销毁: CEffect__expire (0x7A645E) 标记墓碑标志位 0x20
└─ 存档持久化: 与 CEffect__save_read (0x7AA300) 构成序列化对偶CEffectManager 并非全局单例。每个物品(CItem)和每个角色单位(CCharacter)各持有一个独立实例,通过虚函数表第 99 槽获取(CItem__getEffectMgr_vt99 0x514520、CCharacter__getEffectMgr_vt99 0x51C960),并在首次使用时按需创建 (0x513E90)。本文档重点阐明其对 CEffect 的管理契约(详见第 7 节)。
在逆向分析中,用于交叉核实字段与生命周期的三条核心构造路径如下:
| 函数名称 | 内存地址 | 主要用途与触发场景 |
|---|---|---|
CEffect__ctorFromDat | 0x7AAA30 | 从 DAT 数据节点反序列化构造 |
CEffect__ctorRuntime | 0x7A9AB0 | 运行时代码直接构造(存档读取、装备属性烘焙均走此路径) |
CEffect_CopyCtor | 0x7AA170 | 对象深拷贝克隆 |
CEffect__reset | 0x7A97B0 | 全字段清零重置,为确认默认初始值的权威出处 |
2. 内存数据结构 (CEffect Memory Layout)
CEffect 的 RTTI 继承关系为 CEffect : CRunicCore,虚函数表位于 0x219A888,单个对象在内存中占用 304 字节 (0x130)。
| 偏移量 | 数据类型 | 字段含义与业务描述 | 写入来源 / 赋值函数 | 默认初始值 |
|---|---|---|---|---|
+0x00 | ptr | 虚函数表指针 (vftable) | 构造函数 | 0x219A888 |
+0x04 | int | CRunicCore 基类内部字段 | 0x405918 | 0 |
+0x08 | int | 效果等级 (Level) —— 唯一作为 X 轴输入曲线求值的等级 | LEVEL 键 / CEffect__setLevel 0x518430 | ctorFromDat 路径不主动写入;ctorRuntime 在 0x7A9AFB 清零 |
+0x0C | float | 伤害吸收上限比例 (SOAKSCALE);同时是暴击率乘数 | SOAKSCALE 键 0x7AB044;CAffix__setEffectMult0x0C_ifUnset 0x7A47D4(仅当仍为哨兵值时) | 9999989.0 |
+0x10 | float | 词缀级覆写量(残留字段,无读取点) | 构造置 1.0;CAffix__setEffectField0x10_all 0x7A4811 无条件覆写全部子效果 | 1.0 |
+0x14 | int | 伤害类型枚举 (DAMAGE_TYPE) | DAMAGE_TYPE 键 0x7AB545 | 1 = Magical (0x7A98E4) |
+0x18 | float | 已持续时间 (Elapsed Time) | 逐帧 tick 更新 / 存档读取 | 0.0 |
+0x1C | int | 效果类型索引 (TYPE),范围 0–238 | TYPE 键 0x7AABE9 | 193 = NONE |
+0x20 | int | 激活方式枚举 (ACTIVATION) | ACTIVATION 键 0x7AAC95 | 1 = DYNAMIC (0x7AAA88) |
+0x24 | float | 持续时长 (DURATION) | DURATION 键 0x7AAD89 | -900.0 (INSTANT 哨兵值) |
+0x28 | u32 | 效果状态标志位 (Flags),详见第 3 节 | 多处调用 | 初始为 0,随后 |= 0x40 |
+0x2C | int | 图标名称经 intern 后的字符串 ID (ICON) | ICON 键 0x7AB140 | −1 |
+0x30/+0x34 | u64 | 被引用目标的唯一 GUID (UNIQUE_GUID) | CEffect__setReferencedGuid 0x7A6140 等 | −1 / −1 |
+0x38/+0x3C | u64 | GUID2(来源技能或来源单位的 GUID) | setOwnerSkill 0x7A92D4 / setSource 0x7A8CEB | −1 / −1 |
+0x40/+0x44 | ptr + cookie | 来源单位指针 (CBaseUnit*) 与弱引用句柄 | CEffect__setSource 0x7A8CA0 | 0 / −1 |
+0x48/+0x4C | ptr + cookie | 来源技能指针 (CSkill*) 与弱引用句柄 | CEffect__setOwnerSkill 0x7A9290 | 0 / −1 |
+0x50 | wstring 28B | 效果名称 (NAME),经两次 toUpperW 存储 | NAME 键 0x7AB0B5 | 空字符串 |
+0x6C | wstring 28B | 联动标识名 (LINK_NAME) | CEffect__setLinkName 0x7A62C9 | 空字符串 |
+0x88 | wstring 28B | 覆盖曲线名称 (GRAPHOVERRIDE) | GRAPHOVERRIDE 键 0x7AB414 | 空字符串 |
+0xA4 | Ogre::Vector3 | 残留字段,无读取点;实际恒为零向量 | 三个构造函数均调 Ogre::Vector3::Vector3(void)(空实现);CEffect__reset 0x7A98D7 赋 ZERO | Vector3::ZERO |
+0xB0 | ptr | 自身持有的粒子特效实例指针 | CEffect__playParticleFx 0x7A6383 | 0 |
+0xB4 | wstring 28B | 粒子特效资源名 (PARTICLE_FX) | PARTICLE_FX 键 0x7AB32B | 空字符串 |
+0xD0 | ptr | 所属词缀指针 (CAffix*) | CEffect__setOwnerAffix | 0 |
+0xD4 | float | 经计算并缓存的最终数值 | CEffect__rollAndCacheValue 0x7A85C0 | 0.0 |
+0xD8 | float | 最终数值向上取整结果 (ceil) | 同上 | 0.0 |
+0xDC…+0xEC | float[5] | 五个基础数值槽 (Value Slots 0–4) | readValueSlot 循环 0x7AB469 | 0.0 |
+0xF0…+0x100 | CGraph*[5] | 五条曲线对象指针,各数值槽独立对应一条 | CEffect__bindGraphs 0x7AB442 | 0 |
+0x104…+0x110 | vector<float> | STATMODIFYPERCENT 解析列表(已预先除以 100) | CEffect__addStatModifier 0x7A9EC0 | 空列表 |
+0x114…+0x120 | vector<CStatDef*> | STATMODIFYNAME 解析出的属性定义指针列表 | 同上 | 空列表 |
+0x124 | ptr | 残留字段,无读取点 | 有来源技能取 CSkill+0x08 (0x7A930F),否则取来源单位 +0x1C4 (0x7A8CCC/0x7AAB5C) | 0 |
+0x128 | ptr | UNITTHEME 单位主题对象指针 | UNITTHEME 键 0x7AADDC | 0 |
+0x12C | int | 属性修正来源枚举 (STAT_SOURCE_TYPE) | STAT_SOURCE_TYPE 键 0x7ABA84 | 0 |
NOTE
整张结构表中有 3 个字段是只写不读的残留字段:+0x10、+0xA4、+0x124。 三者的写入点都已逐指令定位,但在下述扫描范围内均无任何读取点: 效果/词缀/管理器/增益栏全簇(0x7A4000–0x7B5400)、单位与装备与物品存档段(0x510000–0x5C0000)、 技能段(0x6B0000–0x6E0000),以及 0x401000–0xA00000 全范围内一次「同时触碰该偏移与 CEffect 特征偏移 (+0xD4/+0xD8/+0xDC/+0xF0/+0x12C)」的交集筛查。
逐个说明:
+0xA4(Ogre::Vector3)恒为零向量。 三个构造函数调的都是Ogre::Vector3::Vector3(void)—— Ogre 的默认构造函数是空实现,不初始化 x/y/z;随后CEffect__reset(0x7A98D7) 统一赋Vector3::ZERO, 而三条构造路径都会经过reset。sub_7A9C20在克隆时把它从另一个效果复制过来(同样是零)。+0x124的两个写入者甚至来源不一致:setOwnerSkill取CSkill+0x08(该字段是CSkill构造函数的 第一个参数,即技能的 owner,见CSkillManager__addSkill0x6D6A55的传参), 而setSource/ctorFromDat取来源单位+0x1C4。语义不统一本身就是它已经废弃的旁证。 顺带说明:setSource里那个看似校验的sub_403B80,函数体只有一句return *(p + 8)。+0x10曾被记为「唯一读取点在 tooltip 组装路径」,那是误判:CEffect__build_tooltip_desc在0x7A710F读的[eax+10h]属于第 2 参数CEffectDisplayValues(同一段连续读它的+0x0C/+0x10/+0x14/+0x18/+0x1C),不是CEffect+0x10。 同理CEffectDisplayValues_CopyCtor(0x7A64E0) 里的+0x10也是那个类的字段。
三者都被 CEffect_CopyCtor 照常复制,所以它们仍算对象状态的一部分——只是没有任何代码消费。 +0x10 值得单独留意:CEffectMgr__addAffix 的最后一个浮点参数就是写它的, 也就是说有一个对外的 API 参数喂给了一个死字段。
+0x0C 与 +0x10 是一对:CEffectMgr__addAffix 的最后两个浮点参数分别写它们,但语义不对称 —— 前者只改还是哨兵值 9999989.0 的子效果,后者无条件覆写全部。+0x0C 的读取器是 CEffect__getCritChanceMult (0x7F0690,函数体只有 fld [ecx+0Ch]; retn),其代码调用方为 CUnit__executeInstantEffect(3 处)、sub_630970 与 CAffix__setEffectMult0x0C_ifUnset。再往下的消费链未能证实——该访问器因链接器同体折叠还兼任了别的类的虚表槽,直接 xref 不足以定位真实使用点,故此处不对「用在暴击还是护甲吸收」下结论。
早期分析中的疑点已在本轮验证中全部明确:+0x0C 确认为 SOAKSCALE(默认缺省值为 9999989.0),+0x6C 为 LINK_NAME(由 CEffect__setLinkName 写入并同步置位 0x800)。+0xF0 并非单条曲线指针,而是长度为 5 的数组,分别对应 5 个数值槽;CEffect__reset 中的 5 次循环会同时清零 +0xDC+4i 与 +0xF0+4i。
2.1 C++ 结构体声明
下列声明由上表逐字段还原而来,偏移量与 sizeof 均已通过 static_assert 自校验,可直接用于编写内存补丁或 Hook 代码。
// ---- 辅助类型 ----
// 引擎自有的动态数组容器,四个字段一组,共 16 字节
template <class T>
struct core_vector {
T* data; // +0x00
unsigned int size; // +0x04
unsigned int capacity; // +0x08
unsigned int growth; // +0x0C 扩容步长
};
// 本 exe 使用的 MSVC std::wstring 实测为 28 字节(含 8 字符 SSO 缓冲区)
struct wstring28 { unsigned char raw[28]; };
// RTTI 继承链的根,共 8 字节
struct CRunicCore {
void** vftable; // +0x00
int field_04; // +0x04 构造时置 0
};
// ---- 枚举 ----
enum EActivation { // +0x20,表 g_EffectActivationNames @0x3B9E930
ACTIVATION_PASSIVE = 0,
ACTIVATION_DYNAMIC = 1, // 构造默认值
ACTIVATION_TRANSFER = 2,
};
enum EDamageType { // +0x14,表 @0x3B9CAB0
DAMAGE_PHYSICAL = 0, DAMAGE_MAGICAL = 1, DAMAGE_FIRE = 2, DAMAGE_ICE = 3,
DAMAGE_ELECTRIC = 4, DAMAGE_POISON = 5, DAMAGE_ALL = 6,
};
enum EStatSourceType { // +0x12C,表 @0x3B9E988
STATSRC_ON_CAST_CASTER = 0, STATSRC_ON_CAST_RECEIVER = 1,
STATSRC_ON_UPDATE_CASTER = 2, STATSRC_ON_UPDATE_SELF = 3,
};
enum EEffectFlags { // +0x28,详见第 3 节
EFF_DURATION_WRITTEN = 0x000001, // DURATION 字符串非空
EFF_DISPLAYPOSITIVE = 0x000002,
EFF_EXCLUSIVE = 0x000004,
EFF_NONMAGICAL = 0x000008,
EFF_SAVE = 0x000010,
EFF_EXPIRED = 0x000020, // 运行时墓碑位
EFF_DESC_CACHED = 0x000040, // 由 reset 置位,供 Tooltip 使用
EFF_USEOWNERLEVEL = 0x000080,
EFF_GRAPHS_BOUND = 0x000100, // NOGRAPH 为假即置位,与 GRAPHOVERRIDE 无关
EFF_MODIFIERISBONUS = 0x000200,
EFF_LINK_NAME = 0x000800,
EFF_PARTICLE_FX = 0x001000,
EFF_UNITTHEME = 0x002000,
EFF_REF_GUID_VALID = 0x004000, // +0x30/+0x34 有效
EFF_GUID2_VALID = 0x008000, // +0x38/+0x3C 有效
EFF_REMOVEONOWNERDIE = 0x010000,
EFF_ICON = 0x020000,
EFF_DISPLAYMAXMOD = 0x040000,
EFF_APPLIES_WEAPON = 0x080000, // CSocketable__tagEffectsByHostItemType
EFF_APPLIES_ARMOR = 0x100000,
EFF_IN_BYPASS_TABLE = 0x200000,
};
// ---- 主体 ----
// RTTI: CEffect : CRunicCore vtable @ 0x219A888 sizeof == 0x130 (304)
struct CEffect : CRunicCore {
/* +0x08 */ int level; // LEVEL —— 曲线求值唯一的 X 轴输入
/* +0x0C */ float soakScale; // SOAKSCALE / 暴击率乘数,缺省 9999989.0f
/* +0x10 */ float vestigial_10; // 词缀级覆写量:无读取点
/* +0x14 */ int damageType; // DAMAGE_TYPE (EDamageType)
/* +0x18 */ float elapsed; // 已持续时间
/* +0x1C */ int typeId; // TYPE,0..238,缺省 193 = NONE
/* +0x20 */ int activation; // ACTIVATION (EActivation)
/* +0x24 */ float duration; // DURATION,-900 = INSTANT,-1000 = ALWAYS
/* +0x28 */ unsigned int flags; // EEffectFlags 位组合
/* +0x2C */ int iconStringId; // ICON 的 intern id,-1 表示无
/* +0x30 */ unsigned __int64 referencedGuid; // 由 NAME 解析出的目标 GUID
/* +0x38 */ unsigned __int64 sourceGuid; // GUID2:来源技能或来源单位的 UNIQUE_GUID
/* +0x40 */ CBaseUnit* sourceUnit;
/* +0x44 */ int sourceUnitCookie; // 弱引用注册号,初值 -1
/* +0x48 */ CSkill* ownerSkill;
/* +0x4C */ int ownerSkillCookie; // 弱引用注册号,初值 -1
/* +0x50 */ wstring28 name; // NAME,经两次 toUpperW
/* +0x6C */ wstring28 linkName; // LINK_NAME
/* +0x88 */ wstring28 graphOverride; // GRAPHOVERRIDE
/* +0xA4 */ Ogre::Vector3 vestigial_A4; // 残留字段:无读取点,恒为零向量
/* +0xB0 */ void* particleInstance; // 自身持有,析构时虚析构并释放
/* +0xB4 */ wstring28 particleFx; // PARTICLE_FX
/* +0xD0 */ CAffix* ownerAffix; // 所属词缀,析构时反向摘除
/* +0xD4 */ float rolledValue; // 已计算并缓存的最终数值
/* +0xD8 */ float rolledValueCeil; // 上一字段的 ceil
/* +0xDC */ float slot[5]; // 五个基础数值槽
/* +0xF0 */ CGraph* graph[5]; // 五条曲线,与数值槽一一对应
/* +0x104*/ core_vector<float> statModifyPercent; // STATMODIFYPERCENT,已除以 100
/* +0x114*/ core_vector<CStatDef*> statModifyDef; // STATMODIFYNAME 解析结果
/* +0x124*/ void* vestigial_124; // 残留字段:无读取点,取自 CSkill+0x08 或单位 +0x1C4
/* +0x128*/ void* unitTheme; // UNITTHEME 解析结果
/* +0x12C*/ int statSourceType; // STAT_SOURCE_TYPE (EStatSourceType)
};
static_assert(sizeof(CRunicCore) == 0x08, "CRunicCore layout");
static_assert(sizeof(CEffect) == 0x130, "CEffect layout");
static_assert(offsetof(CEffect, flags) == 0x28, "");
static_assert(offsetof(CEffect, name) == 0x50, "");
static_assert(offsetof(CEffect, slot) == 0xDC, "");
static_assert(offsetof(CEffect, graph) == 0xF0, "");
static_assert(offsetof(CEffect, unitTheme) == 0x128, "");NOTE
statModifyPercent 与 statModifyDef 是并行数组,同一下标构成一组 (属性定义, 倍率)。二者的 size 由 CEffect__addStatModifier 同步递增,正常情况下恒等。
3. 偏移 +0x28 的 22 个标志位详解
+0x28 处的 32 位整型(Flags)用于控制效果的各种行为模式与状态标记。
3.1 由 DAT 配置键直接控制的标志位
以下标志位在 ctorFromDat 中均成对处理(配置 true 置位,配置 false 显式清除):
| 掩码 | 关联配置键 | 含义与行为 | 置位 / 清位地址 |
|---|---|---|---|
0x2 | DISPLAYPOSITIVE | 在 UI 提示中作为正面增益显示 | 0x7AAF23 / 0x7AAF29 |
0x4 | EXCLUSIVE | 独占效果:同名同类型不叠加,仅刷新持续时间 | 0x7AB9CC / 0x7AB9D2 |
0x8 | NONMAGICAL | 标记为非魔法效果 | 0x7AAF68 / 0x7AAF6E |
0x10 | SAVE | 允许持久化存入存档文件 | 0x7AAE48 / 0x7AAE4E |
0x80 | USEOWNERLEVEL | 采用所有者(来源单位)的等级覆盖效果等级 | 0x7AAED8 / 0x7AAEE1 |
0x200 | MODIFIERISBONUS | 属性修正作为额外加成计算 | 0x7AAFAD / 0x7AAFB6 |
0x10000 | REMOVEONOWNERDIE | 当来源单位死亡时,该效果自动移除 | 0x7AAE8D / 0x7AAE96 |
0x40000 | DISPLAYMAXMODIFIER | 在 UI 提示中展示最大修正值 | 0x7AAFF8 / 0x7AB001 |
3.2 根据 DAT 属性间接计算的标志位
(非布尔开关,根据对应字符串是否非空或解析是否成功动态判定):
| 掩码 | 含义与判定条件 | 判定地址 |
|---|---|---|
0x1 | 显式配置了 DURATION —— 读取到的时长字符串长度非空即置位 | 0x7AAD11 |
0x100 | 已执行曲线绑定流程 —— 只要 NOGRAPH 为假即置位(不代表真的绑上了曲线) | 0x7AB429 |
0x800 | LINK_NAME 字符串非空 | 0x7A62EA / 0x7A6303 |
0x1000 | PARTICLE_FX 粒子资源名非空 | 0x7AB33F / 0x7AB348 |
0x2000 | UNITTHEME 单位主题解析成功 | 0x7AAE0F / 0x7AAE18 |
0x20000 | ICON 图标字符串成功 intern 注册 | 0x7AB1C7 / 0x7AB1D0 |
WARNING
以下两项命名极具误导性,需特别注意:
0x1并不代表「ALWAYS 永久生效」:ctorFromDat(0x7AACFB) 仅检查DURATION字符串长度是否大于 0。若非空则置位,若为空则将字符串替换为ALWAYS后继续向下执行。其消费端主要在词缀系统:CAffix__setDuration与CAffix__deriveDurationFromEffects均通过test byte ptr [edx+28h], 1判定该子效果的时长是否有效。0x100并不代表「配置了 GRAPHOVERRIDE」,也不代表「曲线已成功绑定」:它位于五槽绑定循环中,只要未启用NOGRAPH就会无条件置位,既与是否指定覆盖曲线无关,也不关心CEffect__bindGraphs的返回值是否为 NULL。判断曲线是否真正生效,应当检查+0xF0而非该标志位。
3.3 运行时动态设置的状态标志位
| 掩码 | 含义与行为 | 设置函数与地址 |
|---|---|---|
0x20 | 墓碑标志(已失效作废),等待管理器下一帧清理 | CEffect__expire 0x7A645E |
0x40 | 描述文本缓存标记 | CEffect__reset 0x7A9828 置位;用于 Tooltip 生成 |
0x400 | 附魔标记:本条效果由附魔师附加,而非掉落时摇出 | CEquipment__markNewEffectsAsEnchanted 0x73E20E(全 exe 唯一写入点) |
0x4000 | 偏移 +0x30/+0x34 的目标 GUID 有效 | CEffect__setReferencedGuid 0x7A6155 |
0x8000 | 偏移 +0x38/+0x3C 的来源 GUID2 有效 | setSource 0x7A8D00 / setOwnerSkill 0x7A92E7 |
0x80000 | 本效果适用于武器部件 (Weapon, ID 8) | CSocketable__tagEffectsByHostItemType 0x57D901 |
0x100000 | 本效果适用于防具部件 (Armor, ID 13) | 同上 0x57D8F4 |
0x200000 | 已转移至惰性旁路更新表 | linkEffect_bypassTable 0x7B1BFB |
4. 24 个配置键深度解析
各数据读取函数的缺省行为高度一致:若指定的配置键不存在,则直接返回传入的默认实参。下表中的默认值即为汇编调用点处压栈的默认参数。
注:用量统计分别对应原版 9989 个与 MOD 库 202191 个独立 [EFFECT] 数据块中的有效配置数量。
4.1 效果定义与类型主键
TYPE (<STRING>)
| 属性 | 说明 |
|---|---|
| 写入目标 | 偏移 +0x1C,存储 0–238 的类型索引 |
| 读取流程 | 0x7AAB90 读取文本 → 0x7AABE9 查表匹配成功后写入 |
| 默认值 | 空字符串 |
| 缺省后果 | 保持初始值 193 = NONE,效果完全失效 |
| 错误后果 | 同样保持 193。引擎将转大写后的文本与 g_EffectTypeNames_239 (0x28A28D8) 中的 239 项逐一比对,不存在错误抛出分支,未匹配成功直接跳过赋值 |
| 实际用量 | 原版 9986 / MOD 库 202135 |
IMPORTANT
查找表的比较上界指令为 cmp edi, 1A24h (0x7AABE5),其中 0x1A24 = 6692 = 239 × 28(std::wstring 占用 28 字节)。这是 EFFECTSLIST.DAT 必须恰好为 239 行的硬编码来源。
ACTIVATION (<STRING>)
| 属性 | 说明 |
|---|---|
| 写入目标 | 偏移 +0x20 (整型枚举) |
| 读取流程 | 0x7AAC3D 读取文本 → 0x7AAC95 查表匹配后写入 |
| 默认值 | DYNAMIC (1) |
| 合法取值 | PASSIVE (0)、DYNAMIC (1)、TRANSFER (2)。查找表 g_EffectActivationNames 位于 0x3B9E930,严格包含 3 项 (cmp edi, 54h = 3 × 28) |
| 缺省后果 | 返回默认实参 DYNAMIC → 1 |
| 错误后果 | 静默回退为 DYNAMIC (1),而非 PASSIVE (0) |
| 实际用量 | 原版 9788 / MOD 库 199178 |
NOTE
「拼错静默变 DYNAMIC」在样本库里有实证:MOD 库中有 18 处写 NORMAL、12 处写 ALWAYS。 这两个值都不在 PASSIVE / DYNAMIC / TRANSFER 三项之内,反查落空后 +0x20 保持构造初值 1, 因此这 30 条效果实际都是 DYNAMIC——与作者意图无关,也没有任何报错。 原版则不存在非法取值。
查表未匹配时直接跳转至 0x7AAC98,+0x20 保持构造初始值。而在构造函数中初始值被显式置为 1 (0x7AAA6A mov ebx, 1 + 0x7AAA88 mov [esi+20h], ebx)。因此,拼写错误与完全不配置该键产生完全相同的结果。
DAMAGE_TYPE (<STRING>)
| 属性 | 说明 |
|---|---|
| 写入目标 | 偏移 +0x14 (整型枚举) |
| 读取流程 | 0x7AB4A9 读取文本 → 0x7AB545 查表匹配后写入 |
| 默认值 | PHYSICAL (0) |
| 合法取值 | Physical (0)、Magical (1)、Fire (2)、Ice (3)、Electric (4)、Poison (5)、All (6)。共 7 项(比较前自动转大写) |
| 缺省后果 | 采用默认实参 PHYSICAL → 0 |
| 错误后果 | 保持 CEffect__reset (0x7A98E4) 设置的初始值 1 = Magical |
| 实际用量 | 原版 2395 / MOD 库 45983 |
WARNING
缺省与拼写错误的结果并不对称:漏写得到 Physical (物理),拼写错误得到 Magical (魔法)。 此外,有 9 种特殊效果类型在 0x7AB4DB 处会完全无视该配置键,强制硬编码覆写为 1 (Magical):TYPE 索引 2、0x11、0x23、0x25、0x26、0x27、0x28、0x56、0x76。
NAME (<STRING>)
| 属性 | 说明 |
|---|---|
| 写入目标 | 偏移 +0x50,连续经过两次 toUpperW 转换为全大写后存储 (0x7AB08A, 0x7AB0A1) |
| 默认值 | 空字符串 |
| 缺省后果 | 保持空字符串。空名称会导致同名去重机制产生异常行为(详见第 7 节) |
| 错误后果 | 自由字符串格式。但在以下特殊类型中会被用于反查对象 GUID:索引 0x92–0x97、0xB5、0xB6(解析属性 GUID),以及 0xA0、0xA1(解析实体 GUID)。在这两类中名称拼错将导致引用失效 |
| 实际用量 | 原版 7057 / MOD 库 139746 |
LEVEL (<INTEGER>)
| 属性 | 说明 |
|---|---|
| 写入目标 | 偏移 +0x08 |
| 读取地址 | 0x7AB2A8 |
| 默认值 | 0 |
| 跳过读取条件 | ctorFromDat (0x7AB271) 的判定条件为 `if (来源单位为空 |
| 缺省后果 | 值为 0 |
| 错误后果 | 执行两道规范化清理:-1 → 0 (0x7AB2B8);无符号比较 > 1000 → 0 (0x7AB2C4)。由于采用无符号比较,任意负数均会被重置为 0 |
| 实际用量 | 原版 1268 / MOD 库 11561 |
+0x08 是唯一直接输入 CGraph__evaluate 进行曲线求值的等级参数。角色自身等级(CBaseUnit+0x110)、技能点投入等级(CSkill+0xCC)、需求等级(LEVEL_REQUIRED)与词缀等级(CAffix+0x90)是彼此独立的变量,切勿混淆。
4.2 持续时间与时效控制
DURATION (<STRING>)
| 属性 | 说明 |
|---|---|
| 写入目标 | 偏移 +0x24 (float) |
| 读取地址 | 0x7AACC8 |
| 默认值 | 空字符串 → 立即被规范化为 ALWAYS (0x7AAD09) |
| 三态解析规则 | 1. INSTANT → 存入 -900.0;2. ALWAYS 或空串 → 存入 -1000.0;3. 其余情况按浮点数解析,必须 > 0 才保留数值,否则同样存入 -1000.0 |
| 缺省后果 | 存入 -1000.0 (ALWAYS),且标志位 0x1 不置位 |
| 错误后果 | 解析失败或数值 ≤ 0 时存入 -1000.0。但标志位 0x1 依然会被置位(只要原字符串非空),这会导致词缀系统将非法时长判定为有效时长 |
| 实际用量 | 原版 8676 / MOD 库 177358 |
NOTE
INSTANT 瞬时标记对 175 种效果类型而言属于完全不产生逻辑的空操作,具体类型清单参见 技能设计指南第 5 节。
4.3 七大通用布尔开关
以下 7 个开关均通过 CDataGroup__getBool (0x677F30) 读取,默认实参一律为 0 (false),且均采用「真置位 / 假清位」的双向赋值逻辑,因此显式配置 false 与完全不写在底层完全等价。
| 配置键 | 对应标志位 | Getter 调用地址 | 原版用量 | MOD 库用量 |
|---|---|---|---|---|
SAVE | 0x10 | 0x7AAE3F | 1760 | 55724 |
REMOVEONOWNERDIE | 0x10000 | 0x7AAE84 | 117 | 2281 |
USEOWNERLEVEL | 0x80 | 0x7AAECF | 1212 | 23677 |
DISPLAYPOSITIVE | 0x2 | 0x7AAF1A | 0 | 1 |
NONMAGICAL | 0x8 | 0x7AAF5F | 13 | 104 |
MODIFIERISBONUS | 0x200 | 0x7AAFA4 | 962 | 15541 |
DISPLAYMAXMODIFIER | 0x40000 | 0x7AAFEF | 135 | 780 |
EXCLUSIVE (<BOOL>) —— 唯一默认值具备动态条件的布尔键
| 属性 | 说明 |
|---|---|
| 写入目标 | 标志位 0x4 |
| 读取地址 | 0x7AB9C3 |
| 默认值 | (flags & 4) == 4(即继承此步骤前 0x4 是否已被动态预置) |
| 动态预置条件 | 在 0x7AB91F 的分流中:当 TYPE 索引为 52 (DAMAGE) 或 156 (DAMAGE CHANCE),且 DURATION 既非 -900 亦非 -1000 时,0x7AB94A 会自动将 0x4 预置为 true |
| 结论与规律 | 具有有限持续时间的 DAMAGE(即 DoT 持续伤害)默认即为独占模式:同名同类型无法叠加,仅刷新持续时间。其余效果类型默认均允许叠加 |
| 调整建议 | 若希望 DoT 能够多层叠加,必须显式声明 <BOOL>EXCLUSIVE:false;若希望其他类型不叠加,需显式声明 EXCLUSIVE:true |
| 实际用量 | 原版 2390 / MOD 库 57261 |
4.4 数值、曲线与属性修正
NOGRAPH (<BOOL>)
| 属性 | 说明 |
|---|---|
| 写入目标 | 不存储在对象字段中,仅作为 ctorFromDat 内部的局部控制变量 (0x7AB3A2) |
| 默认值 | 0 (false) |
| 核心作用 | 为 false 时:在五槽循环中为每个数值槽调用 CEffect__bindGraphs 并置位 0x100;为 true 时:完全跳过曲线绑定 |
| 缺省后果 | 尝试绑定曲线。但是否真的绑上,取决于该效果类型在 EFFECTSLIST.DAT 中是否登记了曲线名(详见第 6 节) |
| 实际用量 | 原版 255 / MOD 库 21055 |
IMPORTANT
NOGRAPH 只对登记了默认曲线的 36 个效果类型有实际意义。全部 239 个类型中,仅 36 个在 EFFECTSLIST.DAT 的 GRAPH1 / GRAPH2 两列填写了曲线名;其余 203 个类型即便不写 NOGRAPH,CEffect__bindGraphs 也会因取到空名而返回 NULL,天然走区间随机分支。
因此「未写 NOGRAPH 导致 MIN~MAX 失效」这一判断只在那 36 个类型上成立,另需注意:一旦声明了 GRAPHOVERRIDE,任何类型都会被强制绑定曲线。
GRAPHOVERRIDE (<STRING>)
| 属性 | 说明 |
|---|---|
| 写入目标 | 偏移 +0x88,转大写后存储 |
| 读取地址 | 0x7AB3DB;作为第 3 个参数传递给 CEffect__bindGraphs (0x7AB442) |
| 默认值 | 空字符串 |
| 缺省后果 | 传递空字符串,采用效果类型在 EFFECTSLIST.DAT 中登记的默认曲线(若该列为空则不绑定) |
| 错误后果 | 若指定的曲线名称在曲线管理器中查不到,对应数值槽的曲线指针为 NULL —— 效果等同于该槽启用了 NOGRAPH |
| 实际用量 | 原版 84 / MOD 库 6573 |
WARNING
GRAPHOVERRIDE 的作用范围是全部 5 个数值槽,而非仅槽 0。五槽绑定循环中,同一个字符串被逐次传入 CEffect__bindGraphs,一旦非空即整体替换掉查表得到的默认名(0x7A6093–0x7A60A5)。
由此产生一个容易忽略的副作用:对那 203 个本无默认曲线的类型声明 GRAPHOVERRIDE,会把同一条曲线一次性绑到 5 个槽上,使原本走区间随机的数值全部改走曲线分支。
SOAKSCALE (<FLOAT>)
| 属性 | 说明 |
|---|---|
| 写入目标 | 偏移 +0x0C |
| 读取地址 | 0x7AB044 |
| 默认值 | 保持构造初始值 9999989.0 |
| 缺省 / 错误 | 保持 9999989.0。该数值是引擎内建用于表示「无吸收上限」的极大哨兵值 |
| 实际用量 | 原版 355 / MOD 库 4065 |
STATMODIFYNAME / STATMODIFYPERCENT (均为 <STRING>)
这两个键成对配合使用,支持以英文逗号分隔的列表格式声明多项属性修正。
| 属性 | 说明 |
|---|---|
| 读取地址 | 0x7AB572 / 0x7AB5E1 |
| 默认值 | 均为空字符串 |
| 前置依赖 | 在 0x7AB5A7 处判定:若 STATMODIFYNAME 为空,STATMODIFYPERCENT 根本不会被读取 |
| 拆分与配对 | 经 sub_67C870 按 , 拆分后逐项配对。若百分比列表项数少于属性名项数,缺失项自动补齐 100 (0x7AB6EC,即系数 1.0) |
| 存储结构 | CEffect__addStatModifier (0x7A9EC0):属性定义指针存入 +0x114,百分比数值除以 100 后存入 +0x104,两者构成并行数组 |
| 错误后果 | 若 CStatRegistry__findDef 未能查找到属性定义,该项将被整对丢弃 (0x7A9EDE),无报错提示 |
| 实际用量 | 原版 1002 / 991;MOD 库 22173 / 22298 |
4.5 视觉表现与关联属性
ICON (<STRING>)
| 属性 | 说明 |
|---|---|
| 写入目标 | 偏移 +0x2C (intern 字符串 ID),并维护标志位 0x20000 |
| 读取地址 | 0x7AB116 |
| 默认值 | 空字符串 |
| 缺省后果 | +0x2C 为 −1,标志位 0x20000 清除 |
| 错误后果 | 先在已注册 intern 表中查找;若未找到且文本非空,会自动新建一条 intern 条目 (0x7AB17E–0x7AB1A4)。因此图标名拼错不会得到 −1,而是得到一个指向空贴图的有效 ID |
| 实际用量 | 原版 51 / MOD 库 2809 |
PARTICLE_FX (<STRING>)
| 属性 | 说明 |
|---|---|
| 写入目标 | 偏移 +0xB4,转大写后存储;文本非空则置位标志位 0x1000 |
| 读取地址 | 0x7AB309 |
| 默认值 | 空字符串 |
| 缺省后果 | 保持空字符串,0x1000 清除,不播放粒子 |
| 错误后果 | 字符串正常保存且 0x1000 置位。在播放时 CEffect__playParticleFx (0x7A6363) 因资源不存在而跳过,+0xB0 保持为 0,不会引发崩溃 |
| 实际用量 | 原版 131 / MOD 库 353 |
UNITTHEME (<STRING>)
| 属性 | 说明 |
|---|---|
| 写入目标 | 偏移 +0x128,经 sub_512EA0 (0x7AADDC) 解析为主题对象指针;成功置位 0x2000,失败清除 |
| 默认值 | 空字符串 |
| 缺省 / 错误 | +0x128 均为 0 且标志位 0x2000 清除 |
| 实际用量 | 原版 1353 / MOD 库 25204 |
注意:UNITTHEME 会被序列化进存档,且是以 GUID 形式持久化存储的(详见第 8 节)。
LINK_NAME (<STRING>)
| 属性 | 说明 |
|---|---|
| 写入目标 | 偏移 +0x6C,转大写存储;非空置位标志位 0x800,空则清除 |
| 读取地址 | 0x7AB21F → CEffect__setLinkName 0x7A6290 |
| 默认值 | 空字符串 |
| 实际用量 | 原版 80 / MOD 库 1095 |
STAT_SOURCE_TYPE (<STRING>)
| 属性 | 说明 |
|---|---|
| 写入目标 | 偏移 +0x12C (整型枚举) |
| 读取流程 | 0x7ABA11 读取文本 → 0x7ABA84 反查枚举 |
| 默认值 | 空字符串 |
| 合法取值 | ON CAST CASTER (0)、ON CAST RECEIVER (1)、ON UPDATE CASTER (2)、ON UPDATE SELF (3)。查找表位于 0x3B9E988(注意:词之间使用空格而非下划线) |
| 缺省后果 | 若效果的 ACTIVATION 为 PASSIVE (+0x20 == 0),引擎将在 0x7ABA91 处自动补正为 3 = ON UPDATE SELF;其余情况保持为 0 |
| 错误后果 | 查表未命中时直接保持为 0 (ON CAST CASTER)。注意:拼写错误时不会触发上述 PASSIVE 兜底补正,因为兜底逻辑仅位于缺省的 else 分支中 |
| 实际用量 | 原版 128 / MOD 库 4484 |
IMPORTANT
这个枚举不只是「标注来源」,它还是 STATMODIFYNAME / STATMODIFYPERCENT 的生效开关。 属性缓存重算函数 CEffectMgr__recomputeStats_239cache 在 0x7AFF6E 处的判据是 效果的属性修正列表非空 && STAT_SOURCE_TYPE >= 2,只有 ON UPDATE CASTER (2) 与 ON UPDATE SELF (3) 能通过。
也就是说:配了 STATMODIFYNAME 却把 STAT_SOURCE_TYPE 写成 ON CAST CASTER(0) 或 ON CAST RECEIVER(1)——包括拼错后静默落回 0 的情况——属性倍率在这条路径上根本不会被套用。 详见 CEffectManager 参考手册 第 7 节。
5. 五个基础数值槽与别名映射机制
CEffect 拥有 5 个基础浮点数值槽(+0xDC…+0xEC)。在 DAT 文件中通过何种键名写入各个数值槽,由 CEffect__readValueSlot (0x7A5F00) 分两步解析判定:
第一步:按效果类型专属别名读取
别名表 g_EffectParamNames_239x5 位于 0x28AABA0,维护了 239 行 × 5 列的 std::wstring 矩阵(每项 28 字节,行步长 140 字节)。行号对应效果类型 ID,列号对应数值槽 0–4。其内容完全对应于 EFFECTSLIST.DAT 中各行的 VALUE1 到 VALUE5 列。 常用的 MIN、MAX、CHANCE、MINFORCE 等键名均源自此处的别名注册,它们并非引擎内部的全局固定关键字。
第二步:未命中别名时回退至通用槽位名
若别名不存在(对应列为 NA)或在当前数据块中未读取到,解析器将在 0x7A5F96 分支回退至通用名称:
| 数值槽编号 | 通用配置键名 | 生成机制 |
|---|---|---|
槽 0 (+0xDC) | VALUE | 特殊处理:槽 0 仅为 VALUE,不存在 VALUE1 |
槽 1 (+0xE0) | VALUE2 | 由 L"VALUE" 拼接 intToWString(2) 获得 |
槽 2 (+0xE4) | VALUE3 | 拼接 intToWString(3) |
槽 3 (+0xE8) | VALUE4 | 拼接 intToWString(4) |
槽 4 (+0xEC) | VALUE5 | 拼接 intToWString(5) |
核心结论与工程准则
- 通用名全局有效:
VALUE、VALUE2、VALUE3、VALUE4、VALUE5对全部 239 种效果类型均有效。在无法确认某类型的专属别名时,可直接使用通用名。 - 优先级规则:专属别名 > 通用名。若两者同时存在,专属别名具有更高优先级。
- 未读到的槽位保持 0.0:数值写入逻辑位于
0x7AB41C–0x7AB477,只有当读取到有效数值时才会覆写目标槽位(if (flt_289A6C8 != v37) *(float*)v35 = v37;)。 - 禁止填写
-999:-999在底层被用作浮点读取失败的哨兵值,填入该数值将导致解析逻辑产生不可预期的行为。
NOTE
引擎中存在两张同构的 239×5 表,读 EFFECTSLIST.DAT 时不要混淆:
| 表 | 地址 | 来源列 | 用途 |
|---|---|---|---|
g_EffectParamNames_239x5 | 0x28AABA0 | VALUE1–VALUE5 | 各槽位的配置键别名 |
g_EffectGraphNames_239x5 | 0x28B2E58 | GRAPH1–GRAPH5 | 各槽位的默认曲线名 |
二者行步长同为 140 字节、项步长同为 28 字节,但服务于完全不同的两个阶段:前者决定「从 DAT 的哪个键读数」,后者决定「读到的数如何参与计算」。曲线表的实测分布见第 6.1 节。
6. 核心数值计算与曲线求值公式
最终效果数值的计算由 CEffect__rollAndCacheValue (0x7A85C0) 负责,其核心分流逻辑如下:
lo = 槽0数值 (通常为 MIN), hi = 槽1数值 (通常为 MAX)
if (hi == 0) hi = lo; // 若 MAX 未配置或为 0,自动取 MIN
自动排序保证: lo <= hi // 若 MIN 与 MAX 填反,底层自动纠正顺序
// ★ 无论走哪条分支,都先掷一次随机数,且两条 RNG 路径都把结果同时写回 lo 与 hi 两个局部
roll = random(lo, hi) // 0x7A8697 全局 RNG / 0x7A86DC 种子 RNG
// 0x7A869C fst [var_4] + 0x7A86A3 fstp [var_8]:两个槽被同一结果覆盖
statMul = CEffect__evalStatModifier_resolveUnit(...) // 计算属性加成倍率
分支一: 存在已绑定的曲线 (+0xF0 != NULL)
最终数值 = CGraph__evaluate(graph, LEVEL) × (roll × 0.01) × statMul
// 0x7A87AD fld [var_8] → 0x7A87B1 fmul 0.01 → 0x7A87B7 fmulp(乘曲线)→ 0x7A87B9 fmul statMul
分支二: 未绑定曲线 (无曲线指针)
最终数值 = roll × statMul计算结果存入 +0xD4,向上取整结果存入 +0xD8。 整个计算过程不存在数值截断或钳位(Clamp) —— EFFECTSLIST.DAT 中的 MINVAL/MAXVAL 不参与实际逻辑计算。 需要进一步纠正的是:这两列也不是「UI 用的钳位范围」。按立即数字节扫描 0x401000–0x2200000, g_EffectMinVal_239 (0x28A2500) 全程只出现 1 次,就是加载器的那次写入,没有任何读取点; g_EffectMaxVal_239 (0x28A2140) 只被读 1 次(0x7A71FD),且立刻套进 Ogre::Math::Sign—— 只取符号,量级被丢弃。该符号的用途是判定「数值往哪个方向变算好事」, 从而在 GOODDES 与 BADDES 两套描述串之间选择。详见 239 个 TYPE 的数值槽对照表。
CAUTION
本节曾长期流传一个错误公式:「有曲线时 MIN 被当作百分比、掷出的随机值被丢弃、MAX 完全不参与」。 逐指令重查后确认不成立——0x7A869C 与 0x7A86A3 把同一个掷出值写进了 var_4 和 var_8 两个局部,而曲线分支在 0x7A87AD 取的正是 var_8,即掷出值本身。
两条分支用的是同一个随机数,区别只在「要不要再乘曲线并当成百分比」。 所以 MAX 在两条分支里都参与;只有 MAX == 0(自动取 MIN)或 MAX == MIN 时, 才退化成「MIN 当百分比」这个特例——原版大量效果恰好 MIN == MAX,旧结论正是这么被看漏的。
另有一处旁支:0x7A871E 处若 DISPLAYMAXMODIFIER(0x40000)置位且第二个实参为真, statMul 会被强制置为 1.0。
6.1 曲线到底会不会被绑上
分支一与分支二的分野完全取决于 +0xF0(槽 0 的曲线指针)是否为 NULL,而它由 CEffect__bindGraphs (0x7A6040) 决定。该函数的逻辑仅三步:
name = g_EffectGraphNames_239x5[typeId][slotIdx] // 239 行 × 5 列,行距 140 字节,项距 28 字节
if (GRAPHOVERRIDE 非空) name = GRAPHOVERRIDE // 覆盖名对 5 个槽一律生效
if (name 为空) return NULL // 与 NOGRAPH 无关,直接不绑
return GraphMgr__findGraphByName(name)g_EffectGraphNames_239x5 位于 0x28B2E58,是与数值槽别名表 (0x28AABA0) 同构的第二张 239×5 表,内容来自 EFFECTSLIST.DAT 各行的 GRAPH1–GRAPH5 列。对原版该文件的实测结果:
| 列 | 非空行数 |
|---|---|
GRAPH1(槽 0) | 36 |
GRAPH2(槽 1) | 36 |
GRAPH3 / GRAPH4 / GRAPH5(槽 2–4) | 0 |
即:239 个效果类型中只有 36 个登记了默认曲线,且只覆盖槽 0 与槽 1。全部 12 条被引用的曲线为 BASE_WEAPON_DAMAGE、ATTRIBUTE_BONUS、ARMOR_PLAYER_BYLEVEL_FORSET、ARMOR_MONSTER_BYLEVEL、HEALTH_PLAYER_GENERIC、HEALTH_MONSTER_BYLEVEL、MANA_PLAYER_GENERIC、DAMAGE_MONSTER、STEAL_MANA、STEAL_HEALTH_AND_MANA、ITEM_DEFENSE_REQUIREMENTS、SUMMONDURATION。
IMPORTANT
判断一个效果走哪条分支,按下列顺序即可确定:
- 写了
GRAPHOVERRIDE且该曲线存在 → 分支一(5 个槽全部绑定)。 - 写了
NOGRAPH:true→ 分支二。 - 以上都没有:查
EFFECTSLIST.DAT中该类型行的GRAPH1列。非空则槽 0 绑定 → 分支一;为空 → 分支二。
换言之,「MIN 到 MAX 均匀随机」并不总是需要 NOGRAPH:true:对那 203 个未登记曲线的类型,它本来就是分支二。只有落在 36 个有曲线的类型上时,NOGRAPH:true 才是必需的。
需要澄清的是:走分支一并不意味着区间随机失效。随机照掷、MAX 照样是掷点上界, 只是掷出的结果被当成百分比再去乘曲线值。NOGRAPH:true 改变的是量纲(从「曲线值的百分之几」 变成「绝对值」),不是「有没有随机」。
随机数生成机制:当存在来源技能时,系统采用带独立种子的 RNG 流(CSkill+0x1C8,确保同一次技能施放内的所有效果共用同一随机序列,这是多人联机同步的基础);仅在无来源技能时才采用全局随机数生成器。
7. CEffectManager 的生命周期与管理契约
CEffectManager 通过以下核心契约管理其下挂的所有 CEffect 对象:
- 多队列分流入表:
CEffectMgr__linkEffect(0x7B1D70) 根据效果的+0x20(ACTIVATION) 属性将指针放入不同的管理列表(入表指令在0x7B1F13):0 (PASSIVE)→ 挂入mgr+0x181 (DYNAMIC)→ 挂入mgr+0x282 (TRANSFER)→ 挂入mgr+0x38
- 存活判定与生命周期: 存活判据是「
DURATION为-900/-1000哨兵、或ACTIVATION == 2 (TRANSFER)、或DURATION > 已持续时间」,且未被打上墓碑位0x20。 需注意0x7B1E20–0x7B1E5F这段判定位于CEffectMgr__linkEffect内部,用途是EXCLUSIVE去重时判断旧效果是否仍存活,并非每帧 tick 的生命周期检查;后者在CEffectMgr_expireEffects_perframe(0x7AF2A0) 里,用的是同一套判据。 - 同名去重与独占替换:
EXCLUSIVE(标志位0x4)只检查新挂入的这一条,旧效果带不带该位无关紧要。命中时的行为是让已存在的同名同类型效果全部过期,新效果照常入表 —— 是替换而非「拒绝新的」或「原地刷新旧的」。判定标准为NAME与TYPE联合完全匹配。若多个效果的NAME均为空字符串,在底层会被视为「同名」而发生互相排斥。 - 延迟销毁与墓碑机制: 调用
CEffect__expire(0x7A645E) 时,仅将效果标记为墓碑状态(置位0x20),内存对象的实际回收由CEffectMgr_expireEffects_perframe在当前帧末尾统一执行。若标记了REMOVEONOWNERDIE(0x10000),当来源单位死亡时该效果将自动触发过期。 - 按名称精准移除: 运行时的按名移除由
CEffectMgr__removeEffectsByName(0x7AECA0) 完成,匹配+0x50处经过大写规范化的效果名称,遍历三张表。
NOTE
本节只列出管理器对 CEffect 的对外契约。该类的完整解剖 —— 1240 字节字段图、四张表的分流规则、六阶段挂载流程、三条卸载路径的所有权归属、239 项属性缓存 —— 见 CEffectManager 参考手册。
8. 存档结构与序列化机制
8.1 逐字段二进制布局
效果对象的反序列化入口为 CEffect__save_read (0x7AA300),函数签名为 (this, stream, saveVersion, readGuid2)。其通过流对象的虚表槽 +8 (定长数据读取) 与 +12 (字符串读取) 执行反序列化。读取顺序即为二进制流中的实际存储顺序:
| 序号 | 字段大小与格式 | 目标内存偏移 | 读取条件与前置标志 |
|---|---|---|---|
| 1 | u32 | +0x28 状态标志位 | 总是读取 |
| 2 | u16 长度 + UTF-16 字节流 | +0x50 NAME | 总是读取 |
| 3 | u16 长度 + UTF-16 | +0x6C LINK_NAME | 条件读取:flags & 0x800 |
| 4 | u16 长度 + UTF-16 | +0xB4 PARTICLE_FX | 条件读取:flags & 0x1000 |
| 5 | u16 长度 + UTF-16 | +0x88 GRAPHOVERRIDE | 条件读取:flags & 0x100 |
| 6 | u64 GUID | 解析为 +0x128 UNITTHEME 指针;未找到则清空 0x2000 | 条件读取:flags & 0x2000 |
| 7 | u64 GUID | +0x30/+0x34 目标 GUID | 条件读取:flags & 0x4000 |
| 8 | u64 GUID | +0x38/+0x3C 来源 GUID2 | 条件读取:readGuid2 && (flags & 0x8000) |
| 9 | u8 数量 count,随后 count 个 u32 | 数值槽 +0xDC + 4i | 总是读取 |
| 10 | u16 数量 n,随后 n 组 (u64, f32) | 调用 CEffect__addStatModifier 重建属性修正列表 | 总是读取 |
| 11 | u32 | +0x1C 效果类型索引 (TYPE) | 总是读取 |
| 12 | u32 | +0x14 伤害类型 (DAMAGE_TYPE) | 总是读取 |
| 13 | u32 | +0x20 激活方式 (ACTIVATION) | 总是读取 |
| 14 | u32 | +0x08 等级 (LEVEL) | 总是读取 |
| 15 | u32 | +0x24 持续时长 (DURATION 浮点二进制模式) | 总是读取 |
| 16 | u32 | +0x18 已持续时间 | 总是读取 |
| 17 | f32 | +0xD4 已计算数值;随后写入 +0xD8 = ceil(val) | 总是读取 |
| 18 | u32 | +0x12C 属性来源枚举 (STAT_SOURCE_TYPE) | 总是读取 |
| 19 | u32 | +0x2C 图标 intern ID | 当 saveVersion 为 32 或 33 时 |
| 19′ | u8 长度 + UTF-16 文本 | 读取图标文本并重新 intern 注册存入 +0x2C | 其他存档版本 且 flags & 0x20000 |
CAUTION
在第 9 步读取数值槽时,count 为 u8 类型且缺乏上限安全检查(0x7AA515 中循环 for (i = 0; i < (u8)a2; ++i) 直接写入 this + 4*i + 220)。若存档数据损坏且 count > 5,将引发严重的内存越界写入。
8.2 存档反序列化调用链
CEffect__save_read 在游戏引擎中共有 4 处读取调用点:
| 调用地址 | 所在函数 | 业务上下文 |
|---|---|---|
0x5AA321 | CItemSaveState_read | 物品自身附加效果容器的恢复(0x5AA255 推进偏移 0x124 后读取效果列表) |
0x5AB984 | CItemSaveState_read | 恢复嵌入在 CSkillEffectData 技能记录中的关联效果 |
0x570CD6 / 0x5723D3 | sub_570280 | 单位与角色存档读取路径 |
0x573204 / 0x574740 | sub_573120 | 单位附加状态恢复路径 |
在物品存档读取路径中,调用方传入的 readGuid2 参数固定为 0,因此保存在装备上的效果不会持久化 GUID2 来源信息。
8.3 序列化写入与存档安全保护
CAUTION
早期结论「CEffect 没有独立的流写入方法」是错的,已更正。 写入对偶确实存在:CEffect__save_write (0x7A9380)。之前没找到它,是因为当时按 「谁调 Save_scrambleFileInPlace」这条线索回溯,而它其实是靠流对象的虚表槽与读函数配对的。
读写两侧靠同一个流对象的不同虚表槽配对:
| 函数 | 流虚表槽 | |
|---|---|---|
| 读 | CEffect__save_read 0x7AA300 | +8(定长)、+12(字符串) |
| 写 | CEffect__save_write 0x7A9380 | +4 |
字段顺序逐项一致,签名也对称(this、流对象、writeGuid2 标志)。两个只在写侧才看得出来的细节:
- 数值槽的
count是写侧裁出来的。0x7A9539–0x7A9557的循环是for (i = 0; i < 5; i++) if (slot[i] != 0) count = i + 1;,即取「最后一个非零槽的下标 + 1」。 尾部为 0 的槽不写入存档——这就是读侧第 9 步那个count为何是变长的。 副作用:一条效果如果只用了槽 0 和槽 4,中间三个 0 也会被写进去;但只用槽 0 时只写 1 个。 flags & 0x2000那个 u64 是UNITTHEME对象的+0x90/+0x94(0x7A94D1), 对象为空则写-1/-1。所以读侧拿到的是一个 GUID、需要重新解析回对象指针——两侧对得上。- 图标(
flags & 0x20000)写侧没有版本分支,一律写u8 长度 + UTF-16, 把+0x2C的 intern id 反查回字符串(红黑树查找,查不到用默认串)。 对应读侧saveVersion不是 32/33 的那条分支。
层级上读写完全镜像:
| 读 | 写 |
|---|---|
CEffect__save_read 0x7AA300 | CEffect__save_write 0x7A9380 |
CItemSaveState_read 0x5A9920 | CItemSaveState_write 0x5A8C40(两侧都递归,用于处理孔内物品) |
sub_570280 / sub_573120 | sub_56EE90 / sub_56F6F0 |
CEquipment__saveToRecord (0x590DC0) 做的是另一件事:用 CEffect_CopyCtor 把活动效果克隆进 CItemSaveState(0x59118C、0x5911A2、0x591348)。它是「把状态搬进存档结构」, 真正的字节序列化在 CEffect__save_write。 SAVE 配置项对应的标志位 0x10 是决定效果是否允许持久化的唯一过滤闸门。
NOTE
导出的 .SV 存档文件在落盘前会经过 Save_scrambleFileInPlace (0x680DC0) 加密混淆,并通过 Save_djb2Checksum (0x680470) 生成校验码。因此无法直接使用文本编辑器修改存档文件。
8.4 修改 DAT 配置对已有存档的实际影响
TYPE以索引形式存入存档(极高危险性) 第 11 步持久化保存的是 0–238 的整型行号索引而非类型名称。如果 MOD 修改了EFFECTSLIST.DAT的总行数或排列顺序,旧存档中所有物品与角色的效果类型将发生整体错位(例如「移动速度提升」错误映射为「流血伤害」)。因此该文件严禁调整行序。已存在的效果数值不会重新计算 5 个基础数值槽(第 9 步)、已计算数值
+0xD4(第 17 步)、LEVEL与DURATION均已完整固化在存档中。载入时不会重新抽取随机数,因此在 MOD 中修改MIN/MAX,旧存档中已有装备的属性数值纹丝不动,仅对新生成的掉落物生效。但「不重新绑定曲线」这一说法不成立:
CEffect__save_read读流结束后,若flags & 0x100置位(判定在0x7AA6F5),会调用sub_7A61A0——该函数对 5 个槽逐一CEffect__bindGraphs(0x7A61EA) 重新绑定曲线,并按实际绑定结果重写0x100位。所以改GRAPHOVERRIDE会影响旧存档里的曲线指针(但不会重算已缓存的+0xD4)。另有一条旧版本补偿路径:
saveVersion ≤ 0x1C(28)且槽 0、槽 1 均大于 10 时(判定在0x7AA745),会重新求值并覆写+0xD4/+0xD8。现行版本走不到这条分支。状态标志位被完整还原
EXCLUSIVE、SAVE、USEOWNERLEVEL等 22 个标志位以整体 u32 形式存入存档(第 1 步),载入后完全还原保存时的状态。未持久化的动态字段
SOAKSCALE(+0x0C)、来源单位/技能指针、所属词缀指针、5 条曲线指针均不进入存档,在载入后保持构造初始值或在挂载流程中动态重建。
9. 配置速查表
[EFFECT] 24 个核心配置键 数据类型 缺省或错误处理说明
──────────────────────────────────────────────────────────────────────────
TYPE 字符串 缺省/错误 → 193 NONE (完全失效,必填项)
ACTIVATION 字符串 缺省/错误 → 1 DYNAMIC (可选: PASSIVE|DYNAMIC|TRANSFER)
DURATION 字符串 缺省 → ALWAYS; 解析<=0 → ALWAYS (可选: INSTANT|ALWAYS|秒数)
NAME 字符串 缺省 → 空名称 (去重失效); 内存中存两次大写结果
LEVEL 整型 缺省 → 0; 负数或>1000 → 0 (USEOWNERLEVEL+有来源时不读)
DAMAGE_TYPE 字符串 缺省 → Physical; 写错 → Magical (7 项枚举,不区分大小写)
STAT_SOURCE_TYPE 字符串 缺省+PASSIVE → 3 (ON UPDATE SELF); 写错 → 0 (空表回退)
SOAKSCALE 浮点 缺省/错误 → 9999989.0 (无吸收上限)
UNITTHEME 字符串 缺省/错误 → 无单位主题
ICON 字符串 缺省 → 无图标; 写错 → 注册新的空 intern 条目
PARTICLE_FX 字符串 缺省 → 不播放; 写错 → 资源未找到静默跳过
LINK_NAME 字符串 缺省 → 空字符串
GRAPHOVERRIDE 字符串 缺省 → 用类型自带曲线(仅 36 类有); 写错 → 曲线置空
一旦声明即覆盖全部 5 个槽
NOGRAPH 布尔 默认 false; 仅对登记了默认曲线的 36 个类型有意义
EXCLUSIVE 布尔 有限时长的 DAMAGE/DAMAGE CHANCE 自动为 true,其余为 false
SAVE 布尔 默认 false (不存入存档)
REMOVEONOWNERDIE 布尔 默认 false (来源死亡时不自动移除)
USEOWNERLEVEL 布尔 默认 false (不采用来源单位等级)
DISPLAYPOSITIVE 布尔 默认 false (原版未曾使用)
NONMAGICAL 布尔 默认 false (不标记为非魔法)
MODIFIERISBONUS 布尔 默认 false
DISPLAYMAXMODIFIER 布尔 默认 false
STATMODIFYNAME 字符串 属性名列表 (逗号分隔);为空时 PERCENT 不被读取
STATMODIFYPERCENT 字符串 百分比列表 (逗号分隔);项数不足自动补齐 100
五个基础数值槽
──────────────────────────────────────────────────────────────────────────
优先级: 专属别名 (EFFECTSLIST.DAT 该行的 VALUE1..VALUE5) > 通用槽位名
通用名: 槽0=VALUE 槽1=VALUE2 槽2=VALUE3 槽3=VALUE4 槽4=VALUE5 (注: 无 VALUE1)
-999 为底层读取失败哨兵值,严禁作为数值配置。
数值计算公式
──────────────────────────────────────────────────────────────────────────
存在曲线: 最终值 = 曲线求值(LEVEL) × (random(MIN,MAX) × 0.01) × 属性加成倍率
未绑曲线: 最终值 = 均匀随机(MIN, MAX) × 属性加成倍率
MAX 为 0 自动取 MIN;MIN/MAX 顺序错误底层自动纠正;全程无数值钳位。10. 常见排错与排坑指南
Q1:配置了效果但完全不生效,游戏亦无任何报错提示?
- 首先检查
TYPE拼写:若类型名称未能匹配 239 种合法类型,底层将静默回退至 193 (NONE),导致效果完全失效; - 其次检查
DURATION:对于 175 种基础效果类型,声明INSTANT将直接被视为空操作。
Q2:修改了 MIN 与 MAX 数值,但游戏中数值并未如期波动?
- 先按第 6.1 节的三步判定确认该效果走的是哪条分支。若走曲线分支,掷出的
MIN~MAX随机值会被当作百分比去乘曲线值,量纲完全不同——数值看着「没变化」往往是因为曲线值本身很小或很大; - 若该类型属于登记了默认曲线的 36 种之一,补写
<BOOL>NOGRAPH:true即可切回区间随机; - 若该类型本无默认曲线却仍未随机,检查是否误写了
GRAPHOVERRIDE—— 它会把曲线一次性绑到全部 5 个槽上。
Q3:调整了效果数值后,新角色/新掉落生效,但旧角色身上的旧装备数值未变?
- 属于正常现象。旧存档中的效果数值已作为静态快照固化(第 8.4 节),反序列化载入时不会重新求值或重掷随机数。
Q4:更新 MOD 后,玩家旧存档中的装备属性发生大面积混乱或错位?
- 检查是否修改了
EFFECTSLIST.DAT的总行数或排列顺序。效果类型以行号索引入档,顺序变动会导致旧存档中的所有效果类型全局错位。
Q5:希望持续伤害(DoT)能够多层叠加,但实际测试只能刷新持续时间?
- 引擎针对具有持续时间的
DAMAGE(52) 与DAMAGE CHANCE(156) 默认将EXCLUSIVE预置为true。若需支持多层叠加,必须显式声明<BOOL>EXCLUSIVE:false。
Q6:声明了独占(EXCLUSIVE)的两条效果依然同时存在并生效?
- 独占去重依赖
NAME与TYPE的联合匹配。检查两条效果的NAME是否完全一致(包含大小写、空格与下划线)。若名称不完全一致,将被判定为两条独立效果。
Q7:配置了 STATMODIFYPERCENT 但未产生属性加成?
- 检查
STATMODIFYNAME是否为空。若未提供属性名,引擎在0x7AB5A7处会直接跳过百分比字段的读取。
Q8:被动效果的属性修正行为异常?
- 检查
STAT_SOURCE_TYPE拼写:当ACTIVATION:PASSIVE且缺省该键时,引擎自动设置为 3 (ON UPDATE SELF);若拼写错误,将因未命中查表而保持为 0 (ON CAST CASTER),且不会触发被动兜底补正。
Q9:声明为物理伤害,但在实战中却受到魔法抗性减免?
- 检查
DAMAGE_TYPE拼写:拼写错误将回退至初始值 1 (Magical),只有缺省或正确拼写PHYSICAL才会映射为 0 (Physical)。此外需排查该类型是否属于 9 种强制指定为 Magical 的特殊效果类型。