AzerothCore module that implements Vanilla-style spell batching (WoW 1.12 behavior), reproducing the ~400 ms tick window where all spell effects within the same tick resolve simultaneously.
| Field | Value |
|---|---|
| AzerothCore | 3.0.0 — commit bf770c9 or later |
| WoW Client | 3.3.5a (Wrath of the Lich King) |
| Platform | Windows / Linux |
This module was developed and tested against AzerothCore commit
bf770c9. Later commits may require adjusting the offsets incore.patch.
On the original Vanilla WoW servers (patch 1.12), spells were not processed in real time. The server accumulated all spells cast during a window of approximately 400 milliseconds and resolved them all simultaneously at the end of that window.
This produced PvP interactions impossible in later versions of the game:
- Mutual kill: two players kill each other simultaneously — both die.
- Mutual CC: two crossed Polymorphs turn both players into sheep at the same time.
- Counterspell race: two simultaneous Counterspells lock the school of both mages.
- Ghost charge: a single-charge aura (Divine Favor, Hot Streak, etc.) can benefit multiple spells that complete their cast within the same tick.
In standard WotLK, the first packet to reach the server always wins. This module restores the Vanilla behavior.
The module intercepts spells at three points in the spell lifecycle.
HandleCastSpellOpcode → QueueSpell() instead of spell->prepare()
When a player presses a spell, instead of executing it immediately, it is added to that player's queue. When the next batch tick arrives, the manager runs a two-phase cycle:
Phase 1 — VALIDATE
For each spell in queue:
CheckCast() against the current world state.
If target is alive and in range → mark as valid.
Phase 2 — EXECUTE
For each validated spell (in arrival order):
spell->prepare(skipInitialCheck=true)
The skip prevents a heal earlier in the batch from invalidating the next damage spell.
This ensures all spells in the same tick see the same world state at validation time, not the state altered by earlier spells in the same batch.
When a cast-time spell's timer reaches zero, the engine normally calls cast()
immediately. The module defers that call until the next batch tick:
m_timer == 0 in Spell::update()
→ sSpellBatchMgr->ShouldBatchCast() == true
→ sSpellBatchMgr->QueueCast(this, skipCheck) // defer
// cast() is called in Phase 3 of the next batch tick
This means for a 2.5 s Frostbolt:
- The cast bar starts immediately (the client is accurate).
- The damage applies at the next batch tick boundary after the cast completes.
Phase 3 — CAST EFFECTS
For each spell in the cast queue:
spell->cast(true) // skipCheck=true — atomic, does not re-validate conditions
// that may have changed within the same tick
skipCheck=true is critical for mutual kills and mutual Polymorph: if the first
spell in the tick kills or CCs the target of the second spell, the second still
applies because its effects are part of the same atomic tick.
The accumulator always advances regardless of whether there are spells in queue:
_accumulator += diff;
if (_accumulator < _windowMs)
return;
_accumulator %= _windowMs; // preserve remainder — no accumulated driftThis produces a fixed server clock, not a rolling window that only runs when there are pending spells.
Some abilities are single-charge auras (spell mods with 1 charge): they are consumed when the player casts an eligible spell. With spell batching, if two eligible spells complete their cast within the same 400 ms tick, both see the charge as available. The charge is consumed exactly once at the end of the tick.
Charge consumption occurs in Player::RemoveSpellMods(), called at the end of
Spell::cast(). During batch processing (_processing == true), instead of calling
DropCharge() immediately, the module defers it:
Spell A cast() → RemoveSpellMods() → IsProcessing() == true → DeferChargeDrop(aura)
Spell B cast() → RemoveSpellMods() → IsProcessing() == true → DeferChargeDrop(aura) (already in set, ignored)
FlushDeferredChargeDrops() → aura->DropCharge() exactly once
_processing = false
Both spells must complete their cast within the same 400 ms window:
400 ms window
├── t=0 ms Spell A (cast-time) completes → enters cast queue
├── t=50 ms Player presses Spell B (instant) → enters prepare queue
└── t=400 ms BATCH TICK
├── Phase 2: Spell B fires → aura available → crit → DeferChargeDrop
├── Phase 3: Spell A fires → aura still available (deferred) → crit
└── Flush: aura consumed exactly once → both critted
If Spell B is pressed much earlier (more than 400 ms before Spell A completes), they fall into different batches and the ghost charge does not apply — identical behavior to how Vanilla 1.12 worked.
| Aura | Eligible spells | Effect |
|---|---|---|
| Divine Favor | Holy Light, Flash of Light, Holy Shock | Both spells are guaranteed critical |
| Infusion of Light | Flash of Light, Holy Light | Both receive cast time reduction |
Classic scenario — Divine Favor:
- Activate Divine Favor.
- Start casting Holy Light (2.5 s).
- Press Holy Shock in the last ~400 ms of the Holy Light cast.
- Both spells crit from the ghost charge.
| Aura | Eligible spells | Effect |
|---|---|---|
| Presence of Mind | Any spell with cast time | Both become instant |
| Hot Streak | Pyroblast | Both Pyroblasts are instant |
| Brain Freeze | Frostfire Bolt, Fireball | Both are instant and free |
| Missile Barrage | Arcane Missiles | Both channels are instant and cheaper |
| Aura | Eligible spells | Effect |
|---|---|---|
| Clearcasting (Omen of Clarity) | Any eligible spell | Both free (no mana cost) |
| Nature's Grace | Next spell with cast time | Both receive −0.5 s cast time |
| Aura | Eligible spells | Effect |
|---|---|---|
| Elemental Focus | Next 2 spells | Spells in the batch cost no mana |
| Tidal Waves | Greater Healing Wave, Healing Wave | Both receive cast time bonus |
| Aura | Eligible spells | Effect |
|---|---|---|
| Surge of Light | Smite | Both Smites are instant |
| Serendipity | Greater Heal, Prayer of Healing | Both receive cast time reduction stacks |
| Aura | Eligible spells | Effect |
|---|---|---|
| Nightfall | Shadow Bolt | Both Shadow Bolts are instant |
| Backlash | Shadow Bolt | Both instant after taking a melee critical |
| Decimation | Soul Fire | Both Soul Fires with reduced cast time |
| Aura | Eligible spells | Effect |
|---|---|---|
| Bloodsurge | Slam | Both Slams are instant |
| Sudden Death | Execute | Execute available outside 20 % HP for both |
| Taste for Blood | Overpower | Both Overpowers available |
| Aura | Eligible spells | Effect |
|---|---|---|
| Lock and Load | Explosive Trap, Immolation Trap | Both charges preserved for both spells in the tick |
| Cobra Strikes | Pet attacks | Both attacks are guaranteed critical |
| Aura | Eligible spells | Effect |
|---|---|---|
| Rime / Freezing Fog | Howling Blast | Both free and instant |
| Sudden Doom | Death Coil | Both cost no runes |
| Killing Machine | Frost Strike, Obliterate | Both guaranteed critical |
| Aura | Eligible spells | Effect |
|---|---|---|
| Ruthlessness | Finishing moves | Both generate an extra combo point |
| Relentless Strikes | Finishing moves | Both restore energy |
| Initiative | Openers (Ambush, Garrote…) | Both generate an extra combo point |
The following interactions occur when two players use abilities against each other within the same 400 ms window. The "With batching" column describes the result this module produces.
| Scenario | Without batching | With batching |
|---|---|---|
| Charge vs Charge | First one lands, second fails | Both stunned simultaneously |
| Intercept vs Intercept | First one applies | Both apply the stun |
| Charge vs lethal damage | Order decides who dies first | Both effects apply in the same tick |
| Intimidating Shout vs Intimidating Shout | First one fears the other | Both players fear each other |
| Scenario | Without batching | With batching |
|---|---|---|
| Polymorph vs Polymorph | First one lands, second fails | Both turned into sheep simultaneously |
| Counterspell vs Counterspell | First one locks the other's school | Both school-locked simultaneously |
| Frostbolt vs Frostbolt | First may kill before second lands | Both damage instances apply in the same tick |
| Spellsteal vs Spellsteal | First one steals the buff | Both steal each other's buff |
| Scenario | Without batching | With batching |
|---|---|---|
| Hammer of Justice vs HoJ | First one stuns the other | Both stunned simultaneously |
| Repentance vs Repentance | First one CCs the other | Both CCed simultaneously |
| Hammer of Wrath vs HoW | Only one can execute | Both can execute if both are below 20 % HP |
| Scenario | Without batching | With batching |
|---|---|---|
| Psychic Scream vs Psychic Scream | First one fears the other | Both fear each other simultaneously |
| Silence vs Silence | First one silences the other | Both silenced simultaneously |
| Scenario | Without batching | With batching |
|---|---|---|
| Hex vs Hex | First one hexes the other | Both hexed simultaneously |
| Frost Shock vs Frost Shock | First one slows the other | Both slowed simultaneously |
| Scenario | Without batching | With batching |
|---|---|---|
| Cyclone vs Cyclone | First one CCs the other | Both Cycloned simultaneously |
| Entangling Roots vs Entangling Roots | First one roots the other | Both rooted simultaneously |
| Scenario | Without batching | With batching |
|---|---|---|
| Kidney Shot vs Kidney Shot | First one stuns the other | Both stunned simultaneously |
| Blind vs Blind | First one blinds the other | Both blinded simultaneously |
| Kick vs Kick | First one interrupts the other | Both interrupted simultaneously |
| Scenario | Without batching | With batching |
|---|---|---|
| Death Grip vs Death Grip | First one pulls the other | Both displacement effects apply |
| Strangulate vs Strangulate | First one silences the other | Both silenced simultaneously |
With spell batching, a heal and a damage spell that apply in the same tick resolve simultaneously. Both effects are validated before either executes, so the target's state at validation time determines whether spells are valid — not the state after the first spell applies.
| Scenario | Result |
|---|---|
| Player at 1 HP — lethal damage and a heal land in the same tick | Both apply: if damage exceeds HP after the heal → dies; if heal exceeds damage → survives |
| Healer and DPS target the same player in the same tick | Both effects apply without one invalidating the other |
| Two heals on the same target in the same tick | Both heals apply (skipInitialCheck ensures the second heal is not rejected by state changed by the first) |
| Spell type | Batched? | Reason |
|---|---|---|
| Player instant spells (non-triggered) | ✅ Yes | Opcode handler → prepare queue |
| Player cast-time spells — cast start | ✅ Yes | Opcode handler → prepare queue |
| Player cast-time spells — effects | ✅ Yes | Spell::update() → cast queue |
| Finishing moves, shots, player AoE | ✅ Yes | Normal player casts |
| Triggered spells / server procs | ❌ No | Not initiated by the client |
| Passive spells | ❌ No | Always-on auras, irrelevant |
| Channeled spells (Mind Flay, Blizzard…) | ❌ No | Own tick system |
| Auto Shot / Wand Shoot | ❌ No | Auto-repeat, own rhythm |
| Heroic Strike / Cleave / Maul (on-next-swing) | ❌ No | Tied to melee attack timer |
| Vehicle spells | ❌ No | Vehicle abilities, not direct player casts |
| Pet spells | ❌ No | Separate HandlePetCastSpellOpcode handler |
| Totem spells | ❌ No | Server-side AI |
| NPC spells | ❌ No | Not player input |
The core.patch file documents the changes required in the AzerothCore core.
It touches 5 files:
| File | Change |
|---|---|
Spell.h |
Adds skipInitialCheck parameter to prepare() |
Spell.cpp |
Include, destructor cleanup, defer cast() in prepare() (instant) and update() (cast-time) |
SpellHandler.cpp |
Queue spells in opcode handler; cancel from queue on CMSG_CANCEL_CAST |
Unit.cpp |
Guard in InterruptSpell(): does not interrupt spells already in the cast queue (mutual Polymorph fix) |
Player.cpp |
Guard in RemoveSpellMods(): defers charge consumption during batch processing (ghost charge) |
-
Copy this folder to
modules/mod-spell-batching/in your AzerothCore tree. -
Apply the core patch:
git apply modules/mod-spell-batching/core.patch
If offsets are stale, apply the hunks manually following the comments inside
core.patch. -
Re-run CMake and build:
mkdir -p build && cd build cmake .. -DCMAKE_BUILD_TYPE=RelWithDebInfo -DMODULES=static make -j$(nproc) && make install
-
Enable the module in the generated config (
mod_spell_batching.conf):SpellBatch.Enable = 1 SpellBatch.Window = 400
| Key | Default | Description |
|---|---|---|
SpellBatch.Enable |
0 |
Enable the module |
SpellBatch.Window |
400 |
Batch window in milliseconds |
SpellBatch.MaxQueuePerPlayer |
5 |
Max queued spells per player; extras fall through to normal cast |
SpellBatch.Dedup |
1 |
If the same spell is queued twice, replace the older entry |
SpellBatch.Debug |
0 |
Verbose per-spell log output (SpellBatch.Debug = 1 to enable) |
| Use case | Recommended window |
|---|---|
| Authentic Vanilla 1.12 feel | 400 ms |
| WotLK server with competitive PvP | 100–150 ms |
| PvE only | Does not matter; 400 ms works fine |
- Two players in arena, both at 1 HP (
.modify hp 1). - Both press an instant damage spell at the same time.
- With batching: both die simultaneously.
- Two mages in arena.
- Both complete their Polymorph cast in the same 400 ms tick.
- With batching: both are turned into sheep simultaneously.
- Activate Divine Favor.
- Start casting Holy Light.
- Press Holy Shock in the last ~400 ms of the cast.
- Both spells should crit.
Enable SpellBatch.Debug = 1 and look in Server.log:
SpellBatch: queued spell=12826 caster=... queueSize=1
SpellBatch: batch#N prepare: total=2 executed=2 rejected=0 expired=0
SpellBatch: cast-effect deferred spell=12826
SpellBatch: cast-effect deferred spell=12826
SpellBatch: batch#N+1 cast-effects fired=2
total=2 executed=2 and cast-effects fired=2 confirm that two spells resolved
in the same tick.
- No dangling pointers:
Spell::~Spell()callsRemoveFromCastQueue(this), so a cancelled or interrupted spell can never be invoked from the cast queue. - Nulled before cast(): each cast queue entry is nulled before calling
cast(), so if one spell's effects destroy another queued spell, that spell's destructor marks its entry null and the iterator skips it safely. - No re-batching:
ShouldBatchCast()returnsfalsewhile_processing == true, preventing spells fired inside the batch from being re-batched. - Full queue = fallback: if a player's queue is full (
MaxQueuePerPlayer), the excess spell is cast normally rather than silently dropped. - Expiry: spells that have been in queue for more than
Window * 2ms are discarded to prevent stale spells from firing under severe lag conditions. - Atomic ghost charge: spell mod charge consumption is deferred until all effects in the batch have resolved, ensuring the set of consumed auras is consistent with the state at the start of the tick.