Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,19 @@ when launching with `-console`), not as Lua functions. See the
| `QUEST_DATA_LOAD_RESULT` | `questID, success` |
| `QUEST_REMOVED` | `questID` |
| `QUEST_TURNED_IN` | `questID, xpReward, moneyReward` |
| `UNIT_SPELLCAST_SENT` | `"player", target, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_START` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_STOP` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_DELAYED` | `"player", castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_SUCCEEDED` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_INTERRUPTED` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_FAILED` | `"player", castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_FAILED_QUIET` | `"player", castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_CHANNEL_START` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_CHANNEL_UPDATE` | `"player", castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_CHANNEL_STOP` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_RETICLE_TARGET` | `"player", "", spellID, spellName, rank` |
| `UNIT_SPELLCAST_RETICLE_CLEAR` | `"player", "", spellID, spellName, rank` |
| `UPDATE_INVENTORY_DURABILITY` | *(none)* |
| `UPDATE_SHAPESHIFT_FORM` | *(none)* |
| `VOICE_CHAT_TTS_PLAYBACK_STARTED` | `numConsumers, utteranceID, durationMS, destination` |
Expand Down
164 changes: 161 additions & 3 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@ build instructions.
- [`C_Spell.CancelSpellByID(spellID)` / `CancelSpellByName(name)`](#c_spellcancelspellbyidspellid--cancelspellbynamename)
- [`C_Spell.UnitCastingInfo(unit)` / `C_Spell.CastingInfo()`](#c_spellunitcastinginfounit--c_spellcastinginfo)
- [`C_Spell.UnitChannelInfo(unit)` / `C_Spell.ChannelInfo()`](#c_spellunitchannelinfounit--c_spellchannelinfo)
- [`UNIT_SPELLCAST_*` events (player)](#unit_spellcast_-events-player)
- [`C_Spell.GetSpellLevelInfo(spellID)`](#c_spellgetspelllevelinfospellid)
- [`GetSpellRequiredTargetLevel(spellID)`](#getspellrequiredtargetlevelspellid)

Expand Down Expand Up @@ -8709,6 +8710,31 @@ the unmodified resolver. The ordered list is maintained alongside
the existing `NAME_PLATE_UNIT_ADDED` / `_REMOVED` diff in the
per-tick nameplate walker.

### Unit tokens (GUID literals)

A raw 64-bit GUID literal — `"0x"` followed by up to 16 hex digits
(e.g. `"0xF130000000000A5"`) — works as a unit token against the same
`UnitX` surface, so a GUID can be passed anywhere a token is expected:

```lua
local guid = UnitGUID("target") -- e.g. "0xF13000..."
print(UnitName(guid), UnitHealth(guid), UnitClass(guid))
```

Hex parsing is case-insensitive; the parsed GUID is handed to the engine's
own object-manager lookup (the identical path `"player"` takes), so a GUID
for a unit that isn't currently loaded (out of range, not synced) resolves
to `nil` exactly like any absent unit — no error. Suffix chains compose:
`"0x…target"` walks to the referenced unit's target.

This is the **input** direction only — to obtain a token's GUID use
[`UnitGUID`](#unitguidunit).

**Compatible with SuperWoW.** SuperWoW provides the same GUID-input support,
so when it's loaded we detect it and defer to its resolver (no double
handling); when it isn't, this fills the gap. Either way GUID-token input
behaves identically, so addons needn't care whether SuperWoW is present.

## NameCache

GUID-keyed cache of player names and classes. The engine itself
Expand Down Expand Up @@ -10841,9 +10867,12 @@ so progress is `(GetTime()*1000 - startTimeMs) / (endTimeMs - startTimeMs)`.
> the **effective** cast time (the engine's own cast-time helper: base +
> level scaling + cast-time talents like Improved Frostbolt + the
> haste/cast-speed multiplier), so it matches the in-game cast bar — e.g.
> a Mage's talented Frostbolt reads 2.5s, not the 3.0s base. Fields
> vanilla can't fill are structurally-correct placeholders:
> `castID`/`castBarID` = `nil`, `notInterruptible` = `false`,
> a Mage's talented Frostbolt reads 2.5s, not the 3.0s base. `castID` is
> the cast's **castGUID** — the exact string the
> [`UNIT_SPELLCAST_*`](#unit_spellcast_-events) events carry for this cast,
> so you can correlate the polled info with the events (works for the player
> and other units). Fields vanilla can't fill are structurally-correct
> placeholders: `castBarID` = `nil`, `notInterruptible` = `false`,
> `delayTimeMs` = `0`.

### `C_Spell.UnitChannelInfo(unit)` / `C_Spell.ChannelInfo()`
Expand Down Expand Up @@ -10875,6 +10904,131 @@ otherwise it falls back to `name`/`displayName`/`textureID`/`spellID` with
**`nil` times**. The player path is unchanged (full timing). Same
placeholder fields as `C_Spell.UnitCastingInfo`.

### `UNIT_SPELLCAST_*` events

Backport of the TBC+ cast/channel events to 1.12, for the **local player and
other units**. Ported cast-bar / rotation addons (anything written against
the modern signature) register these instead of vanilla's arg-less
`SPELLCAST_*` events and read `unit, castGUID, spellID` directly. Thirteen
events are provided; six also fire for non-player units:

| Event | Fires when | Units | Args |
|-------|-----------|-------|------|
| `UNIT_SPELLCAST_SENT` | `CMSG_CAST_SPELL` leaves the client (earliest point) | player | `unit, target, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_START` | a cast-time spell begins | all | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_STOP` | a cast-time spell ends (any reason) | all | same |
| `UNIT_SPELLCAST_DELAYED` | pushback extends the cast | player | same |
| `UNIT_SPELLCAST_SUCCEEDED` | the spell goes off (`SMSG_SPELL_GO`) — incl. instants | all | same |
| `UNIT_SPELLCAST_INTERRUPTED` | a started **cast** is interrupted (kick, movement, LoS) — never for channels | all | same |
| `UNIT_SPELLCAST_FAILED` | a cast is rejected before it starts (range, mana, cooldown) with an error shown | player | same |
| `UNIT_SPELLCAST_FAILED_QUIET` | a cast fails with **no** error shown (spammy retry, reticle cancel, …) | player | same |
| `UNIT_SPELLCAST_CHANNEL_START` | a channel begins | all | same |
| `UNIT_SPELLCAST_CHANNEL_UPDATE` | pushback shortens a channel | player | same |
| `UNIT_SPELLCAST_CHANNEL_STOP` | a channel ends | all | same |
| `UNIT_SPELLCAST_RETICLE_TARGET` | a ground-target reticle appears (AoE placement — Blizzard, Flare, …) | player | `unit, "", spellID, spellName, rank` |
| `UNIT_SPELLCAST_RETICLE_CLEAR` | the reticle is placed or cancelled | player | `unit, "", spellID, spellName, rank` |

`unit` (arg1) is the token of the casting unit — `"player"` for your own
casts, or a unit token (`"target"`, `"focus"`, `"party3"`, `"nameplate2"`,
`"pet"`, `"mouseover"`, …) for another unit. `spellName` / `rank` are
ClassicAPI tail extensions (modern stops at `spellID`); addons reading only
the first three positional args are unaffected.

**Per-token fan-out.** A caster GUID can map to several tokens at once (your
`target` is also `party2` and `nameplate1`). Like retail, the event fires
**once per token** currently pointing at the caster, so a `target`-frame
cast bar, a `party2` frame, and a nameplate cast bar each get their own
event with the same castGUID. Tokens are resolved fresh at fire time (they
shift frame-to-frame). If you run **SuperWoW**, its raw-GUID token (`"0x…"`)
is deliberately filtered out — only standard tokens are fanned out.

**Non-player limits.** Only the six events above fire for other units, and
they're **best-effort** — driven purely by the packets an observer receives:
- `SENT` never fires (only your own outgoing casts are visible).
- `DELAYED` / `CHANNEL_UPDATE` never fire (pushback is sent only to the
caster, so another unit's bar can't stretch/shrink from damage).
- `FAILED` never fires (a pre-cast requirement failure is client-local).
- A remote cast/channel only has timing from the moment its
`SMSG_SPELL_START` was observed; casters who were already casting when
they came into range have no start time.

**Channels never fire `INTERRUPTED`.** Retail emits only `CHANNEL_STOP` when a
channel ends, whether it completed or was cut short (verified against retail),
so ClassicAPI matches that for both the player and other units. `INTERRUPTED`
is a cast-only event.

**Reticle events** fire for ground-targeted (AoE) spells only, always for the
player: `RETICLE_TARGET` when the placement reticle comes up, `RETICLE_CLEAR`
when it's placed or cancelled. There's no cast yet, so the castGUID slot
(arg2) is empty — retail pushes `nil` there, but the engine's event
dispatcher can't emit a `nil` mid-argument-list, so ClassicAPI pushes `""`
instead. `unit` (arg1) and `spellID` (arg3) are exact; arg2 is the only
difference and is inconsequential for a reticle.

**castGUID.** A synthesized string in the modern shape
`Cast-<type>-<serverID>-<instanceID>-<zoneUID>-<spellID>-<castUID>`. Vanilla
can't know server / instance / zone, so those three fields are `0`; the
load-bearing parts are the `spellID` (field 6, which addons `strsplit("-")`
out) and a unique-per-cast `castUID` (field 7). **Every event of one cast
carries the same castGUID**, so `SENT` → `START`/`CHANNEL_START` →
`SUCCEEDED` → `STOP`/`CHANNEL_STOP` all pair up — including across the caster
and observers (they converge on the same value), and a chained same-spell
recast gets its own castUID. The `type` and `castUID` follow the
[spell-cast-GUID spec](https://warcraft.wiki.gg/wiki/GUID#Cast):
- **Type 3** (real casts — the common case): `castUID` is time-based — the
low 23 bits are the cast's UNIX-epoch second, the higher bits a per-second
counter.
- **Type 2** (`UNIT_SPELLCAST_FAILED` — a local-only cast that never reached
the server): `castUID` is a plain locally-incrementing integer.

**Ordering** matches modern:

- Cast-time spell: `SENT → START → SUCCEEDED → STOP`.
- Channel: `SENT → CHANNEL_START → SUCCEEDED → CHANNEL_STOP` (CHANNEL_START
before SUCCEEDED, as on retail).
- Instant: `SENT → SUCCEEDED`.

**INTERRUPTED vs FAILED vs FAILED_QUIET** follow modern's split: a spell that
never started (out of range, not enough mana, on cooldown, LoS to a target)
fires `FAILED`, except for a fixed whitelist of "quiet" `SpellCastResult`
codes that fire `FAILED_QUIET` instead — `SPELL_IN_PROGRESS` (casting while
already casting / a spell-queue rejection), `DONT_REPORT` (fake fails, a
cancelled ground reticle), and `CHARMED`. That whitelist mirrors the 3.3.5
client's own unit-spellcast dispatch, mapped to vanilla's `SpellCastResult`
enum. A spell that was *already casting* and gets stopped (an enemy kick,
moving to cancel, breaking LoS mid-cast) fires `INTERRUPTED`. Holding
the cast key while running fires `INTERRUPTED` repeatedly (once per retry),
each reusing the interrupted cast's castGUID — matching retail.

**Channel pushback (player).** Taking damage while channeling shortens the
channel in vanilla; `CHANNEL_UPDATE` fires on each hit and
[`C_Spell.UnitChannelInfo`](#c_spellunitchannelinfounit--c_spellchannelinfo)'s
`endTimeMs` re-anchors to the server's new remaining time, so cast bars
shrink correctly. (The event carries no time — like retail it's a "re-read
now" trigger; timing is read back from `UnitChannelInfo`.)

Every fire is gated on whether any frame is registered for that event, so
the whole system costs one pointer-compare per state transition when no
addon uses it (no arg synthesis, no DBC lookups, no per-token fan-out).

```lua
local f = CreateFrame("Frame")
for _, e in ipairs({
"UNIT_SPELLCAST_START", "UNIT_SPELLCAST_STOP",
"UNIT_SPELLCAST_SUCCEEDED", "UNIT_SPELLCAST_CHANNEL_START",
}) do f:RegisterEvent(e) end
f:SetScript("OnEvent", function()
-- vanilla passes event/arg1/... as globals, not function params
if arg1 == "target" then print(event, arg3) end -- arg3 = spellID
end)
```

> **Additive to the vanilla `SPELLCAST_*` events.** The engine's own arg-less
> `SPELLCAST_START` / `SPELLCAST_CHANNEL_UPDATE` / … still fire as before;
> these `UNIT_`-prefixed events are the modern layer on top. The empowered-cast
> events (`UNIT_SPELLCAST_EMPOWER_*`, a Dragonflight addition) are not
> implemented — vanilla has no empowered casts.

### `C_Spell.GetSpellLevelInfo(spellID)`

Returns the raw `Spell.dbc` level fields for a spell:
Expand Down Expand Up @@ -12332,6 +12486,10 @@ local guid = UnitGUID("target") -- "0xF13000059A002553" (the F130... prefix tag
local guid = UnitGUID("party1") -- works even if party1 is on a different continent
```

The returned string is itself accepted as a unit token — `UnitName(guid)`,
`UnitHealth(guid)`, etc. all work — so a GUID round-trips as a stable
handle. See [Unit tokens (GUID literals)](#unit-tokens-guid-literals).

**Works for OOR party / raid members.** Earlier versions of this
function returned nil for `"partyN"` / `"raidN"` when the member's
CGObject wasn't currently active in the client (e.g. on another
Expand Down
64 changes: 64 additions & 0 deletions src/Offsets.h
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,24 @@ enum Offsets {
// input it's the right primitive.
FUN_TOKEN_TO_GUID = 0x00515970,

// `__fastcall(const uint64_t *guid /*ecx*/, int *outCount /*edx*/) ->
// char** tokenArray` — the engine's GUID → unit-token REVERSE map. Fills
// a reused static buffer array (the returned pointer) with the name
// string of every ENGINE-NATIVE token currently pointing at `guid` and
// writes the count to `*outCount`. Checks, in order: player, pet, target,
// party1..4, partypet1..4, raid1..40, raidpet1..40, npc, mouseover — the
// same slots the per-token unit-event broadcast `FUN_00515e50` fans out
// to (that function is just this map + a fire-per-token loop). Does NOT
// know ClassicAPI's synthetic `focus` / `nameplateN` tokens (the engine
// has no concept of them) — callers add those separately. Non-throwing:
// returns count 0 pre-world (resolves the active player internally and
// bails if absent). The returned array is a shared static reused on the
// next call, so snapshot the strings before doing anything that could
// re-enter. nampower names this `GetNamesFromGUID` (left as an unfilled
// TODO in its offsets.hpp). Used by `Unit::Identity::TokensForGUID` for
// the phase-2 `UNIT_SPELLCAST_*` remote-unit fan-out.
FUN_UNIT_TOKENS_FROM_GUID = 0x00515C50,

// Mouseover-unit GUID globals — the `"mouseover"` token resolves to
// these (see the token-dispatch list above). Written ONLY by
// `FUN_SET_MOUSEOVER_UNIT`; zero when nothing is moused over. Used by
Expand Down Expand Up @@ -1216,6 +1234,20 @@ enum Offsets {
// collision, per-channel-start frequency).
FUN_SPELL_CHANNEL_START = 0x006E7550,

// `MSG_CHANNEL_UPDATE` handler (`FUN_006e75f0`) — same `int __stdcall(
// uint32_t *opCode, CDataStore *packet)` shape. Body: a single u32 =
// the channel's new REMAINING time (ms). Sent to the caster on damage
// pushback (which shortens a vanilla channel) and once more at the
// channel's end (remaining == 0). Verified at 0x006e75f0: reads the u32,
// then fires vanilla's `SPELLCAST_CHANNEL_UPDATE(remaining)` (event
// 0x157) on pushback or `SPELLCAST_CHANNEL_STOP` (0x158) at end — but
// stores the new end NOWHERE, so `Spell::Cast`'s g_channel.endMs
// (computed once at start) never reflects pushback. Co-hooked to
// re-anchor endMs (and fire UNIT_SPELLCAST_CHANNEL_UPDATE). nampower
// hooks it too (SpellChannelUpdateHandlerHook — accepted co-hook, per-
// pushback frequency, same as the START sibling).
FUN_SPELL_CHANNEL_UPDATE = 0x006E75F0,

// `SMSG_SPELL_FAILED_OTHER` handler — `int __stdcall(uint32_t *opCode,
// CDataStore *packet)`, same shape as FUN_SPELL_DELAYED. Body:
// `casterGuid(u64, plain), spellId(u32)`. In (v)mangos cores this is
Expand Down Expand Up @@ -1245,6 +1277,21 @@ enum Offsets {
// hook it.
FUN_SPELL_FAILURE = 0x006E8D80,

// `Spell_C_SpellFailed` — the CLIENT-side cast-failure entry, distinct
// from the two SMSG_SPELL_FAILURE/_OTHER packet handlers above. Fires
// for the local player's own cast rejections (out of range, no mana,
// "spell not ready", LoS, interrupted, …). `__fastcall(uint32_t
// spellId, uint8_t spellResult /*edx*/, int unk1, int unk2, bool
// failedByServer)` — signature per nampower's Spell_C_SpellFailedT.
// Backs `UNIT_SPELLCAST_FAILED` (`Spell::CastEvents`). Not hooked
// elsewhere in this project (we hook the packet handlers, not this).
FUN_SPELL_C_SPELL_FAILED = 0x006E1A00,

// `Spell_C_SpellFailed` result code for a fake/suppressed failure
// (Unleashed Potential and similar) — the engine's SPELL_FAILED_DONT_
// REPORT. nampower filters it out of its failure event; so do we.
SPELL_FAILED_DONT_REPORT = 23,

// `CGUnit_C::ClearCastingSpell` — `__thiscall void(CGUnit *unit,
// int spellID, char notify, char cleanup)` (nampower names the offset
// but never hooks it). THE engine choke point for "this unit stopped
Expand Down Expand Up @@ -3518,6 +3565,23 @@ enum Offsets {
// `FUN_006E3D10`. Non-zero means "the cast bar is showing this
// spell"; cleared to 0 when the cast finishes or is cancelled.
VAR_CURRENT_CAST_SPELL = 0x00CECA88,

// Ground/reticle-targeting flags — non-zero while a spell is awaiting a
// target click (the AoE reticle / target cursor). `SpellIsTargeting()`
// (`0x006E6CD0`) returns true iff this != 0 (via its predicate
// `FUN_006E48A0`, `return DAT_00CECAC0 != 0`); `Spell_C_TargetSpell`
// (`0x006E5250`) sets the flag bits from the spell's target type, and the
// clear helper `FUN_006E4900` (from `SpellStopTargeting`) / a ground
// placement zeroes it. Polled by `Spell::CastEvents::PollReticle` to fire
// UNIT_SPELLCAST_RETICLE_TARGET / _CLEAR.
VAR_SPELL_TARGETING_FLAGS = 0x00CECAC0,
// The pending cast spellID — written by `Spell_C_CastSpell` (`0x006E4B60`)
// to the spell being cast, and used elsewhere as a `Spell.dbc` index (e.g.
// `CancelSpell`'s `records[this]`). While `VAR_SPELL_TARGETING_FLAGS` is
// set it's the spell whose reticle is up, so the RETICLE events read the
// spellID from here.
VAR_PENDING_CAST_SPELL = 0x00CEAC58,

// The cast-start state writer — `__fastcall(int spellID, int
// targetState)`. Sets `VAR_CURRENT_CAST_SPELL` (pushing any current
// spell into `VAR_QUEUED_CAST_SPELL`; `spellID == 0` restores the queued
Expand Down
Loading
Loading