Skip to content

Commit 37a9c87

Browse files
committed
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.
1 parent 2403ff7 commit 37a9c87

6 files changed

Lines changed: 330 additions & 87 deletions

File tree

src/Offsets.h

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -733,6 +733,18 @@ enum Offsets {
733733
// re-stamp expiration so stacking-DoT refreshes are picked up.
734734
FUN_ON_AURA_STACKS_CHANGED = 0x00612450,
735735

736+
// `CGUnit_C::OnAuraRemoved` — same ABI as `OnAuraAdded`
737+
// (`__fastcall(unit /*ecx*/, edx_unused, slot, spellId)`; the disassembly
738+
// of the diff dispatcher `FUN_00604d00` at the call site `0x00604dc4`
739+
// shows `ECX = unit`, stack `[slot, spellId]`). The sibling of
740+
// `OnAuraAdded`: `FUN_00604d00` fires this when a descriptor aura slot
741+
// goes from occupied to empty — a genuine removal (fell off, dispelled,
742+
// or replaced by a higher rank, which fires Removed(old)+Added(new)).
743+
// `Aura::Source` co-hooks it to EVICT the cached entry so the
744+
// `GetAuraDataByIndex` fallback can't resurrect a superseded/dispelled
745+
// aura that's still inside its computed duration window.
746+
FUN_ON_AURA_REMOVED = 0x00612320,
747+
736748
// CGPlayer-side sub-struct, allocated for any player-controlled
737749
// unit (local self, target, party, raid, inspect targets — all of
738750
// them). Holds player-specific data that's *not* in the broadcast

src/aura/Api.cpp

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,16 @@ int PushAuraByIndex(void *L, const char *unitToken, int index,
9292
Data::Filter filter, bool playerOnly = false) {
9393
const uint8_t *unit = ResolveUnit(unitToken);
9494
const int slot = Data::FindNthSlot(unit, index, filter, playerOnly);
95-
if (slot < 0) {
96-
Game::Lua::PushNil(L);
95+
if (slot >= 0) {
96+
Data::Push(L, unit, slot);
9797
return 1;
9898
}
99-
Data::Push(L, unit, slot);
99+
// Descriptor exhausted — an aura the engine dropped from the slot array
100+
// (rogue stealth, party range fluctuation) may still be live in the
101+
// Aura::Source cache. Surface it after the descriptor entries.
102+
if (Data::PushNthCacheFallback(L, unit, index, filter, playerOnly))
103+
return 1;
104+
Game::Lua::PushNil(L);
100105
return 1;
101106
}
102107

@@ -218,6 +223,8 @@ void AppendRangeToArray(void *L, const uint8_t *unit, int outerIdx,
218223
Data::Push(L, unit, slot);
219224
Game::Lua::SetTable(L, outerIdx);
220225
}
226+
// Append auras the descriptor dropped but Aura::Source still has live.
227+
Data::AppendCacheFallbacks(L, unit, filter, playerOnly, outerIdx, nextKey);
221228
}
222229

223230
int __fastcall Script_GetUnitAuras(void *L) {

src/aura/Data.cpp

Lines changed: 178 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -350,10 +350,13 @@ const char *DispelName(uint32_t dispelTypeID) {
350350
return (name == nullptr || *name == '\0') ? nullptr : name;
351351
}
352352

353-
void Push(void *L, const uint8_t *unit, int slot) {
354-
const uint32_t spellID = ReadSpellID(unit, slot);
353+
// Writes the full modern AuraData table on top of the Lua stack from
354+
// already-resolved values. Shared by the descriptor path (`Push`) and the
355+
// cache-fallback path (`PushFromCache`). Net stack effect: +1 (the table).
356+
static void BuildTable(void *L, uint32_t spellID, int applications,
357+
bool isHelpful, double duration, double expirationTime,
358+
uint64_t casterGuid) {
355359
const uint8_t *spellRecord = SpellRecord(spellID);
356-
const bool isHelpful = slot < Offsets::UNIT_AURA_BUFF_COUNT;
357360

358361
uint32_t dispelTypeID = 0;
359362
if (spellRecord != nullptr) {
@@ -370,31 +373,71 @@ void Push(void *L, const uint8_t *unit, int slot) {
370373
Game::Lua::SetFieldString(L, "name", name);
371374
Game::Lua::SetFieldString(L, "icon", icon);
372375
Game::Lua::SetFieldNumber(L, "applications",
373-
static_cast<double>(ReadStacks(unit, slot)));
376+
static_cast<double>(applications));
374377
Game::Lua::SetFieldNumber(L, "spellId", static_cast<double>(spellID));
375378
Game::Lua::SetFieldString(L, "dispelName", dispel);
376379
Game::Lua::SetFieldBool(L, "isHelpful", isHelpful);
377380
Game::Lua::SetFieldBool(L, "isHarmful", !isHelpful);
381+
Game::Lua::SetFieldNumber(L, "duration", duration);
382+
Game::Lua::SetFieldNumber(L, "expirationTime", expirationTime);
383+
384+
if (casterGuid != 0) {
385+
// `sourceUnit` is a token (nil when the caster maps to no current
386+
// token); `sourceGUID` is the raw "0x..." GUID and is set whenever we
387+
// have a caster — a ClassicAPI extension (not in retail AuraData) that
388+
// survives the caster leaving token range and doubles as a unit token
389+
// under SuperWoW.
390+
char tokenBuf[32];
391+
char guidBuf[Guid::STRING_SIZE];
392+
const char *sourceUnit = Unit::Identity::TokenFromGUID(
393+
casterGuid, tokenBuf, sizeof tokenBuf);
394+
const char *sourceGUID =
395+
Guid::FormatAsString(casterGuid, guidBuf, sizeof guidBuf);
396+
if (sourceUnit != nullptr)
397+
Game::Lua::SetFieldString(L, "sourceUnit", sourceUnit);
398+
if (sourceGUID != nullptr)
399+
Game::Lua::SetFieldString(L, "sourceGUID", sourceGUID);
400+
}
401+
402+
Game::Lua::SetFieldNumber(L, "charges", 0);
403+
Game::Lua::SetFieldNumber(L, "maxCharges", 0);
404+
Game::Lua::SetFieldNumber(L, "timeMod", 1);
378405

379-
// Timing fields. Vanilla doesn't broadcast cast time / duration
380-
// for ANY unit's auras except the local player's — for everyone
381-
// else (target, party, raid, mouseover) `expirationTime` stays 0
382-
// and addons have to track expiry themselves via aura-add events.
383-
// For the player we read the engine's `VAR_PLAYER_BUFF_TABLE`
384-
// (same data `GetPlayerBuffTimeLeft` returns), which gives us a
385-
// real expiration timestamp.
406+
// Boolean fields whose modern semantics don't apply in vanilla.
407+
Game::Lua::SetFieldBool(L, "isStealable", false);
408+
Game::Lua::SetFieldBool(L, "isBossAura", false);
409+
Game::Lua::SetFieldBool(L, "isFromPlayerOrPlayerPet", false);
410+
Game::Lua::SetFieldBool(L, "isNameplateOnly", false);
411+
Game::Lua::SetFieldBool(L, "nameplateShowAll", false);
412+
Game::Lua::SetFieldBool(L, "nameplateShowPersonal", false);
413+
Game::Lua::SetFieldBool(L, "canApplyAura", false);
414+
Game::Lua::SetFieldBool(L, "shouldConsolidate", false);
415+
Game::Lua::SetFieldBool(L, "isRaid", false);
416+
417+
// `auraInstanceID` and `points` are deliberately omitted — modern returns
418+
// nil for those when they don't apply, and Lua reading a missing key
419+
// yields nil, so the table doesn't need an explicit entry.
420+
}
421+
422+
void Push(void *L, const uint8_t *unit, int slot) {
423+
const uint32_t spellID = ReadSpellID(unit, slot);
424+
const bool isHelpful = slot < Offsets::UNIT_AURA_BUFF_COUNT;
425+
426+
// Timing fields. Vanilla doesn't broadcast cast time / duration for ANY
427+
// unit's auras except the local player's — for everyone else (target,
428+
// party, raid, mouseover) `expirationTime` stays 0 unless the
429+
// `Aura::Source` cache observed the cast. For the player we read the
430+
// engine's `VAR_PLAYER_BUFF_TABLE` (same data `GetPlayerBuffTimeLeft`
431+
// returns), which gives a real expiration timestamp.
386432
//
387-
// `duration` comes from Spell.dbc → SpellDuration.dbc with
388-
// level scaling. That's the *base* value; talent / glyph
389-
// duration-extension modifiers (Improved PW:F, etc.) are
390-
// already baked into the expiration timestamp on the
391-
// caster's side, so `expirationTime - GetTime()` reflects the
392-
// true remaining time even when `duration` doesn't include
393-
// the talent boost. We populate `duration` for any unit (the
394-
// base value is identical regardless of who's affected); only
395-
// `expirationTime` is player-gated.
433+
// `duration` comes from Spell.dbc → SpellDuration.dbc with level scaling
434+
// (the *base* value); talent / glyph duration-extension modifiers are
435+
// already baked into the expiration timestamp on the caster's side, so
436+
// `expirationTime - GetTime()` reflects true remaining time even when the
437+
// base `duration` doesn't include the talent boost.
396438
double duration = 0.0;
397439
double expirationTime = 0.0;
440+
uint64_t casterGuid = 0;
398441
const uint8_t *player = LocalPlayer();
399442
if (spellID != 0) {
400443
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
@@ -405,72 +448,133 @@ void Push(void *L, const uint8_t *unit, int slot) {
405448
if (entry != nullptr)
406449
expirationTime = PlayerBuffExpirationSeconds(entry);
407450
}
408-
409-
// Caster + non-player expiration from the SMSG_SPELL_GO cache
410-
// (`Aura::Source`), captured at cast time. The vanilla descriptor has
411-
// neither. `sourceUnit` applies to any unit; `expirationTime` only
412-
// fills in here when the player-buff table above didn't — that table is
413-
// authoritative for the player's own auras, the cache covers everyone
414-
// else. Both are best-effort: a miss (aura predates login, or the cast
415-
// wasn't observed) leaves the modern-truthful defaults untouched.
416-
const char *sourceUnit = nullptr;
417-
const char *sourceGUID = nullptr;
418-
char tokenBuf[32];
419-
char guidBuf[Guid::STRING_SIZE];
451+
// Caster + non-player expiration from the SMSG_SPELL_GO cache, captured at
452+
// cast time (the descriptor has neither). The player-buff table above is
453+
// authoritative for the player's own expiration; the cache fills it for
454+
// everyone else. Prefer the applied (caster-modified) duration so it stays
455+
// consistent with `expirationTime` — otherwise a talented DoT (Improved
456+
// Shadow Word: Pain) would show remaining time exceeding the base.
420457
if (spellID != 0) {
421-
uint64_t casterGuid = 0;
458+
uint64_t c = 0;
422459
uint32_t expMs = 0;
423460
uint32_t durMs = 0;
424-
if (Aura::Source::Get(UnitGuid(unit), spellID, &casterGuid, &expMs,
425-
&durMs)) {
461+
if (Aura::Source::Get(UnitGuid(unit), spellID, &c, &expMs, &durMs)) {
426462
if (expirationTime == 0.0 && expMs != 0)
427463
expirationTime = static_cast<double>(expMs) * 0.001;
428-
// Prefer the applied (caster-modified) duration so `duration`
429-
// stays consistent with `expirationTime` — otherwise a talented
430-
// DoT (e.g. Improved Shadow Word: Pain) shows remaining time
431-
// exceeding the base `duration`.
432464
if (durMs != 0)
433465
duration = static_cast<double>(durMs) * 0.001;
434-
if (casterGuid != 0) {
435-
// `sourceUnit` is a token (nil when the caster maps to no
436-
// current token); `sourceGUID` is the raw "0x..." GUID and
437-
// is set whenever we have a caster — a ClassicAPI extension
438-
// (not in retail AuraData) that survives the caster leaving
439-
// token range and doubles as a unit token under SuperWoW.
440-
sourceUnit = Unit::Identity::TokenFromGUID(
441-
casterGuid, tokenBuf, sizeof tokenBuf);
442-
sourceGUID = Guid::FormatAsString(casterGuid, guidBuf,
443-
sizeof guidBuf);
444-
}
466+
casterGuid = c;
445467
}
446468
}
447469

448-
Game::Lua::SetFieldNumber(L, "duration", duration);
449-
Game::Lua::SetFieldNumber(L, "expirationTime", expirationTime);
450-
if (sourceUnit != nullptr)
451-
Game::Lua::SetFieldString(L, "sourceUnit", sourceUnit);
452-
if (sourceGUID != nullptr)
453-
Game::Lua::SetFieldString(L, "sourceGUID", sourceGUID);
454-
Game::Lua::SetFieldNumber(L, "charges", 0);
455-
Game::Lua::SetFieldNumber(L, "maxCharges", 0);
456-
Game::Lua::SetFieldNumber(L, "timeMod", 1);
470+
BuildTable(L, spellID, ReadStacks(unit, slot), isHelpful, duration,
471+
expirationTime, casterGuid);
472+
}
457473

458-
// Boolean fields whose modern semantics don't apply in vanilla.
459-
Game::Lua::SetFieldBool(L, "isStealable", false);
460-
Game::Lua::SetFieldBool(L, "isBossAura", false);
461-
Game::Lua::SetFieldBool(L, "isFromPlayerOrPlayerPet", false);
462-
Game::Lua::SetFieldBool(L, "isNameplateOnly", false);
463-
Game::Lua::SetFieldBool(L, "nameplateShowAll", false);
464-
Game::Lua::SetFieldBool(L, "nameplateShowPersonal", false);
465-
Game::Lua::SetFieldBool(L, "canApplyAura", false);
466-
Game::Lua::SetFieldBool(L, "shouldConsolidate", false);
467-
Game::Lua::SetFieldBool(L, "isRaid", false);
474+
namespace {
475+
476+
// Builds an AuraData table from an `Aura::Source` cache entry — the fallback
477+
// when the descriptor has dropped the aura's slot. Stack count isn't broadcast
478+
// in SMSG_SPELL_GO, so `applications` defaults to 1. `duration` uses the
479+
// applied (caster-modified) ms when known, else the Spell.dbc base.
480+
void PushFromCache(void *L, const uint8_t *unit,
481+
const Aura::Source::CachedAura &c, bool isHelpful) {
482+
double duration = 0.0;
483+
if (c.durationMs != 0) {
484+
duration = static_cast<double>(c.durationMs) * 0.001;
485+
} else {
486+
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
487+
duration = SpellBaseDurationSeconds(c.spellId, unitLevel);
488+
}
489+
const double expirationTime =
490+
(c.expirationMs != 0) ? static_cast<double>(c.expirationMs) * 0.001 : 0.0;
491+
BuildTable(L, c.spellId, 1, isHelpful, duration, expirationTime,
492+
c.casterGuid);
493+
}
468494

469-
// `sourceUnit` / `sourceGUID` are set above when the `Aura::Source`
470-
// cache has a caster for this aura; left unset (→ nil) on a miss.
471-
// `auraInstanceID` and `points` are deliberately omitted — modern
472-
// returns nil for those when they don't apply, and Lua reading a
473-
// missing key yields nil, so the table doesn't need an explicit entry.
495+
// Counts populated descriptor slots matching `filter` (and `playerOnly`).
496+
int CountSlots(const uint8_t *unit, Filter filter, bool playerOnly) {
497+
if (unit == nullptr)
498+
return 0;
499+
const int start = (filter == Filter::Harmful)
500+
? Offsets::UNIT_AURA_BUFF_COUNT
501+
: 0;
502+
const int end = (filter == Filter::Harmful) ? Offsets::UNIT_AURA_TOTAL
503+
: Offsets::UNIT_AURA_BUFF_COUNT;
504+
int n = 0;
505+
for (int slot = start; slot < end; ++slot) {
506+
if (!IsSlotPopulated(unit, slot))
507+
continue;
508+
if (playerOnly && !IsPlayerCast(unit, slot))
509+
continue;
510+
++n;
511+
}
512+
return n;
513+
}
514+
515+
constexpr int kFallbackMax = Offsets::UNIT_AURA_TOTAL;
516+
517+
// Whether cache entry `c` should be surfaced as a fallback for `unit` under
518+
// `filter`/`playerOnly`: it must not already be in a populated descriptor slot
519+
// (no double-listing the same live aura) and, when player-filtered, must have
520+
// been cast by us. Superseded / dispelled auras are kept out of the cache by
521+
// `Aura::Source`'s removal eviction, not filtered here.
522+
bool FallbackEligible(const uint8_t *unit, const Aura::Source::CachedAura &c,
523+
Filter filter, bool playerOnly) {
524+
if (FindSlotBySpellID(unit, c.spellId, &filter, false) >= 0)
525+
return false; // still in the descriptor — returned via the slot path
526+
if (playerOnly && c.casterGuid != Unit::Identity::PlayerGuid())
527+
return false;
528+
return true;
529+
}
530+
531+
} // namespace
532+
533+
bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
534+
Filter filter, bool playerOnly) {
535+
if (unit == nullptr)
536+
return false;
537+
// The descriptor held `descCount` matches; the caller already found fewer
538+
// than `oneBasedIndex` there, so the cache supplies index
539+
// `oneBasedIndex - descCount` (1-based) of the entries it didn't.
540+
const int target = oneBasedIndex - CountSlots(unit, filter, playerOnly);
541+
if (target < 1)
542+
return false;
543+
const uint64_t guid = UnitGuid(unit);
544+
if (guid == 0)
545+
return false;
546+
Aura::Source::CachedAura buf[kFallbackMax];
547+
const int n = Aura::Source::Enumerate(
548+
guid, filter == Filter::Harmful, buf, kFallbackMax);
549+
int seen = 0;
550+
for (int i = 0; i < n; ++i) {
551+
if (!FallbackEligible(unit, buf[i], filter, playerOnly))
552+
continue;
553+
if (++seen == target) {
554+
PushFromCache(L, unit, buf[i], filter == Filter::Helpful);
555+
return true;
556+
}
557+
}
558+
return false;
559+
}
560+
561+
void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
562+
bool playerOnly, int outerIdx, int &nextKey) {
563+
if (unit == nullptr)
564+
return;
565+
const uint64_t guid = UnitGuid(unit);
566+
if (guid == 0)
567+
return;
568+
Aura::Source::CachedAura buf[kFallbackMax];
569+
const int n = Aura::Source::Enumerate(
570+
guid, filter == Filter::Harmful, buf, kFallbackMax);
571+
for (int i = 0; i < n; ++i) {
572+
if (!FallbackEligible(unit, buf[i], filter, playerOnly))
573+
continue;
574+
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
575+
PushFromCache(L, unit, buf[i], filter == Filter::Helpful);
576+
Game::Lua::SetTable(L, outerIdx);
577+
}
474578
}
475579

476580
} // namespace Aura::Data

src/aura/Data.h

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,24 @@ const char *DispelName(uint32_t dispelTypeID);
112112
// Net stack effect: +1 (the table).
113113
void Push(void *L, const uint8_t *unit, int slot);
114114

115+
// Fallback for the index path when the descriptor has dropped an aura's slot
116+
// (rogue stealth, party range fluctuation) but it's still active server-side.
117+
// `oneBasedIndex` is the same index the caller passed to `FindNthSlot`; this
118+
// resolves it against the `Aura::Source` cache *after* the descriptor matches
119+
// (so cache entries append after descriptor ones, preserving existing index
120+
// order). On a hit it pushes the AuraData table and returns true; on a miss it
121+
// pushes nothing and returns false (caller pushes nil). Only entries whose
122+
// helpful/harmful classification matches `filter` and that aren't already in a
123+
// populated descriptor slot are considered.
124+
bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
125+
Filter filter, bool playerOnly = false);
126+
127+
// Appends every eligible `Aura::Source` cache fallback for `unit` matching
128+
// `filter` into the array table at `outerIdx`, continuing from `nextKey`
129+
// (updated in place). The bulk-enumeration analog of `PushNthCacheFallback`,
130+
// for `GetUnitAuras`. Skips entries already present in a populated descriptor
131+
// slot so it never double-lists.
132+
void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
133+
bool playerOnly, int outerIdx, int &nextKey);
134+
115135
} // namespace Aura::Data

0 commit comments

Comments
 (0)