8.7 KiB
Enemy Dual Wield and Thrash source contract — 2026-10-05
src/game/enemyExtraAttacks.ts owns the pinned Spell.dbc contracts and source-shaped helper behavior for creature Dual Wield (674/29651) and Thrash (3391). It does not schedule enemy abilities or mutate combat state.
Client spell rows
The exact client sources are:
| Profile | Spell.dbc | SHA-256 |
|---|---|---|
| Ascension | ../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc | 2f77f18b91ffbae32a19568e4bc05ec78ee384b6ba8dd2a6e05f0e97abbb2dce |
| Epoch | ../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc | d5cce1a83550dcfa9eb2f0251dbb11fd24c272534b2b1a9b230924a44d817ab3 |
Both profiles identify Dual Wield spells 674 and 29651 as effect 40 (SPELL_EFFECT_DUAL_WIELD) with implicit target A = self, raw effect base points 0, no die-side roll, and no damage packet. Spell 674 has Attributes0 336 in Ascension and 80 in Epoch; spell 29651 has Attributes0 80 in both. Spell 674's damage class is 1; 29651's is 0. Both are durationless.
Spell 3391 Thrash is effect 19 (SPELL_EFFECT_ADD_EXTRA_ATTACKS), implicit target A = self, with raw EffectBasePoints = 1 and EffectDieSides = 1. The compiler's base-point convention stores 2 because it normalizes raw base points by adding one. Historical core SpellEffectInfo::CalcValue instead starts with raw BasePoints = 1 and adds one for DieSides = 1, yielding exactly two extra attacks. The contract preserves raw and normalized values separately; do not interpret the normalized 2 as a second die roll.
The matching generated compiler descriptors are in scripts/dungeon-pipeline/compile-campaign-catalog.mjs near lines 1167–1183. Generation already classifies these as utilities rather than direct damage.
Creature Dual Wield behavior
The read-only historical AzerothCore reference implements effect 40 in src/server/game/Spells/SpellEffects.cpp (Spell::EffectDualWield): only on HIT_TARGET it calls SetCanDualWield(true) on the effect target. For creature units, that enables the offhand attack channel; it does not itself deliver an attack.
When Dual Wield is enabled, a creature does not need an equipped offhand item for the core offhand channel to exist: Creature::HasWeapon(OFF_ATTACK) returns true in enabled mode (Creature.cpp:3299). Core creates the same generated min/max base weapon range for BASE_ATTACK and OFF_ATTACK in Creature::SelectLevel, using CreatureBaseStats::GenerateBaseDamage. Creature::CalculateMinMaxDamage in Entities/Unit/StatSystem.cpp then applies the same template variance, AP contribution, damage modifier, and attack-speed math to either hand. Core's default UNIT_MOD_DAMAGE_OFFHAND total-percent modifier is 0.5 (Unit.cpp:401), so the offhand uses half of its otherwise-equivalent creature damage formula. Creature::UpdateEntry assigns CreatureTemplate.BaseAttackTime to both hands (Creature.cpp:617–618). They have separate swing timers; the creature melee AI can add a 200 ms presentation stagger when both are ready together. The initial offhand timer is offset to half a base swing on attack start.
Core's ordinary weapon swing miss calculation adds 19 percentage points while dual wielding (Unit.cpp:15276, MeleeSpellMissChance, when spellId is zero and an offhand is available). Enabled creature offhands satisfy that availability check without an item row, so the penalty applies to their mainhand, offhand, and queued mainhand swings. The runtime applies it only to ordinary creature weapon swings, preserving native/player-defender exclusions.
The source effect only sets the creature capability; it has no aura duration and the core source does not remove it when the caster leaves combat. This simulator clears the capability, timer, and queued swings on its death, navigation/leash, and encounter-reset lifecycle as game policy. That reset behavior is not claimed as an exact spell-expiration rule from the core. RuneWaker actors, summons, and character defenders remain outside this source-creature path. The separate flags_extra USE_OFFHAND_ATTACK and AUTO virtual-item cases are not modeled here.
This formula is core-owned and does not justify copying an already rolled mainhand hit as the offhand hit. Runtime damage should apply the offhand modifier to the generated creature range/AP formula before the ordinary hit-table and defense resolution. For creatures that have not entered enabled Dual Wield mode, the historical core's AUTO mode does require a valid offhand weapon. The locked Healer-Man world source includes creature rows with equipment_id but not the equipment-template table, so that missing mapping must not be guessed.
Thrash behavior
Core Spell::EffectAddExtraAttacks queues its calculated count only when the effect is handled on HIT_TARGET and the target unit is alive. Unit::Update clears _lastDamagedTargetGuid before draining already queued swings (Unit.cpp:561–575). Ordinary mainhand/offhand updates set it to their victim even when mitigation leaves zero damage (Unit.cpp:2827); direct spell damage writes it only when source damage is positive (Spell.cpp:2774); periodic aura ticks do not write it in this source. Unit::AddExtraAttacks captures that current-update target, or the unit's selected target if there is no last-damaged GUID; with neither it queues nothing. The saved queued target remains fixed through the next update. On the next Unit::Update queue-drain phase, HandleProcExtraAttackFor issues one BASE_ATTACK (mainhand) melee update per queued count (Unit.cpp:2934–2945). Ordinary Thrash swings are discarded if the target is gone or outside melee range when drained. They go through the normal melee attack outcome and mitigation path; they are not offhand swings and do not replace or reset the normal mainhand swing timer.
Thrash has a fixed count of two, not a random proc chance. The normal melee proc machinery still handles those swings. The core aura proc filter blocks the same ADD_EXTRA_ATTACKS spell from chaining itself while it is the current extra-attack source; other melee effects keep their own source eligibility rules. Runtime should preserve this bounded same-spell recursion behavior without disabling unrelated melee procs.
Current Stockade source cases
The locked creature_template_spell.sql data lists spell 3391 for Targorr the Dread (1696) and spell 29651 for Bazil Thredd (1716). The SmartAI table casts 674 for three Stockade creatures, always targeting self with an initial 1000–1000 ms update interval and zero repeat interval:
| Entry | Creature | Raw SmartAI event type | Meaning in the core enum | Evidence |
|---|---|---|---|---|
| 1696 | Targorr the Dread | 0 | update in combat | SQL comment incorrectly says out of combat |
| 1711 | Defias Convict | 1 | update out of combat | SQL comment agrees |
| 1716 | Bazil Thredd | 1 | update out of combat | SQL comment incorrectly says in combat |
SMART_EVENT_UPDATE_IC = 0 and SMART_EVENT_UPDATE_OOC = 1 are defined in the historical SmartScriptMgr.h. Prefer these raw event types over the inconsistent Targorr/Bazil SQL comments. Targorr's template lists Thrash, but the locked Stockade SmartAI rows contain no cast action for spell 3391. That source establishes availability, not a Thrash cadence; do not invent one.
The source lock for the world import pins creature.sql, creature_template.sql, creature_template_spell.sql, and smart_scripts.sql, but not an equipment-template mapping. The historical AzerothCore checkout used for behavior references has HEAD e822d718322289dd036fb2daf8867ba41283bf9a; it is read-only reference material and is not represented as the exact server-code revision named by the world source lock.
Runtime integration surface
Import enemyDualWieldContract(spellId, sourceProfile) to validate a 674 or 29651 effect and obtain the explicit offhand formula/timing contract. enemyThrashContract(sourceProfile) supplies the exact two-swing, queued mainhand semantics; resolveEnemyThrashTarget(lastDamagedId, selectedId) implements core target precedence and returns null when neither target is known. Unknown spell/profile pins fail closed. trackEnemyLastDamagedTarget models the current-update source target writers and deliberately ignores periodic ticks.
Runtime integration in combatStore.ts keeps the offhand timer independent of the mainhand's cooldown, drains each saved Thrash target/count before selecting the next cast, gives every hand/swing a distinct deterministic seed, and routes each packet through ordinary hit, mitigation, and proc handling. The offhand starts at half the base swing after engagement; Bloodlust retimes in-flight weapon clocks without changing queued extra attacks or spell cooldowns. Preserve the same-spell extra-attack recursion guard. Do not promote unsupported enemy data into loot, readiness, or all-class balance coverage.