Adding Warcraft III Abilities
This project implements Warcraft III abilities from the authoritative game data and observable gameplay contract. The goal is useful, testable compatibility. TFT's extracted class hierarchy supplies behavioral evidence; the flat C ability plan uses Quake 2-style flags, enums, shared processors and message procedures without runtime object inheritance.
AbilityStrings.txt is the starting point because it states what the player is
supposed to observe. It is not a complete specification, so every implementation
record separates confirmed facts, reasonable deductions, and unresolved behavior.
Ability-owned behavior is the default architecture: keep the full behavior in its ability module and reach it through generic dispatch. Start with Mechanical Workflow; see ownership and testing before adding code.
Item abilities whose state spans an inventory item and another unit have a
second lifecycle contract in Inventory And World Items,
including the AIso/Asou Soul Trap relationship used by Orc08.
Mechanical Workflow
Do these steps in order. Do not skip the poll. Do not write a Python SLK parser. Do not open War3.mpq by hand unless the brief is missing a field you can name.
1. Poll ROC and TFT in one command
-raw without -roc/-tft prints a ROC section then a TFT section. That split is
mandatory: TFT War3x.mpq overlays AbilityData.slk, and the same rawcode can
change cost, targs, BuffID, and even which aliases exist.
The brief already contains:
| Block | What to copy into the implementation record |
|---|---|
id / code / comments |
registry rawcode vs shared procedure identity |
class / parent |
CAbility* name and TFT parent (AAsm, AAat, AAcs, …) |
L1 targs cost cool rng dur heroDur area |
targeting and spell_cmd inputs |
DataA-I plus any non-zero DataC= / DataB= line |
authored numbers via S_SpellData; unitCode/abilCode cells (ncgb, ANin) print as fourcc — read those with S_SpellDataId |
BuffID |
comma list; each token is a different buff |
strings Name/Ubertip/Untip |
visible contract and inverse/autocast |
buffs Buffubertip |
what the status actually does (immunity vs shield, etc.) |
aliases of code= |
other rawcodes that share this code and should share the procedure |
Restrict to one archive set only when comparing a single overlay:
build/bin/ability_audit -data 'data/Warcraft III' -roc -raw Aams
build/bin/ability_audit -data 'data/Warcraft III' -tft -raw Aams
-roc opens War3.mpq only. -tft searches War3x.mpq first, then War3.mpq.
If a later agent uses mpqtool cat Units/AbilityData.slk after -data opened
every archive, they will silently read the TFT overlay and think ROC matches.
RoC's one targs column applies to every rank, while TFT has per-rank columns;
see Aura Targets And Overlays for the parser and
recipient regression.
2. Turn the brief into a one-page contract
Write (or update) docs/games/warcraft-3/<ability>.md from the brief before
coding. Record:
- which rawcodes share
CAbility*(code=plus the alias list); - which
Data*fields the execute path will read; - which BuffID token applies when (empty DataC vs authored DataC is a real split);
- ROC-only omissions (missing
BuffIDis allowed; do not invent DataC); - TFT-only rows (
AbilityData: not foundunder ROC).
Look up the parent in the shared behavior table.
AAsm is the simple-spell pipeline, not “this is easy”.
3. Register every concrete alias, share one procedure
Add one abilitylist row per AbilityData.alias that this change implements.
Point them at the same CAbility* procedure, next to the parent row when that
parent is already registered. Read data through abilityitem_t.code so Aams
and Aam2 keep their own targs/DataC/BuffID. Poll code= before mapping;
a similar name is not a contract (ACmo is Monsoon, not Forked Lightning).
Do not collect creep aliases into a separate s_creep.c. Creep rows are extra
rawcodes of an existing procedure. Unique creep-only behavior already lives in
s_creep_sleep.c. Do not register abstract TFT classes (AAsm, AAat). Item
Instant AMS (Aami/AIxs) and item Cyclone (AIcy) are in scope on their own
briefs. Leave unimplemented parents as TODO with their aliases. See
Creep Ability Aliases.
4. Tests first, from the brief
Use the production cast path (S_CastUnitTargetSpell / S_CastNoTargetSpell).
Put the test in games/warcraft-3/game/tests/t_*.c with suite wc3_spell.
The TEST(suite, name) identifier is suite.name, so the glob has no extra dot:
WC3_PATTERN='wc3_spell.anti_magic_shell.*' matches zero tests.
Minimum coverage, taken from the brief:
- procedure lookup for each registered alias;
- authored field mapping: a non-stock Data cell so the test cannot pass on a
hardcoded retail constant, and a stock-shaped case from
ability_audit -raw(omitted ROC columns, sentinel zeros, actual BuffID/targstokens — do not inject the fourcc or scale the consumer already hardcodes); - positive path and the nearest invalid target (
targs, dead, wrong owner); - duration/expiry or pool-break, and recast — assert the gameplay lock ended
(
channel.code, movement flags, current order), not only the buff/helper; - the inverse another ability can take (Dispel/Purge/death through the real expire path, not wiping slots);
- save/load when new persistent state is added (
abilstatus.data, edict fields, or a liveedict->thinkround-trip).
Enemy targeting tests must mark both players kPlayerTypeHuman and clear
level.alliances. setup_test_world() leaves kPlayerTypeNone, which makes
S_SpellIsEnemy false even when player ids differ.
If edicts point at UnitBalance / UnitData stored in the fixture, fill the
caller in place (void setup(FIX *fix)). Do not return the fixture by value
(the copy's pointers still address the setup frame; Linux then misreads
G_UnitIsHero). Do not park those rows in file-scope statics either — they
are process-global and sit outside the fixture the test owns. Return-by-value
is fine only when the fixture holds no storage edicts point into.
5. Implement in the owning s_*.c
Keep execute/validate/absorb in the ability file. Hook shared predicates
(S_UnitSpellImmune, S_SpellDamage) only when the brief’s buff contract
belongs on that path. Prefer branching on authored data over a second
procedure.
When several owning procedures repeat the same lower-level mechanic, move only
that demonstrated mechanic into a shared helper and pass the policy explicitly.
For example, ordinary unit-target autocast acquisition uses
S_AutocastAcquireUnit(caster, code, friendly, wounded, fallback_range); the
ability procedure still decides relation, wounded-only policy and fallback
radius, while the helper owns nearest-candidate scanning and re-enters the normal
S_CastUnitTargetSpell path. Do not force special acquisition rules such as Moon
Well thresholds or Barkskin's existing-buff exclusion through that helper. The
same rule applies to passive and lifecycle families: Endurance Aura and hostile
Slow Aura consume the shared aura cache instead of open-coding recipient scans;
the cache key owns source relation while the consumer retains any genuinely
ability-specific clamping. Endurance Aura DataA/DataB are already fractional
real values and must not be divided by 100 again. Temporary corpse revival uses G_ReviveCorpseAsSummon()
rather than repeating the ordinary corpse teardown, and corpse-ranking policy
reads the shared G_CorpseUnitLevel() accessor while each ability keeps its own
highest/lowest and tie-break selection rule. Timed-status procedures should use S_SpellApplyTimedStatus() for ordinary
presentation-neutral status writes after the owner has resolved its own BuffID
fallback and exact duration; lifecycle tokens remain deliberately separate from
ordinary buffs. Use S_SpellApplyTimedLife() for the shared BTLF token while
the owner decides whether zero duration means permanent lifecycle state or no
marker at all. Direct ability-created summons should likewise use
S_SummonAbilityAt() (or S_SummonAbilityUnits() for the simple authored family)
so the concrete rawcode is preserved in summon_ability; keep secondary units
spawned by another summon on generic S_SummonAt() when that immediate entity
ownership is the intended contract. Skill procedures should not call unit_addtimedstatus() directly:
even presentation-neutral special state such as Militia expiry, Wind Walk, and
ability-owned timed toggles now uses S_SpellApplyTimedStatus(), while each owner
still keeps its transform, invisibility, animation, expiry, and payload policy. This
presentation-neutral primitive returns the authoritative status slot, so special
abilities can attach payload without rescanning abilstatus; this also replaces
manual post-apply status lookups in Avatar/Polymorph and preserves presentation-
neutral transfer/pulse uses such as Spell Steal, Stasis Trap, and Earthquake. Ordinary target buffs
that also use standard authored TargetArt layer S_SpellApplyTimedTargetStatus()
on top. Use S_SpellBuffId() for the common authored
primary-BuffID validity check while keeping ROC/TFT fallback choice local. Use
S_SpellHeroDuration() or S_SpellResistantDuration() for the two established
duration policies instead of repeating the predicate at every call site.
S_SpellBuffToken() only parses ordered BuffID fields; the owning ability still
decides which token is correct. S_SpellApplyStun() similarly owns only the
standard Bstu/level-one status application; callers keep hit validation and
duration policy. For simple area nukes whose demonstrated contract is exactly
“damage living enemies inside this radius,” use S_SpellDamageEnemiesInRadius();
do not force ground-only, cone, target-mask, capped-damage, or status-bearing
variants through it.
For homing unit-target spell missiles whose launch contract is the same, use
S_SpawnUnitTargetSpellMissile() for authored MissileArt, source player/team
presentation, target-incarnation tracking, movement and projectile presentation,
and resolve impact targets through S_SpellProjectileTarget(). Keep impact policy
inside the owning ability: Death Coil still owns Undead heal/non-Undead damage
semantics, while Thunder/Fire Bolt owns damage and stun duration. Do not extend
this helper to artillery/fixed-point attacks or add flags for unrelated missile
policies.
For timed statuses that retain an applying entity, persist both source and
source_spawn_time and resolve later reads through S_SpellStatusSource().
Disease Cloud, Entangling Roots, and Incinerate use this contract; each still
owns its own expiry, refresh, pulse, and damage semantics. Do not dereference a
persisted status source directly after the source edict slot may have been reused.
For channel thinkers, resolve the captured caster incarnation through
S_SpellChannelOwner() instead of repeating owner->inuse / spawn_time checks.
Non-channel ability helpers may use the same resolver when they explicitly snapshot
channel->owner_spawn_time as an identity token. Spell approaches, Chain Lightning marker/thinker ownership,
Mass Teleport cancellation, delayed Incinerate/death-AOE helpers, Land Mine, Reincarnation, Flare, and timed
Metamorphosis reversion use that contract. Once the token is stored, route subsequent owner-incarnation reads through
the resolver rather than open-coding the pointer/inuse/generation test. Their timing/effect semantics remain ability-owned.
Save-safe ability helper thinkers that already use channel->owner_spawn_time as
an incarnation token use the same resolver. Non-channel delayed helpers create
that pointer+generation contract through S_SpellIdentityThinker(); current
users include Pocket Factory, Graveyard/Exhume production, Stasis Trap arming,
Divine Shield expiry, and Lightning Shield carrier/source tracking. This is
identity plumbing only and
does not make spell approaches, Chain Lightning markers, delayed explosions, or
ward arming thinkers into channel abilities. Reveal lifecycles are intentionally not unified beyond the shared FOW writer. Far Sight owns a player-scoped, caster-independent timed reveal thinker; Flare owns a caster-bound reveal thinker and resolves its captured caster incarnation through S_SpellChannelOwner(). Do not replace those with one generic reveal thinker unless retail-equivalent ownership and cancellation semantics are demonstrated.
For unit-target channel thinkers,
use S_SpellChannelTargetThinker() after the
owning procedure has validated the target. It layers the common goal pointer and
target-incarnation snapshot onto S_SpellChannelThinker(); later ticks/cleanup
resolve that snapshot through S_SpellChannelTarget(). Non-channel approach
thinkers may reuse S_SpellChannelTarget() only when they already store the same
goalentity + channel->target_spawn_time identity pair; spell-target approaches,
Cannibalize/corpse-cargo approaches, Unsummon thinker lookup, and Acid Bomb use that
contract. Route later reads of the captured target generation through the resolver.
Range rechecks, duration, pulse cadence, pause/ownership changes, cancellation side effects and
cleanup remain ability-owned. Do not reallocate thinker->channel after either
channel constructor: the constructor already owns that lifecycle storage.
For chained/bouncing unit spells, use S_SpellBounceTargetAllowed() for the
demonstrated common candidate contract (alive, authored target mask, friendly or
enemy relation, and jump radius) and S_SpellTargetVisited() for small
synchronous visited arrays. Keep selection and lifecycle in the owner: Chain
Lightning uses persistent marker edicts because delayed jumps must survive save
state and chooses the nearest unvisited target; Forked Lightning retains its
candidate/random policy; Healing Wave remains synchronous and chooses its next
friendly according to its existing scan order. Do not replace those policies
with a callback- or flag-driven generic chain interpreter.
For relocation abilities, resolve destination legality and fallback in the owner,
then use S_SpellRelocateUnit() only for the demonstrated common commit step:
source/destination SpecialArt, FOW blocker invalidation, relink, and
G_UnitPositionChanged(). Mass Teleport and Way Gate intentionally keep different
destination/search, grouping, failure, and order-cleanup policy. Gameplay-owned persistent presentation should use the owned-effect helpers where
possible rather than open-coding effect ownership or maintaining a parallel
registry. This applies equally to attached effects and point-space effects (such
as Haunted Gold Mine ring slots); ability-local tags and slot metadata stay local
rather than being pushed into the generic effect helper. Use
G_SpawnOwnedAbilityEffectAtPoint() for world-space art and
G_SpawnOwnedAbilityEffectTarget() for attached target/caster art; the owning
ability still controls extra tags, offsets, and the cleanup trigger.
Simple persistent toggles may use S_ToggleUnitAbilityStatus(), but toggles with
animation, expiry, or other side effects retain their own wrappers.
Homing spell missiles likewise resolve the captured caster and target incarnations through
S_SpellProjectileOwner() / S_SpellProjectileTarget(), and unit-target channel thinkers
resolve their target through S_SpellChannelTarget(); the owning procedure still decides
liveness, relation, range, impact/tick policy, and cleanup. Simple
UnitID + DataA count + Dur summon procedures use S_SummonAbilityUnits(),
which records the concrete ability alias on every point/ring result; specialized
point summons use S_SummonAbilityAt() instead of open-coding
summon_ability. The owning ability still controls effects, buffs, facing,
corpse handling, replacement and other summon policy. See
Ability Message Procedures. The final authoritative position commit is shared through S_SpellCommitRelocation() so s.origin2 and s.origin.x/y stay synchronized before FOW dirtiness, relinking and position-change events even when presentation differs; S_SpellRelocateUnit() is the standard SpecialArt-at-both-ends wrapper, while Blink keeps its own source/destination art.
Shared immediate-order names and campaign aliases
An order string can identify more than one ability that shares a base order.
Resolve the cast against the unit's concrete learned or assigned ability alias
before falling back to the base rawcode, so alias-specific UnitID and summon
counts are used. OrcX01 demonstrates this: its intro removes the preplaced
ngzc Misha and issues Rexxar the immediate order spiritwolf. Rexxar learns
campaign ability Arsg, whose AbilityData base is Feral Spirit AOsf. The
order name is inherited, but Arsg summons one ngzc; the base AOsf row
summons two Spirit Wolves. wc3_spell.rexxar_spiritwolf_order_uses_learned_summon_misha_alias
drives that immediate order and checks the concrete summon type and ability
identity.
6. Verify the focused pattern, then the family
make test-wc3-engine WC3_PATTERN='wc3_spell.<name>*'
make test-wc3-engine WC3_PATTERN='wc3_save.*' # edict_t / abilstatus / new think pointer
Do not debug make test +test '*' while finishing one ability. Never
globals.RunFrame() or level.started/scriptsStarted in spell tests;
that path dies in jass_runevents(NULL). Advance buffs with
unit_updatestatuses and thinkers with G_RunEntities.
7. Batch: one agent per unrelated group
When finishing several abilities in one pass, split the list into groups that
do not share a procedure, s_*.c owner, or shared hook (S_SpellDamage,
S_UnitSpellImmune, edict_t fields, abilstatus payload). Spawn one
subagent per unrelated group. Each child polls its own rawcodes, writes its
own tests and docs, and commits only its group.
Keep in the same agent (do not split):
code=aliases of one implementation (Aams/Aam2/ACam);- rawcodes that already share a procedure or will after the change;
- anything that edits the same shared predicate or
edict_tlayout.
The parent agent owns s_skills.c registry rows if two groups would both
touch it; otherwise each child adds its own rows. Do not spawn a subagent per
rawcode when the brief shows they are the same code=.
Sources Of Truth
Use sources in this order, choosing the smallest set that answers the question:
- Active ROC/TFT archive data.
- Existing OpenWarcraft3 runtime contracts and neighboring implementations.
- Controlled observation in the retail game.
- Independent references such as Liquipedia, Warcraft III wikis, and Warsmash.
Game.dlldisassembly for details that remain ambiguous or cannot be observed.
Retail data is authoritative for authored values. External references are useful for explaining behavior, but they must not override an active ROC/TFT row without a reason. Game.dll is evidence about one binary build, not a portable source file.
Data Flow
The normal ability path is:
MPQ AbilityStrings.txt
-> name, tooltip, hotkey, visible behavior
MPQ AbilityData.slk
-> area, range, duration, cost, cooldown, targets, DataA-I, rawcode IDs
DDX/SLK loaders
-> AbilityData_t and abilityLevel_t
ability registry
-> ability_t
runtime consumers
-> damage, status, movement, combat, summons, morphs, or presentation
Resolve values through the existing accessors rather than copying level tables into C:
S_SpellData(code, level, index)forDataAthroughDataI.S_SpellDataId(code, level, index)for rawcode-valued data fields.S_SpellNumber(code, field, level)forArea,Rng, and related numbers.S_SpellDuration(code, level, hero)forDurandHeroDur.G_AbilityLevel(FS_SLKKey(classname), level)when the complete normalized row is needed.
Levels and data indices are 1-based at the public helper boundary. Use ability_audit
to inspect ROC and TFT in one shot (see Mechanical Workflow):
AbilityData.slk Column Reference
AbilityData.slk is the authoritative source for authored numeric and rawcode
ability data. It maps to AbilityData_t and its per-level abilityLevel_t
records in g_unitrow.h. The full game SLK has roughly 200 columns per level;
only the fields the runtime actually reads are present in abilityLevel_t.
Per-ability columns (AbilityData_t)
| Column | Field | Notes |
|---|---|---|
alias |
id |
the rawcode looked up at runtime |
code |
code |
the base-class rawcode (aliases share this) |
levels |
levels |
number of levels authored for this entry |
reqLevel |
reqLevel |
hero level required to learn |
levelSkip |
levelSkip |
levels between learnable ranks |
hero |
hero |
true for hero-only abilities |
item |
item |
true for item-class abilities |
race |
race |
faction string (human, orc, etc.) |
sort |
sort |
ordering hint; not a gameplay value |
Per-level columns (abilityLevel_t, suffix N = level index 1–4)
| Column | Field | Accessor |
|---|---|---|
targs |
targs |
target filter string; drives S_SpellAllowsTarget |
Rng |
range |
S_SpellNumber(code, ABILITY_NUMBER_RANGE, level) |
Dur |
dur |
S_SpellDuration(code, level, false) |
HeroDur |
heroDur |
S_SpellDuration(code, level, true) |
Cool |
cool |
S_SpellNumber(code, ABILITY_NUMBER_COOLDOWN, level) |
Cost |
cost (mana) |
S_SpellNumber(code, ABILITY_NUMBER_COST, level) |
Area |
area |
S_SpellNumber(code, ABILITY_NUMBER_AREA, level) |
Cast |
cast |
cast-point delay |
DataA–DataI |
data[0]–data[8] |
S_SpellData(code, level, 1–9) for floats; S_SpellDataId for rawcode IDs |
UnitID |
unitID |
summoned-unit rawcode; S_SpellUnitId(code, level) |
BuffID |
buffID |
buff rawcode applied on success; read via G_AbilityLevel(code, level)->buffID |
EfctID |
efctID |
effect rawcode; drives G_SpawnAbilityEffect* with WC3_EFFECT_EFFECT |
Data11/Data12/Data13 are the ROC column names for DataA/DataB/DataC
respectively. Both names refer to the same three fields; S_SpellData handles
both archive variants.
Pocket Factory (ANsy) is a data-driven timed producer: UnitID selects the
factory for the learned rank, Dur controls its lifetime, DataA is the
Clockwerk production interval, DataB is the rank-specific Clockwerk rawcode,
and DataC is each Clockwerk's timed life. A classless ability-owned thinker is
owned by the factory and runs through G_RunEntities; factory removal therefore
cancels production without adding Pocket Factory rules to general entity code.
What is NOT in AbilityData.slk
Projectile and missile art are not authored in AbilityData.slk. They live in two other places:
- Unit weapon art (
UnitWeapons.slk/UnitWeapons_t.attack[i].art) — the missile model for normal and ranged attacks. This is per-weapon, not per-ability. - Buff/ability presentation (
AbilityBuffData.slk/ the runtime buff row) — themissileArt,targetArt,specialArt,effectArtstrings that driveG_SpawnAbilityEffect*. These are looked up by the buff rawcode (BuffID), not by the ability rawcode.
Other fields absent from AbilityData.slk: cast/target animation names, sound
sets, and the WC3_EFFECT_CASTER/WC3_EFFECT_SPECIAL art paths. Those come
from the buff row or from AbilityStrings.txt fields such as animProps.
Reading a row in practice
# ROC section then TFT section, including strings, buffs, and code= aliases:
build/bin/ability_audit -data 'data/Warcraft III' -raw Ablo
In C, read a specific level's field through the public helpers:
/* DataA of Bloodlust at level 1 (1-indexed): */
FLOAT attack_bonus = S_SpellData(MAKEFOURCC('A','b','l','o'), 1, 1);
/* Buff rawcode applied by level 1: */
LPCSTR buff = G_AbilityLevel(MAKEFOURCC('A','b','l','o'), 1)->buffID;
/* Summoned unit rawcode for a summon ability: */
DWORD unit_id = S_SpellUnitId(MAKEFOURCC('A','H','w','e'), 1);
Never hardcode per-level constants copied from the SLK; always read them at runtime through the normalized row so the implementation works for both ROC and TFT data sets and for any custom-map overrides.
Reading AbilityStrings
Ability strings provide a compact behavioral sketch:
| Field | Use | Limitation |
|---|---|---|
Name, Tip, Hotkey |
command-card presentation | does not prove order registration |
Ubertip |
visible effect, target class, level fields | may omit edge cases and implementation details |
Untip, Unubertip |
toggle/autocast inverse | does not define status cleanup |
Researchtip, Researchubertip |
hero learning presentation | may describe a related campaign variant |
Bufftip, Buffubertip |
player-visible status | does not define stacking or dispel rules |
<rawcode,Field> |
authored value dependency | field units still come from the normalized SLK row |
Globalmessage, Globalsound |
visible cast/death feedback | empty values are meaningful and should remain empty |
Do not treat a tooltip as a complete target predicate. For example, “enemy land units” establishes the ordinary War Stomp filter, but immunity, summoned units, collision radius, stun replacement, and timing still require a runtime contract or observation.
Campaign entries can be separate object-data variants. AOw2 in the campaign table
is not automatically the same implementation record as standard Orc AOws; confirm
the rawcode and active AbilityData.slk row before sharing a handler.
Implementation Triage
Start with abilities whose behavior maps directly to an existing runtime contract:
| Category | Typical contract | First implementation strategy |
|---|---|---|
| direct damage/heal | ability_t with AB_SPELL |
apply the authored amount to validated targets |
| timed buff/debuff | unit_addtimedstatus |
add and expire the status through normal lifecycle code |
| toggle | AB_TOGGLE plus status |
execute the same command to add/remove the state |
| passive aura | ability modifier hooks consumed by movement/combat | calculate from active nearby owners; avoid stale recipient state |
| attack proc | ability procedure at attack resolution | evaluate chance and effect in the owning ability at damage time |
| autocast | autocast messages | acquire targets, then issue the ordinary spell order |
| channel/wave | AB_CHANNEL and thinker |
schedule ticks and cancellation explicitly |
| summon | summon helpers and unit rawcode | use authored UnitID/Data values and duration |
| morph/transform | unit-type rebinding contract | preserve the edict and restore all affected unit state |
| cargo/inventory | cargo or item lifecycle | use the existing handle and ownership rules |
Regeneration auras follow this model: Aoar/Aabr/Aarm are passive base handlers, while custom/stock aliases retain their own Area, targs, DataA, and DataB through AbilityData.code normalization. See Regeneration Auras And Fountains.
Do not force passive, attack, aura, or autocast abilities into a visible cast handler
just because their object-data class is named CAbility*. The runtime contract owns
where the effect is evaluated.
Ability procedures use TFT CAbility* class names. Look up the FourCC in
tft-ability-classes.txt and name the function after its class: CAbilityDoom,
CAbilityWarStomp, CAbilityFrostArmor. Do not invent names or derive them
from rawcode abbreviations. Procedure macros prepend C to a PascalCase argument;
BZ_SIMPLE_SPELL_PROC(AbilityDoom) { ... } defines CAbilityDoom and its AbilityDoom_Execute body,
whose parameters are caster, st, and spell.
The rawcode-to-procedure mapping and all static flags/target/order data live only in
abilitylist in s_skills.c. S_AbilityItem(actual_code) produces an abilityitem_t with the requested
rawcode and resolved registry row. Validation and execution receive this item, so two aliases can share a
procedure while reading different authored data. There is no canonical code assignment or .alias = true rule.
Display text is fetched through authored ability profiles/strings and map overrides; gameplay values use
G_AbilityData / G_AbilityLevel by the actual rawcode.
Register concrete AbilityData.alias row IDs and AbilityData.code implementation IDs, plus necessary internal
commands. Do not register the abstract TFT class tree. See behavior and identity
and data-backed registry for lookup and verification details.
The active rawcode portion of games/warcraft-3/game/skills/s_skills.c is generated
from the *AbilityStrings.txt files in data/strings. It is grouped by source file,
uses the text entry's Name as its block comment, and preserves the existing handler
mapping. Regenerate and check it with:
The generator uses the first sorted text file when a rawcode is defined more than
once. Entries without a text definition remain in the final No AbilityStrings source
file group. The commented TODO entries are also generated, remain disabled, and are
delimited by their own generated markers.
Adding a New Ability
1. Identify the ability
Poll the ability first (see Mechanical Workflow):
The brief already prints class: / parent= from tft-ability-classes.txt. Confirm
registry status only if the brief is not enough:
2. Identify shared behavior
The retail parent is evidence for behavior to reproduce through flags and explicit procedure delegation. It does not determine our runtime memory layout. Common retail families:
| Parent | Category | Shared behavior |
|---|---|---|
CAbilitySimpleSpell (AAsm) |
Most hero/unit spells | mana cost, cooldown, targeting, spell_cmd pipeline |
CAbilityAutoTargetSpell (AAat) |
Autocast spells (Heal, Slow) | autocast hooks + spell pipeline |
CAbilityRangerArrow (AHRa) |
Toggle attack arrows | autocast + toggle status |
CAbilityPassive (APas) |
Passive abilities | no command handler; consumed by combat/movement |
CAbilityMorph (Amor) |
Morph abilities | unit-type rebinding |
CAbilityAura (aura) |
Aura abilities | persistent nearby-unit effects |
CAbilitySpell (AAsp) |
Channel/toggle/complex spells | mana + cooldown but not simple-spell targeting |
CAbilityPersistentBonus (APbo) |
Item stat bonuses | passive bonus applied/removed on equip |
CPower (powr) |
Fundamental abilities (Move, Attack) | always present on units |
Before implementation, record the expected inputs, target rules, timing, result, and inverse/cleanup behavior from the data. Add the smallest representative fixture and a failing test through production dispatch for missing or incorrect behavior. See Testing; rawcode registration alone is not a behavioral test.
3. Define the ability
Work in the owning games/warcraft-3/game/skills/s_*.c file and include s_skills.h.
Choose the macro by the messages the behavior needs:
| Form | Body parameters / return | Dispatch contract |
|---|---|---|
BZ_SIMPLE_SPELL_PROC(AbilityName) { ... } |
caster, st, spell; void |
A_EXECUTE calls the body and returns true; other messages delegate to CAbilitySimpleSpell |
BZ_COMMAND_PROC(AbilityName) { ... } |
clent; void |
handles A_COMMAND; other messages return false |
BZ_ITEM_PROC(AbilityName) { ... } |
clent; BOOL |
handles A_ITEM_USE; body success permits charge consumption; other messages return false |
BZ_ABILITY_PROC(CAbilityName) { ... } |
ent, msg, call; intptr_t |
explicit switch for custom validation, lifecycle, orders, updates, or other messages |
The three body macros take one name argument. They generate a public CAbilityName procedure and a
file-local helper (AbilityName_Execute, AbilityName_Command, or AbilityName_ItemUse). The macro supplies
both the helper's static forward declaration and its definition header. Do not add a separate helper declaration,
manual signature, second callback argument, or semicolon between the macro and its body. Use AbilityName
without the leading C for these macros; BZ_ABILITY_PROC takes the full CAbilityName.
For a spell that uses the shared pipeline:
/* Name=Doom
* Ubertip="Curses a target enemy unit, dealing damage over time."
*/
BZ_SIMPLE_SPELL_PROC(AbilityDoom) { target_status_execute(caster, st, spell); }
Put single-use execution logic directly in the body; keep a named helper only for behavior shared by multiple
procedures. If rawcodes have identical behavior and dispatch policy, point their registry rows at one procedure.
For example, AUfa and AUfu both use CAbilityFrostArmor; there is no separate CAbilityFrostArmorAuto
wrapper. Keep each requested rawcode in abilityitem_t.code so authored data remains distinct. A shared
procedure does not itself add autocast support; that requires the appropriate policy and message handlers.
The macro forward-declares AbilityDoom_Execute and routes A_EXECUTE to it. Flags and target shape belong
in the registry row. For behavior that needs more messages, use BZ_ABILITY_PROC (which supplies ent, msg,
and call), write the switch directly, and delegate unhandled messages to the TFT parent procedure.
See Holy Light's complete procedure for custom
A_VALIDATE/A_EXECUTE cases and explicit parent delegation. Read call's union member only inside the
corresponding message case, handle absent payloads as required, and never retain borrowed payload pointers.
A handled false result is final; delegate only messages the procedure does not handle.
BZ_VALIDATED_SPELL_PROC(NAME, VALIDATE, EXECUTE) still takes three arguments and defines the whole
procedure. It remains available for existing shared validation/execution helpers; it does not take a trailing
body like the three one-argument macros. Use an explicit procedure when additional message handling is needed.
Command abilities use the same body syntax. BZ_COMMAND_PROC(AbilityMove) { ... } forward-declares
AbilityMove_Command(LPEDICT clent), routes A_COMMAND to it, and supplies its function header.
clent is call->client when supplied, otherwise the procedure's ent. Other messages return false.
Use the generated AbilityMove_Command name for same-file direct calls or menu callbacks. For example,
AbilityBuild_Command installs itself as client->menu.refresh. These helpers are file-local; cross-module
ability dispatch goes through the procedure contract.
Item use follows BZ_ITEM_PROC(AbilityItemHeal) { ... }. It forward-declares
BOOL AbilityItemHeal_ItemUse(LPEDICT clent) and supplies that function header. A_ITEM_USE passes the
procedure's ent as clent; the body returns whether use succeeded so inventory can consume a charge.
Other messages return false without running the body. See item implementations
for success and rejection paths. Unlike the command macro, the item macro passes ent directly and does not
select call->client. Preserve false returns when the effect cannot apply, such as healing a full-health unit.
4. Declare and register
Add an extern declaration to s_skills.h:
Add the concrete row to abilitylist in s_skills.c, preserving its authored rawcode:
When promoting an inactive // TODO: entry, add a complete active row with the procedure, flags, and target
shape; the TODO line is an inventory entry, not a ready-to-uncomment initializer. Register concrete AbilityData
aliases/implementation codes and necessary internal commands, not abstract TFT helper classes.
Regenerate the AbilityStrings grouping after editing the active mapping, then run its check mode:
These commands require the extracted data/strings/*AbilityStrings.txt inputs. The generator preserves your
handler mapping and removes corresponding inactive TODO entries; it does not implement or choose behavior.
5. Select command policy
Use AB_SPELL for the shared cast processor and handle A_EXECUTE plus optional A_VALIDATE in the procedure.
Combine independent policies (AB_CHANNEL, AB_TOGGLE, AB_AUTOCAST) in the registry row. Bespoke orders use
AB_COMMAND/A_COMMAND; consumable item effects use AB_ITEM/A_ITEM_USE.
For named immediate orders, supply ability_t.orders and handle A_ORDER. For persistent per-unit work,
register AB_UPDATE and handle A_UPDATE. Lifecycle messages belong in the owning procedure.
S_AbilityHasCommand and S_AbilityCommand provide the common HUD, player and item entry contract.
Concrete relatives can call the same helper or parent procedure: Fire Bolt and Thunder Bolt share effect code
while the per-use abilityitem_t carries the actual rawcode. There is no runtime parent wiring.
Corpse-fed no-target channels keep acquisition and periodic behavior in their ability procedure. Acan
(Cannibalize) selects the nearest authored-target-valid corpse within DataB, reserves it for the active
channel, and applies DataA HP/second on the simulation cadence. Full health, authored Dur, or normal
channel interruption ends the cast and consumes the reserved corpse. Shared corpse eligibility honors
UnitData.deathType, runtime reservation/unraisable state, the ability's Targets Allowed field, and for stored
corpses resolves the friendly holder as the effective world position. ROC and
TFT both author DataA=10, DataB=800, and Dur=33 for stock Acan; see
Corpse Lifecycle, Cannibalize, and Raise Dead.
6. Write tests
Write focused tests before launching the game. See Testing for the required coverage: registration lookup, authored data mapping, positive path, nearest negative path, duration/expiry, and save/load.
7. Verify coverage
python3 tools/wc3_ability_class_audit.py --format=coverage
python3 tools/wc3_ability_class_audit.py --format=hierarchy | rg -A2 ANdo
The audit verifies active registration against the extracted TFT reference. It does not prove behavior, correct procedure sharing, or autocast support. Check both the registry mapping and focused gameplay assertions.
The focused WC3 target runs the generated fixture archive in both classic and TFT modes. Choose the suite that
covers the actual behavior (wc3_unit.*, inventory, or another owner) as needed; finish with the full suite.
Behavior Record
Before writing the handler, create a short record in the relevant source or test:
For an implemented handler, put the matching string-table contract immediately
above the function or ability registration as a comment. Use the exact Name and
Ubertip text when it is short enough to remain readable; preserve placeholders
such as <AOws,DataA1> and <AOws,Dur1> so the comment stays tied to authored
data rather than hardcoding a value. For toggle/autocast abilities, also include
Untip and Unubertip above the inverse command path. These comments are an
implementation index, not a replacement for the source strings or a claim that
every tooltip sentence is fully implemented.
Example:
/* Name=War Stomp
* Ubertip="Slams the ground, dealing <AOws,DataA1> damage to nearby enemy land units and stunning them for <AOws,Dur1> seconds."
*/
BZ_SIMPLE_SPELL_PROC(AbilityStomp) { /* ability-owned implementation */ }
For an inverse/toggle path:
Do not infer toggle semantics from Untip alone. Retail Avatar is a timed
one-shot spell whose BHav buff owns its lifetime.
If a handler is shared by several rawcodes, list each rawcode's string contract
or state explicitly which entries share the implementation. If the entry is not
implemented, annotate the registry entry with TODO instead of copying a
tooltip above an unrelated function.
rawcode: AOws
archive: ROC/TFT row checked separately
strings: OrcAbilityStrings.txt entry and relevant tooltip fields
class: authored class or unresolved
state: instant, toggle, passive, autocast, channel, or morph
inputs: Area, DataA, Dur, and any other consumed fields
relationship: target ownership, alive state, unit type, immunity rules
side effects: damage, status, movement, summon, item, or presentation changes
timing: cast point, impact, tick, duration, cooldown, inverse
confidence: confirmed, observed, inferred, or unresolved
next check: the smallest test or observation that could disprove it
An inferred field is acceptable when the record says it is inferred and a focused test covers both the expected path and the nearest negative path. Do not silently turn a tooltip guess into a general engine rule.
Presentation And Animation
Ability strings do not normally specify model animation. Resolve presentation in layers:
- Check the unit model's available sequences.
- Check
UnitProfile.uani/animPropsRequired Animation Names. - Reuse the logical animation request system (
spell,morph,stand,attack) instead of hardcoding a model-specific sequence. - Check authored ability art, effect, sound, and
AnimSounds.slkdata. - Observe timing in retail if the effect must line up with an animation event.
A missing animation string in Game.dll does not prove that no animation is played.
The choice may be made by shared casting code, an animation enum, unit data, or the
model itself. Disassembly is useful here only when observation and model/data inspection
cannot establish the required timing or state transition.
Example: War Stomp
The standard Orc War Stomp entry describes an instant self-centered effect:
- damage nearby enemy land units;
- stun those units;
- use
DataAfor damage andDurfor stun duration; - use
Areafor the radius; - expose the normal War Stomp name and hotkey.
The local AOws handler in games/warcraft-3/game/skills/s_ability_stubs.c already
implements that gameplay core through spell_cmd, S_SpellData, S_SpellNumber,
S_SpellDuration, relationship checks, and the timed stun status.
Current Campaign Audit
The campaign strings are not interchangeable with standard ability rawcodes. A
name match is not enough: the registry lookup uses the rawcode, and the campaign
rows must be checked separately in AbilityData.slk.
Confirmed from games/warcraft-3/game/skills/s_skills.c and
data/strings/CampaignAbilityStrings.txt:
| Campaign entry | Registry status | Result |
|---|---|---|
AOw2 War Stomp |
CAbilityWarStompCampaign |
campaign procedure reads its requested rawcode |
ANsh / AOs2 Shockwave |
CAbilityShockwaveCampaign / CAbilityShockwaveCairne |
shared campaign area-damage helper, separate rawcodes |
ANcf Breath of Fire |
CAbilityBreathOfFireCampaign |
shared campaign area-damage helper |
Acdh, ANhw, ANhx |
campaign Drunken Haze, Healing Wave, and Hex procedures | currently share campaign_status_execute; verify each gameplay contract |
ACs7, ACs8, Arsq, Arsg, Arsp, Acef, Arsw, AOls |
campaign summon procedures | share campaign_summon_execute with authored rawcodes |
ANbr, ANsb |
CAbilityBattleRoar, CAbilityStormBoltCampaign |
dedicated execution bodies |
Asb1, Asb2, Asb3, ANsu |
Naga Submerge | specialized paired submerge / unsubmerge morph using authored Data A / UnitID forms; enter only on swimmable non-walkable terrain; submerged form uses existing invisibility/detection contract; exact morph presentation timing remains |
AOr2, AOr3 |
CAbilityEnduranceAuraCampaign, CAbilityReincarnationCairne |
currently share campaign_toggle_execute; verify passive/lifecycle requirements |
These are registration and code-path observations, not a completeness claim. Verify the behavior in
skills/s_campaign_abilities.c with focused tests before consolidating a campaign row with a standard procedure.
Human Ability Audit
The Human registry entries audited from HumanAbilityStrings.txt are owned by
skills/s_human_abilities.c. ROC and TFT currently provide identical normalized rows
for the selected entries. Active spells use the shared spell_cmd path, and Heal,
Inner Fire, Slow, and Spell Steal use the generic one-enabled-ability autocast state in
edict_t.autocast_code.
| Runtime owner | Abilities | Authored inputs consumed |
|---|---|---|
| unit-target spell | Aerial Shackles, Control Magic, Cloud, Inner Fire, Heal, Slow, Invisibility | Rng, Dur/HeroDur, BuffID, DataA-C |
| timed morph | Polymorph (Aply) |
Ply1/DataA, Ply2-Ply5/DataB-E, Rng, Dur, BuffID, target mask |
| point spell/thinker | Flare, Dispel Magic | Area, Dur, DataB |
| toggle/status | Defend, Magic Defense | DataA-F, Dur, HeroDur |
| timed transformation | Avatar | BHav, DataA-C, Dur |
| attack resolution | Feedback, Flak Cannons, Fragmentation Shards, Barrage, Storm Hammers | DataA-E, Area, unit attack splash fields |
| capability/presentation marker | Sphere, Phoenix Morphing, Flying Machine Bombs, True Sight, Magic Sentry | registry presence; no invented cast behavior |
Central consumers apply Slow/Defend movement factors, Inner Fire armor, attack
locks, Defend piercing reduction/reflection,
Feedback mana burn, and attack splash. Invisibility clears on attacks, spell commits,
and status expiry. human_ability_think owns Flare reveal updates and Aerial Shackles
damage ticks and is appended to the save callback roster.
Polymorph keeps the original edict/class/stat identity and stores only reversible model, scale, and movement-speed presentation state. The configured Ply2-Ply5 form supplies the temporary model/movement speed, Ply1 gates Neutral Hostile creep level, and the configured timed buff owns expiry/dispel restoration. See Polymorph.
Avatar stores its applied armor, maximum-health, and attack-damage deltas on the
edict. BHav owns the lifetime; expiration, death, and ability removal subtract the
stored values, clamp current health, remove the alternate animation property, and
invalidate the info panel. Hero stat recomputation includes
temporary_health_bonus, so attribute changes cannot erase an active Avatar bonus.
Spell effects call S_SpellDamage, which rechecks BHav at impact time, while
physical attack damage continues through T_Damage.
Detection is a per-player visibility concern; True Sight and Magic Sentry must be
implemented in the snapshot/FOW visibility contract rather than by globally clearing
RF_HIDDEN.
Useful checks:
build/bin/ability_audit -data 'data/Warcraft III' -raw Adef
make test-wc3-engine WC3_PATTERN='wc3_spell.*'
make test-wc3-engine WC3_PATTERN='wc3_save.*'
The remaining questions should be handled independently:
- Does damage occur at command acceptance, cast point, or impact?
- Which units are excluded besides air units, allies, dead units, and the caster?
- Which logical caster animation is requested?
- Which effect and sound are authored, and when are they emitted?
- Does stun replace, refresh, or combine with an existing stun?
Use a focused test or retail observation for each question. Do not block the gameplay implementation on unrelated questions.
Disassembly: Optional Evidence
For the demo's actual FOURCC-to-class registrations, use the Python extractor and offset reference. Its generated mapping includes internal type IDs; it does not guess object-data aliases.
Use Game.dll when it answers a specific unresolved question, not as the default
implementation workflow. Good questions include:
- whether a cast applies an effect at cast start or at an animation callback;
- which shared helper enforces an unusual target predicate;
- how a morph preserves handles, inventory, buffs, or health;
- whether a campaign variant has a distinct lifecycle;
- which logical animation or sound event is selected when data is inconclusive.
Keep the binary digest and exact command when recording a result:
shasum -a 256 data/Warcraft3demo/Game.dll
rabin2 -I data/Warcraft3demo/Game.dll
r2 -q -e bin.cache=true -A \
-c 'izz~CAbilityWarStomp' -c 'izz~AOws' -c 'q' \
data/Warcraft3demo/Game.dll
Prefer call-site and behavior evidence over class names. If using radare2, verify
pdg against pdf; decompiler output is orientation only. Do not transplant an
address from the demo DLL into another build. Preserve unresolved binary findings as
notes attached to the specific ability rather than making them a prerequisite for
all ability work.
Testing
Recreate the gameplay situation in automated tests before launching the game. Use production order/ability dispatch, the real animation and update callbacks, deterministic simulation time, and representative archive fixtures. If the intended reproducer is a sequence of player or script actions, test that sequence rather than only the final helper. Extend the harness or fixture generator if it cannot yet exercise the path. See the test-first workflow.
Each new ability needs focused tests for:
- registration and rawcode lookup;
- the authored level and field mapping (non-stock Data and a stock-shaped /
omitted-column case from the ROC then TFT brief; use the brief's BuffID/
targs); - the positive target/effect path;
- the nearest invalid target or inverse state, including another ability stripping the status through the real expire path;
- duration, cancellation, expiry, and repeated casts where applicable — assert the gameplay lock ended, not only the buff or helper;
- save/load when the ability adds persistent entity state or a thinker; for shared lifecycle helpers prefer an end-to-end round trip of the combined contract (for example projectile owner/target generations, corpse reservation flag + marker, or owned-effect owner + target generation) rather than only independent scalar field tests;
- ROC and TFT rows when the archives differ.
For Raven Form, reproduce both completion of Morph and replacement by Move before its end callback, followed by ascent, pause, and reversal. Assert the retained order, form, animation interval, height, and cleanup at the relevant steps. Exercise the generic scheduler as well as the ability callback so a missing registration or lifecycle hook cannot hide behind a passing helper test. Use the generated morph model to select actual sequences; do not require a campaign replay to prove these transitions. A save/load or parser bug belongs in the corresponding fixture-backed regression suite.
Run the affected WC3 tests first, then the full suite:
make test-wc3-engine WC3_PATTERN='wc3_unit.*'
make test-wc3-engine WC3_PATTERN='wc3_spell.*'
make test
Only use a bounded game run for a named property the tests and tools cannot measure, such as the final rendered pixels. State that gap before launching and exercise it directly. Animation selection, effect lifetime, status cleanup, and return to the normal state should already have automated assertions. Once covered, rerun the tests instead of the game.
Cyclone (Acyc)
ROC and TFT AbilityData.slk define Cyclone as a one-level, 600-range unit spell with DataA-C = 1,0,0,
Dur = 20, HeroDur = 6, and BuffID = Bcyc,Bcy2. Its allowed targets are enemy organic air and ground units;
the TFT tooltip states the non-mechanical restriction explicitly. TFT registers Acyc as CAbilityCyclone with
CAbilitySimpleSpell (AAsm) as its parent, while Bcyc and Bcy2 are CBuffCyclone and
CBuffCycloneTwo respectively.
OpenRealm stores the active authored buff in abilstatus[] and derives Cyclone's action and target locks from either
Cyclone buff rawcode. The concrete procedure validates the enemy/non-mechanical rule, applies the first authored
BuffID for Dur or HeroDur, and stops the target's current order. Generic Move, Attack, spell-caster, spell-target,
and damage paths consult the status predicate, so expiry restores behavior without an edict_t or network-state field.
Known Pitfalls
- Do not parse
AbilityData.slkwith an ad-hoc script.ability_audit -rawalready uses the same DDX schema as the game. - Do not treat
-data 'data/Warcraft III'as ROC. That directory containsFrozen Throne/War3x.mpq; first-wins MPQ search returns the TFT row unless-rocopened War3.mpq alone. - Do not treat
code=aliases as identical behavior.AamsandAam2shareCAbilityAntiMagicShellandBuffID=Bams,Bam2, but empty DataC appliesBams(cannot be targeted) and DataC=300 appliesBam2(spell-damage pool). The Buffubertip, not the comments column, states that split. - ROC may omit
BuffIDwhile*AbilityStrings.txtstill names the buff. Document the fallback; do not invent DataC that the ROC row does not have. BuffID=Bams,Bam2is two tokens.unit_addtimedstatusreads only the first four characters of the string you pass it; pick the token the brief says to apply.targswithoutfriend/enemy/neutralallows any living air/ground unit, including enemies.Aam2addsfriend,selfand must reject enemies.TEST(wc3_spell, foo_bar)is matched bywc3_spell.foo_bar/wc3_spell.foo*, notwc3_spell.foo.*.S_SpellIsEnemyis false whenmapinfo->players[n].playerTypeiskPlayerTypeNone. Set Human (or another real type) in the fixture.- Do not assume a campaign rawcode is interchangeable with its standard counterpart.
- Do not expose a passive ability as a command merely because it has a
CAbility*class name. - Do not hardcode per-level values that already exist in
AbilityData.slk. - Do not silently ignore an unresolved effect, sound, target rule, or animation; record it and add the smallest check that will resolve it.
- Do not copy disassembly addresses between DLL builds.
- Do not treat Warsmash or another engine as recovered Blizzard source. Use it as an independent behavioral reference.
See also:
- Ability coverage
- Anti-Magic Shell — worked example of the
-rawbrief and DataC/BuffID split - Dispel Magic — Adis/Adch/Advm shared procedure, DataB vs DataE, per-buff heals
- Unit animation properties
- Ability and item effects
- Warcraft III data model
- Diagnostic tools
Ability-owned orders and persistent behavior
This is the direction for new gameplay work and for refactoring behavior encountered in general-purpose files.
Split behavior by the ability that owns it; do not grow g_monster.c, m_unit.c, or g_ai.c with individual spell rules.
Keep an ability's order strings, validation, state transitions, animation moves, completion functions, and timed effects in its
skills/s_*.c owner. For immediate orders outside the spell pipeline, register ability_t.orders and handle A_ORDER;
for effects that outlive an active order, register AB_UPDATE and handle A_UPDATE. s_skills.c owns generic dispatch and deduplicates shared
update handlers at initialization. Do not add a spell-name branch or direct spell update to m_unit.c/g_monster.c.
See Raven Form for the order/update contract and persistence tests.
Unit-data behaviors use AB_INNATE and S_UnitAbilityEvent for spawn/rebind, idle acquisition,
move interruption, damage, entity removal, and automatic-target eligibility. Each message carries
an abilityitem_t; the owner decides whether its per-unit state applies. See
Creep Sleep for ACsp and its direct CAbilitySleepAlways parent delegation.
Encapsulation includes setup, interruption, cancellation, inverse orders, expiry/death/removal cleanup, and restoration
after save/load where applicable. Moving a command function into skills/ while leaving its timer or cleanup in a generic
unit loop is incomplete. Use existing lifecycle hooks or add a small generic hook when necessary. The shared mechanism
dispatches or applies an ability's result; the ability decides its own rules. Keep this flat and data-oriented.
skills/s_move.c owns reusable locomotion (steering, steps, collision policy, route goals, and support height).
Attack, Follow, Harvest, and Build call that ability's movement operations while owning their own goals and arrival
conditions. g_ai.c owns acquisition/behavior transitions, and g_monster.c owns initialization and generic animation dispatch.
Shared math, routing algorithms, collision queries, serialization, and in-place type rebinding remain reusable services;
ability ownership does not mean duplicating these mechanisms in each ability.