Skip to content

UNIT_SPELLCAST_* event family + GUID unit tokens - #11

Merged
brues-code merged 6 commits into
masterfrom
unit_spellcast
Aug 1, 2026
Merged

UNIT_SPELLCAST_* event family + GUID unit tokens#11
brues-code merged 6 commits into
masterfrom
unit_spellcast

Conversation

@brues-code

@brues-code brues-code commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Backports the modern UNIT_SPELLCAST_* event family to 1.12.1 (local player + remote units) and adds GUID-literal unit tokens.

UNIT_SPELLCAST_* events

  • Local playerSENT / START / STOP / DELAYED / SUCCEEDED / INTERRUPTED / FAILED / FAILED_QUIET / CHANNEL_START / CHANNEL_UPDATE / CHANNEL_STOP, derived from Spell::Cast's existing cast/channel state with modern event ordering. Channel pushback re-anchors UnitChannelInfo's end time; wrap-safe timing (no negative values on long-uptime machines).
  • Non-player units (phase 2)START / STOP / CHANNEL_START / CHANNEL_STOP / SUCCEEDED / INTERRUPTED, recovered from the SMSG_SPELL_START packet plus the CGUnit_C::ClearCastingSpell choke point, fanned out to every unit token the caster GUID currently maps to (target / focus / nameplateN / party / raid / mouseover).
  • ReticleRETICLE_TARGET / RETICLE_CLEAR for ground-targeted spells.
  • FAILED vs FAILED_QUIET — verified against the 3.3.5 client's own unit-spellcast dispatch (a fixed whitelist: CHARMED / DONT_REPORT / SPELL_IN_PROGRESS), mapped to 1.12's SpellCastResult enum. SPELL_IN_PROGRESS (cast-while-casting / spell-queue rejection) is the common quiet case.
  • castGUID — synthesized per cast in the documented Cast-<type>-... format and returned as UnitCastingInfo / CastingInfo's castID, consistent across a cast's events.

GUID unit tokens

  • Every Unit* function accepts a raw "0x<hex>" GUID literal in place of a unit token (folded into the existing FUN_TOKEN_TO_GUID hook, so one branch covers the whole surface). Reuses the token suffix walker, so "0x...target" composes.
  • Gated on SuperWoW's absence (GetModuleHandleA("SuperWoWhook.dll")) — SuperWoW already provides GUID input via its own resolver patch, so we defer to it when loaded. Behaves identically either way.

Testing

In-game on Turtle/Octo 1.12.1: event ordering + channel timing (priest/mage), remote casts and interrupts, castID correlation, FAILED_QUIET on spam-cast, and GUID-token input verified both with SuperWoW absent (our path) and present (defers to SuperWoW).

Adds the TBC+ cast/channel events (START, STOP, DELAYED, SUCCEEDED,
INTERRUPTED, FAILED, SENT, CHANNEL_START, CHANNEL_UPDATE, CHANNEL_STOP)
for the player, so cast-bar / rotation addons written against the modern
signature work on 1.12. Each carries (unit, castGUID, spellID, spellName,
rank) -- SENT inserts `target` at arg2 -- with a synthesized modern-shape
castGUID (Cast-3-0-0-0-<spellID>-<castUID>) shared across all of a cast's
events so START pairs with STOP. arg1 is always "player" (player-only phase).

The events are derived, not independently tracked: Spell::Cast already
hooks every cast/channel path for UnitCastingInfo/UnitChannelInfo, so
Spell::CastEvents reuses that state.
  - START/STOP/DELAYED/CHANNEL_START/STOP: polled from Cast's OnWorldTick
    against the previous snapshot.
  - SUCCEEDED: fired from the SMSG_SPELL_GO hook (covers instants too).
    Deferred for channels so it lands after CHANNEL_START (modern order).
  - INTERRUPTED/FAILED: split by result code in a Spell_C_SpellFailed
    co-hook -- a started cast that aborts (kick/move/LoS) fires INTERRUPTED
    (repeated per retry, reusing the cast guid); a pre-cast rejection fires
    FAILED.
  - SENT: minted from the CMSG_CAST_SPELL send, threading its castGUID
    forward so all of a cast's events share one guid.
  - CHANNEL_UPDATE: from a new MSG_CHANNEL_UPDATE co-hook, which also
    re-anchors g_channel.endMs to the server's remaining time so channel
    pushback (which shortens vanilla channels) shows in UnitChannelInfo.

Every fire is gated on Event::Custom::HasListeners (new), so the system
costs one pointer-compare per transition when no addon registers.

Supporting changes:
  - Net::SendObserver: shared co-hook on the NetClient send funnel with an
    AutoSubscribe list, so CastEvents (SENT) and ComboDuration (combo-point
    capture) both watch outgoing packets without owning the hook. Extracted
    from ComboDuration's private hook.
  - Cast.cpp: player channel now self-expires at its computed endMs in the
    poll (was cleared only by the ~1s-lagged +0x228 field, so CHANNEL_STOP
    trailed the real end by ~1s); wrap-safe delta compare so a long-uptime
    ms tick past 2^31 can't misfire it.

Player-only; remote-unit fan-out, FAILED_QUIET, and empowered casts are
not implemented.
Fires the six observable cast/channel events -- START, STOP,
CHANNEL_START, CHANNEL_STOP, SUCCEEDED, INTERRUPTED -- for other units,
not just the player. Each is fanned out once per unit token currently
mapping to the caster (target / focus / nameplateN / party / raid / pet /
mouseover), so a target frame, a party frame, and a nameplate cast bar
all get their own event with a shared castGUID -- matching retail.

Token fan-out reuses the engine's own GUID->token reverse map
(FUN_00515c50, nampower's unfilled GetNamesFromGUID) for native tokens,
plus focus/nameplate which the engine can't see, via
Unit::Identity::TokensForGUID. SuperWoW's raw-GUID token ("0x...") is
filtered out so only standard tokens fan out.

Remote events are derived from Spell::Cast's existing packet hooks:
  - START / CHANNEL_START from HandleSpellStart (SMSG_SPELL_START)
  - SUCCEEDED from the SPELL_GO hook (Aura::Source)
  - STOP / CHANNEL_STOP at the computed end, or on abort
  - INTERRUPTED (casts only) from HandleCastAborted (ClearCastingSpell /
    failure packets)
A per-caster state machine (PollRemote, from OnWorldTick) fires them in
modern order; a channel's SPELL_GO cast->channel ClearCastingSpell is
filtered so it doesn't self-interrupt at its own start.

Not fired for remotes (data is caster-only / client-local): SENT,
DELAYED, FAILED, CHANNEL_UPDATE.

Also in this change:
  - castGUID follows type rule from https://warcraft.wiki.gg/wiki/GUID#Cast : real casts are Type 3 with a
    time-based castUID (low 23 bits = UNIX second, high bits = per-second
    counter, matching retail's structure), threaded across a cast's events
    (player and remote converge on the same value); FAILED is Type 2 with
    a flat incrementing integer.
  - Channels never fire INTERRUPTED (player or remote) -- retail emits
    only CHANNEL_STOP whether a channel completes or is cut short.

arg1 is the caster's unit token ("player" or another token); the
spellName / rank tail extensions are unchanged.
UnitCastingInfo / CastingInfo now return the cast's castGUID as castID
(return 7) instead of nil -- the same string the UNIT_SPELLCAST_* events
carry, so an addon can correlate the polled info with the events. It's
pulled from Spell::CastEvents (the single source of the castUID) via a new
CurrentCastGuid accessor, and works for the player and remote units. The
timing stays sourced from Spell::Cast -- only the castGUID crosses over,
so there's no duplication of cast state.

UnitChannelInfo is left retail-exact (no castID; channels don't carry
one), even though our channel events do include the shared castGUID.

Also corrects the spell-cast-GUID spec attribution in comments/docs to
warcraft.wiki.gg/wiki/GUID#Cast (it was wrongly credited to Wowhead).
Backports the ground-target reticle events for the player:
RETICLE_TARGET when an AoE placement reticle comes up (Blizzard, Flare,
Rain of Fire, ...), RETICLE_CLEAR when it's cancelled. Args are
(unit, "", spellID, spellName, rank); arg1 is always "player".

Driven by polling the engine's targeting-flag global (0x00CECAC0 -- the
predicate behind SpellIsTargeting) from OnWorldTick: 0->nonzero fires
RETICLE_TARGET with the pending spellID (0x00CEAC58, written by
Spell_C_CastSpell); nonzero->0 fires RETICLE_CLEAR. CLEAR is suppressed
on placement (a CMSG_CAST_SPELL for the reticle spell went out, seen via
the shared send observer), so it fires only on a genuine cancel --
matching retail, which emits no CLEAR when the spell is placed/cast.

The castGUID slot (arg2) is "" rather than retail's nil: there's no cast
to identify yet, and the engine's event dispatcher can't emit a nil
mid-argument-list (a NULL %s corrupts the args after it -- the nil trick
only survives as the last arg). unit (arg1) and spellID (arg3) are exact.
Vanilla's Spell_C_SpellFailed dropped SPELL_FAILED_DONT_REPORT silently.
Modern surfaces suppressed (no-error-text) failures as
UNIT_SPELLCAST_FAILED_QUIET rather than FAILED.

Verified against the 3.3.5 client's unit-spellcast dispatch (FUN_007fecc0),
which selects the QUIET event for a fixed 3-code whitelist, mapped to 1.12's
SpellCastResult enum (FUN_006e23e0):
  CHARMED (0x14) . DONT_REPORT (0x17) . SPELL_IN_PROGRESS (0x61)

SPELL_IN_PROGRESS (casting while already casting / a nampower spell-queue
rejection) is the common case that previously mis-fired as a loud FAILED.
Fold a "0x<hex>" branch into the existing FUN_TOKEN_TO_GUID hook so every
Unit* function (UnitName, UnitHealth, UnitClass, UnitExists, ...) accepts a
64-bit GUID literal in place of a token. The parsed GUID goes straight to
the engine's own object-manager lookup (the path "player" takes), so an
unloaded/out-of-range GUID resolves to nil like any absent unit, no error.
Reuses the existing suffix walker, so "0x...target" composes.

Gated on SuperWoW's absence (GetModuleHandleA("SuperWoWhook.dll"), cached):
SuperWoW already provides GUID input via its own resolver hook, so when it
is loaded we defer to avoid two layers parsing the same string. Behaves
identically either way. Input direction only.
@brues-code
brues-code merged commit 29e3dab into master Aug 1, 2026
1 check passed
@brues-code
brues-code deleted the unit_spellcast branch August 1, 2026 02:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant