Compare commits

...
5 Commits
Author SHA1 Message Date
Brues b81f672ad7 fix(aura): classify auras by polarity flag, not slot range
The server seats buffs in slots 0..31 and debuffs in 32..47 only while it
can: once the 16 debuff slots are full, _AddSpellAuraHolder parks further
debuffs in free BUFF slots and sets UNIT_FLAG_AURAS_VISIBLE so the client
still renders them (tortoise-wow, verified). Classifying by slot range
therefore reported those as buffs -- absent from HARMFUL, and wrong in
isHelpful / isHarmful. Confirmed live on a 20-aura target: 18 harmful, two
of them sitting in buff slots, where the old code returned 16.

Every visible aura carries its real polarity in the per-slot flag nibble
(SpellAuraHolder::SetAuraFlag writes 0x04 positive / 0x08 negative), so
HELPFUL / HARMFUL now select on that instead. All the walkers -- by index,
by spell ID, by name, GetUnitAuras, GetAuraSlots -- share one order helper,
SlotInFilterOrder, which visits the polarity's home range first and the
other range after, so every index is unchanged whenever nothing has
spilled. Aura::Source's KindForSlot classifies the same way, keeping the
descriptor-drop fallback consistent with the query paths. The out-of-range
group array carries spell IDs with no flags, so that path stays
range-based -- the seating range is the only polarity it has.

GameTooltip:SetUnitAura now bridges the two index spaces. The engine's
SetUnitBuff / SetUnitDebuff index their own slot RANGE, which diverges from
the polarity index space as soon as a debuff spills, so the method resolves
(index, filter) to an absolute slot and hands the engine the index that
slot holds within its own range, picking the method by where the slot
lives. It counts with the engine's TOOLTIP visibility predicate
(FUN_GAMETOOLTIP_AURA_VISIBLE = 0x00534DF0), not the UnitBuff one
(0x00519860): the latter adds a per-player immunity-bitmap check off record
+0x2B0, so the two index spaces are not interchangeable. Both decompiled to
confirm the walks being mirrored.

The aura tooltip builder only ever receives spellId / level / stacks, so a
debuff parked in a buff slot renders identically through SetUnitBuff.

Note this is deliberately MORE than the engine does: stock UnitBuff /
UnitDebuff split by slot range and show a spilled debuff as a buff. Retail's
contract is the aura's actual polarity, and the descriptor already carries
it, so we report that.
2026-09-04 03:58:14 -05:00
Brues fdbe66b057 fix(aura): let a duration rule edit an aura its own trigger creates
Turtle's Bestial Wrath gives the hunter's pet Scent of Blood for 18s, but
C_UnitAuras reported 8s and then dropped the timer entirely while the buff
was still up.

Server-side (tortoise-wow spell_hunter_bestial_wrath) Bestial Wrath neither
applies the aura nor carries its duration on the wire: effect 0 is
TRIGGER_SPELL(Scent of Blood, 52995), and the script then stretches the
holder that trigger just created via SetAuraMaxDuration/SetAuraDuration.
Both are plain field writes, and 1.12's only duration packet is scoped to an
aura-bearer that is a PLAYER, which a pet is not -- so the client never hears
the 18s and keeps the 8s base it computed when the aura landed. Exactly the
class of edit Aura::Source's duration rules exist to mirror; two things had
to change before one could express it.

The affected-aura selector demanded a SpellFamilyFlags overlap, and every
Turtle custom spell carries flags of 0 (308 in the hunter family alone), so
no mask could ever name this aura. It now takes a family-flag overlap and/or
a SpellIconID, at least one of the two -- an icon is shared across a spell's
whole rank ladder, so it stays as rank-proof as a flag, and icon 2245 picks
out exactly the four Scent of Blood records and nothing else. Registrars
reject the mask-0 + icon-0 rule that would match a whole class family, and
RefreshDurationByFamily takes the same invariant so the two entry points
cannot disagree about what they match.

A trigger can also CREATE the aura it edits, which the immediate-apply model
could not see. The trigger's SMSG_SPELL_GO arrives before the triggered
cast's, so editing at trigger time either matches nothing or is overwritten
moments later when the triggered cast stores its own base duration -- the
edit is lost either way. Idempotent ops now ARM the target as well, and the
next matching store re-applies the edit, from the cast path or the
application path, whichever the aura arrives on. Only refresh and set arm:
re-running those lands on the same value, while reduce would shave twice and
remove would kill an aura that was legitimately re-applied, so Conflagrate
behaves exactly as before.

The rule reads Bestial Wrath's own duration from the client's spell data
instead of hardcoding the server's 18s, so a rebalance that patches the DBC
carries over on its own.

Fixes brues-code/pfUI#50
2026-09-02 19:39:59 -05:00
Brues bd1bd9f5d8 feat(aura): slot-batching API and lazy caster match
C_UnitAuras.GetAuraSlots / GetAuraDataBySlot / UnitAuraBySlot: enumerate a
unit's auras once into opaque slot ids and fetch each by id, so a full scan
walks the aura array once instead of once per index (the by-index getters
re-walk from slot 0 on every call). Slot ids cover live descriptor slots,
the stealth / range cache fallback, and the out-of-range group array, so
the slot path keeps every source the by-index path had.

GetAuraSlots also takes a table as a 5th argument (ClassicAPI extension)
and fills it in place, returning `continuationToken, n`. Lua 5.0 allocates
an `arg` table for EVERY vararg call (luaD_precall 0x006F6050 ->
adjust_varargs 0x006F6200 -> luaH_new, verified in Ghidra), so a Lua helper
receiving the retail multi-return costs one table per batch. The fill form
keeps per-frame scans allocation-free; AuraUtil.ForEachAura / FindAura use
it with a per-nesting-depth buffer pool instead of retail's vararg helpers.

The descriptor walkers (FindNthSlot, FindSlotBySpellID/Name, GetUnitAuras)
now evaluate IsPlayerCast only when the filter restricts on caster. It
scans the Aura::Source cache (~1000 entries in a raid) and was paid for
every populated slot on every call, PLAYER token or not.

Cache-fallback eligibility is centralized (EligibleFallbacks /
EligibleGroupFallbacks) so the by-index, bulk, and by-slot paths share one
ordering, which is the ordinal an opaque cache slot id encodes.
2026-09-02 16:19:31 -05:00
Brues b6698ecc7e docs(readme): list UnitCreatedBySpell in the Unit row
Already documented in docs/API.md; only the README summary table was
missing it. Inserted in alphabetical position.
2026-09-02 02:34:37 -05:00
Brues e123a6facc fix(cast): fire CHANNEL_STOP/START on same-spell re-channel
Recasting a channel while already channeling it (e.g. Fishing while
fishing) left the cast bar frozen: PollPlayer detected a new channel by
spellID only, but a re-channel keeps channelSpellID == g_chanSpell and
the continuous channel never lets +0x228 (and thus g_channel.spellID)
return to 0 in between, so neither CHANNEL_STOP nor CHANNEL_START fired.

Also flag a same-spell re-channel when the channel was re-stamped
(channelStartMs advanced) AND a fresh CMSG_CAST_SPELL is pending for the
spell (g_pendingGuid). Both are required so neither false-fire trap
triggers: the two-stamp-per-start sequence (SMSG_SPELL_START then
MSG_CHANNEL_START) is absorbed because the first CHANNEL_START already
consumed g_pendingGuid, and a rejected recast (SPELL_IN_PROGRESS) never
re-stamps so channelStartMs is unchanged.
2026-09-02 02:05:55 -05:00
13 changed files with 1046 additions and 307 deletions
+82 -55
View File
@@ -14,24 +14,61 @@
-- * FindAuraByName(auraName, unit, filter)
-- * UnpackAuraData(auraData)
--
-- Iteration batches through C_UnitAuras.GetAuraSlots + a by-slot fetch
-- (continuation tokens), exactly as retail's does: one enumeration per batch,
-- then a direct fetch per aura -- linear in the aura count. The by-index getters
-- (GetAuraDataByIndex / UnitAura) re-walk the aura array from slot 0 on every
-- call, so iterating by index is quadratic; don't go back to it here.
--
-- Two deliberate differences from that source:
-- 1. Retail's ForEachAura/FindAura batch through GetAuraSlots / GetAuraDataBySlot
-- (continuation tokens) -- 1.12 has no slot-batching API, so the bodies here
-- iterate by index (1, 2, ...) until the accessor returns no aura. Same public
-- contract, different internals.
-- 2. The non-packed ForEachAura and FindAura source each aura from the positional
-- C_UnitAuras.UnitAura (no table), so both are zero-allocation here -- FindAura
-- in particular, where the retail version allocates a table per aura scanned.
-- 1. Retail's helpers receive GetAuraSlots' return list as varargs
-- (ForEachAuraHelper / FindAuraRecurse). Lua 5.0 builds an `arg` table for
-- EVERY vararg call, so that shape allocates one table per batch here --
-- exactly the per-frame garbage this library exists to avoid. We use
-- GetAuraSlots' fill-a-table form instead (a table as its 5th argument,
-- a ClassicAPI extension): the slot ids land in a reusable buffer and the
-- call returns `continuationToken, n`. No vararg frame anywhere.
-- 2. The non-packed ForEachAura and FindAura fetch each aura through the
-- positional C_UnitAuras.UnitAuraBySlot (no table), the way retail's
-- AuraUtil did before it retired the positional by-slot accessor. So both
-- are zero-allocation here -- FindAura in particular, where the current
-- retail version allocates a table per aura scanned.
--
-- The comparator / dispel-priority / border helpers (DefaultAuraCompare,
-- UnitFrameDebuffComparator, ShouldDisplayDebuff, SetAuraBorder*, IsPriorityDebuff)
-- are NOT ported: they depend on isPriorityAura / auraInstanceID / the aura
-- visualization pipeline / secure-call machinery, none of which exist on 1.12.
local UnitAura = C_UnitAuras.UnitAura
local GetAuraSlots = C_UnitAuras.GetAuraSlots
local GetAuraDataBySlot = C_UnitAuras.GetAuraDataBySlot
local UnitAuraBySlot = C_UnitAuras.UnitAuraBySlot
AuraUtil = AuraUtil or {};
-- Slot-id buffers for GetAuraSlots' fill form, one per nesting level: a `func`
-- or `predicate` that itself iterates another unit's auras gets its own buffer
-- instead of clobbering the outer scan's. Costs one table per nesting level for
-- the session (retail's varargs are re-entrant for free; ours must be explicit).
-- An error thrown out of `func` skips the release and leaves `depth` one higher
-- for the rest of the session -- harmless: later scans use the next buffer up,
-- allocated once.
local slotBufs = {};
local depth = 0;
local function AcquireSlotBuf()
depth = depth + 1;
local buf = slotBufs[depth];
if not buf then
buf = {};
slotBufs[depth] = buf;
end
return buf;
end
local function ReleaseSlotBuf()
depth = depth - 1;
end
-- Returns the AuraData table's fields in the classic `UnitAura` positional order,
-- for code written against that shape. Retail appends `unpack(auraData.points)`;
-- vanilla auras carry no points, so the tuple ends at timeMod (15 values). Field
@@ -58,66 +95,56 @@ function AuraUtil.UnpackAuraData(auraData)
auraData.timeMod;
end
-- Calls `func` for each aura on `unit` matching `filter`, in index order, until
-- `func` returns a truthy value or the auras run out. Non-packed (default) passes
-- the 15 positional `UnitAura` values (no table); `usePackedAura` passes the
-- AuraData table from GetAuraDataByIndex. `batchSize <= 0` does nothing; any other
-- value (or nil) visits every matching aura -- `batchSize` is retail's slot-batch
-- hint, not a cap on how many auras are visited.
-- Calls `func` for each aura on `unit` matching `filter`, in order, until `func`
-- returns a truthy value or the auras run out. Non-packed (default) passes the 15
-- positional `UnitAura` values (no table); `usePackedAura` passes the AuraData
-- table. `batchSize` is the number of slots fetched per GetAuraSlots call (nil =
-- all at once); it never caps how many auras are visited. `batchSize <= 0` does
-- nothing.
function AuraUtil.ForEachAura(unit, filter, batchSize, func, usePackedAura)
if batchSize and batchSize <= 0 then
return;
end
local index = 1;
while true do
local done;
if usePackedAura then
local auraData = C_UnitAuras.GetAuraDataByIndex(unit, index, filter);
if auraData == nil then
local buf = AcquireSlotBuf();
local continuationToken, n;
repeat
continuationToken, n = GetAuraSlots(unit, filter, batchSize, continuationToken, buf);
for i = 1, n do
local done;
if usePackedAura then
done = func(GetAuraDataBySlot(unit, buf[i]));
else
done = func(UnitAuraBySlot(unit, buf[i]));
end
if done then
ReleaseSlotBuf();
return;
end
done = func(auraData);
else
local name, icon, count, dispelType, duration, expirationTime, source,
isStealable, nameplateShowPersonal, spellId, canApplyAura,
isBossDebuff, castByPlayer, nameplateShowAll, timeMod =
UnitAura(unit, index, filter);
if name == nil then
return;
end
done = func(name, icon, count, dispelType, duration, expirationTime,
source, isStealable, nameplateShowPersonal, spellId, canApplyAura,
isBossDebuff, castByPlayer, nameplateShowAll, timeMod);
end
if done then
return;
end
index = index + 1;
end
end
-- Recursive worker: `...` is the current aura's positional values (nil when the
-- index is past the last aura). Tail-recurses to the next index. Lua 5.0 has
-- proper tail calls, so depth is constant regardless of aura count.
local function FindAuraRecurse(predicate, unit, filter, index, arg1, arg2, arg3, ...)
if ... == nil then
return nil; -- past the last aura -> not found
end
if predicate(arg1, arg2, arg3, ...) then
return ...;
end
index = index + 1;
return FindAuraRecurse(predicate, unit, filter, index, arg1, arg2, arg3,
UnitAura(unit, index, filter));
until continuationToken == nil;
ReleaseSlotBuf();
end
-- Returns the positional values of the first aura for which `predicate` returns
-- truthy, or nil. `predicate` receives up to three caller args followed by the
-- aura's positional values: predicate(arg1, arg2, arg3, name, icon, ...). Sources
-- auras from the positional accessor, so no table is allocated while scanning.
-- auras from the positional by-slot accessor, so no table is allocated while
-- scanning.
function AuraUtil.FindAura(predicate, unit, filter, arg1, arg2, arg3)
return FindAuraRecurse(predicate, unit, filter, 1, arg1, arg2, arg3,
UnitAura(unit, 1, filter));
local buf = AcquireSlotBuf();
local continuationToken, n;
repeat
continuationToken, n = GetAuraSlots(unit, filter, nil, continuationToken, buf);
for i = 1, n do
local slot = buf[i];
if predicate(arg1, arg2, arg3, UnitAuraBySlot(unit, slot)) then
ReleaseSlotBuf();
return UnitAuraBySlot(unit, slot);
end
end
until continuationToken == nil;
ReleaseSlotBuf();
return nil;
end
-- Returns the positional values of the first aura whose name matches `auraName`,
+2 -2
View File
@@ -103,8 +103,8 @@ reference in **[docs/API.md](docs/API.md)**.
| [Tracking](docs/API.md#tracking) | `GetNumTrackingTypes`, `GetTrackingInfo`, `SetTracking` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `UnitSpellTargetName`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration`, `C_UnitAuras.UnitAura`, `C_UnitAuras.UnitBuff`, `C_UnitAuras.UnitDebuff` |
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatedBySpell`, `UnitCreatureFamilyID`, `UnitCreatureID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `UnitSpellTargetName`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySlot`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetAuraSlots`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration`, `C_UnitAuras.UnitAura`, `C_UnitAuras.UnitAuraBySlot`, `C_UnitAuras.UnitBuff`, `C_UnitAuras.UnitDebuff` |
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
| [XMLUtil](docs/API.md#xmlutil) | `C_XMLUtil.DoesTemplateExist`, `C_XMLUtil.GetTemplateInfo`, `C_XMLUtil.GetTemplates` |
+102 -26
View File
@@ -650,6 +650,8 @@ build instructions.
- [`C_UnitAuras.GetBuffDataByIndex(unit, index)` / `GetDebuffDataByIndex(unit, index)`](#c_unitaurasgetbuffdatabyindexunit-index--getdebuffdatabyindexunit-index)
- [`C_UnitAuras.UnitAura(unit, index [, filter])`](#c_unitaurasunitauraunit-index--filter)
- [`C_UnitAuras.UnitBuff(unit, index [, filter])` / `UnitDebuff(unit, index [, filter])`](#c_unitaurasunitbuffunit-index--filter--unitdebuffunit-index--filter)
- [`C_UnitAuras.GetAuraSlots(unit [, filter [, maxSlots [, continuationToken]]])`](#c_unitaurasgetauraslotsunit--filter--maxslots--continuationtoken)
- [`C_UnitAuras.GetAuraDataBySlot(unit, slot)` / `UnitAuraBySlot(unit, slot)`](#c_unitaurasgetauradatabyslotunit-slot--unitaurabyslotunit-slot)
- [`C_UnitAuras.GetUnitAuraBySpellID(unit, spellID [, filter])`](#c_unitaurasgetunitaurabyspellidunit-spellid--filter)
- [`C_UnitAuras.GetPlayerAuraBySpellID(spellID)`](#c_unitaurasgetplayeraurabyspellidspellid)
- [`C_UnitAuras.GetAuraDataBySpellName(unit, spellName [, filter])`](#c_unitaurasgetauradatabyspellnameunit-spellname--filter)
@@ -1207,13 +1209,17 @@ units each frame.
truthy value or the auras run out.
- Non-packed (default): `func` receives the 15 positional values of
[`C_UnitAuras.UnitAura`](#c_unitaurasunitauraunit-index--filter) — no table is
built per aura.
[`C_UnitAuras.UnitAuraBySlot`](#c_unitaurasgetauradatabyslotunit-slot--unitaurabyslotunit-slot).
No table is built per aura.
- `usePackedAura` true: `func` receives the `AuraData` table from
`GetAuraDataByIndex`.
`GetAuraDataBySlot`.
`batchSize` of `0` or less does nothing; any other value (or `nil`) visits every
matching aura — `batchSize` is a slot-batch hint, not a cap on the count.
The scan fetches slot ids in batches through
[`C_UnitAuras.GetAuraSlots`](#c_unitaurasgetauraslotsunit--filter--maxslots--continuationtoken),
then reads each aura by slot id. So the cost grows with the aura count, not
with its square. `batchSize` sets how many slot ids one batch fetches. With
`nil`, one batch fetches all of them. The value never caps how many auras
`func` visits. A `batchSize` of `0` or less does nothing.
```lua
AuraUtil.ForEachAura("target", "HARMFUL", nil, function(name, icon, count)
@@ -5478,10 +5484,13 @@ go to a different resolver and won't match).
### `GameTooltip:SetUnitAura(unit, index, [filter])`
Unified-aura method. The engine splits this into `SetUnitBuff` and
`SetUnitDebuff`; we dispatch to the right one based on the `filter`
string (`"HARMFUL"` → `SetUnitDebuff`, anything else → `SetUnitBuff`).
`filter` defaults to helpful when omitted.
Unified-aura method. `index` is in the same `HELPFUL` / `HARMFUL` index
space as `C_UnitAuras.GetAuraDataByIndex`, so an index you got from
`C_UnitAuras` always opens the matching tooltip. That includes a debuff
that the server parked in a buff slot. The tooltip itself comes from the
engine's own `SetUnitBuff` or `SetUnitDebuff`. `filter` defaults to
helpful when omitted. Only its `HELPFUL` or `HARMFUL` token is read, so
pass an index from the same plain list.
```lua
GameTooltip:SetOwner(UIParent, "ANCHOR_CURSOR")
@@ -5491,9 +5500,9 @@ GameTooltip:SetUnitAura("player", 1) -- defaults to HELPFUL
GameTooltip:Show()
```
Pure dispatcher — no engine changes; the underlying logic is whatever
the engine's `SetUnitBuff` / `SetUnitDebuff` already does. Just lets you use
the unified call shape (which most aura libraries backport from)
The engine methods index their own slot ranges, which differ from the
`C_UnitAuras` index space once a debuff sits in a buff slot. This method
translates between the two, so you never need to
without conditionally splitting on filter.
### `GameTooltip:SetSpellByID(spellID)`
@@ -15263,10 +15272,14 @@ Backport of the `C_UnitAuras` namespace. Returns
Reads primarily off the unit's `m_objectFields` descriptor — same
data source `UnitBuff` / `UnitDebuff` use. The descriptor has 48 aura
slots total: 32 helpful (buffs) at indices 0..31, 16 harmful
(debuffs) at indices 32..47. Functions in this namespace take a
1-based Lua index that translates onto whichever range the filter
selects.
slots. The server seats buffs in slots 0 to 31 and debuffs in slots 32
to 47 while it can. When the 16 debuff slots are full, it puts further
debuffs into free buff slots. Each slot also carries a flag that records
the aura's real polarity, and `HELPFUL` / `HARMFUL` select on that flag.
So a debuff that sits in a buff slot is still a debuff here. The native
`UnitBuff` / `UnitDebuff` split by slot range and report such a debuff as
a buff. Indices are stable in the normal case: a `HARMFUL` walk visits
slots 32 to 47 first, then any spilled debuffs in 0 to 31.
When a party/raid member has **no live unit object at all** (a
different map, far out of range), there is no descriptor to read — but
@@ -15283,8 +15296,8 @@ these functions surface them, exactly as the built-in `UnitBuff` /
| `applications` | number | stack count (engine stores `stacks-1`, we display `+1`) |
| `spellId` | number | spell ID from the descriptor's aura array |
| `dispelName` | string | `"Magic"` / `"Curse"` / `"Disease"` / `"Poison"` (from `SpellDispelType.dbc`), or `""` if non-dispellable |
| `isHelpful` | boolean | true for slot < 32 |
| `isHarmful` | boolean | true for slot >= 32 |
| `isHelpful` | boolean | true when the slot's polarity flag marks the aura positive |
| `isHarmful` | boolean | true when the slot's polarity flag marks the aura negative. Read from the flag, not the slot number, so a debuff parked in a buff slot reads harmful |
| `duration` | number | applied duration in seconds. When the aura's cast was observed (in the `Aura::Source` cache), this is the caster-modified duration — talent/glyph extensions like Improved Shadow Word: Pain included — so it stays consistent with `expirationTime` (`remaining ≤ duration`). On a cache miss it falls back to the base `Spell.dbc → SpellDuration.dbc` value with level scaling. Returns 0 for spells flagged "no duration" (passives, paladin auras, infinite buffs) |
| `expirationTime` | number | for `unit == "player"`, read from the engine's player-buff table at `0x00BC6040` (same data `GetPlayerBuffTimeLeft` returns). For any other unit, taken from the `Aura::Source` cache (cast time + duration captured from `SMSG_SPELL_GO`; see below). `0` when neither source has it. `expirationTime - GetTime()` gives the true remaining time |
| `sourceUnit` | string | unit token of the caster (`"player"`, `"raid7"`, `"nameplate1"`, …), resolved from the `Aura::Source` cache. `nil` if the cast wasn't observed or the caster maps to no current token |
@@ -15301,8 +15314,8 @@ The optional `filter` string is a pipe-separated set of upper-case
tokens (`"HELPFUL"`, `"HARMFUL"`,
`"HELPFUL|PLAYER"`, etc.). Honored:
- **`HELPFUL`** (default) / **`HARMFUL`** — pick the buff or debuff range.
In `GetUnitAuras`, supplying neither returns both ranges.
- **`HELPFUL`** (default) / **`HARMFUL`** — pick buffs or debuffs by each
aura's polarity flag. In `GetUnitAuras`, supplying neither returns both.
- **`PLAYER`** — restrict to auras the local player cast, via the
`Aura::Source` caster cache (`sourceGUID == ` player GUID). Combines with
the range tokens (`"HARMFUL|PLAYER"` = your debuffs only). Because the
@@ -15408,8 +15421,9 @@ end
separated by `|` or spaces, each token optionally negated with a
leading `!`. This build honors:
- `HELPFUL` / `HARMFUL` — buffs / debuffs. With neither token the
query returns both (the indexed getter defaults to helpful).
- `HELPFUL` / `HARMFUL` — buffs / debuffs, by each aura's polarity flag
rather than its slot number. With neither token the query returns both
(the indexed getter defaults to helpful).
- `PLAYER` / `!PLAYER` — only auras the local player cast, or only
auras the player did not cast. Caster data comes from casts this
session, so an aura present before you saw it cast has no caster and
@@ -15470,6 +15484,64 @@ The `filter` still honors the `PLAYER`, `DISPELLABLE`, and `CROWD_CONTROL`
predicates. Namespaced under `C_UnitAuras` so they never clash with the native
global `UnitBuff` / `UnitDebuff` (which keep their texture-first return).
### `C_UnitAuras.GetAuraSlots(unit [, filter [, maxSlots [, continuationToken]]])`
Returns a continuation token, then the slot ids of the auras on `unit` that
match `filter`. A slot id is an opaque number. Pass it to `GetAuraDataBySlot`
or `UnitAuraBySlot` to read that aura.
```lua
local token, slot1, slot2 = C_UnitAuras.GetAuraSlots("target", "HARMFUL", 2)
```
The slot ids come in the order that the by-index getters visit the auras.
`maxSlots` limits how many ids one call returns. With `nil` or `0`, one call
returns all of them. When more auras remain, the first return value is a
token. Pass this token back as `continuationToken` to get the next batch. When
the batch reaches the last aura, the first return value is `nil`.
Use this function instead of a loop over `GetAuraDataByIndex`. The by-index
getters walk the aura array from the start on every call, so a loop over them
repeats that walk for each index. One `GetAuraSlots` call plus one by-slot
fetch per aura walks the array once.
[`AuraUtil.ForEachAura`](#aurautilforeachaura) uses this path.
A slot id is valid in the frame that returned it. Do not store slot ids
across frames. `filter` takes the same tokens as `GetAuraDataByIndex`. The
range is `HELPFUL` unless the filter has `HARMFUL`.
**Fill a table instead (ClassicAPI extension).** Pass a table as the fifth
argument. The function writes the slot ids into it as `t[1]` to `t[n]`,
clears old entries after `n`, sets `t.n`, and returns `continuationToken, n`.
Use this form in code that runs every frame. On this client's Lua, a call to
a function with `...` in its parameter list allocates a table. So a Lua helper
that receives the normal return list allocates once per call, and the fill
form does not.
```lua
local slots = {}
local token, n = C_UnitAuras.GetAuraSlots("target", "HARMFUL", nil, nil, slots)
for i = 1, n do
local name = C_UnitAuras.UnitAuraBySlot("target", slots[i])
end
```
### `C_UnitAuras.GetAuraDataBySlot(unit, slot)` / `UnitAuraBySlot(unit, slot)`
`GetAuraDataBySlot` returns the `AuraData` table for the aura that slot id
`slot` names on `unit`. It returns `nil` when the slot id no longer names an
aura. `UnitAuraBySlot` returns the same aura as the 15 positional values of
[`C_UnitAuras.UnitAura`](#c_unitaurasunitauraunit-index--filter), so it
allocates nothing. Get slot ids from `GetAuraSlots`.
```lua
local token, slot = C_UnitAuras.GetAuraSlots("player", "HELPFUL", 1)
if slot then
local d = C_UnitAuras.GetAuraDataBySlot("player", slot)
print(d.name, d.applications)
end
```
### `C_UnitAuras.GetUnitAuraBySpellID(unit, spellID [, filter])`
Linear-searches `unit`'s aura array for the first populated slot
@@ -15565,11 +15637,14 @@ trigger lands:
| `triggerFamily` | `SpellFamilyName` of the triggering cast (e.g. `6` priest). |
| `triggerSchool` | School index the trigger must be (`0` physical … `2` fire, `5` shadow, `6` arcane), or `< 0` for any. Matching by family + school covers a whole class of spells at once — every rank, plus server-added ones — rather than a named ability. |
| `affectedFamily` | `SpellFamilyName` of the affected aura (e.g. `5` warlock, `11` shaman, `6` priest). |
| `affectedFamilyFlags` | A `SpellFamilyFlags` bitmask; the affected aura matches if it overlaps. Family + flag is rank-proof (covers every rank at once). |
| `affectedIcon` | `SpellIconID` the affected aura must have, or `0` to match any. |
| `affectedFamilyFlags` | A `SpellFamilyFlags` bitmask; the affected aura matches if it overlaps. Family plus flag is rank-proof, so it covers every rank at once. Pass `0` to select on the icon alone. |
| `affectedIcon` | `SpellIconID` the affected aura must have, or `0` to match any. Give this when the aura has no `SpellFamilyFlags`, which is common for a server's custom spells. An icon is shared by a spell's whole rank ladder, so it stays rank-proof. |
| `op` | `"refresh"` (reset to full duration), `"reduce"` (subtract `valueSeconds`, removing the aura if it would go non-positive), `"set"` (to `valueSeconds`), `"remove"`. |
| `valueSeconds` | Amount for `reduce`/`set`; ignored otherwise. |
Give at least one of `affectedFamilyFlags` and `affectedIcon`. A rule with
neither would match every aura of the class, so it is rejected.
The rule only fires for the aura **cast by the same unit** as the trigger
(these mechanics act on the caster's own DoT), and misses are excluded for
free (a missed trigger isn't in the packet's hit list).
@@ -15577,8 +15652,9 @@ free (a missed trigger isn't in the packet's hit list).
Rules keyed to an **exact trigger spellID** (rather than a family + school
category) are registered inside the DLL instead. `!!!ClassicAPI`'s built-in
Turtle mods — Conflagrate shaving 3s off the caster's Immolate, Molten Blast
refreshing the caster's Flame Shock — live in `src/turtle/DurationMods.cpp`,
and Carnage's roll-gated Rip/Rake refresh in `src/turtle/Carnage.cpp`.
refreshing the caster's Flame Shock, and Bestial Wrath stretching the pet's
Scent of Blood to its own length — live in `src/turtle/DurationMods.cpp`, and
Carnage's roll-gated Rip/Rake refresh in `src/turtle/Carnage.cpp`.
The one rule registered from Lua
([Util/AuraDurationModifiers.lua](../AddOns/!!!ClassicAPI/Util/AuraDurationModifiers.lua))
+2
View File
@@ -213,6 +213,8 @@ filenames in Lua 5.0 by behaviour.
|--------------|--------------------------|---------------------------------------------------|
| `0x006F3140` | `setobj` / TValue copy | 16-byte memcpy |
| `0x006F58B0` | `equalobj` | called by `lua_equal` |
| `0x006F6050` | `luaD_precall` | frame setup for a call; Lua closure: reads Proto (`+0xc` of the closure), `is_vararg` at Proto `+0x46` → `adjust_varargs`, `numparams` `+0x45`, `maxstacksize` `+0x47`; C closure: reserves 0x14 slots and calls the C function |
| `0x006F6200` | `adjust_varargs` | stock 5.0: pads missing fixed params with nil, then `luaH_new(L, nvar, 1)` + `luaH_setnum` per extra arg + sets field `"n"` and pushes the table. So EVERY call to a vararg Lua function allocates one table, whether the body uses `arg` or `...` — keep vararg helpers off per-frame paths |
| `0x006F65A0` | `luaD_call` | the actual call dispatcher |
| `0x006F6960` | `luaD_pcall` | protected call dispatcher |
| `0x006F7340` | `luaD_growstack` (or checkstack) | invoked when stack needs space |
+19
View File
@@ -1497,6 +1497,16 @@ enum Offsets {
UNIT_AURA_DEBUFF_COUNT = 16, // slot range 32..47 (harmful)
UNIT_AURA_TOTAL = 48,
UNIT_AURA_VISIBLE_MASK = 0x0E, // nibble mask used by the engine's visibility gate
// Per-slot flag nibble bits as the server writes them (tortoise-wow
// SpellAuraHolder::SetAuraFlag, verified): a positive aura gets HELPFUL
// (+ CANCELABLE unless SPELL_ATTR_CANT_CANCEL), a negative aura gets
// HARMFUL. This is the aura's real polarity. The slot range is only where
// the server preferred to seat it — once the 16 debuff slots are full it
// parks further debuffs in 0..31 (and sets UNIT_FLAG_AURAS_VISIBLE so the
// client renders them), so a slot number alone misreads those.
UNIT_AURA_FLAG_CANCELABLE = 0x01,
UNIT_AURA_FLAG_HELPFUL = 0x04,
UNIT_AURA_FLAG_HARMFUL = 0x08,
// PLAYER_FIELD_MOD_DAMAGE_DONE_POS/NEG — the player's per-school spell
// damage bonus. Offsets from the engine's own UpdateField name/index
@@ -1528,6 +1538,15 @@ enum Offsets {
// `+0x2B0`. Both `Script_UnitBuff` and `Script_UnitDebuff` call
// it to filter their aura iteration; we call it the same way.
FUN_SPELL_IS_VISIBLE_AURA = 0x00519860,
// The TOOLTIP variant of the gate: what `Script_GameTooltip_SetUnitBuff`
// (0x00534AC0) / `SetUnitDebuff` (0x00534E30) apply per slot while
// turning a Lua index into a slot. `__fastcall(spellRecord) -> bool`:
// Attributes & 0x80 (HIDDEN_CLIENTSIDE) clear, AttributesEx & 0x10000000
// (NO_AURA_ICON) clear, and no EffectApplyAuraName in {44, 45, 151}
// (the tracking auras). Verified by decompile; differs from the UnitBuff
// gate above, so an index into one space is not an index into the other
// — `GameTooltip:SetUnitAura` counts with THIS one when it translates.
FUN_GAMETOOLTIP_AURA_VISIBLE = 0x00534DF0,
// Player-only aura timing tables. Unlike UNIT_FIELD_AURA (which is
// populated for any unit but has no timing info), these are the
+136 -15
View File
@@ -21,7 +21,9 @@
// tokens separated by `|` and/or whitespace, each optionally negated with a
// leading `!`. Whole-token matching, so `RAID_PLAYER_DISPELLABLE` is not
// mistaken for `PLAYER` and `!PLAYER` is a negation rather than a match.
// Honored: `HELPFUL` / `HARMFUL` (descriptor slot ranges), `PLAYER` /
// Honored: `HELPFUL` / `HARMFUL` (the aura's flag-nibble polarity — see
// Data.h; NOT the slot range, which misreads debuffs spilled into buff
// slots), `PLAYER` /
// `!PLAYER` (caster == / != the local player, from the Aura::Source cache),
// `DISPELLABLE` / `!DISPELLABLE` (dispel type is / isn't one a
// dispel/purge/steal can remove — Spell.dbc Dispel ∈ Magic/Curse/Disease/
@@ -352,22 +354,135 @@ int __fastcall Script_GetPlayerAuraBySpellID(void *L) {
return 1;
}
// Iterates one slot range and pushes AuraData tables into `outer` at
// sequential keys starting from `nextKey`. Updates `nextKey` so a
// follow-up call can append to the same outer table.
// C_UnitAuras.GetAuraSlots(unit [, filter [, maxSlots [, continuationToken]]])
// -> continuationToken, slot1, slot2, ...
//
// Enumerates the opaque slot ids of every aura on `unit` matching `filter`, in
// the order the by-index getters visit them, `maxSlots` at a time (nil / 0 =
// all). The first return is the token to pass back for the next batch, or nil
// when this batch reached the end — retail's batching contract, and what makes
// AuraUtil.ForEachAura linear: one enumeration per batch plus a direct by-slot
// fetch per aura, instead of a fresh from-slot-0 walk per index. The token is
// the 1-based ordinal to resume at; opaque to callers. Encoding of the slot ids
// is documented at `Data::OPAQUE_STRIDE`.
int __fastcall Script_GetAuraSlots(void *L) {
const char *unitToken = ArgUnit(L, 1);
const char *filterStr = ArgOptString(L, 2);
const int maxSlots = ArgInt(L, 3);
const int token = ArgInt(L, 4);
// ClassicAPI extension: a table as the 5th argument selects FILL mode —
// the slot ids are written into it (t[1..n], stale tail cleared, t.n = n)
// and the call returns `continuationToken, n` instead of the vararg list.
// Lua 5.0 builds an `arg` table for every vararg CALL, so a Lua helper
// that receives the retail multi-return allocates one table per batch;
// filling the caller's table here is what keeps a per-frame scan
// allocation-free (AuraUtil and pfUI's ScanAuraSlots use this form).
const bool fill = Game::Lua::Type(L, 5) == Game::Lua::TYPE_TABLE;
int slots[Data::OPAQUE_SLOTS_MAX];
int total = 0;
if (unitToken != nullptr) {
const uint8_t *unit = ResolveUnit(unitToken);
const uint64_t guid = (unit == nullptr) ? GuidForOutOfRange(unitToken) : 0;
const ParsedFilter pf = ParseFilters(filterStr);
total = Data::CollectSlots(unit, guid, RangeFilter(pf), pf.ToMatch(), slots,
Data::OPAQUE_SLOTS_MAX);
}
int start = (token > 0) ? token - 1 : 0;
if (start > total)
start = total;
int n = total - start;
if (maxSlots > 0 && n > maxSlots)
n = maxSlots;
const bool more = start + n < total;
if (fill) {
for (int i = 0; i < n; ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::PushNumber(L, static_cast<double>(slots[start + i]));
Game::Lua::RawSet(L, 5);
}
// Clear what a previous, longer fill left past n. The array is dense
// by construction, so stop at the first hole.
for (int k = n + 1;; ++k) {
Game::Lua::PushNumber(L, static_cast<double>(k));
Game::Lua::RawGet(L, 5);
const bool hole = Game::Lua::Type(L, -1) == Game::Lua::TYPE_NIL;
Game::Lua::SetTop(L, -2);
if (hole)
break;
Game::Lua::PushNumber(L, static_cast<double>(k));
Game::Lua::PushNil(L);
Game::Lua::RawSet(L, 5);
}
Game::Lua::PushString(L, "n");
Game::Lua::PushNumber(L, static_cast<double>(n));
Game::Lua::RawSet(L, 5);
Game::Lua::SetTop(L, 0);
if (more)
Game::Lua::PushNumber(L, static_cast<double>(start + n + 1));
else
Game::Lua::PushNil(L);
Game::Lua::PushNumber(L, static_cast<double>(n));
return 2;
}
// Vararg form. Everything below only pushes, so the args can go; a full
// batch is up to OPAQUE_SLOTS_MAX + 1 values, past Lua's guaranteed C-call
// headroom.
Game::Lua::SetTop(L, 0);
if (Game::Lua::CheckStack(L, n + 1) == 0) {
Game::Lua::PushNil(L);
return 1;
}
if (more)
Game::Lua::PushNumber(L, static_cast<double>(start + n + 1));
else
Game::Lua::PushNil(L);
for (int i = 0; i < n; ++i)
Game::Lua::PushNumber(L, static_cast<double>(slots[start + i]));
return 1 + n;
}
// Shared body of GetAuraDataBySlot (table) / UnitAuraBySlot (positional):
// pushes the aura an opaque slot id from GetAuraSlots names, or a single nil
// for an id that no longer names one.
int PushAuraBySlot(void *L, Data::Emit emit) {
const char *unitToken = ArgUnit(L, 1);
if (unitToken == nullptr || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::PushNil(L);
return 1;
}
const int slot = ArgInt(L, 2);
const uint8_t *unit = ResolveUnit(unitToken);
const uint64_t guid = (unit == nullptr) ? GuidForOutOfRange(unitToken) : 0;
if (Data::PushBySlot(L, unit, guid, slot, emit))
return (emit == Data::Emit::Positional) ? 15 : 1;
Game::Lua::PushNil(L);
return 1;
}
// C_UnitAuras.GetAuraDataBySlot(unit, slot) -> AuraData | nil
int __fastcall Script_GetAuraDataBySlot(void *L) {
return PushAuraBySlot(L, Data::Emit::Table);
}
// C_UnitAuras.UnitAuraBySlot(unit, slot) -> the 15 positional UnitAura values | nil
// Zero-allocation sibling of GetAuraDataBySlot, namespaced under C_UnitAuras
// like C_UnitAuras.UnitAura. What AuraUtil's non-packed iteration reads.
int __fastcall Script_UnitAuraBySlot(void *L) {
return PushAuraBySlot(L, Data::Emit::Positional);
}
// Iterates the auras of one polarity (in `SlotInFilterOrder`, the same order
// the by-index getters use) and pushes AuraData tables into `outer` at
// sequential keys starting from `nextKey`. Updates `nextKey` so a follow-up
// call can append to the same outer table.
void AppendRangeToArray(void *L, const uint8_t *unit, int outerIdx,
Data::Filter filter, int &nextKey, Data::Match match) {
const int start = (filter == Data::Filter::Harmful)
? Offsets::UNIT_AURA_BUFF_COUNT
: 0;
const int end = (filter == Data::Filter::Harmful)
? Offsets::UNIT_AURA_TOTAL
: Offsets::UNIT_AURA_BUFF_COUNT;
for (int slot = start; slot < end; ++slot) {
if (!Data::IsSlotPopulated(unit, slot))
continue;
if (!Data::MatchesAura(match, Data::IsPlayerCast(unit, slot),
Data::ReadSpellID(unit, slot)))
for (int i = 0; i < Offsets::UNIT_AURA_TOTAL; ++i) {
const int slot = Data::SlotInFilterOrder(filter, i);
if (!Data::SlotMatchesFilter(unit, slot, filter, match))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
Data::Push(L, unit, slot);
@@ -497,6 +612,12 @@ static void RegisterLuaFunctions() {
&Script_UnitBuff);
Game::Lua::RegisterTableFunction("C_UnitAuras", "UnitDebuff",
&Script_UnitDebuff);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetAuraSlots",
&Script_GetAuraSlots);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetAuraDataBySlot",
&Script_GetAuraDataBySlot);
Game::Lua::RegisterTableFunction("C_UnitAuras", "UnitAuraBySlot",
&Script_UnitAuraBySlot);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetUnitAuraBySpellID",
&Script_GetUnitAuraBySpellID);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetPlayerAuraBySpellID",
+322 -147
View File
@@ -272,6 +272,49 @@ bool IsSlotPopulated(const uint8_t *unit, int slot) {
return IsVisible(SpellRecord(spellID));
}
bool IsSlotHarmful(const uint8_t *unit, int slot) {
if (slot < 0 || slot >= Offsets::UNIT_AURA_TOTAL)
return false;
auto *desc = Descriptor(unit);
if (desc == nullptr)
return false;
const uint8_t byte = *(desc + Offsets::OFF_UNIT_FIELD_AURAFLAGS + slot / 2);
const uint8_t nibble = (byte >> ((slot & 1) * 4)) & 0xF;
return (nibble & Offsets::UNIT_AURA_FLAG_HARMFUL) != 0;
}
bool IsSlotTooltipVisible(const uint8_t *unit, int slot) {
if (slot < 0 || slot >= Offsets::UNIT_AURA_TOTAL)
return false;
auto *desc = Descriptor(unit);
if (desc == nullptr)
return false;
const int spellID = *reinterpret_cast<const int *>(
desc + Offsets::OFF_UNIT_FIELD_AURA + slot * 4);
if (spellID <= 0) // the engine's own `0 < id` test (signed)
return false;
const uint8_t byte = *(desc + Offsets::OFF_UNIT_FIELD_AURAFLAGS + slot / 2);
const uint8_t nibble = (byte >> ((slot & 1) * 4)) & 0xF;
if ((nibble & Offsets::UNIT_AURA_VISIBLE_MASK) == 0)
return false;
const uint8_t *rec = SpellRecord(static_cast<uint32_t>(spellID));
if (rec == nullptr)
return false;
using Fn = char(__fastcall *)(const uint8_t *);
auto fn = reinterpret_cast<Fn>(
static_cast<uintptr_t>(Offsets::FUN_GAMETOOLTIP_AURA_VISIBLE));
return fn(rec) != 0;
}
int SlotInFilterOrder(Filter filter, int i) {
// Harmful: 32..47 first, then 0..31 (where the server parks debuffs once
// the 16 are full). Helpful: 0..31 then 32..47 — positive auras never
// spill downward in practice, so that tail is a no-op safety net.
if (filter == Filter::Harmful)
return (i + Offsets::UNIT_AURA_BUFF_COUNT) % Offsets::UNIT_AURA_TOTAL;
return i;
}
bool IsPlayerCast(const uint8_t *unit, int slot) {
const uint32_t spellID = ReadSpellID(unit, slot);
if (spellID == 0)
@@ -327,6 +370,30 @@ bool MatchesAura(const Match &match, bool isPlayerCast, uint32_t spellID) {
CcMatches(match.cc, spellID);
}
// `MatchesAura` for a descriptor slot the caller has ALREADY established is
// populated. The caster lookup is the expensive predicate (a scan of the
// Aura::Source cache per slot), and `CasterMatches` ignores its answer when the
// match has no caster restriction — so skip the lookup entirely in that case.
static bool PopulatedSlotMatches(const uint8_t *unit, int slot,
const Match &match) {
const bool castByPlayer =
match.caster != CasterMode::Any && IsPlayerCast(unit, slot);
return MatchesAura(match, castByPlayer, ReadSpellID(unit, slot));
}
bool SlotMatches(const uint8_t *unit, int slot, const Match &match) {
return IsSlotPopulated(unit, slot) && PopulatedSlotMatches(unit, slot, match);
}
bool SlotMatchesFilter(const uint8_t *unit, int slot, Filter filter,
const Match &match) {
if (!IsSlotPopulated(unit, slot))
return false;
if (IsSlotHarmful(unit, slot) != (filter == Filter::Harmful))
return false;
return PopulatedSlotMatches(unit, slot, match);
}
// Group-array analog of IsPlayerCast: the member has no descriptor, so there is
// no slot to attribute by and the cache is consulted by (guid, spellID) alone.
// A miss counts as "not the player" (same as IsPlayerCast).
@@ -340,17 +407,10 @@ int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
Match match) {
if (unit == nullptr || oneBasedIndex < 1)
return -1;
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 matches = 0;
for (int slot = start; slot < end; ++slot) {
if (!IsSlotPopulated(unit, slot))
continue;
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
for (int i = 0; i < Offsets::UNIT_AURA_TOTAL; ++i) {
const int slot = SlotInFilterOrder(filter, i);
if (!SlotMatchesFilter(unit, slot, filter, match))
continue;
if (++matches == oneBasedIndex)
return slot;
@@ -362,18 +422,16 @@ int FindSlotBySpellID(const uint8_t *unit, uint32_t spellID,
const Filter *filter, Match match) {
if (unit == nullptr || spellID == 0)
return -1;
const int start = (filter != nullptr && *filter == Filter::Harmful)
? Offsets::UNIT_AURA_BUFF_COUNT
: 0;
const int end = (filter != nullptr && *filter == Filter::Helpful)
? Offsets::UNIT_AURA_BUFF_COUNT
: Offsets::UNIT_AURA_TOTAL;
for (int slot = start; slot < end; ++slot) {
for (int i = 0; i < Offsets::UNIT_AURA_TOTAL; ++i) {
const int slot = (filter != nullptr) ? SlotInFilterOrder(*filter, i) : i;
if (!IsSlotPopulated(unit, slot))
continue;
if (ReadSpellID(unit, slot) != spellID)
continue;
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
if (filter != nullptr &&
IsSlotHarmful(unit, slot) != (*filter == Filter::Harmful))
continue;
if (!PopulatedSlotMatches(unit, slot, match))
continue;
return slot;
}
@@ -384,19 +442,17 @@ int FindSlotBySpellName(const uint8_t *unit, const char *spellName,
const Filter *filter, Match match) {
if (unit == nullptr || spellName == nullptr || *spellName == '\0')
return -1;
const int start = (filter != nullptr && *filter == Filter::Harmful)
? Offsets::UNIT_AURA_BUFF_COUNT
: 0;
const int end = (filter != nullptr && *filter == Filter::Helpful)
? Offsets::UNIT_AURA_BUFF_COUNT
: Offsets::UNIT_AURA_TOTAL;
for (int slot = start; slot < end; ++slot) {
for (int i = 0; i < Offsets::UNIT_AURA_TOTAL; ++i) {
const int slot = (filter != nullptr) ? SlotInFilterOrder(*filter, i) : i;
if (!IsSlotPopulated(unit, slot))
continue;
const char *name = LocalizedSpellName(SpellRecord(ReadSpellID(unit, slot)));
if (name == nullptr || std::strcmp(name, spellName) != 0)
continue;
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
if (filter != nullptr &&
IsSlotHarmful(unit, slot) != (*filter == Filter::Harmful))
continue;
if (!PopulatedSlotMatches(unit, slot, match))
continue;
return slot;
}
@@ -657,7 +713,7 @@ static Attribution CachedAttribution(const Aura::Source::CachedAura &c) {
void Push(void *L, const uint8_t *unit, int slot, Emit emit) {
const uint32_t spellID = ReadSpellID(unit, slot);
const bool isHelpful = slot < Offsets::UNIT_AURA_BUFF_COUNT;
const bool isHelpful = !IsSlotHarmful(unit, slot); // nibble, not slot range
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
PushEnriched(L, UnitGuid(unit), spellID, isHelpful, ReadStacks(unit, slot),
unitLevel, unit != nullptr && unit == LocalPlayer(), slot,
@@ -722,36 +778,16 @@ bool DescriptorHasVisibleAura(const uint8_t *unit) {
return false;
}
// Counts populated descriptor slots matching `filter` (and `caster`).
int CountSlots(const uint8_t *unit, Filter filter, Match match) {
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 (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(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`/`caster`: the spell must be a user-visible aura, 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.
// Whether cache entry `c` may surface as a fallback for `unit` under `filter`:
// the spell must be a user-visible aura and must not already be in a populated
// descriptor slot (no double-listing the same live aura). The caller's
// caster/dispel/CC predicates are applied separately by `CachedMatches`.
// 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, Match match) {
Filter filter) {
if (!IsVisible(SpellRecord(c.spellId)))
return false; // hidden/internal aura (e.g. SPELL_ATTR_HIDDEN_CLIENTSIDE):
// the descriptor path drops these via IsSlotPopulated, so
@@ -759,37 +795,58 @@ bool FallbackEligible(const uint8_t *unit, const Aura::Source::CachedAura &c,
// they leak through as tooltip-less phantom buffs.
if (FindSlotBySpellID(unit, c.spellId, &filter, Match{}) >= 0)
return false; // still in the descriptor — returned via the slot path
if (!MatchesAura(match, c.casterGuid == Unit::Identity::PlayerGuid(), c.spellId))
return false;
return true;
}
// Applies `match` to a cache entry. The entry carries its caster, so the caster
// test is a plain GUID compare — no scan, nothing to defer.
bool CachedMatches(const Aura::Source::CachedAura &c, const Match &match) {
return MatchesAura(match, c.casterGuid == Unit::Identity::PlayerGuid(),
c.spellId);
}
// The eligible cache-fallback entries for `unit` under `filter`, in `Enumerate`
// order, unfiltered by any `Match` (callers apply `CachedMatches`). Applies the
// fallback gates: reconcile the cache against the descriptor, then yield nothing
// while the descriptor shows any visible aura (it is authoritative then — see
// DescriptorHasVisibleAura). `buf` must hold kFallbackMax entries. Returns the
// count. An entry's ordinal here is what an opaque cache slot id encodes, which
// is why the sequence must not depend on the caller's match.
int EligibleFallbacks(const uint8_t *unit, Filter filter,
Aura::Source::CachedAura *buf) {
if (unit == nullptr)
return 0;
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
if (DescriptorHasVisibleAura(unit))
return 0; // descriptor authoritative — see DescriptorHasVisibleAura
const uint64_t guid = UnitGuid(unit);
if (guid == 0)
return 0;
Aura::Source::CachedAura raw[kFallbackMax];
const int n = Aura::Source::Enumerate(
guid, filter == Filter::Harmful, raw, kFallbackMax);
int m = 0;
for (int i = 0; i < n; ++i) {
if (FallbackEligible(unit, raw[i], filter))
buf[m++] = raw[i];
}
return m;
}
} // namespace
bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
Filter filter, Match match, Emit emit) {
if (unit == nullptr)
return false;
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
if (DescriptorHasVisibleAura(unit))
return false; // descriptor authoritative — see DescriptorHasVisibleAura
// 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, match);
if (target < 1)
return false;
const uint64_t guid = UnitGuid(unit);
if (guid == 0)
return false;
// The fallback yields only while the descriptor shows no visible aura, so
// the descriptor held none of the caller's matches: the cache supplies index
// `oneBasedIndex` (1-based) of the entries passing `match` directly.
Aura::Source::CachedAura buf[kFallbackMax];
const int n = Aura::Source::Enumerate(
guid, filter == Filter::Harmful, buf, kFallbackMax);
const int n = EligibleFallbacks(unit, filter, buf);
int seen = 0;
for (int i = 0; i < n; ++i) {
if (!FallbackEligible(unit, buf[i], filter, match))
if (!CachedMatches(buf[i], match))
continue;
if (++seen == target) {
if (++seen == oneBasedIndex) {
PushFromCache(L, unit, buf[i], filter == Filter::Helpful, emit);
return true;
}
@@ -799,19 +856,10 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
Match match, int outerIdx, int &nextKey) {
if (unit == nullptr)
return;
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
if (DescriptorHasVisibleAura(unit))
return; // descriptor authoritative — see DescriptorHasVisibleAura
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);
const int n = EligibleFallbacks(unit, filter, buf);
for (int i = 0; i < n; ++i) {
if (!FallbackEligible(unit, buf[i], filter, match))
if (!CachedMatches(buf[i], match))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
PushFromCache(L, unit, buf[i], filter == Filter::Helpful);
@@ -823,8 +871,10 @@ void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
namespace {
// Absolute slot range [start, end) for a filter, matching the descriptor
// convention (helpful 0..31, harmful 32..47).
// Absolute slot range [start, end) for a filter in the GROUP ARRAY (helpful
// 0..31, harmful 32..47). The array carries spell IDs only — no flag nibble —
// so the seating range is the only polarity the out-of-range path has. (The
// descriptor path reads the nibble instead; see SlotInFilterOrder.)
void GroupRange(Filter filter, int &start, int &end) {
start = (filter == Filter::Harmful) ? Offsets::UNIT_AURA_BUFF_COUNT : 0;
end = (filter == Filter::Harmful) ? Offsets::UNIT_AURA_TOTAL
@@ -838,21 +888,27 @@ uint32_t GroupSpellID(const uint16_t *arr, int slot) {
return arr[slot];
}
// Whether the group-array slot holds a surfacable aura for this query. Mirrors
// the engine's per-slot test in Script_UnitBuff/UnitDebuff — a non-zero spell
// ID with a real Spell.dbc record that passes the visibility predicate (the
// server only transmits *applied* auras, so there is no flag nibble to check).
// `caster` additionally requires the `Aura::Source` cache to attribute the
// cast to the local player (a miss excludes it — we can't confirm the caster
// from the group array alone).
bool GroupSlotEligible(uint64_t guid, const uint16_t *arr, int slot,
Match match) {
// Whether the group-array slot holds a surfacable aura. Mirrors the engine's
// per-slot test in Script_UnitBuff/UnitDebuff — a non-zero spell ID with a real
// Spell.dbc record that passes the visibility predicate (the server only
// transmits *applied* auras, so there is no flag nibble to check).
bool GroupSlotVisible(const uint16_t *arr, int slot) {
const uint32_t id = GroupSpellID(arr, slot);
if (id == 0 || !IsVisible(SpellRecord(id)))
return id != 0 && IsVisible(SpellRecord(id));
}
// `GroupSlotVisible` plus the caller's `match` predicates. The caster answer
// (`GroupIsPlayerCast` — a cache scan; a miss counts as not-player, since the
// array alone can't confirm the caster) is computed only when `match` restricts
// on caster.
bool GroupSlotMatches(uint64_t guid, const uint16_t *arr, int slot,
const Match &match) {
if (!GroupSlotVisible(arr, slot))
return false;
if (!MatchesAura(match, GroupIsPlayerCast(guid, id), id))
return false;
return true;
const uint32_t id = GroupSpellID(arr, slot);
const bool castByPlayer =
match.caster != CasterMode::Any && GroupIsPlayerCast(guid, id);
return MatchesAura(match, castByPlayer, id);
}
// The groupmate's level, for Spell.dbc duration scaling (0 = skip). Cheap
@@ -872,28 +928,52 @@ int GroupMemberLevel(uint64_t guid) {
// still sit in the cache from when we observed them in range.
bool GroupArrayHasVisibleAura(const uint16_t *arr) {
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
const uint32_t id = GroupSpellID(arr, slot);
if (id != 0 && IsVisible(SpellRecord(id)))
if (GroupSlotVisible(arr, slot))
return true;
}
return false;
}
// Whether cache entry `c` may supplement the (empty) group array: a visible
// aura, not already present in the array, and — when player-filtered — cast by
// us. See GroupArrayHasVisibleAura for when this path runs.
bool GroupFallbackEligible(const uint16_t *arr, const Aura::Source::CachedAura &c,
Match match) {
// aura not already present in the array. The caller's `match` is applied
// separately by `CachedMatches`. See EligibleGroupFallbacks for when this path
// runs.
bool GroupFallbackEligible(const uint16_t *arr,
const Aura::Source::CachedAura &c) {
if (!IsVisible(SpellRecord(c.spellId)))
return false;
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot)
if (GroupSpellID(arr, slot) == c.spellId)
return false;
if (!MatchesAura(match, c.casterGuid == Unit::Identity::PlayerGuid(), c.spellId))
return false;
return true;
}
// The eligible group cache-fallback entries for out-of-range member `guid`, in
// `Enumerate` order, unfiltered by any `Match` (callers apply `CachedMatches`).
// Yields nothing while the group array exposes ANY visible aura: the server's
// out-of-range aura data is then present and authoritative, mirroring how the
// descriptor path treats a populated descriptor (DescriptorHasVisibleAura).
// Only an entirely empty array falls back to the cache — the server resends
// out-of-range stats as deltas only, so a member seen in range and then moved
// away again leaves an empty party-stats aura array (the array is cleared when
// they become visible and never re-sent), yet their auras still sit in the
// cache from when we observed them in range. `buf` must hold kFallbackMax
// entries. An entry's ordinal here is what an opaque group-cache slot id
// encodes, which is why the sequence must not depend on the caller's match.
int EligibleGroupFallbacks(const uint16_t *arr, uint64_t guid, bool harmful,
Aura::Source::CachedAura *buf) {
if (arr == nullptr || GroupArrayHasVisibleAura(arr))
return 0;
Aura::Source::CachedAura raw[kFallbackMax];
const int n = Aura::Source::Enumerate(guid, harmful, raw, kFallbackMax);
int m = 0;
for (int i = 0; i < n; ++i) {
if (GroupFallbackEligible(arr, raw[i]))
buf[m++] = raw[i];
}
return m;
}
// Nth (1-based) cache-fallback aura of `filter`'s kind for an out-of-range
// member whose group array is empty. Pushes the AuraData table on a hit.
bool PushNthGroupCacheFallback(void *L, uint64_t guid, const uint16_t *arr,
@@ -901,10 +981,10 @@ bool PushNthGroupCacheFallback(void *L, uint64_t guid, const uint16_t *arr,
Emit emit = Emit::Table) {
Aura::Source::CachedAura buf[kFallbackMax];
const int n =
Aura::Source::Enumerate(guid, filter == Filter::Harmful, buf, kFallbackMax);
EligibleGroupFallbacks(arr, guid, filter == Filter::Harmful, buf);
int seen = 0;
for (int i = 0; i < n; ++i) {
if (!GroupFallbackEligible(arr, buf[i], match))
if (!CachedMatches(buf[i], match))
continue;
if (++seen == oneBasedIndex) {
const Attribution a = CachedAttribution(buf[i]);
@@ -922,10 +1002,10 @@ void AppendGroupCacheFallbacks(void *L, uint64_t guid, const uint16_t *arr,
int &nextKey) {
Aura::Source::CachedAura buf[kFallbackMax];
const int n =
Aura::Source::Enumerate(guid, filter == Filter::Harmful, buf, kFallbackMax);
EligibleGroupFallbacks(arr, guid, filter == Filter::Harmful, buf);
const int level = GroupMemberLevel(guid);
for (int i = 0; i < n; ++i) {
if (!GroupFallbackEligible(arr, buf[i], match))
if (!CachedMatches(buf[i], match))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
const Attribution a = CachedAttribution(buf[i]);
@@ -941,7 +1021,7 @@ bool PushGroupCacheFallbackMatch(void *L, uint64_t guid, const uint16_t *arr,
bool harmful, uint32_t spellID,
const char *spellName, Match match) {
Aura::Source::CachedAura buf[kFallbackMax];
const int n = Aura::Source::Enumerate(guid, harmful, buf, kFallbackMax);
const int n = EligibleGroupFallbacks(arr, guid, harmful, buf);
for (int i = 0; i < n; ++i) {
if (spellID != 0 && buf[i].spellId != spellID)
continue;
@@ -950,7 +1030,7 @@ bool PushGroupCacheFallbackMatch(void *L, uint64_t guid, const uint16_t *arr,
if (nm == nullptr || std::strcmp(nm, spellName) != 0)
continue;
}
if (!GroupFallbackEligible(arr, buf[i], match))
if (!CachedMatches(buf[i], match))
continue;
const Attribution a = CachedAttribution(buf[i]);
PushEnriched(L, guid, buf[i].spellId, !harmful, 1, GroupMemberLevel(guid),
@@ -974,7 +1054,7 @@ bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
GroupRange(filter, start, end);
int matches = 0;
for (int slot = start; slot < end; ++slot) {
if (!GroupSlotEligible(guid, arr, slot, match))
if (!GroupSlotMatches(guid, arr, slot, match))
continue;
if (++matches == oneBasedIndex) {
PushEnriched(L, guid, GroupSpellID(arr, slot),
@@ -985,10 +1065,8 @@ bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
}
}
// Empty array (server delta-resend gap): supplement from the cache.
if (!GroupArrayHasVisibleAura(arr))
return PushNthGroupCacheFallback(L, guid, arr, oneBasedIndex, filter,
match, emit);
return false;
return PushNthGroupCacheFallback(L, guid, arr, oneBasedIndex, filter, match,
emit);
}
bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
@@ -1008,23 +1086,20 @@ bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
for (int slot = start; slot < end; ++slot) {
if (GroupSpellID(arr, slot) != spellID)
continue;
if (!GroupSlotEligible(guid, arr, slot, match))
if (!GroupSlotMatches(guid, arr, slot, match))
continue;
PushEnriched(L, guid, spellID, slot < Offsets::UNIT_AURA_BUFF_COUNT, 1,
GroupMemberLevel(guid), false, Aura::Source::SLOT_UNBOUND);
return true;
}
if (!GroupArrayHasVisibleAura(arr)) {
const bool both = (filter == nullptr);
if ((both || *filter == Filter::Helpful) &&
PushGroupCacheFallbackMatch(L, guid, arr, false, spellID, nullptr,
match))
return true;
if ((both || *filter == Filter::Harmful) &&
PushGroupCacheFallbackMatch(L, guid, arr, true, spellID, nullptr,
match))
return true;
}
// Empty array (server delta-resend gap): supplement from the cache.
const bool both = (filter == nullptr);
if ((both || *filter == Filter::Helpful) &&
PushGroupCacheFallbackMatch(L, guid, arr, false, spellID, nullptr, match))
return true;
if ((both || *filter == Filter::Harmful) &&
PushGroupCacheFallbackMatch(L, guid, arr, true, spellID, nullptr, match))
return true;
return false;
}
@@ -1049,23 +1124,20 @@ bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
const char *name = LocalizedSpellName(SpellRecord(id));
if (name == nullptr || std::strcmp(name, spellName) != 0)
continue;
if (!GroupSlotEligible(guid, arr, slot, match))
if (!GroupSlotMatches(guid, arr, slot, match))
continue;
PushEnriched(L, guid, id, slot < Offsets::UNIT_AURA_BUFF_COUNT, 1,
GroupMemberLevel(guid), false, Aura::Source::SLOT_UNBOUND);
return true;
}
if (!GroupArrayHasVisibleAura(arr)) {
const bool both = (filter == nullptr);
if ((both || *filter == Filter::Helpful) &&
PushGroupCacheFallbackMatch(L, guid, arr, false, 0, spellName,
match))
return true;
if ((both || *filter == Filter::Harmful) &&
PushGroupCacheFallbackMatch(L, guid, arr, true, 0, spellName,
match))
return true;
}
// Empty array (server delta-resend gap): supplement from the cache.
const bool both = (filter == nullptr);
if ((both || *filter == Filter::Helpful) &&
PushGroupCacheFallbackMatch(L, guid, arr, false, 0, spellName, match))
return true;
if ((both || *filter == Filter::Harmful) &&
PushGroupCacheFallbackMatch(L, guid, arr, true, 0, spellName, match))
return true;
return false;
}
@@ -1081,7 +1153,7 @@ void AppendGroupAuras(void *L, uint64_t guid, Filter filter, Match match,
GroupRange(filter, start, end);
const int level = GroupMemberLevel(guid);
for (int slot = start; slot < end; ++slot) {
if (!GroupSlotEligible(guid, arr, slot, match))
if (!GroupSlotMatches(guid, arr, slot, match))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
PushEnriched(L, guid, GroupSpellID(arr, slot),
@@ -1090,9 +1162,112 @@ void AppendGroupAuras(void *L, uint64_t guid, Filter filter, Match match,
Game::Lua::SetTable(L, outerIdx);
}
// Empty array (server delta-resend gap): supplement from the cache.
if (!GroupArrayHasVisibleAura(arr))
AppendGroupCacheFallbacks(L, guid, arr, filter, match, outerIdx,
nextKey);
AppendGroupCacheFallbacks(L, guid, arr, filter, match, outerIdx, nextKey);
}
// ── Opaque aura slots ──────────────────────────────────────────────────────
int CollectSlots(const uint8_t *unit, uint64_t guid, Filter filter, Match match,
int *out, int cap) {
int n = 0;
if (unit != nullptr) {
for (int i = 0; i < Offsets::UNIT_AURA_TOTAL && n < cap; ++i) {
const int slot = SlotInFilterOrder(filter, i);
if (SlotMatchesFilter(unit, slot, filter, match))
out[n++] = slot;
}
// The cache fallback yields only while the descriptor shows no visible
// aura, so these never interleave with live slots — they follow them,
// exactly as the by-index getters order them.
Aura::Source::CachedAura buf[kFallbackMax];
const int m = EligibleFallbacks(unit, filter, buf);
const int base = (filter == Filter::Harmful) ? OPAQUE_CACHE_HARMFUL
: OPAQUE_CACHE_HELPFUL;
for (int k = 0; k < m && n < cap; ++k) {
if (CachedMatches(buf[k], match))
out[n++] = base + k;
}
return n;
}
// No live CGUnit — the out-of-range group path.
if (guid == 0)
return 0;
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
if (arr == nullptr)
return 0;
Aura::Source::ObserveGroupAuras(guid, arr);
int start, end;
GroupRange(filter, start, end);
for (int slot = start; slot < end && n < cap; ++slot) {
if (GroupSlotMatches(guid, arr, slot, match))
out[n++] = OPAQUE_GROUP + slot;
}
Aura::Source::CachedAura buf[kFallbackMax];
const int m = EligibleGroupFallbacks(arr, guid, filter == Filter::Harmful, buf);
const int base = (filter == Filter::Harmful) ? OPAQUE_GROUP_CACHE_HARMFUL
: OPAQUE_GROUP_CACHE_HELPFUL;
for (int k = 0; k < m && n < cap; ++k) {
if (CachedMatches(buf[k], match))
out[n++] = base + k;
}
return n;
}
bool PushBySlot(void *L, const uint8_t *unit, uint64_t guid, int slot, Emit emit) {
if (slot < 0)
return false;
const int kind = slot / OPAQUE_STRIDE;
const int k = slot % OPAQUE_STRIDE;
switch (kind) {
case 0: // live descriptor slot
if (unit == nullptr || !IsSlotPopulated(unit, k))
return false;
Push(L, unit, k, emit);
return true;
case 1: // helpful cache fallback
case 2: { // harmful cache fallback
if (unit == nullptr || k >= kFallbackMax)
return false;
const Filter filter = (kind == 2) ? Filter::Harmful : Filter::Helpful;
Aura::Source::CachedAura buf[kFallbackMax];
if (k >= EligibleFallbacks(unit, filter, buf))
return false;
PushFromCache(L, unit, buf[k], filter == Filter::Helpful, emit);
return true;
}
case 3: { // out-of-range group-array slot
if (guid == 0 || k >= Offsets::UNIT_AURA_TOTAL)
return false;
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
if (arr == nullptr)
return false;
Aura::Source::ObserveGroupAuras(guid, arr);
if (!GroupSlotVisible(arr, k))
return false;
PushEnriched(L, guid, GroupSpellID(arr, k),
k < Offsets::UNIT_AURA_BUFF_COUNT, 1, GroupMemberLevel(guid),
false, Aura::Source::SLOT_UNBOUND, /*known*/ nullptr, emit);
return true;
}
case 4: // helpful group cache fallback
case 5: { // harmful group cache fallback
if (guid == 0 || k >= kFallbackMax)
return false;
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
if (arr == nullptr)
return false;
const bool harmful = (kind == 5);
Aura::Source::CachedAura buf[kFallbackMax];
if (k >= EligibleGroupFallbacks(arr, guid, harmful, buf))
return false;
const Attribution a = CachedAttribution(buf[k]);
PushEnriched(L, guid, buf[k].spellId, !harmful, 1, GroupMemberLevel(guid),
false, buf[k].slot, &a, emit);
return true;
}
default:
return false;
}
}
} // namespace Aura::Data
+93 -14
View File
@@ -10,6 +10,8 @@
#pragma once
#include "Offsets.h"
#include <cstdint>
// Shared aura-table primitives consumed by `C_UnitAuras.*` (and any
@@ -18,19 +20,27 @@
// `unit + OFF_CGUNIT_OBJECT_FIELDS`; the layout is documented in
// `Offsets.h` under `OFF_UNIT_FIELD_AURA*`.
//
// Slot indexing convention used here matches the engine's:
// Slots are absolute 0..47. The server SEATS positive auras in 0..31 and
// negative ones in 32..47, but that is a preference, not a rule: once the 16
// debuff slots are full it parks further debuffs in 0..31 (and sets
// UNIT_FLAG_AURAS_VISIBLE so the client renders them). An aura's real polarity
// is the per-slot flag nibble (UNIT_AURA_FLAG_HELPFUL / _HARMFUL), which the
// server writes for every visible aura — that is what `Filter` selects on
// here. The engine's own UnitBuff/UnitDebuff split by slot range and so report
// a spilled debuff as a buff; that is a 1.12 UI limitation we deliberately do
// not mirror (retail's contract is the aura's actual polarity). A polarity
// walk visits its home range first, then the other range, so indices stay
// exactly what they were in the common no-spill case.
//
// slot 0..31 → buffs (helpful)
// slot 32..47 → debuffs (harmful)
//
// Callers that take a 1-based Lua index into "buffs" or "debuffs"
// translate to the absolute 0..47 slot before calling in.
// Callers that take a 1-based Lua index into "buffs" or "debuffs" translate
// to the absolute 0..47 slot before calling in.
namespace Aura::Data {
// Filter for slot iteration. The engine's `UnitBuff` / `UnitDebuff`
// pick one direction; modern `C_UnitAuras.GetAuraDataByIndex` defaults
// to helpful when no filter is specified.
// Filter for slot iteration: the aura's polarity per its flag nibble (see the
// file header). The engine's `UnitBuff` / `UnitDebuff` pick one direction;
// modern `C_UnitAuras.GetAuraDataByIndex` defaults to helpful when no filter
// is specified.
enum class Filter { Helpful, Harmful };
// How a resolved aura is emitted onto the Lua stack by the single-aura push
@@ -83,11 +93,32 @@ uint32_t ReadSpellID(const uint8_t *unit, int slot);
// to decide whether to surface an aura through Lua.
bool IsSlotPopulated(const uint8_t *unit, int slot);
// Finds the absolute slot of the `oneBasedIndex`-th populated aura
// matching `filter`. Returns -1 if no such aura. Walks
// 0..UNIT_AURA_BUFF_COUNT-1 for Helpful, UNIT_AURA_BUFF_COUNT..
// UNIT_AURA_TOTAL-1 for Harmful. When `playerOnly` is true, only auras
// the local player cast (per `IsPlayerCast`) are counted/returned.
// The aura's polarity from its flag nibble: true iff the slot carries
// UNIT_AURA_FLAG_HARMFUL. False for an empty slot or a null unit. This, not
// the slot range, is what `Filter` selects on (see the file header).
bool IsSlotHarmful(const uint8_t *unit, int slot);
// The engine's TOOLTIP visibility gate for a slot: occupied, visible nibble,
// and the spell passes `FUN_GAMETOOLTIP_AURA_VISIBLE` — exactly what
// `SetUnitBuff` / `SetUnitDebuff` count while turning an index into a slot.
// Differs from `IsSlotPopulated` (the UnitBuff gate), so use THIS to compute
// the index the engine's tooltip methods expect for a given slot.
bool IsSlotTooltipVisible(const uint8_t *unit, int slot);
// The i-th slot (0..UNIT_AURA_TOTAL-1) in the order a `filter` walk visits the
// descriptor: the polarity's home range first (helpful 0..31, harmful 32..47),
// then the other range, where spilled debuffs live. Every enumeration that
// hands out indices or slot lists uses this so they agree on order.
int SlotInFilterOrder(Filter filter, int i);
// `SlotMatches` plus the polarity test: populated, of `filter`'s polarity, and
// passing `match`.
bool SlotMatchesFilter(const uint8_t *unit, int slot, Filter filter,
const Match &match);
// Finds the absolute slot of the `oneBasedIndex`-th populated aura of
// `filter`'s polarity that passes `match`. Returns -1 if no such aura. Walks
// all 48 slots in `SlotInFilterOrder`.
int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
Match match = {});
@@ -124,6 +155,12 @@ bool IsDispellable(uint32_t spellID);
// per-aura "was cast by the local player" answer and its spell ID.
bool MatchesAura(const Match &match, bool isPlayerCast, uint32_t spellID);
// True iff descriptor `slot` on `unit` is populated (per `IsSlotPopulated`) AND
// passes `match`. The caster answer (`IsPlayerCast` — a scan of the
// `Aura::Source` cache, ~1000 entries in a raid) is computed ONLY when `match`
// restricts on caster, so the common filter-less walk never pays for it.
bool SlotMatches(const uint8_t *unit, int slot, const Match &match);
// Display stack count for the given slot (engine stores stacks-1,
// we add 1). Returns 0 if the unit is null.
int ReadStacks(const uint8_t *unit, int slot);
@@ -230,4 +267,46 @@ bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
void AppendGroupAuras(void *L, uint64_t guid, Filter filter, Match match,
int outerIdx, int &nextKey);
// ── Opaque aura slots (GetAuraSlots / GetAuraDataBySlot) ────────────────────
//
// The by-index getters re-walk the descriptor from slot 0 on every call, so a
// full scan through them is quadratic in the aura count. `CollectSlots`
// enumerates once into opaque slot ids and `PushBySlot` fetches one aura by id
// with no walk — the modern `C_UnitAuras.GetAuraSlots` / `GetAuraDataBySlot`
// batching contract. An id is valid for the frame it was enumerated in.
//
// Encoding (kind = id / OPAQUE_STRIDE, k = id % OPAQUE_STRIDE):
// kind 0 live descriptor slot k (0..UNIT_AURA_TOTAL-1)
// kind 1 k-th eligible helpful cache-fallback entry (OPAQUE_CACHE_HELPFUL)
// kind 2 k-th eligible harmful cache-fallback entry (OPAQUE_CACHE_HARMFUL)
// kind 3 out-of-range group-array slot k (0..47) (OPAQUE_GROUP)
// kind 4 k-th helpful group cache-fallback entry (OPAQUE_GROUP_CACHE_HELPFUL)
// kind 5 k-th harmful group cache-fallback entry (OPAQUE_GROUP_CACHE_HARMFUL)
// Fallback ordinals count the UNFILTERED eligible sequence (visible, not
// already in the live array), so `PushBySlot` needs no filter; `CollectSlots`
// applies the caller's `match` only when choosing which ids to emit.
constexpr int OPAQUE_STRIDE = 0x100;
constexpr int OPAQUE_CACHE_HELPFUL = 1 * OPAQUE_STRIDE;
constexpr int OPAQUE_CACHE_HARMFUL = 2 * OPAQUE_STRIDE;
constexpr int OPAQUE_GROUP = 3 * OPAQUE_STRIDE;
constexpr int OPAQUE_GROUP_CACHE_HELPFUL = 4 * OPAQUE_STRIDE;
constexpr int OPAQUE_GROUP_CACHE_HARMFUL = 5 * OPAQUE_STRIDE;
// Upper bound on ids one `CollectSlots` call can yield: the live array (or the
// group array) plus its cache fallback, each at most UNIT_AURA_TOTAL entries.
constexpr int OPAQUE_SLOTS_MAX = 2 * Offsets::UNIT_AURA_TOTAL;
// Writes the opaque ids of every aura on the unit matching `filter` + `match`
// into `out` (at most `cap`), in the order the by-index getters visit them:
// live slots (or, with no live CGUnit, the group array) first, then the cache
// fallback. `unit` null selects the out-of-range group path for `guid`. Returns
// the count written.
int CollectSlots(const uint8_t *unit, uint64_t guid, Filter filter, Match match,
int *out, int cap);
// Pushes the aura an opaque id names — table or positional per `emit` — and
// returns true; pushes nothing and returns false for an id that names nothing
// (stale, malformed, or the wrong path for the unit's current state).
bool PushBySlot(void *L, const uint8_t *unit, uint64_t guid, int slot, Emit emit);
} // namespace Aura::Data
+143 -17
View File
@@ -360,6 +360,12 @@ Entry *Claim(uint32_t now) {
// refresh path too.
void SignalAuraChanged(uint64_t guid);
// Defined with the duration-modifier machinery below. A trigger can CREATE the
// aura it then edits, in which case the edit is armed at trigger time and
// applied here, when the aura it was waiting for actually lands. See PendingMod.
struct Entry;
void ConsumePendingMods(Entry &e, uint32_t now);
// The SpellGo hook: authoritative caster + caster-modified (talented) timing.
// Identity is `(target, spell, caster)`, so a second caster of the same spell
// opens its own entry instead of overwriting the first's, and a recast by the
@@ -387,6 +393,7 @@ void StoreFromCast(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
e = Claim(now);
*e = {targetGuid, casterGuid, spellId, expirationMs, durationMs,
now, SLOT_UNBOUND, KIND_UNKNOWN, true};
ConsumePendingMods(*e, now);
return;
}
e->stampMs = now; // refresh EvictAbsent grace on any touch
@@ -399,6 +406,7 @@ void StoreFromCast(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
// ourselves (deferred to the world tick, coalesced). A NEW application
// doesn't need this: its descriptor write fires the engine's own event.
SignalAuraChanged(targetGuid);
ConsumePendingMods(*e, now);
}
// The OnAuraAdded / OnAuraStacksChanged application hooks: a descriptor slot,
@@ -429,6 +437,7 @@ void StoreFromApplication(uint64_t targetGuid, uint32_t spellId,
e = Claim(now);
*e = {targetGuid, casterGuid, spellId, expirationMs, durationMs, now,
static_cast<int16_t>(slot), kind, true};
ConsumePendingMods(*e, now);
return;
}
@@ -440,6 +449,11 @@ void StoreFromApplication(uint64_t targetGuid, uint32_t spellId,
// back to unknown.
if (kind != KIND_UNKNOWN)
e->kind = kind;
// An armed server edit applies whoever owns the timing — the server made it
// AFTER the cast this entry came from, so it is the newer truth. It also
// adopts the entry, which makes the ownership guard below keep the edited
// value instead of the application's base-duration guess.
ConsumePendingMods(*e, now);
if (e->casterGuid != 0)
return; // SpellGo owns this entry; keep its caster + talented timing
if (casterGuid != 0)
@@ -523,8 +537,10 @@ void Evict(uint64_t targetGuid, uint32_t spellId, int slot) {
// Molten Blast -> Flame Shock: refresh (RefreshHolder → reset to max)
// A trigger is matched either by exact spellID (from the server's script
// binding, stable across ranks) or by SpellFamilyName + school; the affected
// aura by SpellFamilyName + a family-flag overlap (+ optional icon) — rank-proof,
// exactly how the server's scripts find it. Conflagrate's *full*-consume path
// aura by SpellFamilyName plus a family-flag overlap and/or a SpellIconID —
// rank-proof, exactly how the server's scripts find it, and the icon half is
// what reaches a custom spell with no family flags at all (see
// AffectedMatchesRaw). Conflagrate's *full*-consume path
// removes Immolate, which clears
// the descriptor slot → OnAuraRemoved already handles it; the reduce rule
// covers the keep-ticking case. Probabilistic refreshes (Carnage's roll) are
@@ -549,9 +565,23 @@ int g_modCount = 0;
// The affected-aura selector alone, shared with the Lua-facing by-family
// refresh so the two cannot disagree about what they match.
//
// SpellFamilyName always has to match. Past that a rule discriminates by a
// SpellFamilyFlags overlap, by SpellIconID, or by both; a zero mask or a zero
// icon drops that half of the test. The icon-only form is what names an aura
// whose SpellFamilyFlags are ZERO — every one of Turtle's custom spells is
// (308 in the hunter family alone), so the mask cannot select them while the
// icon still can, and an icon is shared by a spell's whole rank ladder, which
// keeps it rank-proof exactly like a family flag. Registrars reject the
// degenerate mask-0 + icon-0 rule, which would match a whole class family.
bool AffectedMatchesRaw(const uint8_t *rec, uint32_t family, uint64_t mask,
uint32_t icon) {
if (!Spell::Lookup::IsFitToFamily(rec, family, mask))
if (rec == nullptr)
return false;
if (*reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SPELL_RECORD_FAMILY_NAME) != family)
return false;
if (mask != 0 && !Spell::Lookup::IsFitToFamily(rec, family, mask))
return false;
if (icon != 0 &&
*reinterpret_cast<const uint32_t *>(
@@ -616,8 +646,8 @@ void FlushAuraSignals() {
g_pendingSignalCount = 0;
}
void ApplyMod(Entry &e, const DurationMod &m, uint32_t now) {
switch (m.op) {
void ApplyModOp(Entry &e, int32_t op, int32_t valueMs, uint32_t now) {
switch (op) {
case MOD_REFRESH:
if (e.durationMs > 0) {
e.expirationMs = now + e.durationMs; // RefreshHolder → reset to max
@@ -625,14 +655,14 @@ void ApplyMod(Entry &e, const DurationMod &m, uint32_t now) {
}
break;
case MOD_SET:
e.durationMs = static_cast<uint32_t>(m.valueMs);
e.expirationMs = now + static_cast<uint32_t>(m.valueMs);
e.durationMs = static_cast<uint32_t>(valueMs);
e.expirationMs = now + static_cast<uint32_t>(valueMs);
SignalAuraChanged(e.targetGuid);
break;
case MOD_REDUCE:
if (e.expirationMs != 0) {
if (e.expirationMs > now + static_cast<uint32_t>(m.valueMs)) {
e.expirationMs -= static_cast<uint32_t>(m.valueMs);
if (e.expirationMs > now + static_cast<uint32_t>(valueMs)) {
e.expirationMs -= static_cast<uint32_t>(valueMs);
SignalAuraChanged(e.targetGuid);
} else {
e.used = false; // shaved to/past now → server removes it
@@ -645,6 +675,88 @@ void ApplyMod(Entry &e, const DurationMod &m, uint32_t now) {
}
}
void ApplyMod(Entry &e, const DurationMod &m, uint32_t now) {
ApplyModOp(e, m.op, m.valueMs, now);
}
// ---- Deferred duration edits (the trigger creates the aura it edits) ------
//
// A trigger does not always act on an aura that is ALREADY there. Bestial
// Wrath's effect 0 is TRIGGER_SPELL(Scent of Blood), and the server's script
// then stretches the very holder that trigger just created (tortoise-wow
// spell_hunter_bestial_wrath). The trigger's own SMSG_SPELL_GO reaches us
// first, so at trigger time either nothing matches yet, or something matches
// and is overwritten moments later when the triggered cast stores its own base
// duration. Either way the edit is lost.
//
// So a trigger also ARMS its targets, and the next matching store inside the
// window re-applies the edit — from the cast path or the application path,
// whichever the triggered aura arrives on.
//
// Only the IDEMPOTENT ops arm. Re-running refresh or set lands on the same
// value, while reduce would shave a second time and remove would kill an aura
// that was legitimately re-applied. That keeps Conflagrate (reduce) behaving
// exactly as before.
bool OpIsIdempotent(int32_t op) {
return op == MOD_REFRESH || op == MOD_SET;
}
struct PendingMod {
uint64_t target;
uint64_t caster;
uint32_t affectedFamily;
uint64_t affectedMask;
uint32_t affectedIcon;
int32_t op;
int32_t valueMs;
uint32_t untilMs;
bool used;
};
// The triggered cast follows its trigger within one packet burst, so the window
// only has to cover network jitter. Sized like the other arm tables.
constexpr int kPendingModMax = 8;
constexpr uint32_t kPendingModTtlMs = 2000;
PendingMod g_pendingMods[kPendingModMax];
void ArmPendingMod(uint64_t target, uint64_t caster, const DurationMod &m,
uint32_t now) {
PendingMod *slot = nullptr;
for (auto &p : g_pendingMods) {
// Re-arming the same rule on the same target refreshes it in place.
if (p.used && p.target == target && p.affectedFamily == m.affectedFamily &&
p.affectedMask == m.affectedMask && p.affectedIcon == m.affectedIcon) {
slot = &p;
break;
}
if (slot == nullptr && (!p.used || Time::Clock::Reached(now, p.untilMs)))
slot = &p;
}
if (slot == nullptr)
slot = &g_pendingMods[0]; // all live — steal the first
*slot = {target, caster, m.affectedFamily, m.affectedMask,
m.affectedIcon, m.op, m.valueMs, now + kPendingModTtlMs, true};
}
void ConsumePendingMods(Entry &e, uint32_t now) {
for (auto &p : g_pendingMods) {
if (!p.used || p.target != e.targetGuid ||
Time::Clock::Reached(now, p.untilMs))
continue;
// Caster-scoped exactly like the immediate path, adoption included.
if (e.casterGuid != 0 && e.casterGuid != p.caster)
continue;
if (!AffectedMatchesRaw(
Spell::Lookup::RecordForID(static_cast<int>(e.spellId)),
p.affectedFamily, p.affectedMask, p.affectedIcon))
continue;
if (e.casterGuid == 0)
e.casterGuid = p.caster;
ApplyModOp(e, p.op, p.valueMs, now);
p.used = false; // one-shot, like the server's single-holder edit
return;
}
}
// On a trigger cast landing on its hit targets, mirror the server's duration
// edit on the caster's own matching cached aura. Called from SpellGo_h before
// the aura gate — triggers (Conflagrate, Molten Blast) apply no aura of their
@@ -681,6 +793,11 @@ void ApplyDurationModifiers(uint32_t triggerSpellId, uint64_t caster,
ApplyMod(e, m, now);
break; // one matching aura per (rule, target), like the server
}
// The aura may not be here yet, or may be about to be overwritten by
// the cast this trigger sets off. Arm the idempotent ops so the edit
// lands when it arrives — see PendingMod.
if (OpIsIdempotent(m.op))
ArmPendingMod(targets[t], caster, m, now);
}
}
}
@@ -689,8 +806,11 @@ bool RegisterDurationMod(uint32_t triggerSpellId, uint32_t triggerFamily,
int32_t triggerSchool, uint32_t affectedFamily,
uint64_t affectedMask, uint32_t affectedIcon, int op,
int32_t valueMs) {
// Trigger must be identified one way or the other.
if ((triggerSpellId == 0 && triggerFamily == 0) || affectedMask == 0 ||
// Trigger must be identified one way or the other, and the affected aura
// needs at least one discriminator past its family — a flag overlap, an
// icon, or both. Neither would match every aura of a class.
if ((triggerSpellId == 0 && triggerFamily == 0) ||
(affectedMask == 0 && affectedIcon == 0) ||
op < MOD_REFRESH || op > MOD_REMOVE)
return false;
for (int i = 0; i < g_modCount; ++i) { // replace an identical rule
@@ -1395,10 +1515,14 @@ const Net::PacketDispatch::AutoSubscribe _spellGoSub{&SpellGoSub};
// ---- Aura-application co-hooks (timing for proc / triggered auras) -------
// 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;
// Classify by the slot's flag nibble (UNIT_AURA_FLAG_HARMFUL), not its range:
// the server parks debuffs in buff slots once the 16 are full, and the nibble
// is what records their real polarity. The descriptor write pass has already
// landed the flags when the application hooks fire (see Aura::Data).
int8_t KindForSlot(const void *unit, int slot) {
return Aura::Data::IsSlotHarmful(static_cast<const uint8_t *>(unit), slot)
? KIND_HARMFUL
: KIND_HELPFUL;
}
// Stamp expiration for an aura that just landed/refreshed in `slot` on `unit`.
@@ -1454,7 +1578,7 @@ void StampApplication(void *unit, uint32_t spellId, int slot) {
}
const uint32_t expirationMs = durationMs > 0 ? NowMs() + durationMs : 0;
StoreFromApplication(unitGuid, spellId, caster, expirationMs, durationMs,
slot, KindForSlot(slot));
slot, KindForSlot(unit, slot));
}
// Bump the player-stat-inputs signal when an aura change hits the LOCAL
@@ -1641,7 +1765,9 @@ int RefreshJudgements(uint64_t unitGuid, uint64_t attackerGuid) {
uint32_t RefreshDurationByFamily(uint64_t unitGuid, uint32_t family,
uint64_t mask, uint32_t icon,
uint64_t casterGuid) {
if (unitGuid == 0 || mask == 0 || casterGuid == 0)
// Same selector invariant the rule registrar enforces: family plus at
// least one of flag-overlap / icon.
if (unitGuid == 0 || (mask == 0 && icon == 0) || casterGuid == 0)
return 0;
const uint32_t now = NowMs();
for (int i = 0; i < g_usedHigh; ++i) {
+9 -6
View File
@@ -61,10 +61,11 @@ bool Get(uint64_t unitGuid, uint32_t spellId, int slot, uint64_t *outCaster,
uint32_t *outExpirationMs, uint32_t *outDurationMs);
// Refreshes `casterGuid`'s aura on `unitGuid` matching the same selector the
// duration rules use — SpellFamilyName + family-flag overlap + optional icon
// (0 = any) — to a full duration from now. Returns the spellID refreshed, or 0
// if nothing matched. For a mechanic whose duration edit reaches the client
// too late to be attributed to a cast packet, so no rule can express it.
// duration rules use — SpellFamilyName plus a family-flag overlap and/or a
// SpellIconID, at least one of the two — to a full duration from now. Returns
// the spellID refreshed, or 0 if nothing matched. For a mechanic whose
// duration edit reaches the client too late to be attributed to a cast packet,
// so no rule can express it.
uint32_t RefreshDurationByFamily(uint64_t unitGuid, uint32_t family,
uint64_t mask, uint32_t icon,
uint64_t casterGuid);
@@ -108,8 +109,10 @@ enum DurationModOp {
};
// Register a server duration-modifier rule from C++. The trigger is matched by
// exact `triggerSpellId`; the affected aura by SpellFamilyName + a family-flag
// overlap (+ optional `affectedIcon`, 0 = any). `valueMs` is the reduce/set
// exact `triggerSpellId`; the affected aura by SpellFamilyName plus an
// `affectedMask` family-flag overlap and/or an `affectedIcon`, at least one of
// the two (0 drops that half of the test; a custom spell with no family flags
// is named by its icon alone). `valueMs` is the reduce/set
// amount in milliseconds (ignored by refresh/remove). Used by src/turtle
// modules for the server's built-in mods (the family/school-matched variant is
// still Lua-registerable via C_UnitAuras.RegisterAuraDurationModifierByTrigger).
+24 -8
View File
@@ -180,6 +180,7 @@ constexpr int kInterruptDedupMs = 1000;
int g_chanSpell = 0;
int g_chanGuid = 0;
int g_chanStart = 0; // startMs we last fired CHANNEL_START for (detects a recast)
// castGUID minted at SENT, pending until the matching downstream event
// (START / SUCCEEDED / FAILED for the same spell) consumes it — so all of
@@ -478,14 +479,28 @@ void PollPlayer(int castSpellID, int castStartMs, int castEndMs,
}
// ---- Channel ------------------------------------------------------
// Detect a new channel by spellID ONLY (not startMs): a channel is
// stamped twice at start — SMSG_SPELL_START, then MSG_CHANNEL_START
// re-stamps with the server duration — which changes startMs but is the
// SAME channel. A startMs check would spuriously fire STOP+START on the
// re-stamp. A genuine re-channel of the same spell passes through
// g_chanSpell==0 (CHANNEL_STOP) first, so spellID alone still catches it.
(void)channelStartMs;
const bool newChan = channelSpellID != 0 && channelSpellID != g_chanSpell;
// A DIFFERENT channel spell is trivially a new channel. A SAME-spell
// re-channel (recasting Fishing while already fishing) is ALSO a new
// channel and must fire CHANNEL_STOP + CHANNEL_START, but it keeps
// channelSpellID == g_chanSpell (the channel never returns to 0 between the
// two — +0x228 stays set for a continuous channel), so spellID alone can't
// see it. Two independent tells together identify it without a timing
// heuristic — BOTH required:
// - the channel was RE-STAMPED: channelStartMs advanced past the value we
// fired CHANNEL_START for, AND
// - a fresh CMSG_CAST_SPELL is pending for this spell (g_pendingGuid — the
// recast's SENT, not yet consumed).
// Each start stamps the channel TWICE (SMSG_SPELL_START then
// MSG_CHANNEL_START), which advances startMs on the SAME start — but the
// first CHANNEL_START already consumed g_pendingGuid, so the second stamp
// fails the pending test → no spurious restart. A REJECTED recast
// (SPELL_IN_PROGRESS) sends a CMSG (guid pending) but the server never
// re-stamps the channel, so startMs is unchanged → also no restart.
const bool reChannel = channelSpellID != 0 && channelSpellID == g_chanSpell &&
channelStartMs != g_chanStart && g_pendingGuid != 0 &&
g_pendingSpell == channelSpellID;
const bool newChan = channelSpellID != 0 &&
(channelSpellID != g_chanSpell || reChannel);
if (g_chanSpell != 0 && (channelSpellID == 0 || newChan)) {
// Channels only ever fire CHANNEL_STOP — never INTERRUPTED — whether
// they end naturally or are cut short (retail behavior, verified).
@@ -499,6 +514,7 @@ void PollPlayer(int castSpellID, int castStartMs, int castEndMs,
// SUCCEEDED / CHANNEL_STOP on one castGUID.
g_chanGuid = NextCastGuid(channelSpellID);
g_chanSpell = channelSpellID;
g_chanStart = channelStartMs;
Fire(kChannelStart, channelSpellID, g_chanGuid);
// Modern fires SUCCEEDED right after CHANNEL_START. SPELL_GO deferred
// it to here so it lands after START, sharing the channel's guid.
+59 -7
View File
@@ -18,21 +18,24 @@
// target-if-a-player, never the observing caster). We can't hear the change,
// but we see the triggering cast's SMSG_SPELL_GO, so Aura::Source mirrors the
// edit on the cached entry. A rule is a { exact trigger spellID -> affected
// aura by SpellFamilyName + family-flag overlap } pair, applied by
// Aura::Source::ApplyDurationModifiers.
// aura by SpellFamilyName plus a family-flag overlap and/or a SpellIconID }
// pair, applied by Aura::Source::ApplyDurationModifiers.
//
// Conflagrate -> Immolate: shave 3s (Turtle keeps Immolate ticking, -3s;
// stock's full-consume removes it, which
// OnAuraRemoved evicts, so the rule is
// harmless there)
// Molten Blast -> Flame Shock: refresh (RefreshHolder -> reset to full)
// Conflagrate -> Immolate: shave 3s (Turtle keeps Immolate ticking, -3s;
// stock's full-consume removes it, which
// OnAuraRemoved evicts, so the rule is
// harmless there)
// Molten Blast -> Flame Shock: refresh (RefreshHolder -> reset to full)
// Bestial Wrath -> Scent of Blood: set to Bestial Wrath's own duration
//
// Gated on Turtle::Detected() and registered once (a WorldTick latch, since the
// gate reads a FrameXML global only present in-world). Stock's Conflagrate
// still exists, so keeping the gate preserves the previous Lua behaviour
// exactly; Molten Blast's spell IDs don't exist off Turtle anyway.
#include "Offsets.h"
#include "aura/Source.h"
#include "spell/Lookup.h"
#include "turtle/Detect.h"
#include "tick/WorldTick.h"
@@ -51,6 +54,50 @@ constexpr uint32_t kShaman = 11;
constexpr uint64_t kImmolateFlag = 0x4;
constexpr uint64_t kFlameShockFlag = 0x10000000;
// Bestial Wrath -> Scent of Blood (hunter). The talent's proc puts Scent of
// Blood on the pet for its own 8s; casting Bestial Wrath then stretches that
// SAME holder to Bestial Wrath's length. Server-side (tortoise-wow
// spell_hunter_bestial_wrath) it is a bare
// `holder->SetAuraMaxDuration/SetAuraDuration` on the pet's existing holder —
// both are plain field writes that send nothing, and the one duration packet
// 1.12 has is scoped to an aura-bearer that is a PLAYER, which a pet is not.
// So the client keeps showing the 8s it computed at application and the timer
// simply runs out under a buff that is still up.
//
// The trigger is visible, though, and Bestial Wrath's implicit target is
// TARGET_UNIT_CASTER_PET, so the pet is in its SMSG_SPELL_GO hit list and the
// standard rule machinery reaches the right unit. If the pet has no Scent of
// Blood the server returns without doing anything; we mirror that for free,
// since no cache entry means no match.
//
// Selected by ICON, not by family flags: every Turtle custom spell carries
// SpellFamilyFlags of 0, so no mask can name this aura, while icon 2245 picks
// out exactly the four Scent of Blood records in the hunter family and nothing
// else (client DBC verified) — and it keeps picking them if Turtle adds a rank.
constexpr uint32_t kHunter = 9;
constexpr uint32_t kBestialWrath = 19574;
constexpr uint32_t kScentOfBloodIcon = 2245;
// The server hardcodes 18s; Bestial Wrath's own DBC duration agrees, so we
// read the DBC and keep this only for a record we can't resolve.
constexpr int32_t kBestialWrathFallbackMs = 18000;
// Bestial Wrath's own base duration, straight from the engine's duration
// helper (`skipMod` = base: the server applies no caster mods to the value it
// writes). Reading it instead of hardcoding means a Turtle rebalance that
// patches the client's spell data carries over on its own.
int32_t BestialWrathDurationMs() {
const uint8_t *rec =
Spell::Lookup::RecordForID(static_cast<int>(kBestialWrath));
if (rec == nullptr)
return kBestialWrathFallbackMs;
using GetDuration_t = int(__fastcall *)(const uint8_t *spellRecord,
int unit, char skipMod);
const int ms = reinterpret_cast<GetDuration_t>(
static_cast<uintptr_t>(Offsets::FUN_GET_SPELL_DURATION))(
rec, 0, /*skipMod*/ 1);
return ms > 0 ? ms : kBestialWrathFallbackMs;
}
bool g_registered = false;
void RegisterAll() {
@@ -66,6 +113,11 @@ void RegisterAll() {
Aura::Source::AddDurationMod(id, kShaman, kFlameShockFlag, /*icon*/ 0,
Aura::Source::DURATION_MOD_REFRESH,
/*valueMs*/ 0);
Aura::Source::AddDurationMod(kBestialWrath, kHunter, /*mask*/ 0,
kScentOfBloodIcon,
Aura::Source::DURATION_MOD_SET,
BestialWrathDurationMs());
}
void OnTick() {
+53 -10
View File
@@ -13,6 +13,7 @@
#include "Game.h"
#include "Offsets.h"
#include "aura/Data.h"
#include "guid/Guid.h"
#include "object/Resolve.h"
@@ -20,13 +21,38 @@
namespace Unit::Tooltip {
// The 1-based index the engine's `SetUnitBuff` / `SetUnitDebuff` would need to
// land on `slot`: one plus the slots before it in ITS range (0..31 or 32..47)
// that pass the engine's tooltip visibility gate — the exact walk
// `FUN_00534AC0` / `FUN_00534E30` perform (verified by decompile).
static int EngineIndexForSlot(const uint8_t *unit, int slot) {
const int home =
slot < Offsets::UNIT_AURA_BUFF_COUNT ? 0 : Offsets::UNIT_AURA_BUFF_COUNT;
int index = 1;
for (int s = home; s < slot; ++s) {
if (Aura::Data::IsSlotTooltipVisible(unit, s))
++index;
}
return index;
}
// `GameTooltip:SetUnitAura(unit, index, [filter])` — modern unified-aura
// method. 1.12 splits this into `SetUnitBuff` (slot 32) and
// `SetUnitDebuff` (slot 33); we dispatch to the right one based on the
// `filter` string ("HELPFUL" → SetUnitBuff, "HARMFUL" → SetUnitDebuff).
// method. 1.12 has `SetUnitBuff` (slot 32) and `SetUnitDebuff` (slot 33), each
// indexing its own slot RANGE (0..31 / 32..47) under the engine's tooltip
// gate. `C_UnitAuras` indexes by the aura's polarity nibble instead (see
// Aura::Data), so the two index spaces diverge once the server has parked a
// debuff in a buff slot — and an index an addon got from `C_UnitAuras` must
// still open the right tooltip. So: resolve (index, filter) to the absolute
// slot in OUR space, then hand the engine the index THAT slot has within its
// own range under ITS gate, choosing SetUnitBuff or SetUnitDebuff by where the
// slot lives. The aura tooltip builder only ever receives spellId / level /
// stacks, so a debuff in a buff slot renders identically through SetUnitBuff.
//
// `filter` defaults to "HELPFUL" when omitted, matching modern
// behavior. Anything other than a HARMFUL match goes to SetUnitBuff.
// `filter` defaults to "HELPFUL" when omitted, matching modern behavior; only
// its HELPFUL/HARMFUL half is read (a PLAYER-filtered index is not an index
// into the plain list — pass an index from the same plain list, as FrameXML
// does). A unit with no live CGUnit (out-of-range groupmate) passes straight
// through: the engine reads the group array by range there, exactly as we do.
static int __fastcall Script_GameTooltipSetUnitAura(void *L) {
// Args: self (table), unit (string), index (number), filter (optional string)
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
@@ -51,14 +77,31 @@ static int __fastcall Script_GameTooltipSetUnitAura(void *L) {
}
}
// Drop the filter arg before calling — the existing
// SetUnitBuff/SetUnitDebuff take (self, unit, index) only.
Game::Lua::SetTop(L, 3);
int index = static_cast<int>(Game::Lua::ToNumber(L, 3));
bool useDebuffMethod = isHarmful;
const auto *unit = static_cast<const uint8_t *>(
Game::ResolveUnitToken(Game::Lua::ToString(L, 2)));
if (unit != nullptr) {
const int slot = Aura::Data::FindNthSlot(
unit, index,
isHarmful ? Aura::Data::Filter::Harmful : Aura::Data::Filter::Helpful);
if (slot >= 0) {
index = EngineIndexForSlot(unit, slot);
useDebuffMethod = slot >= Offsets::UNIT_AURA_BUFF_COUNT;
}
// slot < 0: nothing at that index in our space; the raw index goes
// through and the engine's own walk shows nothing either.
}
// Rebuild the args as (self, unit, engineIndex) — the engine methods take
// exactly those three; the filter string is consumed here.
Game::Lua::SetTop(L, 2);
Game::Lua::PushNumber(L, static_cast<double>(index));
using Script_t = int(__fastcall *)(void *L);
auto fn = reinterpret_cast<Script_t>(
isHarmful ? Offsets::FUN_SCRIPT_GAMETOOLTIP_SET_UNIT_DEBUFF
: Offsets::FUN_SCRIPT_GAMETOOLTIP_SET_UNIT_BUFF);
useDebuffMethod ? Offsets::FUN_SCRIPT_GAMETOOLTIP_SET_UNIT_DEBUFF
: Offsets::FUN_SCRIPT_GAMETOOLTIP_SET_UNIT_BUFF);
return fn(L);
}