Compare commits

...
2 Commits
Author SHA1 Message Date
Brues 939b52ae69 Recover dropped auras in C_UnitAuras; evict on removal
The 1.12 engine drops a unit's UNIT_FIELD_AURA slots when it loses sync with
the unit (rogue stealth, party range fluctuation) even though the auras are
still active server-side, so C_UnitAuras.GetAuraDataByIndex /
GetBuffDataByIndex returned nil for auras that are still up.

Fall back to the Aura::Source cache (caster/timing captured from
SMSG_SPELL_GO at cast time, which survives descriptor clears) when the
descriptor has no slot:

- Aura::Source: entries gain a helpful/harmful kind, recorded from the
  descriptor slot at application time (OnAuraAdded/OnAuraStacksChanged);
  SpellGo-only entries stay unknown and are excluded. New Enumerate() returns
  non-expired entries of a given kind for a unit.
- Aura::Data: a shared BuildTable feeds both the descriptor Push and a new
  cache-fed path; PushNthCacheFallback / AppendCacheFallbacks append cache
  entries after the descriptor ones, deduped against populated slots.
- Aura::Api: the index path and GetUnitAuras consult the fallback.

To keep the fallback from resurrecting auras that are genuinely gone
(replaced by a higher rank, dispelled, fell off early), co-hook OnAuraRemoved
(FUN_00612320) and evict the cache entry. A rogue stealthing does NOT route
through OnAuraRemoved (object-visibility drop, not per-aura removal), so
eviction and stealth-recovery coexist: real removals evict, visibility drops
don't.

Add FUN_ON_AURA_REMOVED offset.
Fixes #5
2026-06-28 13:47:26 -05:00
Brues 2403ff7233 Detect player cast/channel from packets, not just the cast global
The 1.12 engine short-circuits a chained same-spell recast: Spell_C_CastSpell
bails at `spellID == VAR_CURRENT_CAST_SPELL` before setting the global or
firing SPELLCAST_START, so the client never runs its cast path for the 2nd of
a back-to-back same-spell cast (common with nampower spell queueing). And the
player channel field (+0x228) is server-pushed, landing ~1 tick after the
channel starts, so polling it shows the bar ~1s late.

Rework the player UnitCastingInfo / UnitChannelInfo tracking into a hybrid:

- Normal casts: co-hook FUN_CAST_START_SET, stamp g_cast from the spellID
  arg before the original (zero latency); cleared by the VAR==0 WorldTick
  poll.
- Chained same-spell recasts: stamp g_cast from the SMSG_SPELL_START co-hook
  (server castTime), deduped against a fresh client stamp, exempt from the
  VAR clear, self-expiring on endMs.
- Channels: stamp g_channel from the same packet (engine-computed duration)
  instead of the lagging +0x228 poll; self-expire on endMs.
- Cast and channel are mutually exclusive for the player.
- Co-hook SMSG_SPELL_DELAYED for cast pushback (extends endMs + delayMs).

Add FUN_SPELL_DELAYED and FUN_CAST_START_SET offsets.
2026-06-25 20:10:19 -05:00
8 changed files with 533 additions and 161 deletions
+12 -4
View File
@@ -7960,10 +7960,18 @@ so progress is `(GetTime()*1000 - startTimeMs) / (endTimeMs - startTimeMs)`.
> another unit's cast with a server-authoritative cast time, cached per
> caster GUID. So `C_Spell.UnitCastingInfo("target")` now backs
> enemy/target/focus/nameplate cast bars. Instant casts return `nil` (no
> cast bar). Best-effort for remote units: only casts that began while the
> unit was in range are seen, and — since 1.12 has no per-unit interrupt
> packet to hook — an *interrupted* remote cast lingers until its computed
> `endTimeMs` rather than clearing instantly.
> cast bar). Best-effort for remote units, in three ways: only casts that
> began while the unit was in range are seen; since 1.12 has no per-unit
> interrupt packet to hook, an *interrupted* remote cast lingers until its
> computed `endTimeMs` rather than clearing instantly; and the remote
> `startTimeMs`/`endTimeMs` are shifted later by roughly your latency.
> (1.12's `SMSG_SPELL_START` carries only a single `castTime`, so we stamp
> `start = now, end = now + castTime` on receipt. 3.3.5+ avoids the skew
> because its packet carries *both* total and remaining cast time and
> back-dates `start = now − elapsed`; the 1.12 packet has no field to
> recover `elapsed` from.) The **local player is unaffected** by all of
> this — its cast is detected client-side at the moment of cast, no packet
> involved.
>
> `isTradeskill` is real — the spell's `SPELL_ATTR_TRADESPELL` flag, so
> profession recipe casts (smelting, cooking/enchanting recipes, …)
+32
View File
@@ -706,6 +706,14 @@ enum Offsets {
// nampower's SpellStartHandlerHook.
FUN_SPELL_START_HANDLER = 0x006E7640,
// `SMSG_SPELL_DELAYED` handler — `int __stdcall(uint32_t *opCode,
// CDataStore *packet)`. Body: `guid(u64), delayMs(u32)`. The server
// only sends this to the affected caster, so in practice it's always
// the local player's cast pushback (taking damage mid-cast). `Spell::
// Cast` co-hooks it to extend the tracked cast's end time so the bar
// reflects pushback. Mirrored from nampower's SpellDelayedHook.
FUN_SPELL_DELAYED = 0x006E74F0,
// `CGUnit_C::OnAuraAdded` — `__thiscall void(CGUnit *unit, uint32_t
// slot, uint32_t spellId)` (i.e. `__fastcall(unit /*ecx*/, edx_unused,
// slot, spellId)`). Fires for EVERY aura that lands on a tracked unit,
@@ -725,6 +733,18 @@ enum Offsets {
// re-stamp expiration so stacking-DoT refreshes are picked up.
FUN_ON_AURA_STACKS_CHANGED = 0x00612450,
// `CGUnit_C::OnAuraRemoved` — same ABI as `OnAuraAdded`
// (`__fastcall(unit /*ecx*/, edx_unused, slot, spellId)`; the disassembly
// of the diff dispatcher `FUN_00604d00` at the call site `0x00604dc4`
// shows `ECX = unit`, stack `[slot, spellId]`). The sibling of
// `OnAuraAdded`: `FUN_00604d00` fires this when a descriptor aura slot
// goes from occupied to empty — a genuine removal (fell off, dispelled,
// or replaced by a higher rank, which fires Removed(old)+Added(new)).
// `Aura::Source` co-hooks it to EVICT the cached entry so the
// `GetAuraDataByIndex` fallback can't resurrect a superseded/dispelled
// aura that's still inside its computed duration window.
FUN_ON_AURA_REMOVED = 0x00612320,
// CGPlayer-side sub-struct, allocated for any player-controlled
// unit (local self, target, party, raid, inspect targets — all of
// them). Holds player-specific data that's *not* in the broadcast
@@ -2365,6 +2385,18 @@ 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,
// 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
// one). `Spell::Cast` co-hooks it for lag-free, client-side detection of
// NORMAL player casts (stamps from the spellID arg before the original).
// It does NOT fire for a chained same-spell recast: the caller
// `Spell_C_CastSpell` (`0x006E4B60`) short-circuits at `spellID ==
// VAR_CURRENT_CAST_SPELL` *before* reaching here, so those never run the
// client cast path — they're caught by the `SMSG_SPELL_START` backstop
// instead. nampower hooks the higher Spell_C_CastSpell, not this inner
// writer, so contention is low.
FUN_CAST_START_SET = 0x006E4AD0,
// Previous / queued cast — holds the spellID that was active
// before the latest one superseded it (mid-GCD queueing in
// vanilla). When the current cast ends, `FUN_006E4AD0(0, ...)`
+10 -3
View File
@@ -92,11 +92,16 @@ int PushAuraByIndex(void *L, const char *unitToken, int index,
Data::Filter filter, bool playerOnly = false) {
const uint8_t *unit = ResolveUnit(unitToken);
const int slot = Data::FindNthSlot(unit, index, filter, playerOnly);
if (slot < 0) {
Game::Lua::PushNil(L);
if (slot >= 0) {
Data::Push(L, unit, slot);
return 1;
}
Data::Push(L, unit, slot);
// Descriptor exhausted — an aura the engine dropped from the slot array
// (rogue stealth, party range fluctuation) may still be live in the
// Aura::Source cache. Surface it after the descriptor entries.
if (Data::PushNthCacheFallback(L, unit, index, filter, playerOnly))
return 1;
Game::Lua::PushNil(L);
return 1;
}
@@ -218,6 +223,8 @@ void AppendRangeToArray(void *L, const uint8_t *unit, int outerIdx,
Data::Push(L, unit, slot);
Game::Lua::SetTable(L, outerIdx);
}
// Append auras the descriptor dropped but Aura::Source still has live.
Data::AppendCacheFallbacks(L, unit, filter, playerOnly, outerIdx, nextKey);
}
int __fastcall Script_GetUnitAuras(void *L) {
+187 -83
View File
@@ -350,10 +350,13 @@ const char *DispelName(uint32_t dispelTypeID) {
return (name == nullptr || *name == '\0') ? nullptr : name;
}
void Push(void *L, const uint8_t *unit, int slot) {
const uint32_t spellID = ReadSpellID(unit, slot);
// Writes the full modern AuraData table on top of the Lua stack from
// already-resolved values. Shared by the descriptor path (`Push`) and the
// cache-fallback path (`PushFromCache`). Net stack effect: +1 (the table).
static void BuildTable(void *L, uint32_t spellID, int applications,
bool isHelpful, double duration, double expirationTime,
uint64_t casterGuid) {
const uint8_t *spellRecord = SpellRecord(spellID);
const bool isHelpful = slot < Offsets::UNIT_AURA_BUFF_COUNT;
uint32_t dispelTypeID = 0;
if (spellRecord != nullptr) {
@@ -370,87 +373,32 @@ void Push(void *L, const uint8_t *unit, int slot) {
Game::Lua::SetFieldString(L, "name", name);
Game::Lua::SetFieldString(L, "icon", icon);
Game::Lua::SetFieldNumber(L, "applications",
static_cast<double>(ReadStacks(unit, slot)));
static_cast<double>(applications));
Game::Lua::SetFieldNumber(L, "spellId", static_cast<double>(spellID));
Game::Lua::SetFieldString(L, "dispelName", dispel);
Game::Lua::SetFieldBool(L, "isHelpful", isHelpful);
Game::Lua::SetFieldBool(L, "isHarmful", !isHelpful);
// Timing fields. Vanilla doesn't broadcast cast time / duration
// for ANY unit's auras except the local player's — for everyone
// else (target, party, raid, mouseover) `expirationTime` stays 0
// and addons have to track expiry themselves via aura-add events.
// For the player we read the engine's `VAR_PLAYER_BUFF_TABLE`
// (same data `GetPlayerBuffTimeLeft` returns), which gives us a
// real expiration timestamp.
//
// `duration` comes from Spell.dbc → SpellDuration.dbc with
// level scaling. That's the *base* value; talent / glyph
// duration-extension modifiers (Improved PW:F, etc.) are
// already baked into the expiration timestamp on the
// caster's side, so `expirationTime - GetTime()` reflects the
// true remaining time even when `duration` doesn't include
// the talent boost. We populate `duration` for any unit (the
// base value is identical regardless of who's affected); only
// `expirationTime` is player-gated.
double duration = 0.0;
double expirationTime = 0.0;
const uint8_t *player = LocalPlayer();
if (spellID != 0) {
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
duration = SpellBaseDurationSeconds(spellID, unitLevel);
}
if (unit != nullptr && unit == player) {
const uint8_t *entry = FindPlayerBuffEntry(spellID);
if (entry != nullptr)
expirationTime = PlayerBuffExpirationSeconds(entry);
}
// Caster + non-player expiration from the SMSG_SPELL_GO cache
// (`Aura::Source`), captured at cast time. The vanilla descriptor has
// neither. `sourceUnit` applies to any unit; `expirationTime` only
// fills in here when the player-buff table above didn't — that table is
// authoritative for the player's own auras, the cache covers everyone
// else. Both are best-effort: a miss (aura predates login, or the cast
// wasn't observed) leaves the modern-truthful defaults untouched.
const char *sourceUnit = nullptr;
const char *sourceGUID = nullptr;
char tokenBuf[32];
char guidBuf[Guid::STRING_SIZE];
if (spellID != 0) {
uint64_t casterGuid = 0;
uint32_t expMs = 0;
uint32_t durMs = 0;
if (Aura::Source::Get(UnitGuid(unit), spellID, &casterGuid, &expMs,
&durMs)) {
if (expirationTime == 0.0 && expMs != 0)
expirationTime = static_cast<double>(expMs) * 0.001;
// Prefer the applied (caster-modified) duration so `duration`
// stays consistent with `expirationTime` — otherwise a talented
// DoT (e.g. Improved Shadow Word: Pain) shows remaining time
// exceeding the base `duration`.
if (durMs != 0)
duration = static_cast<double>(durMs) * 0.001;
if (casterGuid != 0) {
// `sourceUnit` is a token (nil when the caster maps to no
// current token); `sourceGUID` is the raw "0x..." GUID and
// is set whenever we have a caster — a ClassicAPI extension
// (not in retail AuraData) that survives the caster leaving
// token range and doubles as a unit token under SuperWoW.
sourceUnit = Unit::Identity::TokenFromGUID(
casterGuid, tokenBuf, sizeof tokenBuf);
sourceGUID = Guid::FormatAsString(casterGuid, guidBuf,
sizeof guidBuf);
}
}
}
Game::Lua::SetFieldNumber(L, "duration", duration);
Game::Lua::SetFieldNumber(L, "expirationTime", expirationTime);
if (sourceUnit != nullptr)
Game::Lua::SetFieldString(L, "sourceUnit", sourceUnit);
if (sourceGUID != nullptr)
Game::Lua::SetFieldString(L, "sourceGUID", sourceGUID);
if (casterGuid != 0) {
// `sourceUnit` is a token (nil when the caster maps to no current
// token); `sourceGUID` is the raw "0x..." GUID and is set whenever we
// have a caster — a ClassicAPI extension (not in retail AuraData) that
// survives the caster leaving token range and doubles as a unit token
// under SuperWoW.
char tokenBuf[32];
char guidBuf[Guid::STRING_SIZE];
const char *sourceUnit = Unit::Identity::TokenFromGUID(
casterGuid, tokenBuf, sizeof tokenBuf);
const char *sourceGUID =
Guid::FormatAsString(casterGuid, guidBuf, sizeof guidBuf);
if (sourceUnit != nullptr)
Game::Lua::SetFieldString(L, "sourceUnit", sourceUnit);
if (sourceGUID != nullptr)
Game::Lua::SetFieldString(L, "sourceGUID", sourceGUID);
}
Game::Lua::SetFieldNumber(L, "charges", 0);
Game::Lua::SetFieldNumber(L, "maxCharges", 0);
Game::Lua::SetFieldNumber(L, "timeMod", 1);
@@ -466,11 +414,167 @@ void Push(void *L, const uint8_t *unit, int slot) {
Game::Lua::SetFieldBool(L, "shouldConsolidate", false);
Game::Lua::SetFieldBool(L, "isRaid", false);
// `sourceUnit` / `sourceGUID` are set above when the `Aura::Source`
// cache has a caster for this aura; left unset (→ nil) on a miss.
// `auraInstanceID` and `points` are deliberately omitted — modern
// returns nil for those when they don't apply, and Lua reading a
// missing key yields nil, so the table doesn't need an explicit entry.
// `auraInstanceID` and `points` are deliberately omitted — modern returns
// nil for those when they don't apply, and Lua reading a missing key
// yields nil, so the table doesn't need an explicit entry.
}
void Push(void *L, const uint8_t *unit, int slot) {
const uint32_t spellID = ReadSpellID(unit, slot);
const bool isHelpful = slot < Offsets::UNIT_AURA_BUFF_COUNT;
// Timing fields. Vanilla doesn't broadcast cast time / duration for ANY
// unit's auras except the local player's — for everyone else (target,
// party, raid, mouseover) `expirationTime` stays 0 unless the
// `Aura::Source` cache observed the cast. For the player we read the
// engine's `VAR_PLAYER_BUFF_TABLE` (same data `GetPlayerBuffTimeLeft`
// returns), which gives a real expiration timestamp.
//
// `duration` comes from Spell.dbc → SpellDuration.dbc with level scaling
// (the *base* value); talent / glyph duration-extension modifiers are
// already baked into the expiration timestamp on the caster's side, so
// `expirationTime - GetTime()` reflects true remaining time even when the
// base `duration` doesn't include the talent boost.
double duration = 0.0;
double expirationTime = 0.0;
uint64_t casterGuid = 0;
const uint8_t *player = LocalPlayer();
if (spellID != 0) {
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
duration = SpellBaseDurationSeconds(spellID, unitLevel);
}
if (unit != nullptr && unit == player) {
const uint8_t *entry = FindPlayerBuffEntry(spellID);
if (entry != nullptr)
expirationTime = PlayerBuffExpirationSeconds(entry);
}
// Caster + non-player expiration from the SMSG_SPELL_GO cache, captured at
// cast time (the descriptor has neither). The player-buff table above is
// authoritative for the player's own expiration; the cache fills it for
// everyone else. Prefer the applied (caster-modified) duration so it stays
// consistent with `expirationTime` — otherwise a talented DoT (Improved
// Shadow Word: Pain) would show remaining time exceeding the base.
if (spellID != 0) {
uint64_t c = 0;
uint32_t expMs = 0;
uint32_t durMs = 0;
if (Aura::Source::Get(UnitGuid(unit), spellID, &c, &expMs, &durMs)) {
if (expirationTime == 0.0 && expMs != 0)
expirationTime = static_cast<double>(expMs) * 0.001;
if (durMs != 0)
duration = static_cast<double>(durMs) * 0.001;
casterGuid = c;
}
}
BuildTable(L, spellID, ReadStacks(unit, slot), isHelpful, duration,
expirationTime, casterGuid);
}
namespace {
// Builds an AuraData table from an `Aura::Source` cache entry — the fallback
// when the descriptor has dropped the aura's slot. Stack count isn't broadcast
// in SMSG_SPELL_GO, so `applications` defaults to 1. `duration` uses the
// applied (caster-modified) ms when known, else the Spell.dbc base.
void PushFromCache(void *L, const uint8_t *unit,
const Aura::Source::CachedAura &c, bool isHelpful) {
double duration = 0.0;
if (c.durationMs != 0) {
duration = static_cast<double>(c.durationMs) * 0.001;
} else {
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
duration = SpellBaseDurationSeconds(c.spellId, unitLevel);
}
const double expirationTime =
(c.expirationMs != 0) ? static_cast<double>(c.expirationMs) * 0.001 : 0.0;
BuildTable(L, c.spellId, 1, isHelpful, duration, expirationTime,
c.casterGuid);
}
// Counts populated descriptor slots matching `filter` (and `playerOnly`).
int CountSlots(const uint8_t *unit, Filter filter, bool playerOnly) {
if (unit == nullptr)
return 0;
const int start = (filter == Filter::Harmful)
? Offsets::UNIT_AURA_BUFF_COUNT
: 0;
const int end = (filter == Filter::Harmful) ? Offsets::UNIT_AURA_TOTAL
: Offsets::UNIT_AURA_BUFF_COUNT;
int n = 0;
for (int slot = start; slot < end; ++slot) {
if (!IsSlotPopulated(unit, slot))
continue;
if (playerOnly && !IsPlayerCast(unit, slot))
continue;
++n;
}
return n;
}
constexpr int kFallbackMax = Offsets::UNIT_AURA_TOTAL;
// Whether cache entry `c` should be surfaced as a fallback for `unit` under
// `filter`/`playerOnly`: it must not already be in a populated descriptor slot
// (no double-listing the same live aura) and, when player-filtered, must have
// been cast by us. Superseded / dispelled auras are kept out of the cache by
// `Aura::Source`'s removal eviction, not filtered here.
bool FallbackEligible(const uint8_t *unit, const Aura::Source::CachedAura &c,
Filter filter, bool playerOnly) {
if (FindSlotBySpellID(unit, c.spellId, &filter, false) >= 0)
return false; // still in the descriptor — returned via the slot path
if (playerOnly && c.casterGuid != Unit::Identity::PlayerGuid())
return false;
return true;
}
} // namespace
bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
Filter filter, bool playerOnly) {
if (unit == nullptr)
return false;
// The descriptor held `descCount` matches; the caller already found fewer
// than `oneBasedIndex` there, so the cache supplies index
// `oneBasedIndex - descCount` (1-based) of the entries it didn't.
const int target = oneBasedIndex - CountSlots(unit, filter, playerOnly);
if (target < 1)
return false;
const uint64_t guid = UnitGuid(unit);
if (guid == 0)
return false;
Aura::Source::CachedAura buf[kFallbackMax];
const int n = Aura::Source::Enumerate(
guid, filter == Filter::Harmful, buf, kFallbackMax);
int seen = 0;
for (int i = 0; i < n; ++i) {
if (!FallbackEligible(unit, buf[i], filter, playerOnly))
continue;
if (++seen == target) {
PushFromCache(L, unit, buf[i], filter == Filter::Helpful);
return true;
}
}
return false;
}
void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
bool playerOnly, int outerIdx, int &nextKey) {
if (unit == nullptr)
return;
const uint64_t guid = UnitGuid(unit);
if (guid == 0)
return;
Aura::Source::CachedAura buf[kFallbackMax];
const int n = Aura::Source::Enumerate(
guid, filter == Filter::Harmful, buf, kFallbackMax);
for (int i = 0; i < n; ++i) {
if (!FallbackEligible(unit, buf[i], filter, playerOnly))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
PushFromCache(L, unit, buf[i], filter == Filter::Helpful);
Game::Lua::SetTable(L, outerIdx);
}
}
} // namespace Aura::Data
+20
View File
@@ -112,4 +112,24 @@ const char *DispelName(uint32_t dispelTypeID);
// Net stack effect: +1 (the table).
void Push(void *L, const uint8_t *unit, int slot);
// Fallback for the index path when the descriptor has dropped an aura's slot
// (rogue stealth, party range fluctuation) but it's still active server-side.
// `oneBasedIndex` is the same index the caller passed to `FindNthSlot`; this
// resolves it against the `Aura::Source` cache *after* the descriptor matches
// (so cache entries append after descriptor ones, preserving existing index
// order). On a hit it pushes the AuraData table and returns true; on a miss it
// pushes nothing and returns false (caller pushes nil). Only entries whose
// helpful/harmful classification matches `filter` and that aren't already in a
// populated descriptor slot are considered.
bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
Filter filter, bool playerOnly = false);
// Appends every eligible `Aura::Source` cache fallback for `unit` matching
// `filter` into the array table at `outerIdx`, continuing from `nextKey`
// (updated in place). The bulk-enumeration analog of `PushNthCacheFallback`,
// for `GetUnitAuras`. Skips entries already present in a populated descriptor
// slot so it never double-lists.
void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
bool playerOnly, int outerIdx, int &nextKey);
} // namespace Aura::Data
+90 -10
View File
@@ -69,12 +69,19 @@ uint32_t SpellDurationMs(const uint8_t *spellRecord, bool casterIsPlayer) {
// ---- Cache ---------------------------------------------------------------
// Helpful/harmful classification, recorded from the aura's descriptor slot
// when it's applied (the only place the buff/debuff split is known). SpellGo
// gives no slot, so casts we only saw there stay Unknown until/unless an
// application hook fires for the same aura.
enum Kind : int8_t { KIND_UNKNOWN = -1, KIND_HELPFUL = 0, KIND_HARMFUL = 1 };
struct Entry {
uint64_t targetGuid;
uint64_t casterGuid;
uint32_t spellId;
uint32_t expirationMs; // 0 = infinite / unknown duration
uint32_t durationMs; // applied duration (incl. caster mods); 0 = none
int8_t kind; // Kind; descriptor-slot-derived, KIND_UNKNOWN if only seen via SpellGo
bool used;
};
@@ -88,13 +95,19 @@ int g_writeCursor = 0;
// would replace talented timing with the unmodified base), so it skips
// entries that already carry a caster.
void Store(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
uint32_t expirationMs, uint32_t durationMs, bool fromCast) {
uint32_t expirationMs, uint32_t durationMs, bool fromCast,
int8_t kind) {
if (targetGuid == 0 || spellId == 0)
return;
// Update an existing entry for this exact aura instance.
for (auto &e : g_cache) {
if (e.used && e.targetGuid == targetGuid && e.spellId == spellId) {
// Learn the classification whenever a slot-derived kind arrives —
// independent of caster/timing ownership, and never downgrade a
// known kind back to unknown.
if (kind != KIND_UNKNOWN)
e.kind = kind;
if (!fromCast && e.casterGuid != 0)
return; // SpellGo owns this entry; keep its caster + timing
if (casterGuid != 0)
@@ -108,13 +121,32 @@ void Store(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
const uint32_t now = NowMs();
for (auto &e : g_cache) {
if (!e.used || (e.expirationMs != 0 && now >= e.expirationMs)) {
e = {targetGuid, casterGuid, spellId, expirationMs, durationMs, true};
e = {targetGuid, casterGuid, spellId, expirationMs, durationMs,
kind, true};
return;
}
}
Entry &slot = g_cache[g_writeCursor];
g_writeCursor = (g_writeCursor + 1) % kCacheSize;
slot = {targetGuid, casterGuid, spellId, expirationMs, durationMs, true};
slot = {targetGuid, casterGuid, spellId, expirationMs, durationMs, kind,
true};
}
// Evict the entry for an aura the engine reports gone. Keyed by (target,
// spell) like the rest of the cache. Without this, the GetAuraDataByIndex
// fallback would keep surfacing a dropped aura until its computed expiry —
// e.g. a Rank 1 buff replaced by Rank 2 (engine drops Rank 1 from the
// descriptor) would show as a phantom second aura, or a dispelled buff would
// linger.
void Evict(uint64_t targetGuid, uint32_t spellId) {
if (targetGuid == 0 || spellId == 0)
return;
for (auto &e : g_cache) {
if (e.used && e.targetGuid == targetGuid && e.spellId == spellId) {
e.used = false;
return;
}
}
}
// Drop entries whose timed aura has elapsed so the table doesn't fill with
@@ -176,11 +208,13 @@ void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
if (numTargets == 0) {
// No explicit hit list (self-cast with caster-implicit target).
Store(caster, spellId, caster, expirationMs, durationMs, true);
Store(caster, spellId, caster, expirationMs, durationMs, true,
KIND_UNKNOWN);
return;
}
for (int i = 0; i < numTargets; ++i)
Store(targets[i], spellId, caster, expirationMs, durationMs, true);
Store(targets[i], spellId, caster, expirationMs, durationMs, true,
KIND_UNKNOWN);
}
const Game::HookAutoRegister _hook{Offsets::FUN_SPELL_GO,
@@ -194,7 +228,7 @@ const Game::HookAutoRegister _hook{Offsets::FUN_SPELL_GO,
// paths, so it stamps timing only with `fromCast=false` — Store skips any
// entry SpellGo already owns, so a directly-cast aura keeps its talented
// timing. Base (unmodified) duration is the best estimate without a caster.
void StampApplication(void *unit, uint32_t spellId) {
void StampApplication(void *unit, uint32_t spellId, int8_t kind) {
if (spellId == 0)
return;
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellId));
@@ -206,7 +240,13 @@ void StampApplication(void *unit, uint32_t spellId) {
const uint32_t durationMs = SpellDurationMs(rec, /*casterIsPlayer*/ false);
const uint32_t expirationMs = durationMs > 0 ? NowMs() + durationMs : 0;
Store(unitGuid, spellId, /*casterGuid*/ 0, expirationMs, durationMs,
/*fromCast*/ false);
/*fromCast*/ false, kind);
}
// Classify by the absolute aura slot: 0..BUFF_COUNT-1 = buff (helpful),
// BUFF_COUNT..TOTAL-1 = debuff (harmful).
int8_t KindForSlot(int slot) {
return slot >= Offsets::UNIT_AURA_BUFF_COUNT ? KIND_HARMFUL : KIND_HELPFUL;
}
// OnAuraAdded — a new aura occupies a slot (gives the spellId directly).
@@ -217,7 +257,7 @@ OnAuraAdded_t g_origOnAuraAdded = nullptr;
void __fastcall OnAuraAdded_h(void *unit, void *edx, uint32_t slot,
uint32_t spellId) {
g_origOnAuraAdded(unit, edx, slot, spellId);
StampApplication(unit, spellId);
StampApplication(unit, spellId, KindForSlot(static_cast<int>(slot)));
}
const Game::HookAutoRegister _hookAuraAdded{
@@ -234,8 +274,10 @@ OnAuraStacksChanged_t g_origOnAuraStacksChanged = nullptr;
void __fastcall OnAuraStacksChanged_h(void *unit, void *edx, int slot,
uint8_t stackCount) {
g_origOnAuraStacksChanged(unit, edx, slot, stackCount);
StampApplication(unit, Aura::Data::ReadSpellID(
static_cast<const uint8_t *>(unit), slot));
StampApplication(
unit,
Aura::Data::ReadSpellID(static_cast<const uint8_t *>(unit), slot),
KindForSlot(slot));
}
const Game::HookAutoRegister _hookAuraStacks{
@@ -243,6 +285,26 @@ const Game::HookAutoRegister _hookAuraStacks{
reinterpret_cast<void *>(&OnAuraStacksChanged_h),
reinterpret_cast<void **>(&g_origOnAuraStacksChanged)};
// OnAuraRemoved — a descriptor aura slot went empty: the aura fell off, was
// dispelled, or was replaced by a higher rank (the diff dispatcher fires
// Removed(old) + Added(new)). Evict the cache entry so the fallback stops
// surfacing it. Same ABI as OnAuraAdded (unit in ecx, slot + spellId on the
// stack); `slot` is unused here — we evict by spellId.
using OnAuraRemoved_t = void(__fastcall *)(void *unit, void *edx, uint32_t slot,
uint32_t spellId);
OnAuraRemoved_t g_origOnAuraRemoved = nullptr;
void __fastcall OnAuraRemoved_h(void *unit, void *edx, uint32_t slot,
uint32_t spellId) {
g_origOnAuraRemoved(unit, edx, slot, spellId);
(void)slot;
Evict(Unit::Identity::GuidForObject(unit), spellId);
}
const Game::HookAutoRegister _hookAuraRemoved{
Offsets::FUN_ON_AURA_REMOVED, reinterpret_cast<void *>(&OnAuraRemoved_h),
reinterpret_cast<void **>(&g_origOnAuraRemoved)};
} // namespace
bool Get(uint64_t unitGuid, uint32_t spellId, uint64_t *outCaster,
@@ -260,4 +322,22 @@ bool Get(uint64_t unitGuid, uint32_t spellId, uint64_t *outCaster,
return false;
}
int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut) {
if (unitGuid == 0 || out == nullptr || maxOut <= 0)
return 0;
const int8_t want = harmful ? KIND_HARMFUL : KIND_HELPFUL;
const uint32_t now = NowMs();
int n = 0;
for (const auto &e : g_cache) {
if (n >= maxOut)
break;
if (!e.used || e.targetGuid != unitGuid || e.kind != want)
continue;
if (e.expirationMs != 0 && now >= e.expirationMs)
continue; // expired (infinite-duration entries pass)
out[n++] = {e.spellId, e.casterGuid, e.expirationMs, e.durationMs};
}
return n;
}
} // namespace Aura::Source
+20
View File
@@ -42,4 +42,24 @@ namespace Aura::Source {
bool Get(uint64_t unitGuid, uint32_t spellId, uint64_t *outCaster,
uint32_t *outExpirationMs, uint32_t *outDurationMs);
// One cached aura, as returned by `Enumerate`.
struct CachedAura {
uint32_t spellId;
uint64_t casterGuid; // 0 = caster unknown (application hook, no SpellGo)
uint32_t expirationMs; // 0 = infinite / unknown
uint32_t durationMs; // applied duration (incl. caster mods); 0 = none
};
// Fills `out` with up to `maxOut` cached, non-expired auras on `unitGuid`
// whose helpful/harmful classification matches `harmful`. Returns the count
// written. The classification is recorded from the aura's descriptor slot at
// application time (`OnAuraAdded`/`OnAuraStacksChanged`); entries we only ever
// saw via `SMSG_SPELL_GO` have no slot and so an unknown classification —
// those are excluded (we can't tell which range they belong to).
//
// This is the fallback source for `Aura::Data` when the unit descriptor has
// dropped an aura's slot (rogue stealth, party range fluctuation) even though
// the aura is still active server-side: the cache survives descriptor clears.
int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut);
} // namespace Aura::Source
+162 -61
View File
@@ -17,15 +17,26 @@
// Vanilla 1.12 stores NO cast start/end times anywhere readable (unlike
// 3.3.5+, which keeps them per-unit on the CGUnit). The cast bar is
// Lua-driven off `SPELLCAST_START(duration)` + `GetTime()`. So we
// self-track the LOCAL PLAYER's cast/channel on `WorldTick`:
// - cast: `VAR_CURRENT_CAST_SPELL` (the player cast-bar spellID
// global). On a 0->nonzero transition we stamp startMs = now and
// endMs = now + base cast time (SpellCastTimes.dbc). Instant casts
// (cast time 0) and channels are filtered out here — a channel has
// no cast time and surfaces through the channel field instead.
// self-track the LOCAL PLAYER's cast/channel with a HYBRID of two sources:
// - Normal casts (client path): co-hook the engine's cast-start writer
// `FUN_CAST_START_SET` and stamp from its spellID argument the instant
// the cast begins (startMs = now, endMs = now + effective cast time) —
// zero latency. `g_castFromServer = false`; cleared on `WorldTick` when
// `VAR_CURRENT_CAST_SPELL` returns to 0 (normal end / cancel / interrupt).
// - Chained same-spell recasts (server path): the engine SHORT-CIRCUITS
// `Spell_C_CastSpell` at `spellID == VAR_CURRENT_CAST_SPELL` — it never
// runs the client cast path, never sets the global, never fires
// SPELLCAST_START. So a chained recast is invisible client-side; the
// ONLY witness is the server's `SMSG_SPELL_START` (= nampower's
// SPELL_START_SELF). We stamp `g_cast` from that packet (server cast
// time), dedup'd against a just-made client stamp so a normal cast's
// confirming packet doesn't restart its bar. `g_castFromServer = true`;
// since the global was never set for these, the WorldTick VAR==0 clear
// must skip them — they expire on their computed endMs (and pfUI clears
// on nampower's SPELL_FAILED_SELF for interrupts).
// - channel: the player's broadcast `UNIT_FIELD_CHANNEL_SPELL`
// (descriptor +0x228). endMs = now + base channel duration
// (SpellDuration.dbc).
// (descriptor +0x228), polled on `WorldTick`. endMs = now + effective
// channel duration.
// `now` is the engine ms clock (`FUN_OS_TICKCOUNT_MS`), the same source
// Lua's `GetTime()` scales by 0.001 — so startMs/endMs are directly
// comparable to `GetTime()*1000` in addon progress math.
@@ -81,12 +92,17 @@ struct TrackedSpell {
int spellID; // 0 = not casting / channeling
int startMs;
int endMs;
int delayMs; // accumulated pushback (SMSG_SPELL_DELAYED), 0 = none
};
TrackedSpell g_cast{0, 0, 0};
TrackedSpell g_channel{0, 0, 0};
int g_lastCast = 0;
int g_lastChannel = 0;
TrackedSpell g_cast{0, 0, 0, 0};
TrackedSpell g_channel{0, 0, 0, 0};
// True when g_cast was stamped from SMSG_SPELL_START (a chained same-spell
// recast the client cast path bailed on) rather than the client-side
// FUN_CAST_START_SET hook. Such casts never set VAR_CURRENT_CAST_SPELL, so
// the WorldTick VAR==0 clear must not touch them.
bool g_castFromServer = false;
int NowMs() { return static_cast<int>(reinterpret_cast<TickMs_t>(Offsets::FUN_OS_TICKCOUNT_MS)()); }
@@ -116,18 +132,6 @@ int ChannelDurationMs(int spellID) {
return ms > 0 ? ms : 0; // negative = "infinite"; channels are finite
}
// The local player's currently-channeled spellID (broadcast +0x228), or 0.
int PlayerChannelSpell() {
void *p = Resolve("player");
if (p == nullptr)
return 0;
auto *desc = *reinterpret_cast<const uint8_t *const *>(
static_cast<const uint8_t *>(p) + Offsets::OFF_UNIT_DESCRIPTOR);
if (desc == nullptr)
return 0;
return *reinterpret_cast<const int *>(desc + Offsets::OFF_UNIT_FIELD_CHANNEL_SPELL);
}
const char *SpellName(const uint8_t *rec) {
const int locale = *reinterpret_cast<int *>(Offsets::VAR_LOCALE_INDEX);
return *reinterpret_cast<const char *const *>(rec + OFF_NAME + locale * 4);
@@ -156,6 +160,12 @@ const char *SpellIconPath(const uint8_t *rec) {
int PushCastInfo(void *L, const TrackedSpell &c) {
if (c.spellID == 0)
return 0;
// Self-expire: once the cast window has elapsed, report nothing even if
// the slot wasn't explicitly cleared. Keeps a stale cast (e.g. one whose
// clear we missed) from lingering on an addon's cast bar, and lets a
// fresh stamp for the next cast take over cleanly.
if (NowMs() >= c.endMs)
return 0;
const uint8_t *rec = Spell::Lookup::RecordForID(c.spellID);
if (rec == nullptr)
return 0;
@@ -170,7 +180,7 @@ int PushCastInfo(void *L, const TrackedSpell &c) {
Game::Lua::PushBool(L, false); // 8 notInterruptible
Game::Lua::PushNumber(L, static_cast<double>(c.spellID)); // 9 castingSpellID
Game::Lua::PushNil(L); // 10 castBarID
Game::Lua::PushNumber(L, 0); // 11 delayTimeMs
Game::Lua::PushNumber(L, static_cast<double>(c.delayMs)); // 11 delayTimeMs
return 11;
}
@@ -179,6 +189,12 @@ int PushCastInfo(void *L, const TrackedSpell &c) {
int PushChannelInfo(void *L, int spellID, int startMs, int endMs, bool haveTimes) {
if (spellID == 0)
return 0;
// Self-expire a timed channel once its window elapses (mirrors
// PushCastInfo) — this is how the player's channel clears, since we no
// longer poll the +0x228 field. Remote callers gate on endMs before
// passing haveTimes, so this is a no-op for them.
if (haveTimes && endMs != 0 && NowMs() >= endMs)
return 0;
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
if (rec == nullptr)
return 0;
@@ -199,17 +215,38 @@ int PushChannelInfo(void *L, int spellID, int startMs, int endMs, bool haveTimes
return 8;
}
// True if the unit at stack index 1 resolves to the local player.
bool ArgIsPlayer(void *L) {
if (!Game::Lua::IsString(L, 1))
return false;
const char *token = Game::Lua::ToString(L, 1);
if (token == nullptr)
return false;
void *u = Resolve(token);
return u != nullptr && u == Resolve("player");
// ---- Player cast detection (FUN_CAST_START_SET co-hook) ------------------
// The engine's cast-start writer — the client path for a NORMAL cast. We
// stamp from its spellID argument the instant the cast begins (zero
// latency), *before* the original so any SPELLCAST_START it triggers sees
// fresh times. This does NOT fire for a chained same-spell recast — the
// caller (Spell_C_CastSpell) bails at `spellID == VAR_CURRENT_CAST_SPELL`
// before ever reaching this writer; those are caught by the SMSG_SPELL_START
// path instead. The `spellID == 0` (queued-restore / clear) path is left to
// the OnWorldTick VAR==0 poll so a restore doesn't prematurely wipe a bar.
using CastStartSet_t = void(__fastcall *)(int spellID, int targetState);
CastStartSet_t g_origCastStartSet = nullptr;
void __fastcall CastStartSet_h(int spellID, int targetState) {
if (spellID != 0) {
const int dur = CastTimeMs(spellID);
if (dur > 0) {
const int now = NowMs();
g_cast = TrackedSpell{spellID, now, now + dur, 0};
g_castFromServer = false; // client-tracked; VAR==0 clears it
g_channel.spellID = 0; // a cast supersedes any channel
}
// dur == 0: instant (no bar) or channel (handled via +0x228 poll);
// leave g_cast — don't clobber an unrelated active cast.
}
g_origCastStartSet(spellID, targetState);
}
const Game::HookAutoRegister _castStartHook{
Offsets::FUN_CAST_START_SET, reinterpret_cast<void *>(&CastStartSet_h),
reinterpret_cast<void **>(&g_origCastStartSet)};
// ---- Remote-unit cast tracking (SMSG_SPELL_START) ------------------------
// Vanilla stores no cast record for other units, so we capture their casts
@@ -278,17 +315,58 @@ const RemoteCast *FindRemoteCast(uint64_t caster) {
return nullptr;
}
void RecordRemoteStart(uint64_t caster, int spellID, uint32_t castTime) {
// How recently the client-side FUN_CAST_START_SET hook must have stamped the
// same spell for an SMSG_SPELL_START to count as that cast's confirming
// packet (skip — don't restart the bar) rather than a new chained recast
// (stamp). Must exceed worst-case latency but stay under a GCD / min cast
// time so genuine chained casts aren't wrongly deduped.
constexpr int kCastStartDedupMs = 500;
void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
if (caster == 0 || spellID == 0)
return;
// The local player uses the self-tracked g_cast / g_channel path.
if (caster == Unit::Identity::PlayerGuid())
return;
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
if (rec == nullptr)
return;
const int now = NowMs();
const bool channel = IsChannelSpell(rec);
if (caster == Unit::Identity::PlayerGuid()) {
if (channel) {
// Player channel start. The broadcast +0x228 field that signals
// "channeling" is server-pushed and lands ~1 tick (~1s) after the
// channel actually begins, so a +0x228 poll shows the bar far too
// late — the addon already polled nil at SPELLCAST_CHANNEL_START
// and cleared. This packet IS the channel start: stamp g_channel
// now with the engine-computed duration. We run before the
// original handler that fires SPELLCAST_CHANNEL_START, so the data
// is fresh when addons poll. Cleared by self-expiry (PushChannel-
// Info) at endMs.
const int dur = ChannelDurationMs(spellID);
if (dur > 0) {
g_channel = TrackedSpell{spellID, now, now + dur, 0};
g_cast.spellID = 0; // a channel supersedes any cast
}
return;
}
if (castTime == 0)
return; // instant — no cast bar
// Backstop for chained same-spell recasts: the engine short-circuits
// Spell_C_CastSpell at `spellID == VAR_CURRENT_CAST_SPELL`, so the
// client path (FUN_CAST_START_SET hook) never fires for them — this
// server packet is the only signal. Dedup against a fresh client stamp
// (same spell, started within ~latency) so a normal cast's confirming
// packet doesn't restart its bar; otherwise it's a chained recast the
// client bailed on — take it, and flag it so the VAR==0 poll (which
// never saw it) won't clear it.
if (g_cast.spellID == spellID && now < g_cast.endMs &&
now - g_cast.startMs < kCastStartDedupMs)
return;
g_cast = TrackedSpell{spellID, now, now + static_cast<int>(castTime), 0};
g_castFromServer = true;
g_channel.spellID = 0; // a cast supersedes any channel
return;
}
const int endMs = channel ? now + RemoteChannelDurationMs(rec)
: now + static_cast<int>(castTime);
StoreRemoteCast(caster, spellID, now, endMs, channel);
@@ -312,7 +390,7 @@ int __fastcall SpellStartHandler_h(uint32_t unk, uint32_t opCode,
Net::Read<uint16_t>(packet); // castFlags (unused)
const uint32_t castTime = Net::Read<uint32_t>(packet);
packet->m_read = saved; // restore before the engine re-parses
RecordRemoteStart(caster, spellID, castTime);
HandleSpellStart(caster, spellID, castTime);
}
return g_origSpellStart(unk, opCode, unk2, packet);
}
@@ -322,32 +400,55 @@ const Game::HookAutoRegister _spellStartHook{
reinterpret_cast<void *>(&SpellStartHandler_h),
reinterpret_cast<void **>(&g_origSpellStart)};
// Co-hook on the SMSG_SPELL_DELAYED handler — cast pushback. The server
// only sends it to the affected caster, so it's effectively always the
// local player; extend the tracked cast's end time (and report the
// accumulated delay as delayTimeMs) so an addon's bar stretches on damage.
using SpellDelayed_t = int(__stdcall *)(uint32_t *opCode,
Net::CDataStore *packet);
SpellDelayed_t g_origSpellDelayed = nullptr;
int __stdcall SpellDelayed_h(uint32_t *opCode, Net::CDataStore *packet) {
if (packet != nullptr) {
const uint32_t saved = packet->m_read;
const uint64_t guid = Net::Read<uint64_t>(packet);
const uint32_t delay = Net::Read<uint32_t>(packet);
packet->m_read = saved;
if (delay != 0 && g_cast.spellID != 0 &&
guid == Unit::Identity::PlayerGuid()) {
g_cast.endMs += static_cast<int>(delay);
g_cast.delayMs += static_cast<int>(delay);
}
}
return g_origSpellDelayed(opCode, packet);
}
const Game::HookAutoRegister _spellDelayedHook{
Offsets::FUN_SPELL_DELAYED, reinterpret_cast<void *>(&SpellDelayed_h),
reinterpret_cast<void **>(&g_origSpellDelayed)};
} // namespace
// Per-frame: detect the player's cast/channel start, stamp times.
// Per-frame upkeep for the player. The regular cast is stamped by the
// FUN_CAST_START_SET hook; here we clear it when the engine's cast global
// drops to 0, and poll the broadcast +0x228 field for the channel (which
// has no comparable client-side start hook).
void OnWorldTick() {
const int now = NowMs();
// Clear a CLIENT-tracked cast when the cast global returns to 0 — covering
// normal end, cancel, and interrupt. No grace window is needed: the
// FUN_CAST_START_SET hook stamps g_cast in the same call that writes the
// global non-zero, so it's never transiently 0 right after a stamp.
// Server-stamped chained casts (g_castFromServer) never set the global,
// so they're exempt — they expire on their computed endMs (self-expiry in
// PushCastInfo).
if (g_cast.spellID != 0 && !g_castFromServer &&
*reinterpret_cast<const int *>(Offsets::VAR_CURRENT_CAST_SPELL) == 0)
g_cast.spellID = 0;
const int cast = *reinterpret_cast<const int *>(Offsets::VAR_CURRENT_CAST_SPELL);
if (cast != g_lastCast) {
const int dur = (cast != 0) ? CastTimeMs(cast) : 0;
if (cast != 0 && dur > 0) {
g_cast = {cast, now, now + dur};
} else {
g_cast.spellID = 0; // 0, instant, or channel — no cast bar
}
g_lastCast = cast;
}
const int chan = PlayerChannelSpell();
if (chan != g_lastChannel) {
if (chan != 0) {
g_channel = {chan, now, now + ChannelDurationMs(chan)};
} else {
g_channel.spellID = 0;
}
g_lastChannel = chan;
}
// Channels are stamped from SMSG_SPELL_START (HandleSpellStart) and expire
// on their computed endMs (self-expiry in PushChannelInfo). We don't poll
// the broadcast +0x228 field for the player — it lags the channel start by
// ~1 tick, so the bar would show ~1s late.
}
static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};