核心定位:从技能开发者的视角,把技能 dat 的每一层对应到引擎里的真实类和真实分支,给出「该怎么写」而不是「大家都这么写」。 改技能时最消耗时间的不是想不出效果,而是写了没反应——数据看着没错、GUTS 不报错、游戏也不崩,就是不生效; 本文就是为消灭这类「不报错的错」而写的方法论主线。

分析依据:IDA(idalib MCP)逐指令拆 Torchlight2.exe(32 位,imagebase 0x400000)的技能/效果/词缀链路, 覆盖 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] 的层级与写法; 四个底层类的逐字段解剖见 CEffectCAffixCEffectManagerCEffectDisplayMgr 参考手册。

一句话结论:技能 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. 结论先行 —— 十二条最常咬人的规则

#规则后果
1DURATION:INSTANT175 个效果类型是彻底的空操作写了等于禁用这个效果,不报错
2有限时长的 DAMAGE(也就是 DoT)默认就是 EXCLUSIVE同名同 type 不叠加,只刷新;想叠加必须显式写 EXCLUSIVE:false
3[EVENT_XXX] 块名拼错静默变成 EVENT_START反查无失败分支,时机全错但没有任何提示
414 种 EVENT_*共用同一个解析器和同一套键「这个键能不能放这个块」不是问题,类型只决定触发时机
5数值槽接了曲线时 MIN 被当百分比、MAX 完全不参与想要 MIN~MAX 随机必须写 <BOOL>NOGRAPH:true
6TYPE 查不到时静默保持 193 = NONE效果名拼错 = 整条效果变哑,不报错
7-999 是数值槽的读取失败哨兵任何数值槽都不能填 -999
8[LEVELn]继承链 + 懒构造,等级号不能跳号[LEVEL5] 缺失时 [LEVEL6] 及以后永远不会被构造
9ADD STAT(146)跟 239 属性缓存毫无关系,而且槽 2 是它的触发概率槽 2 留空 → evalSlot 返回 0 → 只有 1% 触发率
10EFFECTSLIST.DAT 必须恰好 239 行行数变了不崩,但所有类型索引整体错位
11ATTACH / DAMAGETYPE / PLAY_HIT 都是死键,正确拼法是 ATTACHES / DAMAGE_TYPE / PLAY_HITS全库约 5200 处白写,肉眼审 dat 发现不了
12武器系技能的伤害主体是 EVENT 块的 WEAPONDAMAGEPCTSOAKSCALEPCT,不是 [EFFECT] 里的 MIN/MAX只调 MIN/MAX 会觉得「怎么改都不动」

查「这个键能写在哪个块」请直接翻 第 16 节 —— 那是从解析函数里逐个提取出来的完整对照表,比骨架更适合当手册用。

还有一条不算规则但更省时间:引擎自带一个 COMBAT LOG 设置项,打开后每施加一个效果打一行日志, 覆盖护甲、格挡、命中结算、技能施法、效果增删共 30 处。调数值前先打开它,见 第 11 节


1. 骨架:三个块名就是三个类

技能 dat 的块层级和引擎的 C++ 类严格一一对应。MSVC 把构造函数和 loadFromDat 内联成了一个, 所以 IDA 里看到的一个大函数既写 vftable 又读 dat。

dat 块真实类(RTTI)构造 + 解析对象大小
[SKILL] 顶层CSkill0x6C2690
[LEVELn]CSkillProperty0x6DC320712 (0x2C8)
[EVENT_*]CSkillEvent0x6CEE60524 (0x20C)

三个类的基类都是 CRunicCoreCSkillEvent 额外多重继承三个接口,这解释了它那九十多个键 为什么横跨完全不同的领域:

// 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 / VALUECStatWatcher__ctorFromDat
[EXECUTE_SKILL]13633串联施放另一个技能:SKILL + RETARGETONEXECUTECSkillEvent__ctorFromDat
[UNITTYPES]10673UNITTYPE / UNITTYPES 过滤
[TARGET_SPECIFIC_UNITS]5351UNIT 白名单 → CSkill+0x64CSkill__ctorFromDat
[IGNORE_SPECIFIC_UNITS]826UNIT 黑名单 → CSkill+0x74同上
[REQUIREMENT_LEFT] / [REQUIREMENT_RIGHT]2136 / 3271左右手武器 UNITTYPE 需求
[SOUND]478CATEGORY/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+0x1B8UNIQUE_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>DURATIONOVERRIDEMSANIMATIONLOOP 时有特殊后处理
1036<FLOAT>SPEED
924<FLOAT>TARGET_RAN_POS_RADIUS
889<FLOAT>MANA_GRAPH_SCALE
579<INTEGER>CHANCE
411<FLOAT>MAXHEIGHTDIFFERENCE目标高度差上限
382 / 338 / 154NOSTRIKEEFFECTS / NOSTEALEFFECTS / NOSTRIKEPARTICLES
374<BOOL>DAMAGESHAPES_HIT_ONCE
338<BOOL>DUELWIELD_REQUIRED需要双持

[SKILL] 顶层用量最高的则是:UNIQUE_GUID(13009)、NAMEACTIVATION_TYPETURNRATEOVERRIDE(12644)DISPLAYNAMETARGET_ALIGNMENTCAN_BE_SILENCED(9480)RANGESKILL_ICON / SKILL_ICON_INACTIVEANIMATIONBASE_DESCRIPTIONMAX_INVEST_LEVEL(4404)TIER1/2/3_DESCRIPTIONHIDDENFINDTARGETANGLESHOW_IN_BUFFLISTALLOWS_TURNINGIGNORES_LOSIGNORES_CAST_SPEEDREQUIRES_PATHABLE

三个后处理

  • 所有 *MS 键读进来一律 × 0.001 存成秒;
  • 冷却小于 0.01 直接归零;
  • 写了 ANIMATIONLOOPDURATIONOVERRIDEMS ≤ 0 时,引擎强行设成 1.0 秒

顺带一个命名不一致:SLOWDOWNMS 虽然带 MS,读的却是 getFloat 不是 getInt

哨兵触发曲线 —— 正规写法是「不写这个键」

LEVEL_REQUIREDMANACOST 读到 -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 走的是 getIntMANACOSTgetFloat


3. [EVENT_*]:选哪个、怎么不写错

事件类型不是某个键的值,而是块名自己

解析时拿 node+8 的 name hash 去表里反查,结果写进 CSkillEvent+0x20

idx块名全库出现次数idx块名全库出现次数
0EVENT_START967067EVENT_UNITDIE1286
1EVENT_END336308EVENT_MISSILEHIT3010
2EVENT_TRIGGER754319EVENT_MISSILEDIE5635
3EVENT_TRIGGER_TWO606410EVENT_DIEBYEFFECT0
4EVENT_TRIGGER_THREE348911EVENT_CASTERDIE129
5EVENT_TRIGGER_FOUR1032512EVENT_UNIT_CREATE27
6EVENT_UNITHIT2240913EVENT_MISSILE_PULSE2355

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_PULSEMISSILE_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 / 263MISSILE_PULSE_DIST / _SECONDS / _OFFSETEVENT_MISSILE_PULSE 的周期参数

WEAPONDAMAGEPCTSOAKSCALEPCT 是我第一版漏掉的两个大件。 前者是绝大多数武器系技能真正的伤害来源(比 [EFFECT] 里写死的 MIN/MAX 常见得多), 后者是调「这一击吃多少护甲」的正门。两者都属于 iDamageShape 接口那一层, 所以只在当作伤害体的那个 EVENT 块上有意义。

解析细节已逐指令核实(CSkillEvent__ctorFromDat 0x6CF4C2 / 0x6CF529): 两个都在 getFloat 之后 fmul 0.01、再钳到 ≥ 0,存进 +0x144 / +0x148, 由 CTriggerableActions__execute 传给 CCharacter__strike_pickWeapon。 所以 WEAPONDAMAGEPCT:50 内部是 0.50SOAKSCALEPCT 的默认值是 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__loadFromDat 0x7A5654getInt 读进 affix+0x8C),挂载时用 (1 << 当前难度) & 掩码 卡门,不中就把这个词缀实例析构掉。 但两件事让它现在不适合动:全库 165753 个 dat 里出现次数是 0; 而构造期默认值是 10(0b1010),还有另外两条构造路径也写这个字段。 「默认 10 却又人人都能生效」这件事我还没对齐,没实测前不要写这个键

[EFFECT] 的高频键

87 个不同键,前二十几个:

次数备注
110360<STRING>TYPE拼错静默变 193 NONE
107452<STRING>ACTIVATIONPASSIVE/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),外加 ADDITIONALDESCRIPTIONTARGETTYPE

内联的 [AFFIX] 定义块常用键:NAMEDURATIONMIN_SPAWN_RANGE / MAX_SPAWN_RANGEWEIGHTRANKSLOTS_OCCUPY(各约 35400 次),以及 PLAY_HITS

三个真实的解析坑(都是引擎的,不是数据写错)

  1. [EFFECTS] 里没写 IGNORE_UNITTYPE 时,父块写的那份会被冲成空; 而 TARGETTYPE 的默认是「当前值」,能穿透下来。两者不对称。
  2. 逐个 [AFFIXES] 的循环只清零 targetTypetarget,ignoreUnitType 不清零—— 会泄漏到下一个 [AFFIXES]。多个 [AFFIXES] 并排时,第一个写了 IGNORE_UNITTYPE 后面几个会跟着继承。
  3. 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.0CUnit__executeInstantEffect(0x559690),执行一次就丢
ALWAYS / 缺失 / "0"-1000.0进三张效果表,每帧 tick,永不自然到期
正数(秒)原值进三张效果表,每帧 tick,到期移除

三条路是完全独立的代码,一个效果类型可能只在其中一条有实现。这就是失效的根源。

175 个类型写 INSTANT 等于禁用

executeInstantEffect 的主 switch 有 231 个 case、54 个跳转目标,其中一个目标吃掉了 175 个 case—— 那里只有一条 fstp st(1)(平衡 FPU 栈)然后直落函数收尾。纯空操作。

落空的集合包括:

  • 全部纯属性类(所有 PERCENT *DAMAGE BONUSCRITICAL CHANCE…)
  • 四个元素状态(BURN/FREEZE/SHOCK/POISON)
  • STUN / SILENCE / BLIND / IMMOBILIZE
  • 整个 CAST SKILL 一族(除裸 74)
  • KNOCK BACK EFFECTHP MOD OVER TIMEUNIT THEME

这个划分本身是合理的——属性加成、DOT、状态、proc 注册全都需要持续时间才有意义。 但后果是:写成 INSTANT 既不报错也不执行。

全库实测:555 处这样白写的配置、涉及 38 种 TYPE(对照组是写 INSTANT 且确实有实现的 63959 处, 所以占比只有 0.86%——不是普遍性问题,但对涉及的那些具体技能是实打实的失效)。排头的几个:

次数TYPE分布
155PERCENT DAMAGE BONUS技能 124、词缀 31
58KNOCK BACK技能 33、词缀 25
56KNOCK BACK EFFECT词缀 51、技能 5
33DAMAGE BONUS词缀 32
30CAST SKILL ON DEATH FROM EFFECT OWNER技能 30
26SHIELD BREAK(205)技能 21 —— 注意 SHIELD BREAK EFFECT(215) 是有实现的
22SHOCK词缀 22

名字差一个词、行为差全部:SHIELD BREAKSHIELD BREAK EFFECT 是两回事。

反过来:有些类型只有 INSTANT 有实现

222 SET SKILL LEVEL127 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;
  • 想让新的顶掉旧的:NAMETYPE 都一致 + 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__executeInstantEffect0x5596B8 取一次 +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 WARPMAX_DEPTH:从不被读取,层数恒等于 MIN_DEPTH
  • TELEPORT 系(157/158/235/236)签名里第二个参数干脆就叫 NA
  • 142 SWAP POS WITH PETPETINDEX:整个分支里没有任何 evalSlot 调用,宠物是随机挑的

判断方法:效果没反应又怀疑是随机没生效时,先试着把 MINMAX 填成同一个值。

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_linkEffectCEffectMgr_AddEffect 都不重算。 所以「刚挂上的效果什么时候出现在 getStat 里」取决于走了哪个入口。

重算时会跳过打了墓碑的效果,所以 EXCLUSIVE 替换不会有重复计数的那一帧

ADD STAT(146)走的是另一套系统

它是技能侧第二常用的效果(4106 次),但跟 239 属性缓存毫无关系: 用 CUnit+0x114CStatManager + 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 有这个独立分支,147151 落到通用挂载路径; 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
64STUN+0xD4139 + 53
128SILENCE+0xD4138
196IMMOBILIZE+0xD4197
202BLINDevalSlot(0)233
144PERCENT BLINDevalSlot(2),0 视为必中无 roll(233 削幅度)
79FLEE EFFECT+0xD4121
80TURN ALIGNMENT+0xD4137
167INTERRUPT无(无条件尝试)136
56INTERRUPT CHANCEevalSlot(0);BOSS→0,精英→×1/3136

CUnit__applyDifficultyResistBoost(0x51D310)是调控制强度的总闸:阵营 == 1 时原值返回 (玩家不吃),否则过 DIFFICULTY_RESISTBOOST_VETERAN / _ELITE 曲线。 改这两张曲线就能整体调怪物在高难度下的控制抗性,比逐个改词缀省事得多。

几个必须知道的语义

  • 144 PERCENT BLIND202 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 次 DURATIONALWAYS, 那些持有者永久免疫四元素状态。作者想要的多半只是护盾吸伤,却顺带拿到了元素免疫。

10. 位移类:哪些能穿墙

TYPE名字落位方式能否穿墙
158 / 235TELEPORT(NOPARTICLE)只查落点格,不查路径
157 / 236TELEPORT RANDOM(NOPARTICLE)含直线扫描不能
142SWAP POS WITH PET两次裸 setPosition,零检查能,两边都能进墙
162WARP TO POI找空地 + 贴地
223PULL写速度向量 → 逐帧积分不能,会卡住
113WARP不动坐标,换关卡不适用

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、效果增删都在内。调数值前先打开它,不用自己插桩。

「配了没反应」的排查顺序

按命中率从高到低:

  1. DURATION 是不是 INSTANT,而该 TYPE 又在 第 5 节 的落空集合里?
  2. TYPE 拼对了吗? 拼错静默变 193 NONE。去 EFFECTSLIST.DAT 里对一遍字面。
  3. 块名拼对了吗? [EVENT_XXX] 拼错静默变 EVENT_START
  4. 这个键在 第 12 节 的死键表里吗?
  5. 接了曲线却指望 MIN~MAX 随机?NOGRAPH:true
  6. ADD STAT 的槽 2 填了吗? 留空 = 1% 触发率。
  7. 等级号跳号了吗? [LEVEL5] 缺失 → 6 以后全不构造。
  8. 指望它叠加,但它是有限时长的 DAMAGE? 那默认就是独占的,加 EXCLUSIVE:false
  9. 数值槽填了 -999? 那是哨兵。

12. 别抄的东西:死键与白写

把技能 dat 里出现过的 232 个键名逐个拿 UTF-16LE 去 exe 里搜,再对每个命中位置查 有没有代码引用该地址(后缀合并出来的子串不会有独立 xref)。下面这些是确认引擎不读的:

数据里出现次数说明
TRIGGERNAME7512值恒为 hit;原版 unpacked 里就有 1376 次
ATTACH4441真键是 ATTACHES exe 里没有任何以 ATTACH 结尾的独立宽串,每个命中都是 ATTACHES / ATTACHESTOMASTER 之类的前缀
DAMAGETYPE598真键是 DAMAGE_TYPE(带下划线)。 串本体在 0x218E57C 确实存在且以 NUL 结尾,但零 xref —— 编译进去了却没人读
CLONECOUNT546CAN_CLONE 是真键,但克隆数量这个不是
PLAY_HIT203真键是 PLAY_HITS(0x219A6CC,由 CAffix__loadFromDat 读)。PLAY_HIT 只是它的前缀
EFFECTSHIDDEN104真键是 STATSHIDDEN
CASTSFROMTARGET92拼错了,真键是 CASTFROMTARGET(少个 S)
PRECOLLIDE66
MANA_COST_SCALE53真键是 MANA_GRAPH_SCALE
COOLDOWNMSTEST20测试遗留
NOTTHEMED8拼错了,真键是 NOTTHEME(多个 D)
TIER4_DESCRIPTION6引擎只解析 TIER1/2/3
BANIMATION4拼错了

ATTACH / DAMAGETYPE / PLAY_HIT 这三行是这次重扫时新查出来的,合计约 5200 处白写, 比原来那张表里除 TRIGGERNAME 之外的所有项加起来还多。三个都是正确拼法只差一两个字符的变体, 所以肉眼审 dat 时基本发现不了。

另外两类数字:全库 206 处 TYPE 值没有注册,438 次 TARGET 写了枚举表外的词。

⚠ 判死键有三个陷阱,我自己在这次重扫里踩了两个。

  1. 数值槽别名按设计就不在 exe 里。 MINFORCE/MAX_PER_HEALTH/IGNORE_RESISTANCE 这类名字来自 EFFECTSLIST.DAT 每行的 VALUE1..VALUE5,搜不到是正常的、它们完全有效。 我第一遍扫出 35 个「搜不到」的键,拿 EFFECTSLIST 的 62 个槽别名过滤后, 有 28 个是这种情况 —— 其中 IGNORE_RESISTANCE(2677 次)我差点当成重大发现写出去。
  2. 「只作为子串出现」不等于死。 MSVC 会做后缀合并,而且字符串池里前后不一定有 NUL 边界。 STAT_SOURCE_TYPE 我在 CEffect__ctorFromDat 里亲眼见它被读,却被「前后必须是 NUL」的 启发式判成子串。唯一可靠的判据是查该地址有没有 xref。
  3. 惰性加载的键是活的。 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 实现, 而 BURNINSTANT 分支里是落空的。
  • 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 AGROCHANCE 被摇两次(先确定性 RNG,通过后落穿再摇一次非确定性的)实际触发率 ≈ (CHANCE/100)²,CHANCE:50 实测约 25%;而且破坏多人游戏确定性。想要 50% 就得填约 70
113 WARPNAME 里的 ^POI 解析没接上,传给换关的是未截断的原串113 的 NAME 不要写 ^,POI 定位这条路是坏的
113 WARPMAX_DEPTH 从不被读取层数恒等于 MIN_DEPTH,别指望随机层
142 SWAP POS WITH PETPETINDEX 是彻底的死参数宠物是随机挑的,无法指定
223 PULLdist 取的是锚点绝对坐标的模长(到关卡原点的距离)同一个 PULL 在离关卡原点越远的地方拉得越快,数值在不同地图区域表现不一致
223 PULL速度闸门条件写反(用严格 < 0,而速度函数恒返 ≥ 0)PULL 不受定身影响,而 TELEPORT 受
147/149/151完全没有空指针检查过图 / 单位刚构造 / 存档回放时触发就崩。别用
181/182181 CONVERT CHARGE TO STAT 给 stat 的是格数,182 …PERCENT… 给的是 0–100 百分数同一条满充能条,181 加「总格数」(比如 4),182 加 100。量纲差 25 倍
168 vs 171168 ADD CHARGE PERCENT 的 1 = 条子的 1%;171 ADD CHARGES 的 1 = 一格混用会差一个数量级
暴击判定式等价于 roll + c > 99真实暴击率 = (c+1)/100,白送一个百分点
暴击CEffect+0x0Cmin(v, 1.0)那个乘数只能压低暴击率,永远不能提高
元素 proc分母 ×5,且读的是抗性基表原值而不是 getResistanceOfType零抗性时 proc 上限也只有 20%;装备和词缀堆的元素抗性对 proc 概率毫无影响,只减伤
156 DAMAGE CHANCE挂表前就地把 a4->TYPE 改成 52,改的是原对象不是副本调用方复用同一 CEffect 实例时,第二次施加就变成裸 DAMAGE、不再掷 CHANCE
110 KILLEXPLODE / 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. 一句话回到问题

「怎么正确设计一个技能」在引擎层面其实只有四个决定:

  1. 块名写对——[EVENT_*]TYPE 拼错都是静默降级,不报错;
  2. DURATION 选对那一态——这是 555 处白写配置的唯一原因;
  3. EXCLUSIVENAME 一起决定叠加还是刷新——DoT 的默认值跟直觉相反;
  4. 数值槽有没有曲线决定 MAX 参不参与——要随机就 NOGRAPH:true

其余的(RANGECOOLDOWNMS[LEVELn] 差异)都有合理默认值,写错了看得见。 真正吃时间的永远是上面这四个「不报错的错」。


16. 附录二:每个块支持哪些子块和键 —— 完整对照表

这张表比第 13 节的骨架更有参考价值:骨架只告诉你「可以这么写」, 这张表告诉你「引擎在这个块里到底读什么」——写在别处的同名键一律不生效。

怎么得到的:对每个解析函数逐指令扫描,凡是 push offset <键名宽串>wstring::wstringCDataGroup__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 行的 VALUE1VALUE5 决定(全库共 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] 子块上读

0x6C6DC40x6C6E9E,前提是节点底下真有一个 [EFFECTS] 子块:

<STRING>TARGET<STRING>TARGETTYPE<STRING>IGNORE_UNITTYPE<TRANSLATE>ADDITIONALDESCRIPTION

IGNORE_UNITTYPE 的默认值是空串,会把 ① 读到的值冲成空;TARGETTYPE 的默认值是「当前值」, 能穿透下来。两者不对称,不是笔误。

③ 在每个 [AFFIXES] 块上读

0x6C73F50x6C75D8,外加子块 [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,每一处都塞进去一个根本不存在的词缀名 (碰巧无害——没有词缀叫 PARENTSELF)。MOD 库里还多出 316 处 <TRANSLATE>ADDITIONALDESCRIPTION 和 17 处 <STRING>TARGETTYPE,同样被吞。 反过来 <INTEGER>AFFIXLEVEL 因为类型不是字符串才逃过——它写在 [AFFIXESREMOVE]是彻底的死键(原版 547 次、MOD 库 12861 次,全部无效)。

值类型表

CDataValue__setFromTag(0x679E20)——上面判「字符串类」靠的就是它:

<TAG>类型码
INTEGER / FLOAT / DOUBLE / UNSIGNED INT / BOOL / INTEGER641 / 2 / 3 / 4 / 6 / 7
TRANSLATEDIALOG8
NOTE9
STRING,以及任何认不出来的 TAG5

STRING 根本没参与比较——它和 <STIRNG><INT><FLOATT><> 走同一条兜底分支, 一律变成字符串。所以 <TAG> 拼错既不报错也不留痕:数值键会因为类型不符被 getInt/getFloat 拒绝,直接返回调用方给的默认值。全库只踩到 1 处 (<UNSIGNEDINT> 少个空格,写在一个 UNIQUE_GUID 上)。

⑤ 两条走不通的路(引擎自己的 bug,不是数据写错)

0x6C6D120x6C6FAE 想判断「交进来的节点自己就是一个 [EFFECT] 块」,可字面量写成了 混合大小写的 L"Effect";而 .DAT 解析器建块名 hash 前会先 substr 去掉方括号再 toUpperW (0x67BE1D0x67BE47),hashStringW 又是大小写敏感的。两个 hash 差得很远 (0x0C5298150x0E421C35,不是碰撞)。后果:

  • [EVENT_*] 底下直挂 [EFFECT](不裹 [EFFECTS])什么都不产生。 事件把这个 [EFFECT] 当节点交进来,解析器于是去收它的 [EFFECT] 子块——没有。原版全库只有 5 处这么写 (SKILLS/ITEMS/NORMALPOTIONNORMALMANAPOTIONELEMENTALS/ELECTRICAOEEXPLODINGGOBLIN/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,0x6C71930x6C79D2 两段继承代码也是死代码。

⑥ 一个容易忽略的闸门

CSkillProperty 只在 [LEVELn] 至少含 [EFFECT] / [EFFECTS] / [AFFIXES] / [AFFIXESREMOVE] 之一时,才构造这个对象 (0x6DE8D10x6DE989)。EFFECTSREMOVE 不在这四个里面。 所以一个只写了 [EFFECTSREMOVE]、其余四块一个都没有的等级,那个块会被整块忽略——想让它生效, 在同一个 [LEVELn] 里随便再放一个(哪怕是空的)[EFFECTS] 就行。现有数据里零处踩到。

PARTICLEONREMOVE 是个很少有人用的键——移除该效果/词缀时播的粒子。

它和 ADDITIONALDESCRIPTION各有四个读点,不是三个:阶段①/②/③ 之外, [EFFECTSREMOVE] 分支里还各有一处(ADDITIONALDESCRIPTION0x6C7E34, PARTICLEONREMOVE0x6C7DE1)。而且因为 [EFFECTSREMOVE] 分支在 [AFFIXESREMOVE] 分支之后执行,两个块都写了同一个键时, 最终生效的是 [EFFECTSREMOVE] 里那份

这条对 ADDITIONALDESCRIPTION 特别要紧 —— 它是玩家在技能说明里能看见的文字。

常见的「键放错块」

下面这些是在真实数据里出现、但该块的解析器不读的键。跟死键不同—— 它们本身是真键,只是写错了地方。

写在应该写在
[LEVELn]WEAPONDAMAGEPCTSOAKSCALEPCTMAXHEIGHTDIFFERENCEDAMAGESHAPES_HIT_ONCENOSTRIKEEFFECTSNOSTEALEFFECTSNOSTRIKEPARTICLESSTATSHIDDENAPPLYEFFECTSFILEFOLLOWS[EVENT_*]
[EFFECTS]CAN_CLONEPLACEONTARGETDAMAGESHAPES_HIT_ONCEAPPLYEFFECTSNOSTRIKEPARTICLESWEAPONDAMAGEPCTDAMAGE_TYPE[EVENT_*]
[AFFIXES]STATSHIDDENATTACHESCAN_CLONEDURATIONOVERRIDEMSSPEEDEXCLUSIVEUSEOWNERLEVEL[EVENT_*][EFFECT]
[EFFECT]TARGET[EFFECTS] / [AFFIXES] 容器层
[AFFIX]MINMAXACTIVATIONTYPEEXCLUSIVEDAMAGE_TYPE内层 [EFFECT]
[EVENT_*]TARGETDURATIONUNITBONEHIDDEN视意图,多半是 [EFFECTS][SKILL]
[AFFIXESREMOVE]AFFIXLEVEL(原版 547 次 / MOD 库 12861 次)无处可写——移除只认名字,不认等级

其中 STATSHIDDEN 只在 [EVENT_*] 里有效(全 exe 只有一个解析点,CSkillEvent__ctorFromDat 0x6CFB94),而它在 [AFFIXES] 里被写了 155 次、[LEVELn] 里 35 次,全部无效。

这张表的两个局限

  1. 只抓三种读取方式(getter / findChildBlock / hashStringW)。 若某个键用别的方式读(例如手写循环比对),会被漏掉,表现为「表里没有但其实有效」。
  2. [EFFECT] 的 5 个数值槽不在表里——它们的名字来自 EFFECTSLIST.DATVALUE1VALUE5 列,随 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 的技能不会多出一个空行。
  • +0x08CSkill* 为空时整段直接返回空串 —— tooltip 一个字都不显示, 这是「技能说明空白」这类问题的第一个排查点。

同族的另外两个入口:CSkillEvent__buildTooltipText(0x6C8FE0)按事件出字, CTriggerableActions__buildDamageTooltipText(0x6DA7E0)专门算伤害那一段。 物品和单位走的是另一条:buildEffectDescForTooltip(0x57F780) → CEffectMgr__buildDescCacheA(0x7B0720),末端复用同样两个叶子函数。

[LEVELn] 下直挂 [EFFECTS],效果默认作用于谁

不是无条件给自己。[LEVELn] 路径时交给解析器的节点就是 [LEVELn] 本身, 阶段① 在它身上读 TARGET;读不到才按技能的 TARGET_TYPE 推一个缺省值 (跳表 0x6C6C16):

技能的 TARGET_TYPE枚举值效果的缺省 TARGET
SELF / SELFANDPETS3 / 10SELF
PETS9PET
OWNER12OWNER
EVERYBODY4EVERYBODY
TARGET / POSITION_RANDOM_AT_TARGET2 / 14TARGET_ALIGNMENT:GOODFRIEND,否则 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_*] 时,效果顺序由什么决定

由文件顺序决定,而且是确定的。 三步都保序:

  1. collectChildBlocksNamed(0x679360)按子块在父块里的出现顺序逐个 push_back;
  2. 构造循环(0x6C70E90x6C7166)按同一顺序逐个 CEffect__ctorFromDat 并压进列表;
  3. 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 关系校验),然后:

  • 词缀支:createAffixByNameAndLevelsetSourceSkill / setLevel / setDurationCEquipment__attachAffix
  • 效果支:先调虚表槽 83(+332)CUnit__canApplyEffectGate_vt83 闸门, 过了再 setOwnerSkill / setSource,最后调虚表槽 84(+336) CUnit__applyEffectToSelf_dispatch 真正施加

槽 83 是谓词、槽 84 才施加,两者在同一循环里先后调用。 另有一个只跑闸门的孪生函数 CSkillEffectAndAffixes__canApplyTo(0x6C57A0), 结构与施加器几乎一致但只返回布尔,供「这件物品能不能用在这个单位上」使用 (CItem__canUseOnUnit 0x57D490)。两者极易混淆,看它调的是槽 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__learnSkill 0x5268D0)、 CTriggerableActions__execute(0x6C9CC0)、CSkill__abortHard(0x6C14A0)。

顺带确定的 PARTICLEONREMOVE

removeFrom 的末尾(0x6C60F0),条件是本次确实移除掉了东西+0x30(该字符串非空)。 满足后按 +0x1C 的名字生成一个单位,并 setPosition目标身上。 所以:没移除掉任何东西时不播;播在目标身上而不是施放者身上。