Skip to content

Commit fdbe66b

Browse files
committed
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
1 parent bd1bd9f commit fdbe66b

4 files changed

Lines changed: 210 additions & 29 deletions

File tree

docs/API.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15629,20 +15629,24 @@ trigger lands:
1562915629
| `triggerFamily` | `SpellFamilyName` of the triggering cast (e.g. `6` priest). |
1563015630
| `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. |
1563115631
| `affectedFamily` | `SpellFamilyName` of the affected aura (e.g. `5` warlock, `11` shaman, `6` priest). |
15632-
| `affectedFamilyFlags` | A `SpellFamilyFlags` bitmask; the affected aura matches if it overlaps. Family + flag is rank-proof (covers every rank at once). |
15633-
| `affectedIcon` | `SpellIconID` the affected aura must have, or `0` to match any. |
15632+
| `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. |
15633+
| `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. |
1563415634
| `op` | `"refresh"` (reset to full duration), `"reduce"` (subtract `valueSeconds`, removing the aura if it would go non-positive), `"set"` (to `valueSeconds`), `"remove"`. |
1563515635
| `valueSeconds` | Amount for `reduce`/`set`; ignored otherwise. |
1563615636

15637+
Give at least one of `affectedFamilyFlags` and `affectedIcon`. A rule with
15638+
neither would match every aura of the class, so it is rejected.
15639+
1563715640
The rule only fires for the aura **cast by the same unit** as the trigger
1563815641
(these mechanics act on the caster's own DoT), and misses are excluded for
1563915642
free (a missed trigger isn't in the packet's hit list).
1564015643

1564115644
Rules keyed to an **exact trigger spellID** (rather than a family + school
1564215645
category) are registered inside the DLL instead. `!!!ClassicAPI`'s built-in
1564315646
Turtle mods — Conflagrate shaving 3s off the caster's Immolate, Molten Blast
15644-
refreshing the caster's Flame Shock — live in `src/turtle/DurationMods.cpp`,
15645-
and Carnage's roll-gated Rip/Rake refresh in `src/turtle/Carnage.cpp`.
15647+
refreshing the caster's Flame Shock, and Bestial Wrath stretching the pet's
15648+
Scent of Blood to its own length — live in `src/turtle/DurationMods.cpp`, and
15649+
Carnage's roll-gated Rip/Rake refresh in `src/turtle/Carnage.cpp`.
1564615650

1564715651
The one rule registered from Lua
1564815652
([Util/AuraDurationModifiers.lua](../AddOns/!!!ClassicAPI/Util/AuraDurationModifiers.lua))

src/aura/Source.cpp

Lines changed: 134 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,12 @@ Entry *Claim(uint32_t now) {
360360
// refresh path too.
361361
void SignalAuraChanged(uint64_t guid);
362362

363+
// Defined with the duration-modifier machinery below. A trigger can CREATE the
364+
// aura it then edits, in which case the edit is armed at trigger time and
365+
// applied here, when the aura it was waiting for actually lands. See PendingMod.
366+
struct Entry;
367+
void ConsumePendingMods(Entry &e, uint32_t now);
368+
363369
// The SpellGo hook: authoritative caster + caster-modified (talented) timing.
364370
// Identity is `(target, spell, caster)`, so a second caster of the same spell
365371
// 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,
387393
e = Claim(now);
388394
*e = {targetGuid, casterGuid, spellId, expirationMs, durationMs,
389395
now, SLOT_UNBOUND, KIND_UNKNOWN, true};
396+
ConsumePendingMods(*e, now);
390397
return;
391398
}
392399
e->stampMs = now; // refresh EvictAbsent grace on any touch
@@ -399,6 +406,7 @@ void StoreFromCast(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
399406
// ourselves (deferred to the world tick, coalesced). A NEW application
400407
// doesn't need this: its descriptor write fires the engine's own event.
401408
SignalAuraChanged(targetGuid);
409+
ConsumePendingMods(*e, now);
402410
}
403411

404412
// The OnAuraAdded / OnAuraStacksChanged application hooks: a descriptor slot,
@@ -429,6 +437,7 @@ void StoreFromApplication(uint64_t targetGuid, uint32_t spellId,
429437
e = Claim(now);
430438
*e = {targetGuid, casterGuid, spellId, expirationMs, durationMs, now,
431439
static_cast<int16_t>(slot), kind, true};
440+
ConsumePendingMods(*e, now);
432441
return;
433442
}
434443

@@ -440,6 +449,11 @@ void StoreFromApplication(uint64_t targetGuid, uint32_t spellId,
440449
// back to unknown.
441450
if (kind != KIND_UNKNOWN)
442451
e->kind = kind;
452+
// An armed server edit applies whoever owns the timing — the server made it
453+
// AFTER the cast this entry came from, so it is the newer truth. It also
454+
// adopts the entry, which makes the ownership guard below keep the edited
455+
// value instead of the application's base-duration guess.
456+
ConsumePendingMods(*e, now);
443457
if (e->casterGuid != 0)
444458
return; // SpellGo owns this entry; keep its caster + talented timing
445459
if (casterGuid != 0)
@@ -523,8 +537,10 @@ void Evict(uint64_t targetGuid, uint32_t spellId, int slot) {
523537
// Molten Blast -> Flame Shock: refresh (RefreshHolder → reset to max)
524538
// A trigger is matched either by exact spellID (from the server's script
525539
// binding, stable across ranks) or by SpellFamilyName + school; the affected
526-
// aura by SpellFamilyName + a family-flag overlap (+ optional icon) — rank-proof,
527-
// exactly how the server's scripts find it. Conflagrate's *full*-consume path
540+
// aura by SpellFamilyName plus a family-flag overlap and/or a SpellIconID —
541+
// rank-proof, exactly how the server's scripts find it, and the icon half is
542+
// what reaches a custom spell with no family flags at all (see
543+
// AffectedMatchesRaw). Conflagrate's *full*-consume path
528544
// removes Immolate, which clears
529545
// the descriptor slot → OnAuraRemoved already handles it; the reduce rule
530546
// covers the keep-ticking case. Probabilistic refreshes (Carnage's roll) are
@@ -549,9 +565,23 @@ int g_modCount = 0;
549565

550566
// The affected-aura selector alone, shared with the Lua-facing by-family
551567
// refresh so the two cannot disagree about what they match.
568+
//
569+
// SpellFamilyName always has to match. Past that a rule discriminates by a
570+
// SpellFamilyFlags overlap, by SpellIconID, or by both; a zero mask or a zero
571+
// icon drops that half of the test. The icon-only form is what names an aura
572+
// whose SpellFamilyFlags are ZERO — every one of Turtle's custom spells is
573+
// (308 in the hunter family alone), so the mask cannot select them while the
574+
// icon still can, and an icon is shared by a spell's whole rank ladder, which
575+
// keeps it rank-proof exactly like a family flag. Registrars reject the
576+
// degenerate mask-0 + icon-0 rule, which would match a whole class family.
552577
bool AffectedMatchesRaw(const uint8_t *rec, uint32_t family, uint64_t mask,
553578
uint32_t icon) {
554-
if (!Spell::Lookup::IsFitToFamily(rec, family, mask))
579+
if (rec == nullptr)
580+
return false;
581+
if (*reinterpret_cast<const uint32_t *>(
582+
rec + Offsets::OFF_SPELL_RECORD_FAMILY_NAME) != family)
583+
return false;
584+
if (mask != 0 && !Spell::Lookup::IsFitToFamily(rec, family, mask))
555585
return false;
556586
if (icon != 0 &&
557587
*reinterpret_cast<const uint32_t *>(
@@ -616,23 +646,23 @@ void FlushAuraSignals() {
616646
g_pendingSignalCount = 0;
617647
}
618648

619-
void ApplyMod(Entry &e, const DurationMod &m, uint32_t now) {
620-
switch (m.op) {
649+
void ApplyModOp(Entry &e, int32_t op, int32_t valueMs, uint32_t now) {
650+
switch (op) {
621651
case MOD_REFRESH:
622652
if (e.durationMs > 0) {
623653
e.expirationMs = now + e.durationMs; // RefreshHolder → reset to max
624654
SignalAuraChanged(e.targetGuid);
625655
}
626656
break;
627657
case MOD_SET:
628-
e.durationMs = static_cast<uint32_t>(m.valueMs);
629-
e.expirationMs = now + static_cast<uint32_t>(m.valueMs);
658+
e.durationMs = static_cast<uint32_t>(valueMs);
659+
e.expirationMs = now + static_cast<uint32_t>(valueMs);
630660
SignalAuraChanged(e.targetGuid);
631661
break;
632662
case MOD_REDUCE:
633663
if (e.expirationMs != 0) {
634-
if (e.expirationMs > now + static_cast<uint32_t>(m.valueMs)) {
635-
e.expirationMs -= static_cast<uint32_t>(m.valueMs);
664+
if (e.expirationMs > now + static_cast<uint32_t>(valueMs)) {
665+
e.expirationMs -= static_cast<uint32_t>(valueMs);
636666
SignalAuraChanged(e.targetGuid);
637667
} else {
638668
e.used = false; // shaved to/past now → server removes it
@@ -645,6 +675,88 @@ void ApplyMod(Entry &e, const DurationMod &m, uint32_t now) {
645675
}
646676
}
647677

678+
void ApplyMod(Entry &e, const DurationMod &m, uint32_t now) {
679+
ApplyModOp(e, m.op, m.valueMs, now);
680+
}
681+
682+
// ---- Deferred duration edits (the trigger creates the aura it edits) ------
683+
//
684+
// A trigger does not always act on an aura that is ALREADY there. Bestial
685+
// Wrath's effect 0 is TRIGGER_SPELL(Scent of Blood), and the server's script
686+
// then stretches the very holder that trigger just created (tortoise-wow
687+
// spell_hunter_bestial_wrath). The trigger's own SMSG_SPELL_GO reaches us
688+
// first, so at trigger time either nothing matches yet, or something matches
689+
// and is overwritten moments later when the triggered cast stores its own base
690+
// duration. Either way the edit is lost.
691+
//
692+
// So a trigger also ARMS its targets, and the next matching store inside the
693+
// window re-applies the edit — from the cast path or the application path,
694+
// whichever the triggered aura arrives on.
695+
//
696+
// Only the IDEMPOTENT ops arm. Re-running refresh or set lands on the same
697+
// value, while reduce would shave a second time and remove would kill an aura
698+
// that was legitimately re-applied. That keeps Conflagrate (reduce) behaving
699+
// exactly as before.
700+
bool OpIsIdempotent(int32_t op) {
701+
return op == MOD_REFRESH || op == MOD_SET;
702+
}
703+
704+
struct PendingMod {
705+
uint64_t target;
706+
uint64_t caster;
707+
uint32_t affectedFamily;
708+
uint64_t affectedMask;
709+
uint32_t affectedIcon;
710+
int32_t op;
711+
int32_t valueMs;
712+
uint32_t untilMs;
713+
bool used;
714+
};
715+
// The triggered cast follows its trigger within one packet burst, so the window
716+
// only has to cover network jitter. Sized like the other arm tables.
717+
constexpr int kPendingModMax = 8;
718+
constexpr uint32_t kPendingModTtlMs = 2000;
719+
PendingMod g_pendingMods[kPendingModMax];
720+
721+
void ArmPendingMod(uint64_t target, uint64_t caster, const DurationMod &m,
722+
uint32_t now) {
723+
PendingMod *slot = nullptr;
724+
for (auto &p : g_pendingMods) {
725+
// Re-arming the same rule on the same target refreshes it in place.
726+
if (p.used && p.target == target && p.affectedFamily == m.affectedFamily &&
727+
p.affectedMask == m.affectedMask && p.affectedIcon == m.affectedIcon) {
728+
slot = &p;
729+
break;
730+
}
731+
if (slot == nullptr && (!p.used || Time::Clock::Reached(now, p.untilMs)))
732+
slot = &p;
733+
}
734+
if (slot == nullptr)
735+
slot = &g_pendingMods[0]; // all live — steal the first
736+
*slot = {target, caster, m.affectedFamily, m.affectedMask,
737+
m.affectedIcon, m.op, m.valueMs, now + kPendingModTtlMs, true};
738+
}
739+
740+
void ConsumePendingMods(Entry &e, uint32_t now) {
741+
for (auto &p : g_pendingMods) {
742+
if (!p.used || p.target != e.targetGuid ||
743+
Time::Clock::Reached(now, p.untilMs))
744+
continue;
745+
// Caster-scoped exactly like the immediate path, adoption included.
746+
if (e.casterGuid != 0 && e.casterGuid != p.caster)
747+
continue;
748+
if (!AffectedMatchesRaw(
749+
Spell::Lookup::RecordForID(static_cast<int>(e.spellId)),
750+
p.affectedFamily, p.affectedMask, p.affectedIcon))
751+
continue;
752+
if (e.casterGuid == 0)
753+
e.casterGuid = p.caster;
754+
ApplyModOp(e, p.op, p.valueMs, now);
755+
p.used = false; // one-shot, like the server's single-holder edit
756+
return;
757+
}
758+
}
759+
648760
// On a trigger cast landing on its hit targets, mirror the server's duration
649761
// edit on the caster's own matching cached aura. Called from SpellGo_h before
650762
// the aura gate — triggers (Conflagrate, Molten Blast) apply no aura of their
@@ -681,6 +793,11 @@ void ApplyDurationModifiers(uint32_t triggerSpellId, uint64_t caster,
681793
ApplyMod(e, m, now);
682794
break; // one matching aura per (rule, target), like the server
683795
}
796+
// The aura may not be here yet, or may be about to be overwritten by
797+
// the cast this trigger sets off. Arm the idempotent ops so the edit
798+
// lands when it arrives — see PendingMod.
799+
if (OpIsIdempotent(m.op))
800+
ArmPendingMod(targets[t], caster, m, now);
684801
}
685802
}
686803
}
@@ -689,8 +806,11 @@ bool RegisterDurationMod(uint32_t triggerSpellId, uint32_t triggerFamily,
689806
int32_t triggerSchool, uint32_t affectedFamily,
690807
uint64_t affectedMask, uint32_t affectedIcon, int op,
691808
int32_t valueMs) {
692-
// Trigger must be identified one way or the other.
693-
if ((triggerSpellId == 0 && triggerFamily == 0) || affectedMask == 0 ||
809+
// Trigger must be identified one way or the other, and the affected aura
810+
// needs at least one discriminator past its family — a flag overlap, an
811+
// icon, or both. Neither would match every aura of a class.
812+
if ((triggerSpellId == 0 && triggerFamily == 0) ||
813+
(affectedMask == 0 && affectedIcon == 0) ||
694814
op < MOD_REFRESH || op > MOD_REMOVE)
695815
return false;
696816
for (int i = 0; i < g_modCount; ++i) { // replace an identical rule
@@ -1641,7 +1761,9 @@ int RefreshJudgements(uint64_t unitGuid, uint64_t attackerGuid) {
16411761
uint32_t RefreshDurationByFamily(uint64_t unitGuid, uint32_t family,
16421762
uint64_t mask, uint32_t icon,
16431763
uint64_t casterGuid) {
1644-
if (unitGuid == 0 || mask == 0 || casterGuid == 0)
1764+
// Same selector invariant the rule registrar enforces: family plus at
1765+
// least one of flag-overlap / icon.
1766+
if (unitGuid == 0 || (mask == 0 && icon == 0) || casterGuid == 0)
16451767
return 0;
16461768
const uint32_t now = NowMs();
16471769
for (int i = 0; i < g_usedHigh; ++i) {

src/aura/Source.h

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,11 @@ bool Get(uint64_t unitGuid, uint32_t spellId, int slot, uint64_t *outCaster,
6161
uint32_t *outExpirationMs, uint32_t *outDurationMs);
6262

6363
// Refreshes `casterGuid`'s aura on `unitGuid` matching the same selector the
64-
// duration rules use — SpellFamilyName + family-flag overlap + optional icon
65-
// (0 = any) — to a full duration from now. Returns the spellID refreshed, or 0
66-
// if nothing matched. For a mechanic whose duration edit reaches the client
67-
// too late to be attributed to a cast packet, so no rule can express it.
64+
// duration rules use — SpellFamilyName plus a family-flag overlap and/or a
65+
// SpellIconID, at least one of the two — to a full duration from now. Returns
66+
// the spellID refreshed, or 0 if nothing matched. For a mechanic whose
67+
// duration edit reaches the client too late to be attributed to a cast packet,
68+
// so no rule can express it.
6869
uint32_t RefreshDurationByFamily(uint64_t unitGuid, uint32_t family,
6970
uint64_t mask, uint32_t icon,
7071
uint64_t casterGuid);
@@ -108,8 +109,10 @@ enum DurationModOp {
108109
};
109110

110111
// Register a server duration-modifier rule from C++. The trigger is matched by
111-
// exact `triggerSpellId`; the affected aura by SpellFamilyName + a family-flag
112-
// overlap (+ optional `affectedIcon`, 0 = any). `valueMs` is the reduce/set
112+
// exact `triggerSpellId`; the affected aura by SpellFamilyName plus an
113+
// `affectedMask` family-flag overlap and/or an `affectedIcon`, at least one of
114+
// the two (0 drops that half of the test; a custom spell with no family flags
115+
// is named by its icon alone). `valueMs` is the reduce/set
113116
// amount in milliseconds (ignored by refresh/remove). Used by src/turtle
114117
// modules for the server's built-in mods (the family/school-matched variant is
115118
// still Lua-registerable via C_UnitAuras.RegisterAuraDurationModifierByTrigger).

0 commit comments

Comments
 (0)