fix(aura): make C_UnitAuras sourceGUID persist and infer self-buff casters

Two changes so an aura's source stops vanishing:

Pin observed casters. The WorldTick expiry sweep (and Store's slot
reuse) dropped a cache entry once its computed expirationMs elapsed —
but that time is only a base-duration estimate for non-player casters
(we lack their mod tables), so a talent-extended debuff expired in the
cache while still live on the target. The entry took the caster with it,
and the descriptor path then rendered the aura with no sourceGUID. Now
an entry whose aura the owning unit's live descriptor still lists is
never timer-reclaimed (DescriptorListsAura resolves the GUID through the
object manager); only genuinely orphaned entries — unit out of range /
stealthed / despawned — expire. The fallback path already skips expired
entries, so no phantom can resurface. PushEnriched also reports an
already-elapsed cached expiration as unknown rather than a negative
remaining time.

Infer self-buff casters. On a caster miss (no cache entry, or one with
no caster), a self-only-target aura can only have been self-cast, so the
unit carrying it is its own source. Factor the record scan out of the
C_Spell.IsSelfBuff Lua wrapper into Spell::IsSelfBuff::IsSelfBuff and
call it from PushEnriched to recover sourceUnit/sourceGUID for
pre-existing self-buffs we never saw applied. Caster is exact; timing
stays unknown.
This commit is contained in:
Brues
2026-08-07 01:45:09 -05:00
parent 954533a170
commit 7caee620e8
4 changed files with 135 additions and 27 deletions
+28 -1
View File
@@ -20,6 +20,7 @@
#include "group/MemberStats.h"
#include "guid/Guid.h"
#include "spell/CrowdControl.h"
#include "spell/IsSelfBuff.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -210,6 +211,18 @@ double SpellBaseDurationSeconds(uint32_t spellID, int unitLevel) {
// Reads the player's level from UNIT_FIELD_LEVEL. Returns 0 when
// unresolved (pre-login, no descriptor) — caller should treat that
// as "skip level scaling".
// True if the engine-tick expiration `expMs` has already reached/passed now.
// Same signed-wrap-safe compare `PlayerBuffExpirationSeconds` uses (absorbs
// the ~24.86-day GetTickCount wrap). An already-elapsed cached expiration is
// not meaningful — the caster is kept regardless, but the time is reported as
// unknown rather than as a negative remaining.
bool ExpirationElapsed(uint32_t expMs) {
using TickCount_t = uint32_t(__fastcall *)();
const uint32_t nowMs = reinterpret_cast<TickCount_t>(
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
return static_cast<int32_t>(nowMs - expMs) >= 0;
}
int PlayerLevel(const uint8_t *player) {
auto *desc = Descriptor(player);
if (desc == nullptr)
@@ -528,13 +541,27 @@ static void PushEnriched(void *L, uint64_t guid, uint32_t spellID,
uint32_t expMs = 0;
uint32_t durMs = 0;
if (Aura::Source::Get(guid, spellID, &c, &expMs, &durMs)) {
if (expirationTime == 0.0 && expMs != 0)
// A cached expiration that already elapsed while the aura is still
// present (non-player casters get an underestimated base duration)
// is not meaningful — report unknown (0) rather than a negative
// remaining time. The caster stays; it's immutable for the life of
// the aura, which is the whole point of pinning the entry.
if (expirationTime == 0.0 && expMs != 0 && !ExpirationElapsed(expMs))
expirationTime = static_cast<double>(expMs) * 0.001;
if (durMs != 0)
duration = static_cast<double>(durMs) * 0.001;
casterGuid = c;
}
}
// Never observed the cast (no cache entry, or one with no caster): a
// self-only-target aura can only have been self-cast, so the unit carrying
// it is its own source. Recovers sourceUnit/sourceGUID for pre-existing
// self-buffs — e.g. a party member buffed before we saw them, or the
// player's own login-persisted buffs. Timing stays unknown (we still never
// saw the cast); only the caster is inferred, and it's exact.
if (casterGuid == 0 && guid != 0 && spellID != 0 &&
Spell::IsSelfBuff::IsSelfBuff(spellID))
casterGuid = guid;
BuildTable(L, spellID, applications, isHelpful, duration, expirationTime,
casterGuid);
}
+55 -4
View File
@@ -149,6 +149,43 @@ int g_writeCursor = 0;
// gets dropped (and OnAuraRemoved evicts real removals directly regardless).
constexpr uint32_t kEvictGraceMs = 2000;
// True if `guid`'s live unit descriptor still lists `spellId` in any aura
// slot. The descriptor is the presence authority while the unit is in view,
// so an entry backing a still-listed aura must never be timer-reclaimed: the
// caster (sourceGUID / sourceUnit) is immutable for the aura's lifetime, but
// our `expirationMs` is only a base-duration estimate for non-player casters
// (we lack their mod tables), which underestimates talent-extended auras and
// elapses while the aura is still up. Resolves the GUID through the object
// manager — non-throwing, returns null when the unit isn't loaded / is out of
// range — so an out-of-range or stealthed unit reports false and its
// fallback-only entry stays timer-reclaimable exactly as before.
bool DescriptorListsAura(uint64_t guid, uint32_t spellId) {
if (guid == 0 || spellId == 0)
return false;
using ResolveByGuid_t = void *(__fastcall *)(int typeMask, const char *dbg,
uint32_t guidLo,
uint32_t guidHi, int priority);
constexpr int kUnitOrPlayerMask =
(1 << Offsets::OBJECT_TYPE_UNIT) | (1 << Offsets::OBJECT_TYPE_PLAYER);
auto resolve = reinterpret_cast<ResolveByGuid_t>(
static_cast<uintptr_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID));
const auto *unit = static_cast<const uint8_t *>(
resolve(kUnitOrPlayerMask, nullptr, static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0));
if (unit == nullptr)
return false;
const auto *desc = *reinterpret_cast<const uint8_t *const *>(
unit + Offsets::OFF_CGUNIT_OBJECT_FIELDS);
if (desc == nullptr)
return false;
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
if (*reinterpret_cast<const uint32_t *>(
desc + Offsets::OFF_UNIT_FIELD_AURA + slot * 4) == spellId)
return true;
}
return false;
}
// `fromCast` true: the SpellGo hook — authoritative caster + caster-modified
// (talented) timing. False: the OnAuraAdded application hook — timing only,
// no caster, and it must not clobber an entry SpellGo already owns (that
@@ -180,9 +217,13 @@ void Store(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
return;
}
}
// Take a free slot, else an expired one, else evict round-robin.
// Take a free slot, else an expired one whose aura the descriptor no
// longer lists (a still-present aura keeps its slot so its caster isn't
// lost — see DescriptorListsAura), else evict round-robin.
for (auto &e : g_cache) {
if (!e.used || (e.expirationMs != 0 && now >= e.expirationMs)) {
if (!e.used ||
(e.expirationMs != 0 && now >= e.expirationMs &&
!DescriptorListsAura(e.targetGuid, e.spellId))) {
e = {targetGuid, casterGuid, spellId, expirationMs, durationMs,
now, kind, true};
return;
@@ -547,8 +588,18 @@ void OnWorldTick() {
const uint32_t now = NowMs();
for (auto &e : g_cache) {
if (e.used && e.expirationMs != 0 && now >= e.expirationMs)
e.used = false;
if (!e.used || e.expirationMs == 0 || now < e.expirationMs)
continue;
// A timer elapse doesn't prove the aura is gone — expirationMs is only
// a base-duration estimate for non-player casters. Keep any entry whose
// aura the owning unit's descriptor still lists (descriptor = presence
// authority in view; caster is immutable for the aura's life). Only
// genuinely orphaned entries (unit out of range / stealthed / despawned
// → no descriptor backing) are timer-reclaimed; the fallback path
// already skips expired entries, so this can't resurface a phantom.
if (DescriptorListsAura(e.targetGuid, e.spellId))
continue;
e.used = false;
}
// Forget snapshots for members we haven't polled recently (left the group,
// or no longer displayed) so a later re-appearance re-baselines cleanly.
+24 -22
View File
@@ -16,7 +16,11 @@
// `Spell.dbc.EffectImplicitTargetA[3]` and `EffectImplicitTargetB[3]`
// — an effect is "self-only" when both targets are either
// `TARGET_NONE` (0) or `TARGET_SELF` (1). Effects with a zero
// `Effect` code are skipped (unused effect slots).
// `Effect` code are skipped (unused effect slots). The record-scan core
// is `Spell::IsSelfBuff::IsSelfBuff` (see the header) so the aura layer
// can share it for sourceGUID inference.
#include "spell/IsSelfBuff.h"
#include "Game.h"
#include "Offsets.h"
@@ -46,23 +50,13 @@ bool IsSelfOnlyTarget(int target) {
} // namespace
static int __fastcall Script_IsSelfBuff(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::PushBool(L, false);
return 1;
}
const int spellID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (spellID <= 0) {
Game::Lua::PushBool(L, false);
return 1;
}
bool IsSelfBuff(uint32_t spellID) {
if (spellID == 0)
return false;
const uint8_t *rec = DBC::Record(Offsets::VAR_SPELL_RECORDS,
Offsets::VAR_SPELL_RECORD_COUNT,
static_cast<uint32_t>(spellID));
if (rec == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
Offsets::VAR_SPELL_RECORD_COUNT, spellID);
if (rec == nullptr)
return false;
bool sawEffect = false;
for (int i = 0; i < EFFECT_COUNT; ++i) {
@@ -75,12 +69,20 @@ static int __fastcall Script_IsSelfBuff(void *L) {
rec + OFF_SPELL_EFFECT_TARGET_A + i * 4);
const int targetB = *reinterpret_cast<const int *>(
rec + OFF_SPELL_EFFECT_TARGET_B + i * 4);
if (!IsSelfOnlyTarget(targetA) || !IsSelfOnlyTarget(targetB)) {
Game::Lua::PushBool(L, false);
return 1;
}
if (!IsSelfOnlyTarget(targetA) || !IsSelfOnlyTarget(targetB))
return false;
}
Game::Lua::PushBool(L, sawEffect);
return sawEffect;
}
static int __fastcall Script_IsSelfBuff(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::PushBool(L, false);
return 1;
}
const int spellID = static_cast<int>(Game::Lua::ToNumber(L, 1));
Game::Lua::PushBool(
L, spellID > 0 && IsSelfBuff(static_cast<uint32_t>(spellID)));
return 1;
}
+28
View File
@@ -0,0 +1,28 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
namespace Spell::IsSelfBuff {
// True iff every active effect of `spellID` targets only the caster — each
// effect's implicit target A and B is `TARGET_NONE` (0) or `TARGET_SELF` (1),
// read from `Spell.dbc.EffectImplicitTargetA/B[3]`. Such an aura can only have
// been self-cast, so a unit carrying one is definitionally its own source.
// False for an unknown spell or one with no active effect. Shared by the
// `C_Spell.IsSelfBuff` Lua binding and the `C_UnitAuras` sourceGUID inference.
bool IsSelfBuff(uint32_t spellID);
} // namespace Spell::IsSelfBuff