核心定位:从技能开发者的视角,把技能 dat 的每一层对应到引擎里的真实类和真实分支,给出「该怎么写」而不是「大家都这么写」。 改技能时最消耗时间的不是想不出效果,而是写了没反应——数据看着没错、GUTS 不报错、游戏也不崩,就是不生效; 本文就是为消灭这类「不报错的错」而写的方法论主线。
分析依据:IDA(idalib MCP)逐指令拆
Torchlight2.exe(32 位,imagebase0x400000)的技能/效果/词缀链路, 覆盖CSkill/CSkillProperty/CSkillEvent/CSkillEffectAndAffixes/CEffect/CAffix/CEffectMgr七个类, 四个巨型分派器(施加期 222 路、tick 47 路、INSTANT 231 路、门卫 217 路)全部用calc_switch_cases解到 case 级; 再拿本机 165753 个.DAT(含原版 unpacked、挑战者大陆、tl2-mikuro-classes-group)做数据侧对账。 所有0x……是绝对地址。关联文档:本文串联
[EVENT_*]/[LEVELn]/[EFFECT]/[AFFIXES]的层级与写法; 四个底层类的逐字段解剖见 CEffect、CAffix、 CEffectManager、CEffectDisplayMgr 参考手册。一句话结论:技能 dat 的块名就是类名,
[EVENT_*]的类型只决定「何时触发」而不决定「能写什么键」, 而真正决定一个效果生效与否的是DURATION落在哪一态——写错这一项,引擎既不报错也不执行, 全库实测有 555 处就这么白写了。修订记录:2026-08-25 —— ①
ACTIVATION静默降级的落点由 PASSIVE 更正为 DYNAMIC: 枚举反查 miss 时CEffect+0x20保持构造初值 1(0x7AAA88),键缺省时 getter 的默认实参也是L"DYNAMIC"(0x7AAC3D), 两条路径同落点,已逐指令复核(初版 §4 / §12 误写为 PASSIVE)。 ② 死键统计是 2026-08-24 的全库快照;职业技能组件已于次日把 498 处DAMAGETYPE修正为DAMAGE_TYPE, 各组件现状以tl2_dat_check.py的无效键审计为准。
0. 结论先行 —— 十二条最常咬人的规则
| # | 规则 | 后果 |
|---|---|---|
| 1 | DURATION:INSTANT 对 175 个效果类型是彻底的空操作 | 写了等于禁用这个效果,不报错 |
| 2 | 有限时长的 DAMAGE(也就是 DoT)默认就是 EXCLUSIVE | 同名同 type 不叠加,只刷新;想叠加必须显式写 EXCLUSIVE:false |
| 3 | [EVENT_XXX] 块名拼错静默变成 EVENT_START | 反查无失败分支,时机全错但没有任何提示 |
| 4 | 14 种 EVENT_* 块共用同一个解析器和同一套键 | 「这个键能不能放这个块」不是问题,类型只决定触发时机 |
| 5 | 数值槽接了曲线时 MIN 被当百分比、MAX 完全不参与 | 想要 MIN~MAX 随机必须写 <BOOL>NOGRAPH:true |
| 6 | TYPE 查不到时静默保持 193 = NONE | 效果名拼错 = 整条效果变哑,不报错 |
| 7 | -999 是数值槽的读取失败哨兵 | 任何数值槽都不能填 -999 |
| 8 | [LEVELn] 是继承链 + 懒构造,等级号不能跳号 | [LEVEL5] 缺失时 [LEVEL6] 及以后永远不会被构造 |
| 9 | ADD STAT(146)跟 239 属性缓存毫无关系,而且槽 2 是它的触发概率 | 槽 2 留空 → evalSlot 返回 0 → 只有 1% 触发率 |
| 10 | EFFECTSLIST.DAT 必须恰好 239 行 | 行数变了不崩,但所有类型索引整体错位 |
| 11 | ATTACH / DAMAGETYPE / PLAY_HIT 都是死键,正确拼法是 ATTACHES / DAMAGE_TYPE / PLAY_HITS | 全库约 5200 处白写,肉眼审 dat 发现不了 |
| 12 | 武器系技能的伤害主体是 EVENT 块的 WEAPONDAMAGEPCT 和 SOAKSCALEPCT,不是 [EFFECT] 里的 MIN/MAX | 只调 MIN/MAX 会觉得「怎么改都不动」 |
查「这个键能写在哪个块」请直接翻 第 16 节 —— 那是从解析函数里逐个提取出来的完整对照表,比骨架更适合当手册用。
还有一条不算规则但更省时间:引擎自带一个 COMBAT LOG 设置项,打开后每施加一个效果打一行日志, 覆盖护甲、格挡、命中结算、技能施法、效果增删共 30 处。调数值前先打开它,见 第 11 节。
1. 骨架:三个块名就是三个类
技能 dat 的块层级和引擎的 C++ 类严格一一对应。MSVC 把构造函数和 loadFromDat 内联成了一个, 所以 IDA 里看到的一个大函数既写 vftable 又读 dat。
| dat 块 | 真实类(RTTI) | 构造 + 解析 | 对象大小 |
|---|---|---|---|
[SKILL] 顶层 | CSkill | 0x6C2690 | — |
[LEVELn] | CSkillProperty | 0x6DC320 | 712 (0x2C8) |
[EVENT_*] | CSkillEvent | 0x6CEE60 | 524 (0x20C) |
三个类的基类都是 CRunicCore。CSkillEvent 额外多重继承三个接口,这解释了它那九十多个键 为什么横跨完全不同的领域:
// CSkillEvent 对象头部四个 vftable 槽
+0x00 CSkillEvent // 主 vftable,基类 CRunicCore
+0x08 iDamageShape // DAMAGE_TYPE / MAX_UNITS_HIT / SOAKSCALEPCT
+0x0C iMissile // MISSILE_PULSE_* / ORIENTTOTARGET
+0x10 iUnitSpawner // MAKEPET / UNIT / MATCH_PET_DURATION一个 EVENT 块同时是伤害判定体、投射物和单位生成器。 不是三选一,是三者叠加—— 你在同一个块里写伤害、写弹道、写召唤,引擎按三套接口分别去取。
加载是两段式的
启动时 CSkillParser(单例 0x369E0F8)只读 MEDIA/SKILLS.RAW 这份索引,不解析任何技能内容:
u32 count
repeat count:
wstring 技能名 // 比如 CLASS_QLJX_SKILL_1_1
wstring dat 文件路径
u8[8] // 两个 int,初值 -1路径过一遍 PackageMgr__findFile 确认存在就登记完事,真正的 [SKILL] 解析推迟到技能第一次被用到。 所以新增技能必须进 SKILLS.RAW,光有 dat 文件不会被发现。
完整的块清单 —— 不止三个
上面三个是骨架,但技能 dat 里实际会出现十几种块。下面这张表按本机 26000 余个技能 dat 里的真实实例数排, 每一行都验过引擎侧有对应的解析器:
| 块 | 实例数 | 装什么 | 解析方 |
|---|---|---|---|
[SKILL] | 13009 | 技能级属性(106 个不同键) | CSkill__ctorFromDat |
[LEVELn] | — | 每级差异(66 个不同键) | CSkillProperty__ctorFromDat |
[EVENT_*] | — | 时机 + 图形 + 伤害体 + 弹道 + 生成器(92 个不同键) | CSkillEvent__ctorFromDat |
[EFFECTS] / [EFFECT] | 875291 次赋值 | 效果本体(87 个不同键) | CEffect__ctorFromDat |
[AFFIXES] | 180612 | 词缀引用(15 个键) | CSkillEffectAndAffixes__ctorFromDat |
[AFFIX] | 35485 | 内联词缀定义 | CAffix__loadFromDat |
[STATWATCHER] | 21080 | 属性监视器:STAT / WATCH_TYPE / PRIMARY_TARGET_TYPE / SECONDARY_TARGET_TYPE / SECONDARY_VALUE / VALUE | CStatWatcher__ctorFromDat |
[EXECUTE_SKILL] | 13633 | 串联施放另一个技能:SKILL + RETARGETONEXECUTE | CSkillEvent__ctorFromDat |
[UNITTYPES] | 10673 | UNITTYPE / UNITTYPES 过滤 | — |
[TARGET_SPECIFIC_UNITS] | 5351 | UNIT 白名单 → CSkill+0x64 | CSkill__ctorFromDat |
[IGNORE_SPECIFIC_UNITS] | 826 | UNIT 黑名单 → CSkill+0x74 | 同上 |
[REQUIREMENT_LEFT] / [REQUIREMENT_RIGHT] | 2136 / 3271 | 左右手武器 UNITTYPE 需求 | — |
[SOUND] | 478 | CATEGORY/NAME/LOOPS/VOLUME/RADIUS/GUID 等 8 键 | — |
[FILES] | 1071 | 多个 FILE | — |
[EFFECTSREMOVE] / [AFFIXESREMOVE] | — | 按名移除;两者判定规则不对称(见第 4 节) | 同 [EFFECT] 一族 |
[STATWATCHER] 和 [EXECUTE_SKILL] 是两个用量很大却少被讨论的块:前者让技能按某个 stat 的 比较结果分支(WATCH_TYPE 的六个取值是 GREATER THAN / GREATER THAN OR EQUAL TO / LESS THAN / LESS THAN OR EQUAL TO / EQUAL TO / NOT EQUAL TO,带空格), 后者直接在事件里串起另一个技能,配 EXECUTE_SKILL_COUNT 控制次数。
UNIQUE_GUID 不是装饰
CSkill+0x1B8 存 UNIQUE_GUID(int64)。它是事件系统的主键,也是 UI 增益条的分组键 (CEffect__sameDisplayGroup 0x7B2460 直接比这个 int64)。两个技能撞 GUID 会让 它们的 EVENT 表和增益条图标互相污染。抄别人的技能当模板时第一件事就是换掉它。
2. [LEVELn] 是继承链,只写差异
CSkillProperty 不是各自独立的等级快照,而是一条链:
CSkillProperty* ctorFromDat(
CSkillProperty *this, // 新分配的 712 字节
CSkill *skill, // → this+0x38
CSkillProperty *inheritFrom, // → this+0x34 上一级,做默认值来源
CDatNode *node, // → this+0x48 [LEVELn] 块
unsigned levelIndex) // → this+0x3C几乎每个键的默认值都是 inheritFrom 的同名字段。 所以 [LEVEL7] 里只写变化的那几个键就够了。 [LEVEL1] 的上级是「技能根块当 LEVEL 0 解析」出来的对象——这就是写在 [SKILL] 顶层的 RANGE 能被所有等级看到的原因。
等级号不能跳号
CSkill__setLevel(0x6C0C80)从当前已构造数往上,逐个找 [LEVEL n],找到就 new 一个, 找不到就停。所以 [LEVEL5] 缺失时,[LEVEL6]…[LEVEL10] 永远不会被构造。 写 15 级技能就得老老实实 1..15 连号。
值得单独记的默认值
| 键 | 偏移 | 没写时是什么 |
|---|---|---|
RANGE | +0x94 | 继承,否则 100.0 |
TARGETRANGE | +0x98 | 继承,否则用本级刚读到的 RANGE |
RANDOMRANGE | +0xA0 | 继承,否则 2.0 |
SPEED | +0xAC | 继承,否则 1.0 |
MONSTERCOOLDOWNMS | +0xB4 | 继承,否则用本级 COOLDOWNMS |
TURNRATEOVERRIDE | +0xC0 | 继承,否则 -1.0 |
TARGET_RAN_POS_RADIUS | +0xC8 | 继承,否则 10.0 |
CHANCE | +0xCC | 继承,否则 100 |
CANPROC | +0xD4 | 继承,否则 true |
SKILL_ICON_INACTIVE | +0x100 | 继承,否则用本级 SKILL_ICON |
DISPLAYNAME | +0x138 | 继承,否则用本级 NAME |
MANA_GRAPH_SCALE | +0x26C | 继承,否则 1.0 |
MAXDURATIONALLOWED | +0x44 | -1.0,唯一不继承的 |
[LEVELn] 里实际最常写的键
66 个不同键,但用量极度集中。注意排第一的不是 RANGE 也不是 COOLDOWNMS:
| 次数 | 键 | 备注 |
|---|---|---|
| 58448 | <FLOAT>RANDOMRANGE | 目标点随机散布半径,默认 2.0 —— 用量第一 |
| 11444 | <INTEGER>COOLDOWNMS | ×0.001 存成秒 |
| 11009 | <TRANSLATE>DESCRIPTION | |
| 3777 | <FLOAT>RANGE | |
| 3641 | <FLOAT>MANACOST | |
| 3518 | <FLOAT>MANACOSTOT | 持续消耗(over time),跟 MANACOST 是两个键 |
| 3303 | <BOOL>CONTINUOUSLOOPING | |
| 3054 | <INTEGER>LEVEL_REQUIRED | |
| 2744 | <INTEGER>MINIMUMTIMEMS | 最短施放时间 |
| 2319 | <INTEGER>DURATIONOVERRIDEMS | 配 ANIMATIONLOOP 时有特殊后处理 |
| 1036 | <FLOAT>SPEED | |
| 924 | <FLOAT>TARGET_RAN_POS_RADIUS | |
| 889 | <FLOAT>MANA_GRAPH_SCALE | |
| 579 | <INTEGER>CHANCE | |
| 411 | <FLOAT>MAXHEIGHTDIFFERENCE | 目标高度差上限 |
| 382 / 338 / 154 | NOSTRIKEEFFECTS / NOSTEALEFFECTS / NOSTRIKEPARTICLES | |
| 374 | <BOOL>DAMAGESHAPES_HIT_ONCE | |
| 338 | <BOOL>DUELWIELD_REQUIRED | 需要双持 |
[SKILL] 顶层用量最高的则是:UNIQUE_GUID(13009)、NAME、ACTIVATION_TYPE、 TURNRATEOVERRIDE(12644)、DISPLAYNAME、TARGET_ALIGNMENT、CAN_BE_SILENCED(9480)、 RANGE、SKILL_ICON / SKILL_ICON_INACTIVE、ANIMATION、BASE_DESCRIPTION、 MAX_INVEST_LEVEL(4404)、TIER1/2/3_DESCRIPTION、HIDDEN、FINDTARGETANGLE、 SHOW_IN_BUFFLIST、ALLOWS_TURNING、IGNORES_LOS、IGNORES_CAST_SPEED、REQUIRES_PATHABLE。
三个后处理
- 所有
*MS键读进来一律 × 0.001 存成秒; - 冷却小于
0.01直接归零; - 写了
ANIMATIONLOOP但DURATIONOVERRIDEMS≤ 0 时,引擎强行设成 1.0 秒。
顺带一个命名不一致:SLOWDOWNMS 虽然带 MS,读的却是 getFloat 不是 getInt。
哨兵触发曲线 —— 正规写法是「不写这个键」
LEVEL_REQUIRED 和 MANACOST 读到 -999 时不用字面值,而是去 REQUIREMENT_GRAPH / MANA_COST_GRAPH 上按等级取点。
关键在于 -999 本身就是读取时传给引擎的默认值:
push 0FFFFFC19h // = -999,getInt 的 default 参数
call CDataGroup__getInt
mov [esi+40h], eax
cmp dword ptr [esi+40h], 0FFFFFC19h // 等于哨兵 → 走曲线
jnz skip所以省略这个键就会走曲线,不需要显式写 -999。数据侧也印证了这一点: 全库 23837 处 LEVEL_REQUIRED 和 4457 处 MANACOST 里,显式写 -999 的是 0 处, 而 REQUIREMENT_GRAPH / MANA_COST_GRAPH 分别出现在 5563 / 3289 个文件里。
显式写 -999 按代码是等效的(比较的是值),但既然全库无人这么写,跟着惯例省略更安全。 顺带注意 LEVEL_REQUIRED 走的是 getInt、MANACOST 走 getFloat。
3. [EVENT_*]:选哪个、怎么不写错
事件类型不是某个键的值,而是块名自己
解析时拿 node+8 的 name hash 去表里反查,结果写进 CSkillEvent+0x20。
| idx | 块名 | 全库出现次数 | idx | 块名 | 全库出现次数 |
|---|---|---|---|---|---|
| 0 | EVENT_START | 96706 | 7 | EVENT_UNITDIE | 1286 |
| 1 | EVENT_END | 33630 | 8 | EVENT_MISSILEHIT | 3010 |
| 2 | EVENT_TRIGGER | 75431 | 9 | EVENT_MISSILEDIE | 5635 |
| 3 | EVENT_TRIGGER_TWO | 6064 | 10 | EVENT_DIEBYEFFECT | 0 |
| 4 | EVENT_TRIGGER_THREE | 3489 | 11 | EVENT_CASTERDIE | 129 |
| 5 | EVENT_TRIGGER_FOUR | 10325 | 12 | EVENT_UNIT_CREATE | 27 |
| 6 | EVENT_UNITHIT | 22409 | 13 | EVENT_MISSILE_PULSE | 2355 |
EVENT_DIEBYEFFECT 引擎支持但全库一次都没人用过。
⚠ 反查没有失败分支。 14 项全比一遍,一个都不匹配就保持索引 0。 块名拼错不报错,静默变成
EVENT_START。 症状是「效果在起手瞬间就全放完了」。
四种 TRIGGER 有什么区别?没有
所有 14 种 EVENT 块共用同一个解析器、同一套键集合。 EVENT_START 用到的 71 个键、 EVENT_TRIGGER 用到的 80 个键,交集之外的差异纯粹是数据里的用法习惯,不是引擎限制—— 任何键放进任何 EVENT 块都会被同样解析。
类型索引只决定什么时候触发:
EVENT_START施法起手EVENT_END收尾EVENT_TRIGGER/_TWO/_THREE/_FOUR挂在动画的触发点上,是四个独立的槽位,不是优先级EVENT_UNITHIT命中单位时EVENT_MISSILE_PULSE按MISSILE_PULSE_SECONDS/MISSILE_PULSE_DIST周期性触发
唯一按类型分叉的默认值是 CAN_CLONE。
EVENT 不住在等级里
这是整套系统里最反直觉的一处。[EVENT_*] 块写在 [LEVELn] 内部,但解析出来的 CSkillEvent 对象不属于那个 CSkillProperty——它们全部进一个全局单例 CSkillEventManager(0x3691448)的三层 map:
map<int64 UNIQUE_GUID, // = CSkill+0x1B8
map<uint 等级号, // = CSkillProperty+0x3C
map<int 块序号, CSkillEvent*>>>CSkillProperty 里只留了 +0x60..0x6D 十四个 bool,标记「本级有没有这一类事件」。
第三层的键是块序号而不是事件类型——所以同一个 [LEVEL1] 里可以塞两个 [EVENT_TRIGGER] 而不互相覆盖:引擎把 14 种块名的所有实例收进同一个向量,按下标编号。
EVENT 块的高频键 —— 伤害其实主要靠这两个
EVENT 块有 92 个不同键。按真实用量排的前二十几个,顺便标出我一开始漏掉的重点:
| 次数 | 键 | 作用 |
|---|---|---|
| 134790 | <STRING>FILE | 场景对象图(粒子/弹道/伤害体) |
| 44862 | <BOOL>STATSHIDDEN | 不在属性面板显示 |
| 44451 | <BOOL>CAN_CLONE | 唯一按事件类型分叉默认值的键 |
| 40421 | <FLOAT>WEAPONDAMAGEPCT | 按武器伤害的百分比出伤 —— 近战/远程技能的主要伤害来源 |
| 37355 | <BOOL>ATTACHES | 特效挂到单位上 |
| 35018 | <BOOL>USEDPS | 按 DPS 而不是单次伤害算 |
| 27848 | <BOOL>DAMAGESHAPES_HIT_ONCE | 同一伤害体只命中一次 |
| 26458 | <BOOL>NOSTEALEFFECTS | 不被吸血/偷取类效果触发 |
| 25079 | <STRING>DAMAGE_TYPE | 伤害类型 |
| 22887 | <FLOAT>SOAKSCALEPCT | 护甲/抗性吸收的缩放百分比 —— 调穿甲的正门 |
| 19800 | <BOOL>PLACEONTARGET | 生成在目标处 |
| 17322 | <BOOL>BUILD_CHARGE | 累积充能条 |
| 14934 | <BOOL>NOSTRIKEEFFECTS | 不触发命中效果 |
| 14559 | <STRING>FIREFROMBONE | 从哪根骨骼发射 |
| 12132 | <BOOL>APPLYEFFECTS | 是否施加 [EFFECTS] |
| 7315 | <FLOAT>CHARGESCALEPCT | 充能对数值的缩放 |
| 6987 | <STRING>FIREATBONE | 打向目标哪根骨骼 |
| 6370 | <INTEGER>DURATIONOVERRIDEMS | 覆盖时长 |
| 4893 | <BOOL>NODAMAGEREFLECTION | 不被伤害反射 |
| 3956 | <BOOL>CASTFROMTARGET | 从目标位置施放(注意不是 CASTSFROMTARGET) |
| 3130 | <INTEGER>MAX_UNITS_HIT | 最多命中几个 |
| 2026 | <BOOL>CANTBEBLOCKED | 无法被格挡 |
| 1546 | <BOOL>MAKEPET | 生成物算宠物 |
| 935 / 50 / 263 | MISSILE_PULSE_DIST / _SECONDS / _OFFSET | EVENT_MISSILE_PULSE 的周期参数 |
WEAPONDAMAGEPCT和SOAKSCALEPCT是我第一版漏掉的两个大件。 前者是绝大多数武器系技能真正的伤害来源(比[EFFECT]里写死的MIN/MAX常见得多), 后者是调「这一击吃多少护甲」的正门。两者都属于iDamageShape接口那一层, 所以只在当作伤害体的那个 EVENT 块上有意义。解析细节已逐指令核实(
CSkillEvent__ctorFromDat0x6CF4C2/0x6CF529): 两个都在getFloat之后fmul 0.01、再钳到 ≥ 0,存进+0x144/+0x148, 由CTriggerableActions__execute传给CCharacter__strike_pickWeapon。 所以WEAPONDAMAGEPCT:50内部是 0.50。SOAKSCALEPCT的默认值是 100(即 1.0 = 完整吃护甲) —— 写小于 100 就是穿甲: 写3意味着这一击只吃 3% 的护甲减免。这一点很容易被当成「护甲缩放百分比越大越好」写反。 它们在[LEVELn]里也各出现 30 次(WEAPONDAMAGEPCT在那儿是<STRING>),属于少见写法,不建议跟。
特效 layout 从哪儿接进来
EVENT 块里 <STRING>FILE: 指向的 .layout 是场景对象图(粒子、投射物、伤害体、时间线), 和 MEDIA/UI/**/*.LAYOUT 那种 CEGUI 控件树格式同源但消费方完全不同。 两者都是 [BASEOBJECT] + DESCRIPTOR,别搞混。
4. [EFFECT] 还是 [AFFIXES]:层级不对称
这五个块名共用同一个解析器 CSkillEffectAndAffixes__ctorFromDat(0x6C6A30): [EFFECT] [EFFECTS] [AFFIXES] [EFFECTSREMOVE] [AFFIXESREMOVE]
但两条支线的层级不一样,这是最容易搞错的地方:
效果是两层: node → [EFFECTS] → [EFFECT] × N // TARGET 写在 [EFFECTS] 容器这一层
词缀是一层: node → [AFFIXES] × N // [AFFIXES] 本身就是一条引用
AFFIX:名字 // 按名字去 MEDIA/AFFIXES/**/*.DAT 找 [AFFIX] 定义
AFFIXLEVEL:n
TARGET:…[AFFIXES] 是引用不是容器——真正的 [EFFECT] 装在被引用的那个 [AFFIX] 定义里。
什么时候用哪个
| 场景 | 用 | 理由 |
|---|---|---|
| 这个技能专属的一两条效果 | [EFFECTS] + [EFFECT] | 就近维护,不用建词缀文件 |
| 多个技能/装备共用同一组效果 | [AFFIXES] 引用 | 改一处生效全部 |
| 需要按等级摇不同档数值 | [AFFIXES] + AFFIXLEVEL | 词缀有 MINLEVEL/MAXLEVEL 档位机制 |
| 需要在 UI 增益条上单独显示 | 都行,但 [AFFIXES] 更自然 | 分组键是来源技能的 GUID |
关于
DIFFICULTIES_ALLOWED:引擎确实读它,但别用。 它是词缀级的键 (CAffix__loadFromDat0x7A5654用getInt读进affix+0x8C),挂载时用(1 << 当前难度) & 掩码卡门,不中就把这个词缀实例析构掉。 但两件事让它现在不适合动:全库 165753 个 dat 里出现次数是 0; 而构造期默认值是 10(0b1010),还有另外两条构造路径也写这个字段。 「默认 10 却又人人都能生效」这件事我还没对齐,没实测前不要写这个键。
[EFFECT] 的高频键
87 个不同键,前二十几个:
| 次数 | 键 | 备注 |
|---|---|---|
| 110360 | <STRING>TYPE | 拼错静默变 193 NONE |
| 107452 | <STRING>ACTIVATION | PASSIVE/DYNAMIC/TRANSFER 三选一,拼错/漏写都静默变 DYNAMIC |
| 101753 | <STRING>DURATION | 三态,见第 5 节 |
| 92996 / 92995 | <FLOAT>MIN / MAX | 槽 0 / 槽 1 |
| 85129 | <STRING>NAME | 和 TYPE 一起构成唯一性键 |
| 44696 | <BOOL>EXCLUSIVE | 默认值有坑,见第 6 节 |
| 30788 | <STRING>DAMAGE_TYPE | |
| 20723 | <STRING>UNITTHEME | |
| 20200 | <BOOL>USEOWNERLEVEL | 置真时跳过词缀的等级下发、用自己原有等级重掷 |
| 18129 / 17995 | <STRING>STATMODIFYPERCENT / STATMODIFYNAME | 按某个 stat 的值缩放本效果 |
| 16552 / 16551 | <FLOAT>MINFORCE / MAXFORCE | 不是击退,是致死抛尸力度 |
| 13847 | <BOOL>MODIFIERISBONUS | |
| 11491 | <FLOAT>CHANCE | |
| 11406 | <BOOL>NOGRAPH | 不写它 MAX 就不参与 |
| 8427 | <INTEGER>LEVEL | |
| 7618 | <BOOL>SAVE | 是否随存档持久化 |
| 4437 | <STRING>GRAPHOVERRIDE | |
| 3813 | <FLOAT>SOAKSCALE | 注意跟 EVENT 块的 SOAKSCALEPCT 是两个不同的键 |
| 3464 | <STRING>STAT_SOURCE_TYPE | |
| 1835 | <BOOL>REMOVEONOWNERDIE | |
| 1756 | <STRING>ICON | |
| 904 | <STRING>LINK_NAME |
[AFFIXES] 只有 15 个键,常用的就四个:AFFIX(180612)、TARGET(119823)、 AFFIXLEVEL(119178)、DURATION(8770),外加 ADDITIONALDESCRIPTION 和 TARGETTYPE。
内联的 [AFFIX] 定义块常用键:NAME、DURATION、MIN_SPAWN_RANGE / MAX_SPAWN_RANGE、 WEIGHT、RANK、SLOTS_OCCUPY(各约 35400 次),以及 PLAY_HITS。
三个真实的解析坑(都是引擎的,不是数据写错)
[EFFECTS]里没写IGNORE_UNITTYPE时,父块写的那份会被冲成空; 而TARGETTYPE的默认是「当前值」,能穿透下来。两者不对称。- 逐个
[AFFIXES]的循环只清零targetType和target,ignoreUnitType不清零—— 会泄漏到下一个[AFFIXES]块。多个[AFFIXES]并排时,第一个写了IGNORE_UNITTYPE后面几个会跟着继承。 AFFIXESREMOVE只看值的类型不看键名(写FOO=也算);EFFECTSREMOVE反过来只看键名不看类型。 补充(2026-08-25 审计):[AFFIXESREMOVE]块自身另有五个真实读点——TARGET/TARGETTYPE/IGNORE_UNITTYPE/PARTICLEONREMOVE/ADDITIONALDESCRIPTION(最后这个会进技能 tooltip); 被取名扫描吞进移除列表的字符串,只有撞上真实词缀名才有后果,全仓 + 原版扫描零撞名。 所以这个坑的实际杀伤力是「别把要保留的键写成裸字符串」,而不是「这些键全都白写」。
AFFIXLEVEL 缺省是 0 不是 -1
-1 是显式写在 dat 里的哨兵,含义「用本等级的 LEVEL_REQUIRED」。它决定摇词缀数值时取哪一档。 留空 = 0 档,通常不是你想要的。
词缀被挂上时会发生什么
CEffectMgr__addAffix(0x7AFFD0)的顺序:
1. 难度门: (1 << 当前难度) & affix.DIFFICULTIES_ALLOWED 不中 → 直接丢弃
2. 设 duration / level / 两个数值乘数
3. push_back 进 CAffix 向量 ← 注意:这一层完全不去重
4. setSource → 递给全部子效果
5. 逐个子效果过门卫 → 施加 dispatch → 注册 proc ← 词缀层到效果层的桥
6. 没给 duration 时从子效果推算
7. 立即重算 239 属性缓存两个要点:
- 词缀层完全不去重,同名词缀可以无限堆。去重只发生在子效果进
linkEffect那一层。 - 难度不中时引擎会把这个词缀实例析构掉。数据侧的表现就是「这条词缀在普通难度下完全不存在」, 不是「存在但数值为 0」。
词缀自身的时长在没显式给时按这个规则推算:只要有一个子效果是 PASSIVE/TRANSFER activation 或 ALWAYS,整条词缀就是永久的;否则取所有带 ALWAYS 位的子效果里最长的那个;一个定时子效果都没有就是 INSTANT。
5. DURATION 三态 —— 最高频的失效原因
DURATION 不查任何枚举表,解析规则是:
| 写法 | 内部值 | 走哪条路 |
|---|---|---|
INSTANT | -900.0 | CUnit__executeInstantEffect(0x559690),执行一次就丢 |
ALWAYS / 缺失 / "0" | -1000.0 | 进三张效果表,每帧 tick,永不自然到期 |
| 正数(秒) | 原值 | 进三张效果表,每帧 tick,到期移除 |
三条路是完全独立的代码,一个效果类型可能只在其中一条有实现。这就是失效的根源。
175 个类型写 INSTANT 等于禁用
executeInstantEffect 的主 switch 有 231 个 case、54 个跳转目标,其中一个目标吃掉了 175 个 case—— 那里只有一条 fstp st(1)(平衡 FPU 栈)然后直落函数收尾。纯空操作。
落空的集合包括:
- 全部纯属性类(所有
PERCENT *、DAMAGE BONUS、CRITICAL CHANCE…) - 四个元素状态(
BURN/FREEZE/SHOCK/POISON) STUN/SILENCE/BLIND/IMMOBILIZE- 整个 CAST SKILL 一族(除裸 74)
KNOCK BACK EFFECT、HP MOD OVER TIME、UNIT THEME
这个划分本身是合理的——属性加成、DOT、状态、proc 注册全都需要持续时间才有意义。 但后果是:写成 INSTANT 既不报错也不执行。
全库实测:555 处这样白写的配置、涉及 38 种 TYPE(对照组是写 INSTANT 且确实有实现的 63959 处, 所以占比只有 0.86%——不是普遍性问题,但对涉及的那些具体技能是实打实的失效)。排头的几个:
| 次数 | TYPE | 分布 |
|---|---|---|
| 155 | PERCENT DAMAGE BONUS | 技能 124、词缀 31 |
| 58 | KNOCK BACK | 技能 33、词缀 25 |
| 56 | KNOCK BACK EFFECT | 词缀 51、技能 5 |
| 33 | DAMAGE BONUS | 词缀 32 |
| 30 | CAST SKILL ON DEATH FROM EFFECT OWNER | 技能 30 |
| 26 | SHIELD BREAK(205) | 技能 21 —— 注意 SHIELD BREAK EFFECT(215) 是有实现的 |
| 22 | SHOCK | 词缀 22 |
名字差一个词、行为差全部:SHIELD BREAK 和 SHIELD BREAK EFFECT 是两回事。
反过来:有些类型只有 INSTANT 有实现
222 SET SKILL LEVEL 和 127 STOP SKILL 在施加期 dispatch 落 default、tick 分派器里也没分支, 只有 INSTANT 这里有实现。它们本来就该写 INSTANT(数据也印证了:222 的 2306 次全是 INSTANT)。
227/228 SKILL ENABLE/DISABLE 是延时一次性开关
它们走入口段的 ACTIVATION==2 直落路径、完全跳过 switch,DURATION 由公共尾巴掷 MINTIME~MAXTIME,开关只在效果过期那一刻才拨。
所以「禁用技能 N 秒」必须成对下发:一个 228 配时间 0(立即禁用),再一个 227 配 N 秒(N 秒后启用)。 只写一个 228 配 N 秒的效果是「N 秒后禁用,然后永远禁用」。
6. 叠加还是刷新:EXCLUSIVE 的默认值陷阱
叠加规则全在 CEffectMgr__linkEffect(0x7B1D70),改 dispatch 没用:
遍历三张表:
if (!(新效果.flags & EXCLUSIVE)) continue; // 只看【新来的】这一条
if (旧.NAME != 新.NAME) continue; // 两边都已 toUpper → 大小写不敏感
if (旧.TYPE != 新.TYPE) continue; // NAME 和 TYPE 必须同时相同
旧效果还活着 → 打墓碑| 情况 | 结果 |
|---|---|
新效果没有 EXCLUSIVE | 无条件叠加,同名同 type 可以堆任意多份 |
新效果有 EXCLUSIVE | 旧的全部打墓碑,新的照样入表 → 替换/刷新,不是拒绝 |
| 任何情况 | 绝不拒绝新效果——这个函数没有 return-false 路径 |
默认值不是 false
默认值 = (TYPE ∈ {52 DAMAGE, 156 DAMAGE CHANCE} && DURATION 是有限正数)
flags.EXCLUSIVE = getBool(node, "EXCLUSIVE", 默认值)有限时长的 DAMAGE(也就是 DoT)自动就是独占的,同名同 type 不堆叠、只刷新持续时间。 想让 DoT 可叠加,必须显式写 EXCLUSIVE:false。
反过来,其它任何 type 默认全可叠加,要独占必须手写 EXCLUSIVE:true。
顺带解释一个老现象:击退能无限叠加,而拉拽是覆盖——因为 43 KNOCK BACK EFFECT 是引擎运行时现造的、 拿不到 EXCLUSIVE 位,而 225 PULL EFFECT 在创建时显式加了。这两个类型数据里都写不了, 数据侧只能写 223 PULL。
名字是唯一性的一半
匹配键是 (NAME, TYPE) 两者同时相同。所以:
- 想让两条同类效果各自独立刷新:给它们不同的
NAME; - 想让新的顶掉旧的:
NAME和TYPE都一致 +EXCLUSIVE:true; NAME入口就做toUpperW,大小写不敏感,但空格不会被规范掉("MY BUFF"≠"MYBUFF")。
7. 五个数值槽:两个必须知道的覆盖规则
MIN / MAX / CHANCE / TIME / PETINDEX 这些名字在 exe 里根本不存在。 它们是 5 个通用数值槽(CEffect+0xDC..+0xEC)的别名,来自 EFFECTSLIST.DAT 每行的 VALUE1..VALUE5。
239 种效果里 184 种只用 (MIN, MAX)。
⚠
-999是读取失败哨兵。任何数值槽都不能填-999。
规则一:接了曲线,槽值变成百分比 —— 但有两条求值路径,别混
引擎有两个函数在算「这个槽最终是多少」,走哪条决定 MAX 参不参与。
路径 A ── CEffect__evalSlot(idx)(0x7A63D0),逐槽读参数,不掷随机:
CEffect__evalSlot(idx):
g = *(this + 0xF0 + 4*idx); // 每槽各有一条可选曲线
if (!g) return *(this + 0xDC + 4*idx); // 无曲线 → 直接用写的数
return graph(LEVEL) * (槽位值 * 0.01); // 有曲线 → 槽值当百分比系数它全程不碰 +0xD4,所以调 evalSlot(0) 的分支里 MAX 是死参数。 40 多个调用方走这条,多数用于第 2~5 槽的 CHANCE / TIME / 半径这类参数。
路径 B ── CEffect__rollAndCacheValue(0x7A85C0),先掷再套曲线:
roll = rand(槽0, 槽1) // 即 rand(MIN, MAX)
g = *(this + 0xF0); // 只用槽 0 那条曲线
值 = g ? graph(LEVEL) * (roll * 0.01) * statMul
: roll * statMul; // 结果缓存进 +0xD4两条分支都用掷出的值,MAX 都参与。 伤害和治疗的主数值走这条: CUnit__executeInstantEffect 在 0x5596B8 取一次 +0xD4,供整个 switch 用。
⚠ 2026-08-28 更正。 本节此前写「掷出的随机值被丢弃、
MAX完全不参与」, 那句话对路径 A 成立,当成通则就错了。错因是 Hex-Rays 在rollAndCacheValue的 带种子 RNG 分支上丢了 st0 返回值(伪影形如v30 = v16),照反编译读会以为随机值被扔。 汇编实证:0x7A8697/0x7A86DC两个call rand_*之后都是fst+fstp把返回值 同时存进两个局部,最终乘法用的正是其中一个。
MAX 为 0 时自动取 MIN(掷值退化成定值);lo/hi 会自动排序,写反不出错;全程无钳位。 GRAPHOVERRIDE 非空时,五个槽的曲线全部换成同一条 override 曲线。
想要纯 MIN~MAX 随机、不要曲线缩放,就必须写 <BOOL>NOGRAPH:true。
规则二:有些分支读 MIN 而不读掷好的值
掷随机的结果写在 CEffect+0xD4,而很多分支直接调 evalSlot(0)——也就是只读 MIN。 对这些类型来说 MAX 是死参数。已确认的例子:
113 WARP的MAX_DEPTH:从不被读取,层数恒等于MIN_DEPTHTELEPORT系(157/158/235/236)签名里第二个参数干脆就叫NA142 SWAP POS WITH PET的PETINDEX:整个分支里没有任何evalSlot调用,宠物是随机挑的
判断方法:效果没反应又怀疑是随机没生效时,先试着把 MIN 和 MAX 填成同一个值。
TYPE 拼错静默变哑
TYPE 字符串拿去 g_EffectTypeNames_239 线性查,查不到时静默保持默认 193 = NONE。 比对只对输入 toUpperW、不对表项,所以 EFFECTSLIST.DAT 里的 NAME 必须本来就全大写。
全库有 206 处 TYPE 值没有注册——那些效果全是哑的。
EFFECTSLIST.DAT 的 239 行硬上限
media/EffectsList.dat 是效果类型注册表,行号 = 效果类型 id,行数被硬卡死在 239。 行数不等于 239 时只往 Ogre 日志写一句 no longer match up with internal effect list 就继续跑—— 不会崩,但索引全体错位,症状是效果全部错乱而不是报错。
想扩表 = 改引擎:得同时挪 15 张 [239] 全局表、每角色 239 个 float 的属性缓存 (CEffectMgr+0x60..+0x41C,紧邻其后就是别的字段,无余量),以及全部按常量下标取值的消费点。 改这个文件时只改描述文本和 DISPLAYPRECISION,别动行数和 NAME 顺序。
8. 属性类效果怎么才真正生效
239 属性缓存
被动属性类效果不走任何 switch,而是 statCache[type] += value 纯下标累加 (CEffectMgr__recomputeStats_239cache 0x7AFE00),消费方按常量下标取。
重算的触发时机不统一:词缀挂载那条(CEffectMgr__addAffix)紧接着就重算, 而 CBaseUnit__getOrCreateEffectMgr_linkEffect 和 CEffectMgr_AddEffect 都不重算。 所以「刚挂上的效果什么时候出现在 getStat 里」取决于走了哪个入口。
重算时会跳过打了墓碑的效果,所以 EXCLUSIVE 替换不会有重复计数的那一帧。
ADD STAT(146)走的是另一套系统
它是技能侧第二常用的效果(4106 次),但跟 239 属性缓存毫无关系: 用 CUnit+0x114 的 CStatManager + CStat,键是 64 位 GUID,倒计时挂在 CStat 实例自己身上。
它从不调 AttachDynamicEffect,所以既不进三张效果表、不参与 EXCLUSIVE 去重, getStat 也永远看不见它。
⚠ 槽 2 是它的触发概率。 已逐指令核实施加期分支(
0x562D1E):roll = rand_int_between(0, 100)(闭区间),然后evalSlot(2) >= roll才继续。 所以槽 2 留空 →evalSlot(2)返回 0 → 只在 roll 恰好为 0 时成立 = 约 0.99% 触发率; 填负数则永远不触发。写ADD STAT一定要把槽 2 填 100。范围要说准:施加期只有
146 ADD STAT有这个独立分支,147–151落到通用挂载路径;146/148/150的概率骰在INSTANT执行器那一侧。所以「ADD STAT家族都要填槽 2」 这个说法偏宽,准确的是:ADD STAT一定要填;SET STAT/CLEAR STAT只在写DURATION:INSTANT时才走概率骰。
147/149/151(ADD/SET/CLEAR STAT ON LEVEL)跟 146/148/150 的唯一区别是把 StatManager 换成关卡自己的。但这三个完全没有空指针检查,getLevel() 返回 0 时直接崩。 全库零使用大概就是这个原因——不是未启用特性,是能用但不安全,别碰。
药水效力的落点
公共尾段里:施加者是 unitType 33(可用道具)时,新效果的五个数值槽全部乘 1 + getStat(90 POTION EFFICIENCY) × 0.01 并重掷。所以药水词缀的实际数值不是 EFFECTSLIST 里写的那个。
9. 控制类:抗性对照表与几个反直觉语义
统一判定式(除 STUN 和四元素状态外全部适用):
命中 ⟺ rand(0,100) <= 命中率 且 rand(0,100) > applyDifficultyResistBoost(getStat(抗性号))rand 是闭区间,所以抗性 0 时仍有 1/101 的天然免疫地板(roll 恰好为 0),抗性 ≥ 100 完全免疫。
| TYPE | 名称 | 命中率来源 | 抗性 stat |
|---|---|---|---|
| 64 | STUN | +0xD4 | 139 + 53 |
| 128 | SILENCE | +0xD4 | 138 |
| 196 | IMMOBILIZE | +0xD4 | 197 |
| 202 | BLIND | evalSlot(0) | 233 |
| 144 | PERCENT BLIND | evalSlot(2),0 视为必中 | 无 roll(233 削幅度) |
| 79 | FLEE EFFECT | +0xD4 | 121 |
| 80 | TURN ALIGNMENT | +0xD4 | 137 |
| 167 | INTERRUPT | 无(无条件尝试) | 136 |
| 56 | INTERRUPT CHANCE | evalSlot(0);BOSS→0,精英→×1/3 | 136 |
CUnit__applyDifficultyResistBoost(0x51D310)是调控制强度的总闸:阵营 == 1 时原值返回 (玩家不吃),否则过 DIFFICULTY_RESISTBOOST_VETERAN / _ELITE 曲线。 改这两张曲线就能整体调怪物在高难度下的控制抗性,比逐个改词缀省事得多。
几个必须知道的语义
144 PERCENT BLIND和202 BLIND是两种不同机制:144 是视野半径按百分比缩减 (233 在这里是直接做百分点相减削它的幅度),202 是视野半径硬钳到 2.0 单位、数值大小无关。 两者都被硬编码排除出「反击施法者」逻辑。63 VELOCITY MULT是替换而非相乘。 逐指令核实过:fld1先置 1.0,有 type-63 时直接覆盖成getStat(63)。所以值本身就是最终倍率,多个叠加是相加——两个各写 0.5 的效果叠起来得 1.0(正常速度), 两个各写 1.5 的叠起来得 3.0。- 返回值语义不一致:
144/202/79/56/167抗性或概率失败时对外仍返回成功, 而128/196/80返回失败。做抵抗提示 UI 或消耗判定时会咬人。 122 SHIELD BUFFER顺带给元素免疫。 门卫里唯一的免疫检查是case 106/107/108/109: return hasActiveEffectOfType(122) == 0——挂着SHIELD BUFFER的单位 免疫燃烧、冰冻、电击、中毒,但不免疫眩晕。全库 1556 次里 564 次DURATION是ALWAYS, 那些持有者永久免疫四元素状态。作者想要的多半只是护盾吸伤,却顺带拿到了元素免疫。
10. 位移类:哪些能穿墙
| TYPE | 名字 | 落位方式 | 能否穿墙 |
|---|---|---|---|
| 158 / 235 | TELEPORT(NOPARTICLE) | 只查落点格,不查路径 | 能 |
| 157 / 236 | TELEPORT RANDOM(NOPARTICLE) | 含直线扫描 | 不能 |
| 142 | SWAP POS WITH PET | 两次裸 setPosition,零检查 | 能,两边都能进墙 |
| 162 | WARP TO POI | 找空地 + 贴地 | 能 |
| 223 | PULL | 写速度向量 → 逐帧积分 | 不能,会卡住 |
| 113 | WARP | 不动坐标,换关卡 | 不适用 |
TELEPORT 那条链名义上有三道走格检查,但第三道(直线扫描)是死代码—— 唯一的调用者恒传一个 (-999,-999,-999) 哨兵,而那道检查的条件正是「哨兵不成立时才查」。
MINRANGE/MAXRANGE 在两族里语义不同,虽然参数名一样:
- 158/235 是传送距离夹紧区间:太远截到
MAXRANGE、太近推到MINRANGE; - 157/236 是搜索环带半径,而且第 7 次尝试起最大半径会放宽到至少 5.0, 所以
MAXRANGE写 1 并不能保证只挪 1 单位。
COOLDOWN(槽 4)的实现是给被传送单位挂一个叫 TELEPORTRESIST 的词缀,闸门读 stat 159。 三处不对称:抗性闸门只在「不是自己施加给自己」时生效,所以冷却挡不住你自己的位移技能; 词缀等级硬编码为 1、与效果 LEVEL 无关;走格拒绝了这次传送,冷却依然会挂上。
162 WARP TO POI 的 POI 靠效果的 NAME 定位(不读任何数值槽), 100 次找空地全失败时无条件落在 POI 原点,那里可能在几何体内部。
11. 排错:先把 COMBAT LOG 打开
引擎自带一个 CSettings 设置项 COMBAT LOG。> 0 时每施加一个效果打一行:
effect applied |c00FFFF88<TYPE名> <NAME> <ACTIVATION名> …|u val:<值> dur:<时长>-><目标>全 exe 有 30 处引用这个设置:护甲卷、格挡判定、命中结算、吸收盾、技能施法、 executeInstantEffect、效果增删都在内。调数值前先打开它,不用自己插桩。
「配了没反应」的排查顺序
按命中率从高到低:
DURATION是不是INSTANT,而该 TYPE 又在 第 5 节 的落空集合里?TYPE拼对了吗? 拼错静默变 193 NONE。去EFFECTSLIST.DAT里对一遍字面。- 块名拼对了吗?
[EVENT_XXX]拼错静默变EVENT_START。 - 这个键在 第 12 节 的死键表里吗?
- 接了曲线却指望 MIN~MAX 随机? 加
NOGRAPH:true。 ADD STAT的槽 2 填了吗? 留空 = 1% 触发率。- 等级号跳号了吗?
[LEVEL5]缺失 → 6 以后全不构造。 - 指望它叠加,但它是有限时长的
DAMAGE? 那默认就是独占的,加EXCLUSIVE:false。 - 数值槽填了
-999? 那是哨兵。
12. 别抄的东西:死键与白写
把技能 dat 里出现过的 232 个键名逐个拿 UTF-16LE 去 exe 里搜,再对每个命中位置查 有没有代码引用该地址(后缀合并出来的子串不会有独立 xref)。下面这些是确认引擎不读的:
| 键 | 数据里出现次数 | 说明 |
|---|---|---|
TRIGGERNAME | 7512 | 值恒为 hit;原版 unpacked 里就有 1376 次 |
ATTACH | 4441 | 真键是 ATTACHES。 exe 里没有任何以 ATTACH 结尾的独立宽串,每个命中都是 ATTACHES / ATTACHESTOMASTER 之类的前缀 |
DAMAGETYPE | 598 | 真键是 DAMAGE_TYPE(带下划线)。 串本体在 0x218E57C 确实存在且以 NUL 结尾,但零 xref —— 编译进去了却没人读 |
CLONECOUNT | 546 | CAN_CLONE 是真键,但克隆数量这个不是 |
PLAY_HIT | 203 | 真键是 PLAY_HITS(0x219A6CC,由 CAffix__loadFromDat 读)。PLAY_HIT 只是它的前缀 |
EFFECTSHIDDEN | 104 | 真键是 STATSHIDDEN |
CASTSFROMTARGET | 92 | 拼错了,真键是 CASTFROMTARGET(少个 S) |
PRECOLLIDE | 66 | |
MANA_COST_SCALE | 53 | 真键是 MANA_GRAPH_SCALE |
COOLDOWNMSTEST | 20 | 测试遗留 |
NOTTHEMED | 8 | 拼错了,真键是 NOTTHEME(多个 D) |
TIER4_DESCRIPTION | 6 | 引擎只解析 TIER1/2/3 |
BANIMATION | 4 | 拼错了 |
ATTACH / DAMAGETYPE / PLAY_HIT 这三行是这次重扫时新查出来的,合计约 5200 处白写, 比原来那张表里除 TRIGGERNAME 之外的所有项加起来还多。三个都是正确拼法只差一两个字符的变体, 所以肉眼审 dat 时基本发现不了。
另外两类数字:全库 206 处 TYPE 值没有注册,438 次 TARGET 写了枚举表外的词。
⚠ 判死键有三个陷阱,我自己在这次重扫里踩了两个。
- 数值槽别名按设计就不在 exe 里。
MINFORCE/MAX_PER_HEALTH/IGNORE_RESISTANCE这类名字来自EFFECTSLIST.DAT每行的VALUE1..VALUE5,搜不到是正常的、它们完全有效。 我第一遍扫出 35 个「搜不到」的键,拿EFFECTSLIST的 62 个槽别名过滤后, 有 28 个是这种情况 —— 其中IGNORE_RESISTANCE(2677 次)我差点当成重大发现写出去。- 「只作为子串出现」不等于死。 MSVC 会做后缀合并,而且字符串池里前后不一定有 NUL 边界。
STAT_SOURCE_TYPE我在CEffect__ctorFromDat里亲眼见它被读,却被「前后必须是 NUL」的 启发式判成子串。唯一可靠的判据是查该地址有没有 xref。- 惰性加载的键是活的。
ATTACHMENT_OFFSETY这种只在CSkillEvent__loadGraphicsFromDat(0x6CB6A0)里读的键,主解析器里搜不到但有效。
枚举表:空格和下划线不能错
凡是 dat 里写字符串、内存里存整数的字段,都靠一张 28 字节步长的 std::wstring 数组线性比对。 比对前字符串会过一遍 toUpperW,所以大小写随便写,但 GREATER THAN 是带空格的、 TARGET_POS 是带下划线的,这些不能错。
ACTIVATION 只有三个合法值:PASSIVE / DYNAMIC / TRANSFER(g_EffectActivationNames,恰好 3 项)。 反查同样没有失败分支——拼错静默变成 DYNAMIC:miss 时 CEffect+0x20 保持构造初值 1(0x7AAA88), 而键缺省时 getter 的默认实参也是 L"DYNAMIC"(0x7AAC3D),两条路径同落点。 所以真正会被咬的是想要 PASSIVE 的效果:拼错或漏写都会让它变成 DYNAMIC,进错表、tick 行为完全不同。
13. 一个可抄的骨架
下面这个骨架按前面各节的高频键表配齐了,注释里标了「为什么这么写」。 路径分隔符用正斜杠 —— 实测 135886 个 FILE 值里 135886 个用正斜杠,只有 27 个用反斜杠。
[SKILL]
<STRING>NAME:MY_SKILL_FIREBOLT
<INTEGER64>UNIQUE_GUID:8123456789012345678 // ★ 抄模板第一件事:换掉它
<STRING>SKILL_TYPE:OFFENSIVE
<STRING>ACTIVATION_TYPE:NORMAL // ANY/PROC/WEAPON/NORMAL/PASSIVE
<STRING>TARGET_ALIGNMENT:EVIL
<STRING>TARGET_TYPE:TARGET
<TRANSLATE>DISPLAYNAME:烈焰箭
<TRANSLATE>BASE_DESCRIPTION:向目标射出一枚火弹。
<TRANSLATE>TIER1_DESCRIPTION:+10% 燃烧伤害
<TRANSLATE>TIER2_DESCRIPTION:+1 弹射次数
<TRANSLATE>TIER3_DESCRIPTION:燃烧范围扩大
<STRING>SKILL_ICON:fireboltIcon
<STRING>SKILL_ICON_INACTIVE:fireboltIconOff // 不写会自动用 SKILL_ICON
<STRING>ANIMATION:CastSpell
<FLOAT>RANGE:15 // 顶层写一次,所有等级继承
<FLOAT>RANDOMRANGE:0 // 默认 2.0,想精准命中就显式归零
<FLOAT>SPEED:1
<FLOAT>TURNRATEOVERRIDE:-1 // -1 = 不覆盖
<FLOAT>FINDTARGETANGLE:360
<INTEGER>MAX_INVEST_LEVEL:15
<BOOL>CAN_BE_SILENCED:true
<BOOL>SHOW_IN_BUFFLIST:false
<BOOL>IGNORES_CAST_SPEED:false
<STRING>REQUIREMENT_GRAPH:SKILL_REQ_TIER1 // ★ 不写 LEVEL_REQUIRED,默认哨兵会走这条曲线
[UNITTYPES] // 谁能学这个技能
<STRING>UNITTYPE:MAGE
[/UNITTYPES]
[LEVEL1]
<TRANSLATE>DESCRIPTION:造成 40-60 点火焰伤害,并点燃目标 4 秒。
<INTEGER>COOLDOWNMS:2000 // 读进来 ×0.001 = 2.0 秒
<INTEGER>MINIMUMTIMEMS:400 // 最短施放时间
<STRING>MANA_COST_GRAPH:MANA_TIER1 // ★ 同理,不写 MANACOST
[EVENT_START] // ★ 块名拼错会静默变 EVENT_START
<STRING>FILE:MEDIA/SKILLS/FX/FIREBOLT_CAST.LAYOUT
<BOOL>ATTACHES:true // ★ 不是 ATTACH,那个是死键
<BOOL>ATTACHOWNER:true
[/EVENT_START]
[EVENT_TRIGGER] // 挂在动画触发点,不是"第一优先级"
<STRING>FILE:MEDIA/MISSILES/FIREBOLT.LAYOUT
<STRING>FIREFROMBONE:R_HAND
<STRING>FIREATBONE:CHEST
<BOOL>ORIENTTOTARGET:true
[/EVENT_TRIGGER]
[EVENT_MISSILEHIT] // ← 伤害真正结算的地方
<STRING>FILE:MEDIA/SKILLS/FX/FIREBOLT_IMPACT.LAYOUT
<STRING>DAMAGE_TYPE:Fire // ★ 带下划线;DAMAGETYPE 是死键
<FLOAT>WEAPONDAMAGEPCT:120 // ★ 武器伤害的 120%,武器系技能的主伤害来源
<FLOAT>SOAKSCALEPCT:100 // ★ 吃满护甲/抗性;调穿甲就压这个数
<BOOL>USEDPS:false // 按单次伤害而不是 DPS
<INTEGER>MAX_UNITS_HIT:1
<BOOL>DAMAGESHAPES_HIT_ONCE:true
<BOOL>CANTBEBLOCKED:false
<BOOL>NODAMAGEREFLECTION:false
<BOOL>PLACEONTARGET:true
<BOOL>APPLYEFFECTS:true // 不写就不会施加下面的 [EFFECTS]
<BOOL>STATSHIDDEN:false
<BOOL>CAN_CLONE:true
[EFFECTS]
<STRING>TARGET:ENEMY // ★ TARGET 写在容器这一层
[EFFECT]
<STRING>TYPE:DAMAGE // ★ 拼错会静默变 193 NONE
<STRING>ACTIVATION:DYNAMIC // ★ 三选一;拼错/漏写都静默变 DYNAMIC,要 PASSIVE 必须拼对
<STRING>DURATION:INSTANT // 52 有 INSTANT 实现,正确
<STRING>DAMAGE_TYPE:Fire
<FLOAT>MIN:40
<FLOAT>MAX:60
<BOOL>NOGRAPH:true // ★ 不加这句 MAX 不参与
<BOOL>USEOWNERLEVEL:false
[/EFFECT]
[EFFECT]
<STRING>TYPE:BURN // 元素状态,INSTANT 会落空!
<STRING>ACTIVATION:DYNAMIC
<FLOAT>DURATION:4 // 所以必须给正数秒
<FLOAT>MIN:12
<FLOAT>MAX:12
<BOOL>NOGRAPH:true
<BOOL>EXCLUSIVE:false // ★ 想让多发箭各自烧
<STRING>NAME:MY_FIREBOLT_BURN // NAME+TYPE 是唯一性键
<BOOL>SAVE:false
[/EFFECT]
[/EFFECTS]
[/EVENT_MISSILEHIT]
[/LEVEL1]
[LEVEL2] // ★ 只写差异,其余全继承
<INTEGER>COOLDOWNMS:1800 // 等级号不能跳
[/LEVEL2]
[/SKILL]几处刻意的写法:
- 伤害挂在
[EVENT_MISSILEHIT]而不是[EVENT_TRIGGER]:弹道技能的伤害要在命中时算。[EVENT_TRIGGER]只负责把弹道发出去。 WEAPONDAMAGEPCT+SOAKSCALEPCT才是武器系技能的伤害主体,[EFFECT]里的MIN/MAX是额外附加的固定伤害。纯法术技能可以只用后者、不写前者。- 每个
[EFFECT]都显式写了ACTIVATION:它在真实数据里用了 107452 次,几乎和TYPE一样常见。 拼错/漏写都静默变DYNAMIC——写DYNAMIC的效果漏写虽无实害,但想要PASSIVE的效果 一旦拼错就进错表、tick 行为完全不同,显式写出来意图才可见。 - 两个
[EFFECT]的DURATION一个INSTANT一个正数——DAMAGE(52)有INSTANT实现, 而BURN在INSTANT分支里是落空的。 BURN显式写了EXCLUSIVE:false和自定义NAME:多发火箭各自独立燃烧而不是互相刷新。- 不写
MANACOST/LEVEL_REQUIRED,只给曲线名——默认值就是哨兵,这才是全库的惯用法。 RANDOMRANGE显式写 0:它的默认值是 2.0,而这是[LEVELn]用量第一的键, 不注意就会得到一个「总是打偏两米」的技能。[LEVEL2]只写了改动的那一个键。
换成词缀引用
想做「持续 8 秒的攻速 buff」时,用词缀引用更好维护:
[AFFIXES] // ★ 一层,本身就是引用
<STRING>AFFIX:MY_HASTE_BUFF // 去 MEDIA/AFFIXES/**/*.DAT 找定义
<INTEGER>AFFIXLEVEL:-1 // 哨兵:用本等级的 LEVEL_REQUIRED
<STRING>TARGET:SELF
<FLOAT>DURATION:8 // 词缀级时长;不写则从子效果推算
[/AFFIXES]而 MEDIA/AFFIXES/MY_HASTE_BUFF.DAT 里:
[AFFIX]
<STRING>NAME:MY_HASTE_BUFF
<INTEGER>MINLEVEL:1
<INTEGER>MAXLEVEL:100
<INTEGER>WEIGHT:100
<INTEGER>RANK:1
<INTEGER>SLOTS_OCCUPY:1
<BOOL>PLAY_HITS:false // ★ 带 S;PLAY_HIT 是死键
[EFFECT]
<STRING>TYPE:PERCENT ATTACK SPEED // 纯属性类
<STRING>ACTIVATION:DYNAMIC
<FLOAT>DURATION:8 // ★ 写 INSTANT 会完全落空
<FLOAT>MIN:20
<FLOAT>MAX:20
<BOOL>NOGRAPH:true
<BOOL>EXCLUSIVE:true // 重复施法时刷新而不是堆叠
<STRING>NAME:MY_HASTE
[/EFFECT]
[/AFFIX]并排写多个
[AFFIXES]时注意 第 4 节 那个泄漏坑:IGNORE_UNITTYPE不会在块之间被清零。要么每块都显式写,要么一块都别写。
按 stat 分支:[STATWATCHER]
用量 21080 个实例,却很少被写进教程。它让技能按某个 stat 的比较结果走不同分支:
[STATWATCHER]
<STRING>STAT:HEALTH
<STRING>WATCH_TYPE:LESS THAN OR EQUAL TO // ★ 六个取值都带空格
<STRING>PRIMARY_TARGET_TYPE:SELF // 缺省 1 = SELF
<STRING>SECONDARY_TARGET_TYPE:VALUE
<FLOAT>SECONDARY_VALUE:30
[/STATWATCHER]WATCH_TYPE 的合法值:GREATER THAN / GREATER THAN OR EQUAL TO / LESS THAN / LESS THAN OR EQUAL TO / EQUAL TO / NOT EQUAL TO。 两个 *_TARGET_TYPE 的合法值:VALUE / SELF / UNIT / LEVEL / ALLY / ENEMY。
串联另一个技能:[EXECUTE_SKILL]
[EXECUTE_SKILL]
<STRING>SKILL:MY_SKILL_FIREBOLT_SPLIT
<BOOL>RETARGETONEXECUTE:true
[/EXECUTE_SKILL]
<INTEGER>EXECUTE_SKILL_COUNT:3 // 写在 EVENT 块这一层14. 附录:会影响设计决策的引擎 bug
这些都是逐指令核实过的,不是猜测。列在这里是因为它们直接改变你该怎么写数据。
| 位置 | 问题 | 对设计的影响 |
|---|---|---|
213 RETARGET / 214 AGRO | CHANCE 被摇两次(先确定性 RNG,通过后落穿再摇一次非确定性的) | 实际触发率 ≈ (CHANCE/100)²,CHANCE:50 实测约 25%;而且破坏多人游戏确定性。想要 50% 就得填约 70 |
113 WARP | NAME 里的 ^POI 解析没接上,传给换关的是未截断的原串 | 113 的 NAME 不要写 ^,POI 定位这条路是坏的 |
113 WARP | MAX_DEPTH 从不被读取 | 层数恒等于 MIN_DEPTH,别指望随机层 |
142 SWAP POS WITH PET | PETINDEX 是彻底的死参数 | 宠物是随机挑的,无法指定 |
223 PULL | dist 取的是锚点绝对坐标的模长(到关卡原点的距离) | 同一个 PULL 在离关卡原点越远的地方拉得越快,数值在不同地图区域表现不一致 |
223 PULL | 速度闸门条件写反(用严格 < 0,而速度函数恒返 ≥ 0) | PULL 不受定身影响,而 TELEPORT 受 |
147/149/151 | 完全没有空指针检查 | 过图 / 单位刚构造 / 存档回放时触发就崩。别用 |
181/182 | 181 CONVERT CHARGE TO STAT 给 stat 的是格数,182 …PERCENT… 给的是 0–100 百分数 | 同一条满充能条,181 加「总格数」(比如 4),182 加 100。量纲差 25 倍 |
168 vs 171 | 168 ADD CHARGE PERCENT 的 1 = 条子的 1%;171 ADD CHARGES 的 1 = 一格 | 混用会差一个数量级 |
| 暴击 | 判定式等价于 roll + c > 99 | 真实暴击率 = (c+1)/100,白送一个百分点 |
| 暴击 | CEffect+0x0C 是 min(v, 1.0) | 那个乘数只能压低暴击率,永远不能提高 |
| 元素 proc | 分母 ×5,且读的是抗性基表原值而不是 getResistanceOfType | 零抗性时 proc 上限也只有 20%;装备和词缀堆的元素抗性对 proc 概率毫无影响,只减伤 |
156 DAMAGE CHANCE | 挂表前就地把 a4->TYPE 改成 52,改的是原对象不是副本 | 调用方复用同一 CEffect 实例时,第二次施加就变成裸 DAMAGE、不再掷 CHANCE |
110 KILL | EXPLODE / NO_LOOT 在三条路径上行为全不同 | INSTANT:EXPLODE 生效、NO_LOOT 被忽略;ALWAYS:EXPLODE 被无视;正数秒到期:EXPLODE 硬开、凶手传 NULL |
MINFORCE/MAXFORCE | 不是击退,是「致死时抛尸的力度」 | 非致死伤害和所有 DoT 路径都用不到它;对 HP RECHARGE 更是永远没用 |
7 vs 124 | 引擎里毫无区别 | 「PLAYER」只存在于 EFFECTSLIST 的命名约定里,exe 里根本没有 HP RECHARGE 这个字符串 |
两项还没定论的
一、DIFFICULTIES_ALLOWED 的默认值对不上。 构造期写死 10(0b1010), 按 (1 << 难度) & 10 算,难度 0 和 2 应该整体拒绝所有词缀——显然不是实际表现。 CAffix 有三条写这个字段的路径(0x7A239F / 0x7A5CAE / 0x7A5E04), 还没排清哪条是常规创建走的。在排清之前别写这个键。
二、定时回血的量纲。recomputeStats 只对 type 6/7/123/124 且有限时长的效果乘 value *= 0.016, 然后 tickHealthRegenAndDoT 又乘一次 dt。按字面算,同数值的「定时 HP RECHARGE」 比「ALWAYS 版」和「同数值的 DAMAGE DoT」都弱约 62.5 倍。
是设计意图还是量纲错误,从反汇编判不出来,需要上机量一次。 在量出结果之前, 做回血类效果建议用 ALWAYS + 外层控制时长,避开这个不确定区。
15. 一句话回到问题
「怎么正确设计一个技能」在引擎层面其实只有四个决定:
- 块名写对——
[EVENT_*]和TYPE拼错都是静默降级,不报错; DURATION选对那一态——这是 555 处白写配置的唯一原因;EXCLUSIVE和NAME一起决定叠加还是刷新——DoT 的默认值跟直觉相反;- 数值槽有没有曲线决定
MAX参不参与——要随机就NOGRAPH:true。
其余的(RANGE、COOLDOWNMS、[LEVELn] 差异)都有合理默认值,写错了看得见。 真正吃时间的永远是上面这四个「不报错的错」。
16. 附录二:每个块支持哪些子块和键 —— 完整对照表
这张表比第 13 节的骨架更有参考价值:骨架只告诉你「可以这么写」, 这张表告诉你「引擎在这个块里到底读什么」——写在别处的同名键一律不生效。
怎么得到的:对每个解析函数逐指令扫描,凡是 push offset <键名宽串> → wstring::wstring → CDataGroup__getXxx 这个序列 就记为一个键,→ findChildBlock / collectChildBlocksNamed 记为一个子块, → hashStringW 记为按名字哈希匹配的键。没走这三条路的读取方式抓不到,见文末局限。
块的嵌套关系
[SKILL] CSkill__ctorFromDat 0x6C2690
├─ [TARGET_SPECIFIC_UNITS] ← 白名单
├─ [IGNORE_SPECIFIC_UNITS] ← 黑名单
└─ [LEVELn] CSkillProperty__ctorFromDat 0x6DC320
├─ [STATWATCHER]
├─ [REQUIREMENT_LEFT] / [REQUIREMENT_RIGHT]
├─ [EFFECT] / [EFFECTS] / [AFFIXES] / [AFFIXESREMOVE]
└─ [EVENT_*] CSkillEvent__ctorFromDat 0x6CEE60
│ + loadGraphicsFromDat 0x6CB6A0(惰性)
├─ [TARGET_SPECIFIC_UNITS] / [IGNORE_SPECIFIC_UNITS]
├─ [EXECUTE_SKILL]
├─ [STATWATCHER]
├─ [EFFECTS] ─→ [EFFECT]
├─ [AFFIXES] ─→ [STATWATCHER]
└─ [EFFECTSREMOVE] / [AFFIXESREMOVE]
[AFFIX] CAffix__loadFromDat 0x7A54E0
├─ [EFFECT] CEffect__ctorFromDat 0x7AAA30
├─ [UNITTYPES]
└─ [NOT_UNITTYPES]
[SKILL]顶层比想象的能写更多:技能根块同时被当作 LEVEL 0 交给CSkillProperty__ctorFromDat解析一遍,所以[LEVELn]的 54 个键在顶层也全部有效 (NAME/RANGE/COOLDOWNMS/DISPLAYNAME/REQUIREMENT_GRAPH都属于这一类)。 反过来CSkill自己那 39 个键只在顶层有效,写进[LEVELn]不生效。
[SKILL] 顶层专属(39)
解析:CSkill__ctorFromDat 0x6C2690``
<STRING>ACTIVATION_TYPE、<BOOL>ALLOWS_TURNING、<STRING>BASE_DESCRIPTION、<FLOAT>BREAKOUT_THRESHHOLD、<BOOL>CAN_APPROACH、<BOOL>CAN_ATTACK_FALLBACK、<BOOL>CAN_BE_SILENCED、<BOOL>CAN_LEFT_MAP、<BOOL>CAN_PROC_FROM_SKILL、<INTEGER>CHARGES、<INTEGER>COLUMN、<BOOL>DONT_STOP_ON_DEATH、<BOOL>DONT_TARGET_SMASHABLES、<STRING>GROUP_NAME、<BOOL>HIDDEN、<BOOL>IGNORES_CAST_SPEED、<BOOL>IGNORES_LOS、<BOOL>IGNORES_VISUAL_SPEED、<BOOL>IS_TRAP、<INTEGER>LEVEL_REQUIRED、<BOOL>LOCAL_ONLY、<BOOL>MATCH_TARGET_ANGLE、<INTEGER>MAX_INVEST_LEVEL、<BOOL>MOVES_TO_TARGET、<INTEGER>PANE、<BOOL>REQUIRES_PASSABLE_LOS、<BOOL>REQUIRES_PATHABLE、<BOOL>REQUIRES_TARGET、<INTEGER>ROW、<BOOL>SHOW_IN_BUFFLIST、<STRING>SKILL_TYPE、<BOOL>SNAP_TO_GROUND_TARGET_TYPE、<BOOL>STOPSWHENOUTOFRANGE、<BOOL>STOP_ON_TARGET_DEATH、<STRING>TARGET_ALIGNMENT、<BOOL>TARGET_BACKWARDS、<BOOL>TARGET_CORPSES、<INTEGER64>UNIQUE_GUID、<BOOL>USEWEAPONANIMATION
只在顶层有效,写进
[LEVELn]无效。
[LEVELn](54)
解析:CSkillProperty__ctorFromDat 0x6DC320``
<STRING>ANIMATION、<STRING>ANIMATIONDW、<STRING>ANIMATIONDWLOOP、<STRING>ANIMATIONLOOP、<STRING>ANIMATIONLOOPEND、<BOOL>CANPROC、<BOOL>CAN_TARGET_INVISIBLE_AND_DISABLED、<INTEGER>CHANCE、<BOOL>CONTINUOUSLOOPING、<INTEGER>COOLDOWNMS、<STRING>DESCRIPTION、<STRING>DISPLAYNAME、<BOOL>DUELWIELD_REQUIRED、<INTEGER>DURATIONOVERRIDEMS、<BOOL>EXCLUSIVE、<FLOAT>FINDTARGETANGLE、<STRING>IGNORE_TARGET_UNITTYPE、<BOOL>INTERRUPTABLE、<INTEGER>LEVEL_REQUIRED、<FLOAT>MANACOST、<FLOAT>MANACOSTOT、<STRING>MANA_COST_GRAPH、<FLOAT>MANA_GRAPH_SCALE、<FLOAT>MAXDURATIONALLOWED、<INTEGER>MINIMUMTIMEMS、<INTEGER>MONSTERCOOLDOWNMS、<STRING>NAME、<STRING>NOTTHEME、<INTEGER>NUM_PETS_ALLOWED、<FLOAT>RANDOMRANGE、<FLOAT>RANDOMRANGEMIN、<FLOAT>RANGE、<FLOAT>RANGEMIN、<STRING>REQUIREMENT_DESCRIPTION、<STRING>REQUIREMENT_GRAPH、<STRING>REQUIREMENT_LEFT、<STRING>REQUIREMENT_RIGHT、<BOOL>SINGLETARGET、<STRING>SKILL_ICON、<STRING>SKILL_ICON_INACTIVE、<FLOAT>SLOWDOWNMS、<FLOAT>SPEED、<STRING>TARGET、<FLOAT>TARGETRANGE、<FLOAT>TARGET_RAN_POS_RADIUS、<STRING>TARGET_TYPE、<STRING>TARGET_UNITTYPE、<STRING>THEMEREQUIRED、<STRING>TIER1_DESCRIPTION、<STRING>TIER2_DESCRIPTION、<STRING>TIER3_DESCRIPTION、<FLOAT>TURNRATEOVERRIDE、<STRING>USAGE_DESCRIPTION、<BOOL>USEWEAPONANIMATION
顶层和每个
[LEVELn]都有效(顶层那份当 LEVEL 0)。
[EVENT_*] 主解析(40)
解析:CSkillEvent__ctorFromDat 0x6CEE60``
<BOOL>APPLYEFFECTS、<BOOL>APPLYEFFECTSALWAYS、<BOOL>BUILD_CHARGE、<BOOL>CANTBEBLOCKED、<BOOL>CAN_CLONE、<BOOL>CASTFROMTARGET、<FLOAT>CHARGESCALEPCT、<BOOL>CLEARTARGETONEXECUTE、<BOOL>DAMAGESHAPES_HIT_ONCE、<STRING>DAMAGE_TYPE、<BOOL>DONT_TARGET_SMASHABLES、<INTEGER>DURATIONOVERRIDEMS、<INTEGER>EXECUTE_SKILL_COUNT、<BOOL>IGNOREALIGNMENTTYPE、<BOOL>MATCHALIGNMENT、<FLOAT>MAXDELAY、<FLOAT>MAXHEIGHTDIFFERENCE、<INTEGER>MAX_UNITS_HIT、<INTEGER>MAX_UNITS_HIT_EXECUTION、<FLOAT>MINDELAY、<FLOAT>MISSILE_PULSE_DIST、<FLOAT>MISSILE_PULSE_OFFSET、<BOOL>MISSILE_PULSE_ONGROUND、<FLOAT>MISSILE_PULSE_SECONDS、<BOOL>NODAMAGEREFLECTION、<BOOL>NOSTEALEFFECTS、<BOOL>NOSTRIKEEFFECTS、<BOOL>NOSTRIKEPARTICLES、<BOOL>PATHFOLLOWSTARGET、<BOOL>PATHTOTARGET、<BOOL>RETARGETONEXECUTE、<FLOAT>SOAKSCALEPCT、<BOOL>STATSHIDDEN、<FLOAT>TARGET_AHEAD_OF_UNIT、<BOOL>TARGET_CORPSES、<FLOAT>TARGET_POS_X、<FLOAT>TARGET_POS_Y、<FLOAT>TARGET_POS_Z、<BOOL>USEDPS、<FLOAT>WEAPONDAMAGEPCT
14 种
EVENT_*块名共用这一套键,类型只决定触发时机。
[EVENT_*] 图形/挂载(32)
解析:CSkillEvent__loadGraphicsFromDat 0x6CB6A0``
<BOOL>ATTACHES、<BOOL>ATTACHIGNORESOBSTRUCTIONS、<BOOL>ATTACHIGNORESUNITS、<FLOAT>ATTACHMENT_OFFSETX、<FLOAT>ATTACHMENT_OFFSETY、<FLOAT>ATTACHMENT_OFFSETZ、<BOOL>ATTACHONLYWHILESKILLACTIVE、<BOOL>ATTACHOWNER、<BOOL>ATTACHTOTARGET、<BOOL>ATTACH_LINE_EMITTER、<BOOL>ATTACH_LINE_EMITTER_TO_MAX_DISTANCE、<BOOL>DAMAGE_REQUIRES_LOS、<BOOL>DONTFORCESTOPPARTICLES、<STRING>FILE、<STRING>FIREATBONE、<STRING>FIREFROMBONE、<BOOL>FOLLOWS、<BOOL>LINE_EMITTER_COLLIDES、<BOOL>LINE_EMITTER_GROUND、<FLOAT>LINE_EMITTER_OFFSET、<BOOL>MAKEPET、<BOOL>MAKEPETOFMASTER、<BOOL>MATCH_PET_DURATION、<BOOL>MISSILE_TARGET_SELF、<BOOL>ORIENTTOTARGET、<BOOL>PLACEONTARGET、<BOOL>SCALEATTACHDISTANCE、<BOOL>SYNCH_PETS_TO_OWNER、<FLOAT>TARGETATTACHMENT_OFFSETX、<FLOAT>TARGETATTACHMENT_OFFSETY、<FLOAT>TARGETATTACHMENT_OFFSETZ、<BOOL>USE_MASTER_FOR_ATTACKS
惰性加载——首次用到该事件的图形时才读。跟上面那 40 个同属
[EVENT_*]块。
[EFFECT](24 + 数值槽)
解析:CEffect__ctorFromDat 0x7AAA30``
<STRING>ACTIVATION、<STRING>DAMAGE_TYPE、<BOOL>DISPLAYMAXMODIFIER、<BOOL>DISPLAYPOSITIVE、<STRING>DURATION、<BOOL>EXCLUSIVE、<STRING>GRAPHOVERRIDE、<STRING>ICON、<INTEGER>LEVEL、<STRING>LINK_NAME、<BOOL>MODIFIERISBONUS、<STRING>NAME、<BOOL>NOGRAPH、<BOOL>NONMAGICAL、<STRING>PARTICLE_FX、<BOOL>REMOVEONOWNERDIE、<BOOL>SAVE、<FLOAT>SOAKSCALE、<STRING>STATMODIFYNAME、<STRING>STATMODIFYPERCENT、<STRING>STAT_SOURCE_TYPE、<STRING>TYPE、<STRING>UNITTHEME、<BOOL>USEOWNERLEVEL
另有 5 个数值槽,名字由
EFFECTSLIST.DAT该 TYPE 行的VALUE1–VALUE5决定(全库共 62 个不同别名,如MIN/MAX/CHANCE/MINFORCE),这些名字在 exe 里查不到是正常的。
[AFFIX] 定义块(12)
解析:CAffix__loadFromDat 0x7A54E0``
<INTEGER>DIFFICULTIES_ALLOWED、<FLOAT>DURATION、<BOOL>HIGH_QUALITY、<INTEGER>MAX_SPAWN_RANGE、<INTEGER>MIN_SPAWN_RANGE、<STRING>NAME、<BOOL>PLAY_HITS、<STRING>PREFIX、<INTEGER>RANK、<INTEGER>SLOTS_OCCUPY、<STRING>SUFFIX、<INTEGER>WEIGHT
子块
[EFFECT]/[UNITTYPES]/[NOT_UNITTYPES]按名字哈希匹配。
[STATWATCHER](7)
解析:CStatWatcher__ctorFromDat 0x7BCE80``
<STRING>PRIMARY_TARGET_TYPE、<STRING>SECONDARY_TARGET_TYPE、<FLOAT>SECONDARY_VALUE、<STRING>SECOND_STAT、<STRING>STAT、<FLOAT>VALUE、<STRING>WATCH_TYPE
可以挂在
[LEVELn]、[EVENT_*]、[AFFIXES]三处。
[EFFECT] / [EFFECTS] / [AFFIXES] / [EFFECTSREMOVE] / [AFFIXESREMOVE]
这五个块名共用 CSkillEffectAndAffixes__ctorFromDat(0x6C6A30)。它把哪个键读在哪个块上, 取决于调用方把哪个节点交给它——这是全篇最绕的一处,所以先说节点:
| 从哪来 | 交给解析器的节点 |
|---|---|
[LEVELn](含 [SKILL] 顶层当 LEVEL 0)—— CSkillProperty__ctorFromDat 0x6DEA50 | [LEVELn] 块本身 |
[EVENT_*] —— CSkillEvent__ctorFromDat 0x6D013C | 上述五种块各自一个(事件把五个块名收进同一个队列,逐个构造一次) |
所以同一批键在两条路径下落在不同的块里。下面每一格都标了地址,ecx(被读的节点)是逐条核过的。
① 在「交进来的那个节点」自己身上读
0x6C6BCC / 0x6C6CA5 / 0x6C6CED / 0x6C7B29:
<STRING>TARGET、<STRING>TARGETTYPE、<STRING>IGNORE_UNITTYPE、<STRING>PARTICLEONREMOVE
- 走
[LEVELn]路径时,这四个直接写在[LEVELn](或[SKILL]顶层)就生效。 - 走
[EVENT_*]路径时,得写在五种块里面;写在[EVENT_*]这一层不生效。
⚠
TARGET缺失和拼错是两种不同的结果,别当成一回事:
- 不写
TARGET→ 引擎按技能的TARGET_TYPE推导一个缺省作用对象 (跳表0x6C6C16:SELF/SELFANDPETS→SELF、PETS→PET、OWNER→OWNER、EVERYBODY→EVERYBODY、TARGET→ENEMY,而TARGET_ALIGNMENT:GOOD时→FRIEND)。 所以自体 buff 不写TARGET通常也是给自己。- 写了非法值 → 连推导都不走,
findNameInWStringTable(0x6871A0)失配返 0, 硬落ENEMY。把TARGET拼错的自体技能会变成作用于敌人。「不命中就保持原值」只在
0x6C6F0C/0x6C7643那两个循环里成立,不适用于阶段①。
② 在 [EFFECTS] 子块上读
0x6C6DC4–0x6C6E9E,前提是节点底下真有一个 [EFFECTS] 子块:
<STRING>TARGET、<STRING>TARGETTYPE、<STRING>IGNORE_UNITTYPE、<TRANSLATE>ADDITIONALDESCRIPTION
IGNORE_UNITTYPE的默认值是空串,会把 ① 读到的值冲成空;TARGETTYPE的默认值是「当前值」, 能穿透下来。两者不对称,不是笔误。
③ 在每个 [AFFIXES] 块上读
0x6C73F5–0x6C75D8,外加子块 [STATWATCHER](0x6C76C8):
<STRING>AFFIX、<INTEGER>AFFIXLEVEL、<FLOAT>DURATION、<STRING>TARGET、<STRING>TARGETTYPE、<STRING>IGNORE_UNITTYPE、<TRANSLATE>ADDITIONALDESCRIPTION
循环每轮把 target 和 targetType 清零,所以容器层的
TARGET不会传给[AFFIXES]——每个[AFFIXES]得自己写(原版正是这么干的,3442 次)。清零后 target 默认落到表里第 0 项ENEMY。 而IGNORE_UNITTYPE不清零,会泄漏到下一个[AFFIXES]块。
④ 两个 REMOVE 块的取名规则不对称
这是最容易写错的一处:
| 块 | 哪些行会被当成「要移除的名字」 |
|---|---|
[AFFIXES](0x6C7833) | 键名必须是 AFFIX,且值类型 ∈ 字符串类 |
[AFFIXESREMOVE](0x6C7D63) | 完全不看键名,只看值类型 ∈ 字符串类 |
[EFFECTSREMOVE](0x6C7EBA) | 只看键名 == EFFECT,不看值类型 |
「字符串类」= <STRING>、<TRANSLATE>、<DIALOG>、<NOTE>(见下面的值类型表)。
于是 [AFFIXESREMOVE] 里每写一行字符串,就往移除列表塞一个名字。原版自己在 [AFFIXESREMOVE] 里写了 559 处 <STRING>TARGET,每一处都塞进去一个根本不存在的词缀名 (碰巧无害——没有词缀叫 PARENT 或 SELF)。MOD 库里还多出 316 处 <TRANSLATE>ADDITIONALDESCRIPTION 和 17 处 <STRING>TARGETTYPE,同样被吞。 反过来 <INTEGER>AFFIXLEVEL 因为类型不是字符串才逃过——它写在 [AFFIXESREMOVE] 里 是彻底的死键(原版 547 次、MOD 库 12861 次,全部无效)。
值类型表
CDataValue__setFromTag(0x679E20)——上面判「字符串类」靠的就是它:
<TAG> | 类型码 |
|---|---|
INTEGER / FLOAT / DOUBLE / UNSIGNED INT / BOOL / INTEGER64 | 1 / 2 / 3 / 4 / 6 / 7 |
TRANSLATE、DIALOG | 8 |
NOTE | 9 |
STRING,以及任何认不出来的 TAG | 5 |
⚠
STRING根本没参与比较——它和<STIRNG>、<INT>、<FLOATT>、<>走同一条兜底分支, 一律变成字符串。所以<TAG>拼错既不报错也不留痕:数值键会因为类型不符被getInt/getFloat拒绝,直接返回调用方给的默认值。全库只踩到 1 处 (<UNSIGNEDINT>少个空格,写在一个UNIQUE_GUID上)。
⑤ 两条走不通的路(引擎自己的 bug,不是数据写错)
0x6C6D12 和 0x6C6FAE 想判断「交进来的节点自己就是一个 [EFFECT] 块」,可字面量写成了 混合大小写的 L"Effect";而 .DAT 解析器建块名 hash 前会先 substr 去掉方括号再 toUpperW (0x67BE1D→0x67BE47),hashStringW 又是大小写敏感的。两个 hash 差得很远 (0x0C529815 对 0x0E421C35,不是碰撞)。后果:
[EVENT_*]底下直挂[EFFECT](不裹[EFFECTS])什么都不产生。 事件把这个[EFFECT]当节点交进来,解析器于是去收它的[EFFECT]子块——没有。原版全库只有 5 处这么写 (SKILLS/ITEMS/NORMALPOTION、NORMALMANAPOTION、ELEMENTALS/ELECTRICAOE、EXPLODINGGOBLIN/EXPLODE×2),而这几个技能文件全库无人引用,是遗留内容。[LEVELn]底下直挂[EFFECT]反而能用,靠的是另一条回退:找不到[EFFECTS]子块时, 收集目标回落成[LEVELn]自己。原版有 5 处这么用(SKILLS/PROCS/FIREPROC和 4 个SKILLS/TEST/)。 ⚠ 但只要同一个[LEVELn]里再放一个[EFFECTS],回退就不成立,兄弟位置的直挂[EFFECT]会被静默丢弃。现有数据里零处踩到,但改老技能时值得留意。同一个
[LEVELn]里的第二个[EFFECTS]块也会被静默丢弃 —— 但这条只对[LEVELn]成立。[LEVELn]路径下CSkillProperty只构造一个CSkillEffectAndAffixes,而它内部用的是 单数的findChildBlock("EFFECTS")(0x6C6D7A);findChildBlock(0x678D10)按名字 hash 首个命中就return,后面的同名块根本不会被看到。[EVENT_*]下并排写多个[EFFECTS]是正常且全部生效的,别把这条推广过去 —— 事件走的是复数的collectChildBlocksNamed("EFFECTS")(0x6CFFB6),收齐后逐块各建一个对象。 数据分布正好印证这个不对称:原版[EVENT_*]下并排两个[EFFECTS]有 28 处(MOD 库 1418 处), 而原版[LEVELn]下一处都没有。MOD 库里踩到的有 26 处(另有 5 处并排三个)。一个活例子:
unpacked/Variant/.../DARKNOXMER/BARRAGE/BARRAGE.DAT的[LEVEL10]并排两个[EFFECTS], 第一个是STUN、第二个是KNOCK BACK(FORCE:200)——只有STUN生效,击退整块丢失。 修法是把两个[EFFECT]合进同一个[EFFECTS]里。
同理,arg_C(继承)三个调用点全传 0,0x6C7193 和 0x6C79D2 两段继承代码也是死代码。
⑥ 一个容易忽略的闸门
CSkillProperty 只在 [LEVELn] 至少含 [EFFECT] / [EFFECTS] / [AFFIXES] / [AFFIXESREMOVE] 之一时,才构造这个对象 (0x6DE8D1–0x6DE989)。EFFECTSREMOVE 不在这四个里面。 所以一个只写了 [EFFECTSREMOVE]、其余四块一个都没有的等级,那个块会被整块忽略——想让它生效, 在同一个 [LEVELn] 里随便再放一个(哪怕是空的)[EFFECTS] 就行。现有数据里零处踩到。
PARTICLEONREMOVE 是个很少有人用的键——移除该效果/词缀时播的粒子。
它和 ADDITIONALDESCRIPTION 都各有四个读点,不是三个:阶段①/②/③ 之外, [EFFECTSREMOVE] 分支里还各有一处(ADDITIONALDESCRIPTION 在 0x6C7E34, PARTICLEONREMOVE 在 0x6C7DE1)。而且因为 [EFFECTSREMOVE] 分支在 [AFFIXESREMOVE] 分支之后执行,两个块都写了同一个键时, 最终生效的是 [EFFECTSREMOVE] 里那份。
这条对 ADDITIONALDESCRIPTION 特别要紧 —— 它是玩家在技能说明里能看见的文字。
常见的「键放错块」
下面这些是在真实数据里出现、但该块的解析器不读的键。跟死键不同—— 它们本身是真键,只是写错了地方。
| 写在 | 键 | 应该写在 |
|---|---|---|
[LEVELn] | WEAPONDAMAGEPCT、SOAKSCALEPCT、MAXHEIGHTDIFFERENCE、DAMAGESHAPES_HIT_ONCE、NOSTRIKEEFFECTS、NOSTEALEFFECTS、NOSTRIKEPARTICLES、STATSHIDDEN、APPLYEFFECTS、FILE、FOLLOWS | [EVENT_*] |
[EFFECTS] | CAN_CLONE、PLACEONTARGET、DAMAGESHAPES_HIT_ONCE、APPLYEFFECTS、NOSTRIKEPARTICLES、WEAPONDAMAGEPCT、DAMAGE_TYPE | [EVENT_*] |
[AFFIXES] | STATSHIDDEN、ATTACHES、CAN_CLONE、DURATIONOVERRIDEMS、SPEED、EXCLUSIVE、USEOWNERLEVEL | [EVENT_*] 或 [EFFECT] |
[EFFECT] | TARGET | [EFFECTS] / [AFFIXES] 容器层 |
[AFFIX] | MIN、MAX、ACTIVATION、TYPE、EXCLUSIVE、DAMAGE_TYPE | 内层 [EFFECT] |
[EVENT_*] | TARGET、DURATION、UNIT、BONE、HIDDEN | 视意图,多半是 [EFFECTS] 或 [SKILL] |
[AFFIXESREMOVE] | AFFIXLEVEL(原版 547 次 / MOD 库 12861 次) | 无处可写——移除只认名字,不认等级 |
其中 STATSHIDDEN 只在 [EVENT_*] 里有效(全 exe 只有一个解析点,CSkillEvent__ctorFromDat 0x6CFB94),而它在 [AFFIXES] 里被写了 155 次、[LEVELn] 里 35 次,全部无效。
这张表的两个局限
- 只抓三种读取方式(getter / findChildBlock / hashStringW)。 若某个键用别的方式读(例如手写循环比对),会被漏掉,表现为「表里没有但其实有效」。
[EFFECT]的 5 个数值槽不在表里——它们的名字来自EFFECTSLIST.DAT的VALUE1–VALUE5列,随 TYPE 变化,不是固定键名。
(上一版还有第 3 条「共享解析器的分支归属只做到按地址切段」。那一格现在按 ecx 逐条核过了, 见上面 ① – ⑥,不再是估算。)
17. 附录三:从 [SKILL] dat 到游戏内 tooltip 的完整流程
这一节回答三个很具体的问题:[LEVELn] 下直挂 [EFFECTS] 时效果默认作用于谁、 tooltip 上会怎么显示、以及没有 [EVENT_*] 时效果的顺序由什么决定。
第一段:dat 怎么变成对象
第二段:对象怎么变成 tooltip 文字
技能侧的入口是 CSkillEffectAndAffixes__buildTooltipText(0x6C62B0)。
几个直接能用的结论:
ADDITIONALDESCRIPTION是 tooltip 的第一行,不是补充说明。它写在容器块上 ([EFFECTS]/[AFFIXES]/ 两个 REMOVE 块),不是写在[EFFECT]里。- 顺序就是文件顺序:效果和词缀都按 dat 里出现的先后进列表,tooltip 按同一顺序出行。
- 空串会被跳过,所以没有
ADDITIONALDESCRIPTION的技能不会多出一个空行。 +0x08的CSkill*为空时整段直接返回空串 —— tooltip 一个字都不显示, 这是「技能说明空白」这类问题的第一个排查点。
同族的另外两个入口:CSkillEvent__buildTooltipText(0x6C8FE0)按事件出字, CTriggerableActions__buildDamageTooltipText(0x6DA7E0)专门算伤害那一段。 物品和单位走的是另一条:buildEffectDescForTooltip(0x57F780) → CEffectMgr__buildDescCacheA(0x7B0720),末端复用同样两个叶子函数。
[LEVELn] 下直挂 [EFFECTS],效果默认作用于谁
不是无条件给自己。 走 [LEVELn] 路径时交给解析器的节点就是 [LEVELn] 本身, 阶段① 在它身上读 TARGET;读不到才按技能的 TARGET_TYPE 推一个缺省值 (跳表 0x6C6C16):
技能的 TARGET_TYPE | 枚举值 | 效果的缺省 TARGET |
|---|---|---|
SELF / SELFANDPETS | 3 / 10 | SELF |
PETS | 9 | PET |
OWNER | 12 | OWNER |
EVERYBODY | 4 | EVERYBODY |
TARGET / POSITION_RANDOM_AT_TARGET | 2 / 14 | TARGET_ALIGNMENT:GOOD 时 FRIEND,否则 ENEMY |
其余(NONE/POSITION/ITEM/TARGET_POS…) | — | 不赋值 → 空串 → 查表返 0 = ENEMY |
所以「被动技能不写 TARGET 也是给自己」只在 TARGET_TYPE 写了 SELF(或 SELFANDPETS)时成立。 TARGET_TYPE 没写、或写成位置类的技能,缺省会落到 ENEMY —— 而且 CSkill__getTargetType(0x6BE4B0)在 skill+0x84 为空时直接返回 0 = NONE, 同样落 ENEMY。自体 buff 请把 TARGET 显式写出来,别指望推导。
顺带提醒:
TARGET拼错比不写更糟。不写会走上面这张表推导;拼错则连推导都不走,findNameInWStringTable失配返 0,硬落ENEMY。
没有 [EVENT_*] 时,效果顺序由什么决定
由文件顺序决定,而且是确定的。 三步都保序:
collectChildBlocksNamed(0x679360)按子块在父块里的出现顺序逐个push_back;- 构造循环(
0x6C70E9–0x6C7166)按同一顺序逐个CEffect__ctorFromDat并压进列表; - tooltip 和后续消费方都按列表下标从小到大遍历。
而 [LEVELn] 路径下只有第一个 [EFFECTS] 会被读(见第 16 节 ⑤), 所以不存在「两个容器块谁先谁后」的歧义 —— 顺序完全等于那一个 [EFFECTS] 里的书写顺序。
施加链已查清(2026-08-28)。 之前两版这里写错过两次,现在的结论有汇编级证据。
一次「事件对目标开火」做两件事,由
CSkillEvent__fireAtUnit(0x6C8AE0)驱动:CSkillEvent__fireAtUnit 0x6C8AE0 先查存活 / 距离 / 视线,再解析施放者 ├─ CSkillEffectAndAffixes__applyTo 0x6C4DD0 施加 [EFFECTS] / [AFFIXES] └─ CSkillEffectAndAffixes__removeFrom 0x6C5BC0 处理 [AFFIXESREMOVE] / [EFFECTSREMOVE]施加器对词缀列表
+0x38和效果列表+0x48各走一轮,每项过三道门 (TARGETTYPE白名单 →IGNORE_UNITTYPE黑名单 →TARGET枚举 0–6 关系校验),然后:
- 词缀支:
createAffixByNameAndLevel→setSourceSkill/setLevel/setDuration→CEquipment__attachAffix- 效果支:先调虚表槽 83(
+332)CUnit__canApplyEffectGate_vt83闸门, 过了再setOwnerSkill/setSource,最后调虚表槽 84(+336)CUnit__applyEffectToSelf_dispatch真正施加槽 83 是谓词、槽 84 才施加,两者在同一循环里先后调用。 另有一个只跑闸门的孪生函数
CSkillEffectAndAffixes__canApplyTo(0x6C57A0), 结构与施加器几乎一致但只返回布尔,供「这件物品能不能用在这个单位上」使用 (CItem__canUseOnUnit0x57D490)。两者极易混淆,看它调的是槽 83 还是 84。⚠ 事件层会顶掉等级层,不是叠加
fireAtUnit里那两次调用是else if(汇编0x6C8D37/0x6C8D6B):if (事件自己的 effect/affix 对象数 != 0) ecx = [事件+0x54][i] ← 用事件那份 else if ([[事件+0x44] + 0x2C] != 0) ecx = CSkillProperty+0x2C ← 才用等级那份所以在某个
[EVENT_*]里写了[EFFECTS]或[AFFIXES], 同一等级的[LEVELn]层那份就会被这个事件掉头不用。 两者不会同时生效。原版
RAILMAN/QUAKE/QUAKE.DAT(Emberquake)正是靠这条:它的[EVENT_TRIGGER]里 只有FILE/WEAPONDAMAGEPCT/SOAKSCALEPCT/USEDPS/TRIGGERNAME, 没有[EFFECTS]/[AFFIXES],所以[LEVEL1]层并排的两个[AFFIXES]走第二条分支生效。触发时机
向上追四层,
fireAtUnit的触发路径是:CSkillList__executeSkill(0x6D6FA0,技能施放)、CSkillMgr__setSkillLevel(0x6D78F0,学习/升级,来自CUnit__learnSkill0x5268D0)、CTriggerableActions__execute(0x6C9CC0)、CSkill__abortHard(0x6C14A0)。顺带确定的
PARTICLEONREMOVE在
removeFrom的末尾(0x6C60F0),条件是本次确实移除掉了东西 且+0x30(该字符串非空)。 满足后按+0x1C的名字生成一个单位,并setPosition到目标身上。 所以:没移除掉任何东西时不播;播在目标身上而不是施放者身上。