mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-12 12:57:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdbe66b057 | ||
|
|
bd1bd9f5d8 | ||
|
|
b6698ecc7e | ||
|
|
e123a6facc |
@@ -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`,
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
+77
-9
@@ -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)
|
||||
@@ -15470,6 +15476,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 +15629,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 +15644,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))
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+127
-4
@@ -352,6 +352,126 @@ int __fastcall Script_GetPlayerAuraBySpellID(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 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 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.
|
||||
@@ -364,10 +484,7 @@ void AppendRangeToArray(void *L, const uint8_t *unit, int outerIdx,
|
||||
? 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)))
|
||||
if (!Data::SlotMatches(unit, slot, match))
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
|
||||
Data::Push(L, unit, slot);
|
||||
@@ -497,6 +614,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",
|
||||
|
||||
+252
-123
@@ -327,6 +327,21 @@ 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);
|
||||
}
|
||||
|
||||
// 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).
|
||||
@@ -348,9 +363,7 @@ int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
|
||||
: 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)))
|
||||
if (!SlotMatches(unit, slot, match))
|
||||
continue;
|
||||
if (++matches == oneBasedIndex)
|
||||
return slot;
|
||||
@@ -373,7 +386,7 @@ int FindSlotBySpellID(const uint8_t *unit, uint32_t spellID,
|
||||
continue;
|
||||
if (ReadSpellID(unit, slot) != spellID)
|
||||
continue;
|
||||
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
|
||||
if (!PopulatedSlotMatches(unit, slot, match))
|
||||
continue;
|
||||
return slot;
|
||||
}
|
||||
@@ -396,7 +409,7 @@ int FindSlotBySpellName(const uint8_t *unit, const char *spellName,
|
||||
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 (!PopulatedSlotMatches(unit, slot, match))
|
||||
continue;
|
||||
return slot;
|
||||
}
|
||||
@@ -722,36 +735,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 +752,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 +813,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);
|
||||
@@ -838,21 +843,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 +883,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 +936,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 +957,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 +976,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 +985,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 +1009,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 +1020,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 +1041,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 +1079,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 +1108,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 +1117,111 @@ 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;
|
||||
int start, end;
|
||||
GroupRange(filter, start, end); // same [start, end) for the descriptor
|
||||
if (unit != nullptr) {
|
||||
for (int slot = start; slot < end && n < cap; ++slot) {
|
||||
if (SlotMatches(unit, slot, 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);
|
||||
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
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// Shared aura-table primitives consumed by `C_UnitAuras.*` (and any
|
||||
@@ -124,6 +126,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 +238,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
|
||||
|
||||
+134
-12
@@ -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
|
||||
@@ -1641,7 +1761,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
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user