Compare commits

..
6 Commits
Author SHA1 Message Date
Brues e793f80f6b feat(spell): add minimap tracking enumeration
Vanilla 1.12 only stores the single active tracking spell
(VAR_ACTIVE_TRACKING_SPELL, 0xBC6378) and never enumerates the tracking
abilities the player knows. Add GetNumTrackingTypes/GetTrackingInfo/
SetTracking, which rebuild that list by scanning the player spellbook for
spells whose EffectApplyAuraName is Track Creatures/Resources/Stealthed
(44/45/151) -- the same discriminator the engine's own active-tracker
updater (FUN_004E4170) uses. This reproduces pfUI's hardcoded tracking
table with no table to maintain and auto-covers server-custom trackers
(e.g. Turtle's Find Trees, 52917).

- GetTrackingInfo returns name, texture, active, category ("spell"),
  spellID; active is read from VAR_ACTIVE_TRACKING_SPELL.
- SetTracking selects a tracker by tail-calling the engine's
  Script_CastSpell(slot, "spell").
- Add offsets VAR_ACTIVE_TRACKING_SPELL and FUN_SCRIPT_CAST_SPELL
  (0x4B42F0), both with RE-provenance comments.
- Document under docs/API.md (Tracking section).

Verified in-game on a Hunter (8 trackers) and a Dwarf racial (1):
active flips on SetTracking, texture matches native GetTrackingTexture,
out-of-range returns nil.
2026-08-24 23:16:34 -05:00
Brues ded767bf19 feat(aura,turtle): support Turtle's Stinging Nettle serpent sting timers
The hunter talent (403; rank passives 51579/51580) makes Mongoose Bite
and triggered fire traps apply the highest known Serpent Sting rank at
20/40% of its duration. Server-side it's a direct AddAura (tortoise-wow
ApplyStingingNettle) — no SMSG_SPELL_GO for the sting — so the cache
seated it caster-less at the full 15s base.

Aura::Source gains a generalized triggered-application rule table (the
judgement-attribution shape, rule-driven): a registered trigger's
SPELL_GO — local player casts only — arms its hit targets for 2s, and a
caster-less application matching the rule's family/mask consumes the arm
in StampApplication (player attribution + the server's own
max(1, base × pct / 100) with player duration mods). Rules can be gated
on a talent passive, read live from the spell-knowledge bitmap at
trigger time so respecs are honored. Because the server's re-apply over
an existing shorter sting reuses the descriptor slot (no OnAuraAdded —
verified in-game with chained Mongoose Bites), the trigger also
refreshes the player's existing cached entry in place, honoring the
server's keep-the-longer-remaining guard. The arm consume runs BEFORE
the WasRecentPlayerCast attribution: the arm names the exact target,
while the recent-cast window is spell-scoped only — a real sting cast at
unit A must not stamp its full duration onto a nettle application on
unit B; a real cast's own application stays protected by the cast-owned
entry guard.

Turtle::StingingNettle registers the rules behind the Turtle::Detected
latch: fire traps by family mask (hunter 0x4 selects exactly the eight
trap-effect spells the server's script binding lists — rank-proof),
Mongoose Bite by exact IDs (its bit 0x2 is shared with Raptor Strike,
which does not trigger nettle). The percents come from the rank spells'
own EffectBasePoints[0] + 1 — the same DBC value the server's
CalculateSimpleValue reads — with the server's 20/40 fallback.
2026-08-24 22:47:52 -05:00
Brues 70a69b64fd fix(spell): fire UNIT_SPELLCAST_DELAYED when the server reconciles cast time
The server scales ranged abilities' cast times by ranged attack speed
(SpellEntry::GetCastTime's IsRangedSpell branch — quiver/Quick Shots),
which the client's prediction helper FUN_006e3340 can't see (its only
caster factor is the spell-haste mult at descriptor +0x22c). So Steady
and Aimed Shot bars started at the fixed 1.5s/2.5s prediction; the
confirming SMSG_SPELL_START already snapped g_cast.endMs to the server's
authoritative castTime, but nothing told addons — the bar kept the
duration it read at UNIT_SPELLCAST_START.

PollPlayer now snapshots the cast's endMs instead of its delayMs and
fires UNIT_SPELLCAST_DELAYED on any end movement — one signal covering
pushback (previously detected via delayMs growth) and the server
reconcile in either direction. Cast bars re-read UnitCastingInfo on
DELAYED and pick up the corrected end. If the packet ever beats the
first poll, START itself carries the corrected end and no DELAYED fires.

Fixes brues-code/pfUI#43
2026-08-24 21:57:11 -05:00
Brues 0f5b50ea14 feat(aura): refresh paladin judgement timers off swings and re-judges
The judgement debuff's duration is maintained server-side with no
client-visible aura change: the paladin's white swings refresh it
(tortoise-wow Unit::DealMeleeDamage - any nonzero melee damage
RefreshHolder's every SPELLFAMILY_PALADIN + SPELL_ATTR_EX3_ALWAYS_HIT
aura the attacker cast on the victim), and a re-judge is an in-place
holder refresh that dirties no descriptor field. Aura::Source's
countdown therefore ran to zero while the debuff stayed up
(brues-code/pfUI#45).

Two client-side triggers mirror the server exactly:

- Swings: Aura::JudgementRefresh subscribes to SMSG_ATTACKERSTATEUPDATE
  (wire format verified against the server's own SendAttackStateUpdate
  writer) and on totalDamage > 0 calls the caster-STRICT
  Aura::Source::RefreshJudgements(victim, attacker) - strict because a
  swing gives no proof of ownership, and adopting there would let any
  tank's white swings pin another paladin's judgement timer.

- The Judgement cast (20271, the one spell every seal judges through):
  the debuff itself is cast server-side with no SPELL_GO the client
  hears (verified live - entries were seated caster-less by
  OnAuraAdded), so the Judgement cast's own SPELL_GO is what names the
  owner. HandleSpellGo refresh-with-adoption's the victim's existing
  judgement entries (the re-judge case) and arms a 2s attribution
  window that StampApplication consumes to give the freshly applied
  debuff its caster - which is what makes the strict swing matching
  work at all.

Refreshes re-fire UNIT_AURA via the existing deferred signal;
StoreFromCast now signals on its in-place recast path too, since an
identity-preserving refresh changes no descriptor field and
event-driven aura bars would never re-read the new timing.

Ability damage (Crusader/Holy Strike) is deliberately NOT a trigger:
melee-class spell damage never routes through the server's refresh
block (DealMeleeDamage's only caller is the white-swing path).

Verified in-game: every landing swing snaps the cached expiration to
now + 10s and pfUI's timer follows; misses/dodges (totalDamage 0) do
not refresh; re-judging resets the timer.

Fixes brues-code/pfUI#45
2026-08-24 18:58:46 -05:00
Brues be6dc200fe fix(addon): restore MouseIsOver's stock 1/nil return contract
The embedded addon replaces FrameXML's MouseIsOver with a wrapper over
the native region:IsMouseOver(), which returns a boolean. Stock 1.12's
MouseIsOver (UIParent.lua:1718) returns 1 on hit and nil otherwise, and
vanilla-era addons compare against that literal - Quiver gates every
widget click on `MouseIsOver(f) == 1`, which `true` silently fails,
deadening its entire config dialog (buttons, checkboxes, icon clicks).

Return 1/nil from the wrapper. 1 is truthy, so modern-style
`if MouseIsOver(f) then` callers are unaffected.
2026-08-24 10:37:39 -05:00
Brues 7f52afeef4 perf(event): fire custom events through O(1) AutoReserve::Slot()
Every custom-event fire resolved its name to a slot via
Event::Custom::Lookup - a linear strcmp scan over the ~46-entry
reservation table, on every single fire. The slot itself was already
cached per table build; only the name->reservation hop was repeated.
On a client that is purely CPU-bound, that is wasted work on paths
that burst exactly when frames are scarce (nameplate churn, cast spam,
loot rolls, mouse/modifier input).

AutoReserve now remembers its reservation index (the ctor's dedup
resolves duplicate names to the same entry) and exposes Slot() - two
loads. Reload safety is unchanged and stays centralized: Slot() reads
through the shared table PrepareForReload/RetryClaims maintain, so the
value can never go stale. Lookup is deleted; LookupByName remains for
engine-defined names.

All fire sites converted to the instance handle:
- CastEvents: the k* name constants become the AutoReserve instances,
  so all 19 fire sites stay textually identical; the Fire/FireReticle/
  FireRemote helpers take the reservation by reference.
- equipmentset: reservations move into Set.h as C++17 inline consts so
  the Data and Api TUs share one handle.
- Cache::QueryLoad::Register takes the AutoReserve pointer instead of
  an event-name string (creature/gameobject callers updated).
- Tts keeps its lazy VanillaTTS-stand-down reservations via
  null-guarded pointers set at registration.
- GlobalMouse/InputEvents/lossofcontrol instances moved above their
  fire sites (static-init list order is irrelevant).
2026-08-24 10:11:10 -05:00
40 changed files with 1137 additions and 204 deletions
+3 -1
View File
@@ -1,3 +1,5 @@
function MouseIsOver(region, topOffset, bottomOffset, leftOffset, rightOffset)
return region:IsMouseOver(topOffset, bottomOffset, leftOffset, rightOffset);
if region:IsMouseOver(topOffset, bottomOffset, leftOffset, rightOffset) then
return 1;
end
end
+61
View File
@@ -584,6 +584,11 @@ build instructions.
- [`GetTotemDuration(slot)`](#gettotemdurationslot)
- [`TargetTotem(slot)`](#targettotemslot)
- [Tracking](#tracking)
- [`GetNumTrackingTypes()`](#getnumtrackingtypes)
- [`GetTrackingInfo(index)`](#gettrackinginfoindex)
- [`SetTracking(index)`](#settrackingindex)
- [TradeSkillUI](#tradeskillui)
- [`C_TradeSkillUI.GetTradeSkillListLink()`](#c_tradeskilluigettradeskilllistlink)
- [`C_TradeSkillUI.GetCraftListLink()`](#c_tradeskilluigetcraftlistlink)
@@ -14141,6 +14146,62 @@ Looks up the totem creature's live GUID (object-manager scan for the
player-owned creature of the slot's tracked entry) and sets the target
through the same engine path `TargetUnit` uses (`CMSG_SET_SELECTION`).
## Tracking
Vanilla 1.12 stores only the tracking spell that is active now. It does
not keep a list of the tracking spells you know. These globals add that
list. A minimap addon can then build a tracking menu without a hardcoded
spell table.
A tracking spell is a spell in your spellbook that finds creatures,
resources, or hidden units. This is the same test the engine uses to find
the active tracker. The list covers Find Herbs, Find Minerals, Find
Treasure, the Hunter Track spells, Sense Undead, Sense Demons, and Track
Humanoids. It also covers server-added trackers, such as Turtle's Find
Trees. You do not have to maintain a spell list.
### `GetNumTrackingTypes()`
Returns the number of tracking spells in your spellbook.
```lua
GetNumTrackingTypes() -- for example, 8 for a Hunter
```
### `GetTrackingInfo(index)`
`index` starts at 1 and follows spellbook order. The function returns
these five values, or `nil` if `index` is out of range:
| # | Value | Type | Notes |
|---|-------|------|-------|
| 1 | `name` | string | Localized spell name. |
| 2 | `texture` | string | Icon path. Give it to `texture:SetTexture(...)`. `nil` if the icon is missing. |
| 3 | `active` | boolean | `true` when this tracker is the one now in effect. |
| 4 | `category` | string | Always `"spell"`. Vanilla has no non-spell trackers. |
| 5 | `spellID` | number | The tracking spell's ID. This is a ClassicAPI extra. The retail 5th value is `nested`, which has no meaning here. |
```lua
local name, texture, active, category, spellID = GetTrackingInfo(1)
```
### `SetTracking(index)`
Turns on the tracking spell at `index`. In vanilla, you select a tracker
when you cast its spell. This function casts it. For an out-of-range
index, it does nothing. To turn tracking off, use the built-in
`CancelTrackingBuff()`.
```lua
for i = 1, GetNumTrackingTypes() do
local name = GetTrackingInfo(i)
if name == "Find Herbs" then
SetTracking(i)
break
end
end
```
## TradeSkillUI
Backports the 2.x+ **trade-skill list link** — the shareable
+31
View File
@@ -1785,6 +1785,13 @@ enum Offsets {
SMSG_SPELL_CHANNEL_UPDATE = 0x13A,
SMSG_SPELL_DELAYED = 0x1E2,
SMSG_SPELL_FAILED_OTHER = 0x2A6,
// Melee swing result broadcast. Body (verified against the server's own
// writer, tortoise-wow Unit::SendAttackStateUpdate): u32 hitInfo,
// packedGuid attacker, packedGuid victim, u32 totalDamage, u8 subCount,
// per-sub {u32 school, f32 coeff, u32 damage, u32 absorb, i32 resist},
// u32 targetState, u32, u32 spellId, u32 blocked. `Aura::JudgementRefresh`
// reads through totalDamage.
SMSG_ATTACKERSTATEUPDATE = 0x14A,
// NetClient send — `__thiscall void(void *conn, CDataStore *packet)`.
// The outgoing counterpart of FUN_NET_MESSAGE_DISPATCH: every CMSG the
@@ -4102,6 +4109,15 @@ enum Offsets {
VAR_PET_SPELLBOOK = 0x00B6F098,
SPELLBOOK_MAX_SLOTS = 0x400,
// Active minimap-tracking spell ID (0 = none). Vanilla tracks exactly
// one active tracker as a plain int here, derived from the player's
// active Track Creatures/Resources/Stealthed aura by `FUN_004E4170`
// (an aura-descriptor observer registered by `FUN_004E4100`). Read by
// `Script_GetTrackingTexture` (`0x004E4A20`), `Script_CancelTrackingBuff`
// (`0x004E4A80`), and `GameTooltip:SetTrackingSpell` (`0x00532C50`);
// `Spell::Tracking::GetTrackingInfo` reads it to report `active`.
VAR_ACTIVE_TRACKING_SPELL = 0x00BC6378,
// Player-spell-knowledge bitmap — `[VAR_PLAYER_SPELL_BITMAP]` is a
// pointer to a dword bitmap covering all spellIDs the player has
// learned, including talent passives, racials, profession recipes,
@@ -4372,6 +4388,15 @@ enum Offsets {
// results). Callable directly from C++ — push args on stack, call.
FUN_SCRIPT_CAST_SPELL_BY_NAME = 0x004B4AB0,
// `Script_CastSpell` — engine's Lua wrapper for the slot-based
// `CastSpell(spellbookSlot, bookType)` global. `int __fastcall(void *L)`.
// Parses (slot, bookType) from stack[1]/[2] via `FUN_004B3EC0`, then
// dispatches through `FUN_SPELL_CAST_DISPATCH` with the current target
// (or self). Raises "Invalid spell slot in CastSpell" for a bad slot.
// Callable from C++ by preparing the stack and tail-calling it (the
// `Spell::Tracking::SetTracking` "select = cast" path does this).
FUN_SCRIPT_CAST_SPELL = 0x004B42F0,
// Inner spell-cast dispatcher — `__fastcall(slot, bookType, targetGuidLo,
// targetGuidHi)`. `Script_CastSpellByName` calls this after resolving
// the spell name to a spellbook slot. For ground-target spells, the
@@ -6499,6 +6524,12 @@ enum Offsets {
// SPELL_ATTR_EX3_IGNORE_CASTER_MODIFIERS (verified via disassembly; the
// server enum agrees). NOT AttributesEx2's bit 29 (that's CANT_CRIT).
SPELL_ATTR_EX3_IGNORE_CASTER_MODIFIERS = 0x20000000,
// AttributesEx3 bit 18 — the paladin judgement-debuff marker. The server's
// melee-swing judgement refresh selects auras by SPELLFAMILY_PALADIN +
// this bit (tortoise-wow SpellDefines.h SPELL_ATTR_EX3_ALWAYS_HIT,
// Unit::DealMeleeDamage). Verified in the client Spell.dbc: all 14
// Judgement of Light/Wisdom/Justice/Crusader debuff ranks carry it.
SPELL_ATTR_EX3_ALWAYS_HIT = 0x00040000,
// AttributesEx channel bits (CHANNELED_1 0x4 | CHANNELED_2 0x40) and the
// AttributesEx2 autorepeat flag (bit 5, Auto Shot / Shoot) — each shared
// by more than one module, so kept here rather than redefined locally.
+6 -9
View File
@@ -68,12 +68,9 @@ namespace AuctionHouse::PostItem {
namespace {
constexpr const char *kEvtStart = "AUCTION_MULTISELL_START";
constexpr const char *kEvtUpdate = "AUCTION_MULTISELL_UPDATE";
constexpr const char *kEvtFailure = "AUCTION_MULTISELL_FAILURE";
const Event::Custom::AutoReserve _r1{kEvtStart};
const Event::Custom::AutoReserve _r2{kEvtUpdate};
const Event::Custom::AutoReserve _r3{kEvtFailure};
const Event::Custom::AutoReserve _evtStart{"AUCTION_MULTISELL_START"};
const Event::Custom::AutoReserve _evtUpdate{"AUCTION_MULTISELL_UPDATE"};
const Event::Custom::AutoReserve _evtFailure{"AUCTION_MULTISELL_FAILURE"};
// Generous per-step abort bound. A split or a post is one server round-trip
// (~tens of frames); 600 frames (~10s at 60fps) means "something's wrong"
@@ -206,7 +203,7 @@ bool FindFreeGeneralSlot(void *L, int *outBag, int *outSlot) {
void Finish(bool success) {
g_job.active = false;
if (!success)
Event::Custom::Fire(Event::Custom::Lookup(kEvtFailure), "");
Event::Custom::Fire(_evtFailure.Slot(), "");
// success needs no event: the last PostWait already fired
// AUCTION_MULTISELL_UPDATE(total, total).
}
@@ -279,7 +276,7 @@ void Tick() {
// Post confirmed once the server pulls the item out of the slot.
if (w.item == nullptr || w.guid != g_job.postGuid) {
++g_job.postedStacks;
Event::Custom::Fire(Event::Custom::Lookup(kEvtUpdate), "%d%d",
Event::Custom::Fire(_evtUpdate.Slot(), "%d%d",
g_job.postedStacks, g_job.totalStacks);
g_job.phase = Phase::Idle;
g_job.waitTicks = 0;
@@ -400,7 +397,7 @@ int __fastcall Script_C_AuctionHouse_PostItem(void *L) {
g_job.runTime = runTime;
g_job.phase = Phase::Idle;
Event::Custom::Fire(Event::Custom::Lookup(kEvtStart), "");
Event::Custom::Fire(_evtStart.Slot(), "");
Game::Lua::PushBool(L, true);
return 1;
}
+78
View File
@@ -0,0 +1,78 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Paladin judgement melee refresh — client mirror of the server's white-swing
// rule (pfUI#45: the judgement debuff's timer ran to zero while the debuff
// stayed up through the paladin's auto-attacks).
//
// The server rule (tortoise-wow Unit::DealMeleeDamage, vmangos lineage): a
// melee swing that dealt ANY damage (`totalDamage > 0` — misses/dodges/parries
// deal none) refreshes every aura on the victim that the attacker cast whose
// spell is SPELLFAMILY_PALADIN with SPELL_ATTR_EX3_ALWAYS_HIT — the judgement
// marker (all 14 Judgement of Light/Wisdom/Justice/Crusader debuff ranks carry
// it, verified in the client Spell.dbc). RefreshHolder resets the holder to
// its own full duration.
//
// The edit itself is packet-silent (UpdateAuraDuration is self-scoped — the
// same blindness every Turtle::DurationMods rule works around), but its
// TRIGGER is not: DealMeleeDamage's only caller is Unit::AttackerStateUpdate
// (white swings), which then broadcasts SMSG_ATTACKERSTATEUPDATE to everyone
// in range — attacker, victim, and totalDamage are its first fields (wire
// format verified against the server's own SendAttackStateUpdate writer; see
// Offsets.h at SMSG_ATTACKERSTATEUPDATE). So the swing packet is an exact
// client-side stand-in for the refresh, for ANY paladin's judgements — ours
// or a raid member's.
//
// Ability damage (Crusader/Holy Strike) does NOT reach that server block —
// melee-class spell damage never routes through DealMeleeDamage on this core
// — so no rule is registered for it. Re-judging is covered separately: the
// judgement debuff is cast as a real triggered spell, its SMSG_SPELL_GO
// refreshes the cache entry via StoreFromCast.
//
// Not gated on Turtle::Detected(): the block is vmangos-lineage core (the
// classic "judgements are refreshed by the paladin's melee attacks"
// mechanic), and the refresh only ever touches existing judgement entries
// with a matching known caster — on a server without the mechanic the debuff
// would genuinely expire, removing the entry via OnAuraRemoved before drift
// could show.
#include "Offsets.h"
#include "aura/Source.h"
#include "net/PacketDispatch.h"
#include "net/PacketReader.h"
#include <cstdint>
namespace Aura::JudgementRefresh {
namespace {
void OnPacket(uint32_t opcode, Net::CDataStore *packet) {
if (opcode != Offsets::SMSG_ATTACKERSTATEUPDATE || packet == nullptr)
return;
Net::Read<uint32_t>(packet); // hitInfo — the damage gate below subsumes it
const uint64_t attacker = Net::ReadPackedGuid(packet);
const uint64_t victim = Net::ReadPackedGuid(packet);
const uint32_t totalDamage = Net::Read<uint32_t>(packet);
// The server's exact gate: any nonzero melee damage refreshes; a fully
// avoided/absorbed swing (totalDamage 0) does not.
if (totalDamage == 0 || attacker == 0 || victim == 0)
return;
Aura::Source::RefreshJudgements(victim, attacker);
}
const Net::PacketDispatch::AutoSubscribe _sub{&OnPacket};
} // namespace
} // namespace Aura::JudgementRefresh
+392 -3
View File
@@ -340,6 +340,10 @@ Entry *Claim(uint32_t now) {
// ---- Writes --------------------------------------------------------------
// Defined in the duration-modifier section below; used by StoreFromCast's
// refresh path too.
void SignalAuraChanged(uint64_t guid);
// 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
@@ -373,6 +377,12 @@ void StoreFromCast(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
e->casterGuid = casterGuid;
e->expirationMs = expirationMs;
e->durationMs = durationMs;
// An in-place recast refresh changes no descriptor field, so the engine
// fires no UNIT_AURA — event-driven consumers (aura-bar addons counting
// down a once-read duration) would never re-read the new timing. Signal it
// 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);
}
// The OnAuraAdded / OnAuraStacksChanged application hooks: a descriptor slot,
@@ -810,6 +820,279 @@ void OnWorldTick() {
const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
// ---- Paladin judgement identity + refresh (pfUI#45) ----------------------
//
// The judgement debuff (Judgement of Light/Wisdom/Justice/Crusader) is cast
// SERVER-side as a triggered spell whose SMSG_SPELL_GO never reaches the
// client, so its cache entry is seated caster-less by OnAuraAdded — and the
// server's refresh mechanics are keyed by caster. The one attributable
// signal is the Judgement CAST itself (SPELL_PALADIN_JUDGEMENT, the single
// spell every seal judges through): its SPELL_GO names caster and victim.
// HandleSpellGo uses it to (a) refresh existing judgement entries WITH
// adoption — a re-judge is an in-place RefreshHolder server-side, no
// descriptor change, no OnAuraAdded — and (b) arm a short window so the
// fresh debuff's application gets attributed to the judging caster. From
// then on the caster-STRICT swing refresh (Aura::JudgementRefresh's
// SMSG_ATTACKERSTATEUPDATE subscriber) can do its job.
constexpr uint32_t kSpellJudgement = 20271; // server SPELL_PALADIN_JUDGEMENT
// The server's judgement-debuff selector, verbatim from its melee-refresh
// block (tortoise-wow Unit::DealMeleeDamage): SPELLFAMILY_PALADIN +
// SPELL_ATTR_EX3_ALWAYS_HIT. All 14 debuff ranks carry it (client DBC
// verified); no shared family-flag mask exists (each seal's debuff has its
// own bit), which is why this isn't an AddDurationMod rule.
bool IsJudgementSpell(const uint8_t *rec) {
constexpr uint32_t kPaladinFamily = 10; // SPELLFAMILY_PALADIN
if (rec == nullptr)
return false;
if (*reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SPELL_RECORD_FAMILY_NAME) != kPaladinFamily)
return false;
return (*reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX3) &
Offsets::SPELL_ATTR_EX3_ALWAYS_HIT) != 0;
}
// Armed attribution: a Judgement cast went off from `caster` at `victim`;
// the triggered debuff's OnAuraAdded lands ~a server tick later (SPELL_GO
// precedes the aura's SMSG_UPDATE_OBJECT — the same ordering kEvictGraceMs
// exists for). One-shot, short TTL so a judge whose debuff never lands
// can't mis-attribute a later application.
struct JudgeArm {
uint64_t victim;
uint64_t caster;
uint32_t untilMs;
};
constexpr int kJudgeArmMax = 4; // simultaneous judging paladins in view
constexpr uint32_t kJudgeArmTtlMs = 2000;
JudgeArm g_judgeArms[kJudgeArmMax];
void ArmJudgementAttribution(uint64_t victim, uint64_t caster) {
const uint32_t now = NowMs();
JudgeArm *slot = nullptr;
for (auto &a : g_judgeArms) {
if (a.victim == victim && a.caster == caster) {
slot = &a; // re-judge inside the TTL — refresh in place
break;
}
if (slot == nullptr &&
(a.victim == 0 || Time::Clock::Reached(now, a.untilMs)))
slot = &a;
}
if (slot == nullptr)
slot = &g_judgeArms[0]; // all live (>4 paladins?) — steal the first
*slot = {victim, caster, now + kJudgeArmTtlMs};
}
// The caster armed for `victim`, or 0. One-shot: consumed on hit.
uint64_t ConsumeJudgementAttribution(uint64_t victim, uint32_t now) {
for (auto &a : g_judgeArms) {
if (a.victim != victim || a.victim == 0 ||
Time::Clock::Reached(now, a.untilMs))
continue;
const uint64_t caster = a.caster;
a = {};
return caster;
}
return 0;
}
// Shared body of the two refresh triggers. `adopt` lets the Judgement-cast
// path claim a caster-less entry (the cast is proof of ownership); the swing
// path stays caster-strict — a swing gives no proof, and adopting there
// would let any tank's white swings pin another paladin's judgement timer.
int RefreshJudgementsImpl(uint64_t unitGuid, uint64_t attackerGuid, bool adopt) {
if (unitGuid == 0 || attackerGuid == 0)
return 0;
const uint32_t now = NowMs();
int refreshed = 0;
for (int i = 0; i < g_usedHigh; ++i) {
Entry &e = g_cache[i];
if (!e.used || e.targetGuid != unitGuid)
continue;
if (e.casterGuid != attackerGuid && !(adopt && e.casterGuid == 0))
continue;
if (e.durationMs == 0)
continue; // never invent a duration
if (!IsJudgementSpell(
Spell::Lookup::RecordForID(static_cast<int>(e.spellId))))
continue;
if (e.casterGuid == 0)
e.casterGuid = attackerGuid; // adoption claim
e.expirationMs = now + e.durationMs; // RefreshHolder → own duration
e.stampMs = now;
SignalAuraChanged(e.targetGuid);
++refreshed;
}
return refreshed;
}
// ---- Triggered applications (server AddAura inference) --------------------
//
// Some server mechanics APPLY an aura as a side effect of another spell via a
// direct AddAura — no cast, no SMSG_SPELL_GO for the applied aura, and a
// duration the packet stream never carries. The application itself reaches
// the client only as a descriptor change (OnAuraAdded → caster-less, base
// duration). But the TRIGGER's SMSG_SPELL_GO is visible, so — same shape as
// the judgement attribution above — a registered trigger cast by the local
// player arms its hit targets for a short window, and the caster-less
// application that follows consumes the arm: player attribution + the rule's
// percentage of the aura's own (player-modified) base duration, mirroring
// the server's `max(1, CalculateDuration(caster) * pct / 100)`.
//
// Registered from src/turtle (Stinging Nettle: Mongoose Bite / fire-trap
// effects apply the highest known Serpent Sting rank at 20/40%). Local
// player only — a rule may be gated on a talent-granted passive
// (`gateSpellId`), and other players' talents aren't client-knowable.
struct TriggeredApplication {
uint32_t triggerSpellId; // exact trigger spell; 0 = match by family+mask below
uint32_t triggerFamily; // (triggerSpellId==0) trigger's SpellFamilyName
uint64_t triggerMask; // (triggerSpellId==0) family-flag overlap — rank-proof
uint32_t gateSpellId; // rule live only while the player knows this; 0 = always
uint32_t affectedFamily; // applied aura's SpellFamilyName
uint64_t affectedMask; // + family-flag overlap
int32_t durationPct; // applied duration = pct% of its own base
};
constexpr int kTrigAppMax = 32;
TriggeredApplication g_trigApps[kTrigAppMax];
int g_trigAppCount = 0;
// Talent gate: does the player currently know `spellID`? The engine's
// spell-knowledge bitmap (same store IsPlayerSpell reads — covers talent
// passives), checked live at trigger time so respecs are honored.
bool PlayerKnowsSpell(uint32_t spellID) {
if (spellID == 0)
return false;
auto *bitmap = Game::Read<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELL_BITMAP));
if (bitmap == nullptr)
return false;
const uint32_t spellCount = Game::Read<uint32_t>(
static_cast<uintptr_t>(Offsets::VAR_SPELL_RECORD_COUNT));
if (spellID > spellCount)
return false;
return (bitmap[spellID >> 5] & (1u << (spellID & 31))) != 0;
}
// Armed rule instance awaiting its application on `victim`. Caster is
// implicitly the local player (only player triggers arm).
struct TrigAppArm {
uint64_t victim; // 0 = empty
uint32_t affectedFamily;
uint64_t affectedMask;
int32_t durationPct;
uint32_t untilMs;
};
constexpr int kTrigAppArmMax = 8;
constexpr uint32_t kTrigAppArmTtlMs = 2000;
TrigAppArm g_trigAppArms[kTrigAppArmMax];
// Mirror the server's re-apply on an EXISTING matching aura at trigger time
// (tortoise-wow ApplyStingingNettle): an existing holder from this caster
// whose remaining time exceeds the scaled duration is kept; otherwise the
// server re-AddAura's at the scaled duration — a remove+add of the same
// spellID that reuses the descriptor slot, so NO OnAuraAdded fires and the
// armed window is never consumed (verified in-game: chained Mongoose Bites
// left the first application's timer running out). The trigger is the only
// audible signal, so the refresh happens here. A caster-less matching entry
// is adopted and stamped unconditionally: it's a nettle application whose
// arm was missed, seated by the application hook at the WRONG base duration,
// so its remaining time can't gate the mirror — and the player's trigger is
// proof of ownership, same as the judgement-cast adoption.
void RefreshTriggeredExisting(uint64_t victim, const TriggeredApplication &r,
uint32_t now) {
for (int i = 0; i < g_usedHigh; ++i) {
Entry &e = g_cache[i];
if (!e.used || e.targetGuid != victim)
continue;
const bool mine = e.casterGuid == Unit::Identity::PlayerGuid();
if (!mine && e.casterGuid != 0)
continue; // another caster's aura — not ours to touch
const uint8_t *rec =
Spell::Lookup::RecordForID(static_cast<int>(e.spellId));
if (!Spell::Lookup::IsFitToFamily(rec, r.affectedFamily,
r.affectedMask))
continue;
const uint32_t base = SpellDurationMs(rec, /*casterIsPlayer*/ true);
if (base == 0)
continue;
uint32_t newMs = base * static_cast<uint32_t>(r.durationPct) / 100;
if (newMs == 0)
newMs = 1;
// The server's keep-the-longer guard, applied only to trusted
// (player-stamped) timing. An elapsed timer yields Remaining 0 and
// refreshes — the invisible server re-adds are exactly what let it
// elapse while the aura stayed up.
if (mine && e.expirationMs != 0 &&
Time::Clock::Remaining(now, e.expirationMs) > newMs)
continue;
e.casterGuid = Unit::Identity::PlayerGuid();
e.durationMs = newMs;
e.expirationMs = now + newMs;
e.stampMs = now;
SignalAuraChanged(victim);
break; // one aura instance per (target, spell, caster) — server touches one holder
}
}
// A player trigger cast landed on `victim`: arm the first live rule for this
// trigger (rules sharing a trigger are registration-ordered — the higher
// talent rank registers first, so its gate wins while known). Also refreshes
// an existing matching aura in place (see RefreshTriggeredExisting) — the arm
// only covers applications that reach the descriptor as a change.
void ArmTriggeredApplications(uint64_t victim, uint32_t triggerSpellId,
const uint8_t *triggerRec) {
for (int i = 0; i < g_trigAppCount; ++i) {
const TriggeredApplication &r = g_trigApps[i];
if (r.triggerSpellId != 0
? r.triggerSpellId != triggerSpellId
: !Spell::Lookup::IsFitToFamily(triggerRec, r.triggerFamily,
r.triggerMask))
continue;
if (r.gateSpellId != 0 && !PlayerKnowsSpell(r.gateSpellId))
continue;
const uint32_t now = NowMs();
RefreshTriggeredExisting(victim, r, now);
TrigAppArm *slot = nullptr;
for (auto &a : g_trigAppArms) {
if (a.victim == victim && a.affectedFamily == r.affectedFamily &&
a.affectedMask == r.affectedMask) {
slot = &a; // re-trigger inside the TTL — refresh in place
break;
}
if (slot == nullptr &&
(a.victim == 0 || Time::Clock::Reached(now, a.untilMs)))
slot = &a;
}
if (slot == nullptr)
slot = &g_trigAppArms[0]; // all live — steal the first
*slot = {victim, r.affectedFamily, r.affectedMask, r.durationPct,
now + kTrigAppArmTtlMs};
return;
}
}
// The armed duration percent for a caster-less application of `rec` on
// `victim`, or 0. One-shot: consumed on hit.
int32_t ConsumeTriggeredApplication(uint64_t victim, const uint8_t *rec,
uint32_t now) {
if (victim == 0 || rec == nullptr)
return 0;
for (auto &a : g_trigAppArms) {
if (a.victim != victim || Time::Clock::Reached(now, a.untilMs))
continue;
if (!Spell::Lookup::IsFitToFamily(rec, a.affectedFamily,
a.affectedMask))
continue;
const int32_t pct = a.durationPct;
a = {};
return pct;
}
return 0;
}
// ---- SMSG_SPELL_GO co-hook ----------------------------------------------
// The hit-target list is all we need: each entry is a unit the cast landed
@@ -847,7 +1130,27 @@ void HandleSpellGo(uint64_t caster, uint32_t spellId, const uint64_t *targets,
// before the aura gate below: the trigger spell applies no aura itself.
ApplyDurationModifiers(spellId, caster, targets, numTargets);
// Judgement: refresh-with-adoption for a re-judge (in-place server
// refresh, no aura event) and arm attribution for the fresh debuff that
// follows. Before the aura gate — Judgement itself applies no aura.
if (spellId == kSpellJudgement) {
for (int i = 0; i < numTargets; ++i) {
RefreshJudgementsImpl(targets[i], caster, /*adopt*/ true);
ArmJudgementAttribution(targets[i], caster);
}
}
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellId));
// Triggered-application rules (Stinging Nettle): a registered trigger
// cast by the local player arms its hit targets so the server-added aura
// that follows (no SPELL_GO of its own) gets player attribution + the
// rule's scaled duration. Before the aura gate — Mongoose Bite applies
// no aura of its own.
if (g_trigAppCount != 0 && caster == Unit::Identity::PlayerGuid())
for (int i = 0; i < numTargets; ++i)
ArmTriggeredApplications(targets[i], spellId, rec);
if (!SpellAppliesAura(rec))
return;
@@ -933,9 +1236,38 @@ void StampApplication(void *unit, uint32_t spellId, int slot) {
// (and attribute the caster) — SpellGo couldn't always land the talented
// value on this target's entry. Otherwise base (we lack other casters'
// mods). See WasRecentPlayerCast.
const bool byPlayer = WasRecentPlayerCast(spellId);
const uint32_t durationMs = SpellDurationMs(rec, byPlayer);
const uint64_t caster = byPlayer ? Unit::Identity::PlayerGuid() : 0;
uint64_t caster = 0;
uint32_t durationMs = 0;
// A triggered-application arm (Stinging Nettle): the server AddAura'd
// this aura off the player's trigger cast at a scaled duration —
// attribute the player and mirror the server's max(1, base × pct / 100),
// player-modified base since the player is the caster. Checked BEFORE
// the recent-cast window below: the arm names this exact TARGET, while
// WasRecentPlayerCast is spell-scoped only — a real Serpent Sting cast
// at unit A must not stamp its full duration onto a nettle application
// landing on unit B moments later. The order is safe for a real cast's
// own application: its entry is already cast-owned (StoreFromCast), and
// StoreFromApplication never overwrites an owned entry's timing.
if (const int32_t pct = ConsumeTriggeredApplication(unitGuid, rec, NowMs())) {
const uint32_t playerBase = SpellDurationMs(rec, /*player*/ true);
if (playerBase > 0) {
caster = Unit::Identity::PlayerGuid();
durationMs = playerBase * static_cast<uint32_t>(pct) / 100;
if (durationMs == 0)
durationMs = 1;
}
}
if (caster == 0) {
const bool byPlayer = WasRecentPlayerCast(spellId);
durationMs = SpellDurationMs(rec, byPlayer);
caster = byPlayer ? Unit::Identity::PlayerGuid() : 0;
// A judgement debuff is cast server-side (no SPELL_GO, so neither
// path above can name its caster) — attribute it to the paladin
// whose Judgement cast just armed this victim. See the judgement
// section.
if (caster == 0 && IsJudgementSpell(rec))
caster = ConsumeJudgementAttribution(unitGuid, NowMs());
}
const uint32_t expirationMs = durationMs > 0 ? NowMs() + durationMs : 0;
StoreFromApplication(unitGuid, spellId, caster, expirationMs, durationMs,
slot, KindForSlot(slot));
@@ -1043,6 +1375,63 @@ bool AddDurationMod(uint32_t triggerSpellId, uint32_t affectedFamily,
affectedIcon, op, valueMs);
}
namespace {
bool RegisterTriggeredApplication(uint32_t triggerSpellId,
uint32_t triggerFamily, uint64_t triggerMask,
uint32_t gateSpellId, uint32_t affectedFamily,
uint64_t affectedMask, int32_t durationPct) {
// Trigger must be identified one way or the other.
if ((triggerSpellId == 0 && (triggerFamily == 0 || triggerMask == 0)) ||
affectedMask == 0 || durationPct <= 0)
return false;
for (int i = 0; i < g_trigAppCount; ++i) { // replace an identical rule
TriggeredApplication &r = g_trigApps[i];
if (r.triggerSpellId == triggerSpellId &&
r.triggerFamily == triggerFamily && r.triggerMask == triggerMask &&
r.gateSpellId == gateSpellId && r.affectedFamily == affectedFamily &&
r.affectedMask == affectedMask) {
r.durationPct = durationPct;
return true;
}
}
if (g_trigAppCount >= kTrigAppMax)
return false;
g_trigApps[g_trigAppCount++] = {triggerSpellId, triggerFamily, triggerMask,
gateSpellId, affectedFamily,
affectedMask, durationPct};
return true;
}
} // namespace
bool AddTriggeredApplication(uint32_t triggerSpellId, uint32_t gateSpellId,
uint32_t affectedFamily, uint64_t affectedMask,
int32_t durationPct) {
return RegisterTriggeredApplication(triggerSpellId, /*triggerFamily*/ 0,
/*triggerMask*/ 0, gateSpellId,
affectedFamily, affectedMask,
durationPct);
}
bool AddTriggeredApplicationByFamily(uint32_t triggerFamily,
uint64_t triggerMask, uint32_t gateSpellId,
uint32_t affectedFamily,
uint64_t affectedMask,
int32_t durationPct) {
return RegisterTriggeredApplication(/*triggerSpellId*/ 0, triggerFamily,
triggerMask, gateSpellId,
affectedFamily, affectedMask,
durationPct);
}
int RefreshJudgements(uint64_t unitGuid, uint64_t attackerGuid) {
// Caster-strict (no adoption) — the Judgement-cast path inside
// HandleSpellGo is the one with proof of ownership; see
// RefreshJudgementsImpl.
return RefreshJudgementsImpl(unitGuid, attackerGuid, /*adopt*/ false);
}
uint32_t RefreshDurationByFamily(uint64_t unitGuid, uint32_t family,
uint64_t mask, uint32_t icon,
uint64_t casterGuid) {
+48
View File
@@ -69,6 +69,21 @@ uint32_t RefreshDurationByFamily(uint64_t unitGuid, uint32_t family,
uint64_t mask, uint32_t icon,
uint64_t casterGuid);
// Mirrors the server's melee-swing judgement refresh (tortoise-wow
// Unit::DealMeleeDamage): a white swing from `attackerGuid` that dealt any
// damage refreshes every aura it cast on `unitGuid` whose spell is
// SPELLFAMILY_PALADIN with SPELL_ATTR_EX3_ALWAYS_HIT (the judgement-debuff
// marker — all Judgement of Light/Wisdom/Justice/Crusader ranks) back to its
// own applied duration. Caster-strict: entries with an unknown caster are
// left alone (a swing gives no proof of ownership). The caster comes from
// the Judgement-cast attribution inside the SpellGo path — the debuff itself
// is cast server-side with no client-visible SPELL_GO, so the Judgement cast
// (20271) is what names the owner: it refresh-with-adoption's existing
// entries (re-judge = in-place server refresh, no aura event) and arms the
// fresh application's attribution. Returns the number refreshed. Fed by
// Aura::JudgementRefresh's SMSG_ATTACKERSTATEUPDATE subscriber.
int RefreshJudgements(uint64_t unitGuid, uint64_t attackerGuid);
// Op codes for AddDurationMod (mirror the Lua op strings).
enum DurationModOp {
DURATION_MOD_REFRESH = 0,
@@ -88,6 +103,39 @@ bool AddDurationMod(uint32_t triggerSpellId, uint32_t affectedFamily,
uint64_t affectedMask, uint32_t affectedIcon, int op,
int32_t valueMs);
// Register a server "triggered application" rule from C++: `triggerSpellId`'s
// SMSG_SPELL_GO — local player casts only — arms its hit targets for a short
// window, and a caster-less aura application on an armed target matching
// `affectedFamily` + a family-flag overlap with `affectedMask` is attributed
// to the player at `durationPct` percent of the aura's own player-modified
// base duration (the server's `max(1, CalculateDuration(caster) * pct / 100)`).
// For server mechanics that AddAura an aura off another spell's hit with no
// cast packet of its own (Turtle's Stinging Nettle: Mongoose Bite / fire-trap
// effects apply the highest known Serpent Sting rank at 20/40% duration).
// A re-trigger over an EXISTING matching aura is also handled: the server's
// re-add reuses the descriptor slot (no application event), so the trigger
// additionally refreshes the player's existing cached entry in place,
// honoring the server's keep-the-longer-remaining guard.
// `gateSpellId` (0 = none) keeps the rule live only while the player knows
// that spell (a talent-granted passive), read from the spell-knowledge bitmap
// at trigger time so respecs are honored; rules matching the same trigger are
// tried in registration order and the first live one arms (register the
// higher talent rank first). Returns false on bad input or a full table.
bool AddTriggeredApplication(uint32_t triggerSpellId, uint32_t gateSpellId,
uint32_t affectedFamily, uint64_t affectedMask,
int32_t durationPct);
// Family-mask trigger form of the above: the trigger matches any cast whose
// spell is SpellFamilyName `triggerFamily` with a family-flag overlap of
// `triggerMask` — rank-proof when the bit selects exactly the trigger set
// (verify against the DBC that no unrelated spell shares it first: hunter
// 0x4 is exactly the fire-trap effects, but 0x2 is Mongoose Bite AND Raptor
// Strike, so that trigger needs the exact-ID form).
bool AddTriggeredApplicationByFamily(uint32_t triggerFamily,
uint64_t triggerMask, uint32_t gateSpellId,
uint32_t affectedFamily,
uint64_t affectedMask, int32_t durationPct);
// One cached aura, as returned by `Enumerate`.
struct CachedAura {
uint32_t spellId;
+1 -1
View File
@@ -143,7 +143,7 @@ void OnWorldTick() {
static_cast<uintptr_t>(Offsets::VAR_IN_WORLD)) == 0)
return;
g_pending = false;
const int slot = Event::Custom::Lookup(kEventName);
const int slot = _reserve.Slot();
if (slot >= 0)
Event::Custom::Fire(slot, "");
// Notify C++ subscribers of the bag-change frame (see UpdateDelayed.h).
+7 -7
View File
@@ -61,8 +61,8 @@ constexpr int kMaxWaitTicks = 1200; // give up and fire failure after this many
struct Registered {
void *cache;
const char *eventName;
uintptr_t getRecord; // this cache class's _GetRecord
const Event::Custom::AutoReserve *reserve; // completion event's reservation
uintptr_t getRecord; // this cache class's _GetRecord
Pending pending[kMaxPending];
int pendingCount;
};
@@ -79,8 +79,7 @@ Registered *Find(void *cache) {
}
void Fire(const Registered *r, uint32_t id, bool success) {
Event::Custom::FireIdSuccess(Event::Custom::Lookup(r->eventName),
static_cast<int>(id), success);
Event::Custom::FireIdSuccess(r->reserve->Slot(), static_cast<int>(id), success);
}
int FindPending(const Registered *r, uint32_t id) {
@@ -176,15 +175,16 @@ const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
} // namespace
void Register(void *cacheInstance, const char *eventName, uintptr_t getRecordAddr) {
if (cacheInstance == nullptr || eventName == nullptr || getRecordAddr == 0)
void Register(void *cacheInstance, const Event::Custom::AutoReserve *reserve,
uintptr_t getRecordAddr) {
if (cacheInstance == nullptr || reserve == nullptr || getRecordAddr == 0)
return;
if (Find(cacheInstance) != nullptr) // idempotent (safe across /reload)
return;
if (g_cacheCount >= kMaxCaches)
return;
g_caches[g_cacheCount].cache = cacheInstance;
g_caches[g_cacheCount].eventName = eventName;
g_caches[g_cacheCount].reserve = reserve;
g_caches[g_cacheCount].getRecord = getRecordAddr;
g_caches[g_cacheCount].pendingCount = 0;
++g_cacheCount;
+14 -10
View File
@@ -15,6 +15,10 @@
#include <cstdint>
namespace Event::Custom {
struct AutoReserve;
}
// Shared async-load machinery for the client query caches that aren't
// item/quest — creature, gameobject, and (potentially) the other
// SMSG_*_QUERY caches. Modeled on `Item::Data`'s pending/timeout/event
@@ -30,10 +34,9 @@
// functions, so hooking creature's and gameobject's separately is fine);
// item/quest have their own parsers and aren't touched.
//
// static const char *kEvent = "CREATURE_DATA_LOAD_RESULT";
// static const Event::Custom::AutoReserve _r{kEvent}; // reserve the event
// static const Event::Custom::AutoReserve _r{"CREATURE_DATA_LOAD_RESULT"};
// ...
// Cache::QueryLoad::Register(cacheInstance, kEvent,
// Cache::QueryLoad::Register(cacheInstance, &_r,
// Offsets::FUN_CREATURE_GET_RECORD); // at init
// Cache::QueryLoad::RequestLoad(cacheInstance, id); // fire a load
//
@@ -44,13 +47,14 @@ namespace Cache::QueryLoad {
// Register a query cache for async-load tracking. `cacheInstance` is the
// cache global (e.g. `Offsets::VAR_CREATURE_CACHE` cast to void*);
// `eventName` is a Custom event the caller has reserved via
// `Event::Custom::AutoReserve`, fired `(id, success)` on completion;
// `getRecordAddr` is the cache class's `_GetRecord` (e.g.
// `Offsets::FUN_CREATURE_GET_RECORD`). `cacheInstance`/`eventName` must
// outlive the process (pass static values). Idempotent — calling again
// for the same cache is a no-op (safe across /reload).
void Register(void *cacheInstance, const char *eventName, uintptr_t getRecordAddr);
// `reserve` is the caller's `Event::Custom::AutoReserve` for the
// completion event, fired `(id, success)`; `getRecordAddr` is the cache
// class's `_GetRecord` (e.g. `Offsets::FUN_CREATURE_GET_RECORD`).
// `cacheInstance`/`reserve` must outlive the process (pass statics).
// Idempotent — calling again for the same cache is a no-op (safe across
// /reload).
void Register(void *cacheInstance, const Event::Custom::AutoReserve *reserve,
uintptr_t getRecordAddr);
// True if `id` is already in the cache (pure peek, no network query).
bool IsCached(void *cacheInstance, uint32_t id);
+1 -1
View File
@@ -361,7 +361,7 @@ void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetCreatureTypeIDs",
&Script_GetCreatureTypeIDs);
Cache::QueryLoad::Register(reinterpret_cast<void *>(Offsets::VAR_CREATURE_CACHE),
kCreatureDataLoadResult, Offsets::FUN_CREATURE_GET_RECORD);
&_reserveLoadResult, Offsets::FUN_CREATURE_GET_RECORD);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+3 -6
View File
@@ -386,7 +386,7 @@ int __fastcall Script_UseEquipmentSet(void *L) {
const uint32_t setID = ArgSetID(L, 1);
const Set *s = Data::FindByID(setID);
if (s == nullptr) {
const int evt = Event::Custom::Lookup(kSwapFinishedEvent);
const int evt = kEvtSwapFinished.Slot();
if (evt >= 0)
Event::Custom::Fire(evt, "%d%d", 0, static_cast<int>(setID));
Game::Lua::PushBoolean(L, 0);
@@ -396,7 +396,7 @@ int __fastcall Script_UseEquipmentSet(void *L) {
// Fire PENDING right after the set-exists check, before any of
// the swap work. Addon UI can use this to gate swap-in-progress
// visuals.
const int pendingEvt = Event::Custom::Lookup(kSwapPendingEvent);
const int pendingEvt = kEvtSwapPending.Slot();
if (pendingEvt >= 0)
Event::Custom::Fire(pendingEvt, "%d", static_cast<int>(setID));
@@ -569,7 +569,7 @@ int __fastcall Script_UseEquipmentSet(void *L) {
virtualPaperdoll[targetSlot - 1] = 0;
}
const int evt = Event::Custom::Lookup(kSwapFinishedEvent);
const int evt = kEvtSwapFinished.Slot();
if (evt >= 0)
Event::Custom::Fire(evt, "%d%d", 1, static_cast<int>(setID));
@@ -620,8 +620,5 @@ static void RegisterLuaFunctions() {
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
static const Event::Custom::AutoReserve _reserve{kEventName};
static const Event::Custom::AutoReserve _reservePending{kSwapPendingEvent};
static const Event::Custom::AutoReserve _reserveFinished{kSwapFinishedEvent};
} // namespace EquipmentSet::Api
+1 -1
View File
@@ -75,7 +75,7 @@ uint32_t NextAvailableID() {
}
void FireChanged() {
const int slot = Event::Custom::Lookup(kEventName);
const int slot = kEvtSetsChanged.Slot();
if (slot >= 0)
Event::Custom::Fire(slot, "");
}
+8 -5
View File
@@ -13,6 +13,8 @@
#pragma once
#include "event/Custom.h"
#include <cstdint>
#include <string>
@@ -109,8 +111,9 @@ constexpr int LOC_MISSING = -1;
// coincidence, not load-bearing).
constexpr int LOC_IGNORED = 1;
// Custom event names. All three are reserved at module-init time
// via `Event::Custom::AutoReserve` in `Api.cpp`.
// Custom event reservations, shared by the Data and Api TUs (C++17 inline
// variables — one instance each; the AutoReserve ctor dedups by name anyway).
// Fire via `kEvt*.Slot()`.
//
// EQUIPMENT_SETS_CHANGED — no payload; fires on any mutation
// (create / save / modify / delete /
@@ -130,8 +133,8 @@ constexpr int LOC_IGNORED = 1;
// pickup→equip pair actually completed,
// matching modern's "we dispatched"
// definition of success).
constexpr const char *kEventName = "EQUIPMENT_SETS_CHANGED";
constexpr const char *kSwapPendingEvent = "EQUIPMENT_SWAP_PENDING";
constexpr const char *kSwapFinishedEvent = "EQUIPMENT_SWAP_FINISHED";
inline const Event::Custom::AutoReserve kEvtSetsChanged{"EQUIPMENT_SETS_CHANGED"};
inline const Event::Custom::AutoReserve kEvtSwapPending{"EQUIPMENT_SWAP_PENDING"};
inline const Event::Custom::AutoReserve kEvtSwapFinished{"EQUIPMENT_SWAP_FINISHED"};
} // namespace EquipmentSet
+10 -10
View File
@@ -244,25 +244,25 @@ void Grow() {
} // namespace
AutoReserve::AutoReserve(const char *name) {
if (name == nullptr || g_reservedCount >= MAX_RESERVED)
if (name == nullptr)
return;
// Dedup: a name reserved twice resolves both instances to the one entry.
for (int i = 0; i < g_reservedCount; ++i) {
if (std::strcmp(g_reserved[i].name, name) == 0)
if (std::strcmp(g_reserved[i].name, name) == 0) {
index_ = i;
return;
}
}
if (g_reservedCount >= MAX_RESERVED)
return; // overflow — index_ stays -1, Slot() reports unclaimed
g_reserved[g_reservedCount].name = name;
g_reserved[g_reservedCount].slot = -1;
index_ = g_reservedCount;
++g_reservedCount;
}
int Lookup(const char *name) {
if (name == nullptr)
return -1;
for (int i = 0; i < g_reservedCount; ++i) {
if (std::strcmp(g_reserved[i].name, name) == 0)
return g_reserved[i].slot;
}
return -1;
int AutoReserve::Slot() const {
return index_ >= 0 ? g_reserved[index_].slot : -1;
}
int LookupByName(const char *name) {
+22 -17
View File
@@ -37,31 +37,36 @@ namespace Event::Custom {
// DLL operate on the table independently of the others.
//
// The name pointer must outlive the engine (a string literal does).
// Same name reserved twice is deduped; reserving more than `MAX_RESERVED`
// names total (see Custom.cpp) silently drops the overflow — keep that cap
// above the codebase-wide `AutoReserve` count.
// Same name reserved twice is deduped (both instances resolve to the same
// reservation); reserving more than `MAX_RESERVED` names total (see
// Custom.cpp) silently drops the overflow — keep that cap above the
// codebase-wide `AutoReserve` count.
//
// Fire through the instance: `Fire(_r.Slot(), …)`. `Slot()` is the O(1)
// read of the reservation's current slot id — it resolves through the
// shared reservation table, so reload invalidation (`PrepareForReload`)
// and re-claiming (`RetryClaims`) stay centralized and the value is never
// stale. Returns -1 while unclaimed (before the first Lua-side
// `RegisterEvent` triggers `RetryClaims`), which `Fire` treats as a no-op.
// Do NOT cache the return across frames — read it at fire time (the read
// is two loads; there is nothing to save).
struct AutoReserve {
explicit AutoReserve(const char *name);
};
int Slot() const;
// Returns the slot id currently assigned to `name`, or -1 if not yet
// claimed (e.g. before the first Lua-side `RegisterEvent` has
// triggered `RetryClaims`). Slot indices may change across `/reload`,
// so call this at fire time rather than caching the value.
//
// Only consults our reservation cache — for engine-defined events
// (UNIT_FACTION, BAG_UPDATE, etc.) use `LookupByName`.
int Lookup(const char *name);
private:
int index_ = -1; // into the reservation table; -1 = dropped (overflow)
};
// Walks the live engine event table at `[VAR_EVENT_TABLE_BASE_PTR]`
// looking for an entry whose name strcmps equal to `name`. Returns
// the slot index, or -1 if no entry matches.
//
// Works for both engine-defined events AND our `AutoReserve`-claimed
// custom events (both populate the same table). Slightly slower than
// `Lookup` (O(table size) vs O(reservations)) — prefer `Lookup` for
// names we've reserved. Use this for engine-defined events we want to
// fire ourselves (e.g., polyfilling missing event dispatches).
// custom events (both populate the same table). O(table size) — for
// names we've reserved, prefer the O(1) `AutoReserve::Slot()`. Use this
// for engine-defined events we want to fire ourselves (e.g., polyfilling
// missing event dispatches).
int LookupByName(const char *name);
// True iff at least one frame is currently registered for the event in
@@ -88,7 +93,7 @@ bool HasListeners(int slot);
// Fire(slot, "%s%d", keyName, down); // MODIFIER_STATE_CHANGED(key, down)
//
// No-op for `eventID < 0`, which lets callers cheaply guard on the
// `Lookup()` result without an explicit if.
// `Slot()` result without an explicit if.
template <typename... Args>
inline void Fire(int eventID, const char *format, Args... args) {
if (eventID < 0)
+1 -1
View File
@@ -98,7 +98,7 @@ void __fastcall FireNotify_h(int factionID, int delta) {
// any addon relying on the chat event firing first would expect.
FireNotify_o(factionID, delta);
const int slot = Event::Custom::Lookup(kEventName);
const int slot = _reserve.Slot();
if (slot >= 0)
Event::Custom::Fire(slot, "%d%d%d", factionID, newStanding, delta);
+1 -1
View File
@@ -107,7 +107,7 @@ void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_GameObjectInfo", "RequestLoadGameObjectByID",
&Script_RequestLoadGameObjectByID);
Cache::QueryLoad::Register(reinterpret_cast<void *>(Offsets::VAR_GAMEOBJECT_CACHE),
kGameObjectDataLoadResult, Offsets::FUN_GAMEOBJECT_GET_RECORD);
&_reserveLoadResult, Offsets::FUN_GAMEOBJECT_GET_RECORD);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+3 -5
View File
@@ -64,8 +64,8 @@ namespace Input::GlobalMouse {
namespace {
constexpr const char *kDownEvent = "GLOBAL_MOUSE_DOWN";
constexpr const char *kUpEvent = "GLOBAL_MOUSE_UP";
const Event::Custom::AutoReserve _reserveDown{"GLOBAL_MOUSE_DOWN"};
const Event::Custom::AutoReserve _reserveUp{"GLOBAL_MOUSE_UP"};
// Held-state bitmap — bit `i` set means the button with 1-based
// `IsMouseButtonDown` ID `i+1` is currently held. Modern API IDs:
@@ -154,7 +154,7 @@ void ProcessMouseMessage(UINT msg, WPARAM wParam) {
else g_mouseButtonMask &= ~bit;
}
const int slot = Event::Custom::Lookup(down ? kDownEvent : kUpEvent);
const int slot = (down ? _reserveDown : _reserveUp).Slot();
if (slot >= 0)
Event::Custom::Fire(slot, "%s", name);
}
@@ -241,8 +241,6 @@ void RegisterLuaFunctions() {
InstallHook();
}
const Event::Custom::AutoReserve _reserveDown{kDownEvent};
const Event::Custom::AutoReserve _reserveUp{kUpEvent};
const Tick::WorldTick::AutoSubscribe _evict{&EvictTick};
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+3 -5
View File
@@ -102,11 +102,9 @@ void SetBit(int bitIdx, bool down) {
if (down) g_modifierMask |= bit;
else g_modifierMask &= ~bit;
// Slot was assigned when the engine populated its event table; our
// hook on `RebuildEventTable` recorded the index. Lookup at fire
// time rather than caching because `/reload` re-runs the rebuild
// and the index may shift.
const int slot = Event::Custom::Lookup(kModifierStateChanged);
// Read the slot at fire time (never cache it) — `/reload` rebuilds the
// event table and the reservation re-claims, which may shift the index.
const int slot = _reserveModifierStateChanged.Slot();
if (slot >= 0)
Event::Custom::Fire(slot, "%s%d", kKeyName[bitIdx], static_cast<int>(down));
}
+4 -4
View File
@@ -58,13 +58,13 @@ static const Event::Custom::AutoReserve _reserveItemDataLoadResult{kItemDataLoad
static const Event::Custom::AutoReserve _reserveGetItemInfoReceived{kGetItemInfoReceived};
static void FireGetItemInfoReceived(int itemID, bool success) {
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kGetItemInfoReceived),
itemID, success);
Event::Custom::FireIdSuccess(_reserveGetItemInfoReceived.Slot(), itemID,
success);
}
static void FireItemDataLoadResult(int itemID, bool success) {
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kItemDataLoadResult),
itemID, success);
Event::Custom::FireIdSuccess(_reserveItemDataLoadResult.Slot(), itemID,
success);
}
// Item-load callbacks are __stdcall, 2 args / 8 bytes — matching the
+1 -1
View File
@@ -318,7 +318,7 @@ void AppendNonBagOwned(uint64_t *out, int *count) {
}
void FireChanged() {
Event::Custom::Fire(Event::Custom::Lookup(kEvtNewItems), "");
Event::Custom::Fire(_r.Slot(), "");
}
// Login-baseline handler. Runs every frame but does no work once the
+4 -5
View File
@@ -344,7 +344,7 @@ StartFn_t g_origStart = nullptr;
// structurally: a new item appears or the ring evicts an old one (shifting
// every index).
void FireFullUpdate() {
Event::Custom::Fire(Event::Custom::Lookup(kFullUpdate), "");
Event::Custom::Fire(_evFullUpdate.Slot(), "");
}
// START_ROLL: let the original build the store node, then read the item's
@@ -373,8 +373,7 @@ void __fastcall Roll_h(void *msg, void *ctx, int a3) {
if (structural)
FireFullUpdate();
if (itemIdx > 0)
Event::Custom::Fire(Event::Custom::Lookup(kRollChanged), "%d%d", itemIdx,
playerIdx);
Event::Custom::Fire(_evRollChanged.Slot(), "%d%d", itemIdx, playerIdx);
}
void __fastcall Won_h(void *ctx, void *msg, int a3) {
@@ -388,7 +387,7 @@ void __fastcall Won_h(void *ctx, void *msg, int a3) {
if (structural)
FireFullUpdate();
if (itemIdx > 0)
Event::Custom::Fire(Event::Custom::Lookup(kRollComplete), "%d", itemIdx);
Event::Custom::Fire(_evRollComplete.Slot(), "%d", itemIdx);
}
void __fastcall Passed_h(void *ctx, void *msg) {
@@ -402,7 +401,7 @@ void __fastcall Passed_h(void *ctx, void *msg) {
if (structural)
FireFullUpdate();
if (itemIdx > 0)
Event::Custom::Fire(Event::Custom::Lookup(kRollComplete), "%d", itemIdx);
Event::Custom::Fire(_evRollComplete.Slot(), "%d", itemIdx);
}
const Game::HookAutoRegister _hookRoll{
+1 -1
View File
@@ -267,7 +267,7 @@ void Complete() {
s_scan.currentGuid = 0;
s_scan.suppressEvents = false;
s_scan.lootMode = false;
Event::Custom::Fire(Event::Custom::Lookup(kEventCompleted), "");
Event::Custom::Fire(_reserve.Slot(), "");
}
void TryStartNext() {
+4 -6
View File
@@ -223,8 +223,8 @@ int BuildList(LocEntry *out, int maxOut) {
// lesson). The scan is bounded (16 debuff slots) and early-outs when idle, so
// the per-frame cost is negligible.
constexpr const char *kEventAdded = "LOSS_OF_CONTROL_ADDED";
constexpr const char *kEventUpdate = "LOSS_OF_CONTROL_UPDATE";
const Event::Custom::AutoReserve _reserveAdded{"LOSS_OF_CONTROL_ADDED"};
const Event::Custom::AutoReserve _reserveUpdate{"LOSS_OF_CONTROL_UPDATE"};
// Identity of an active effect for frame-to-frame diffing: a CC is keyed by
// spellID, a school interrupt by its school mask (spellID 0).
@@ -255,7 +255,7 @@ void OnWorldTick() {
for (int i = 0; i < n; ++i) {
if (!ContainsKey(g_prev, g_prevCount, cur[i])) {
// A newly-applied effect -> LOSS_OF_CONTROL_ADDED(eventIndex).
Event::Custom::Fire(Event::Custom::Lookup(kEventAdded), "%d", i + 1);
Event::Custom::Fire(_reserveAdded.Slot(), "%d", i + 1);
changed = true;
}
}
@@ -269,15 +269,13 @@ void OnWorldTick() {
// forward-compatible with per-unit tracking and matches modern WoW, whose
// LOSS_OF_CONTROL_UPDATE carries the unit token.
if (changed)
Event::Custom::Fire(Event::Custom::Lookup(kEventUpdate), "%s", "player");
Event::Custom::Fire(_reserveUpdate.Slot(), "%s", "player");
for (int i = 0; i < n; ++i)
g_prev[i] = cur[i];
g_prevCount = n;
}
static const Event::Custom::AutoReserve _reserveAdded{kEventAdded};
static const Event::Custom::AutoReserve _reserveUpdate{kEventUpdate};
static const Tick::WorldTick::AutoSubscribe _tick{&OnWorldTick};
// `C_LossOfControl.GetActiveLossOfControlDataCount()` -> number.
+11 -15
View File
@@ -59,13 +59,9 @@ namespace NamePlate::Events {
namespace {
constexpr const char *kEventCreated = "NAME_PLATE_CREATED";
constexpr const char *kEventUnitAdded = "NAME_PLATE_UNIT_ADDED";
constexpr const char *kEventUnitRemoved = "NAME_PLATE_UNIT_REMOVED";
const Event::Custom::AutoReserve _r1{kEventCreated};
const Event::Custom::AutoReserve _r2{kEventUnitAdded};
const Event::Custom::AutoReserve _r3{kEventUnitRemoved};
const Event::Custom::AutoReserve _evtCreated{"NAME_PLATE_CREATED"};
const Event::Custom::AutoReserve _evtUnitAdded{"NAME_PLATE_UNIT_ADDED"};
const Event::Custom::AutoReserve _evtUnitRemoved{"NAME_PLATE_UNIT_REMOVED"};
// Previous tick's snapshot — GUID → nameplate-frame pointer for each
// nameplated unit. Compared against the next tick's walk to compute
@@ -112,14 +108,14 @@ int AssignSlot(uint64_t guid) {
return static_cast<int>(g_slots.size() - 1);
}
// Fire `eventName` with a pre-formatted string as `arg1`. The engine
// Fire `event` with a pre-formatted string as `arg1`. The engine
// dispatcher's `%s` format code pushes the C string into `_G.arg1`
// as a Lua string — no escaping concerns for our own input
// (`"nameplateN"`).
void FireWithString(const char *eventName, const char *value) {
void FireWithString(const Event::Custom::AutoReserve &event, const char *value) {
if (value == nullptr)
return;
const int slot = Event::Custom::Lookup(eventName);
const int slot = event.Slot();
if (slot < 0)
return;
Event::Custom::Fire(slot, "%s", value);
@@ -172,10 +168,10 @@ using LuaRefRef_t = int(__fastcall *)(void *L, int t);
using LuaRefUnref_t = void(__fastcall *)(void *L, int t, int ref);
using LuaRawGetI_t = void(__fastcall *)(void *L, int t, int n);
void FireWithFrame(const char *eventName, void *frame) {
void FireWithFrame(const Event::Custom::AutoReserve &event, void *frame) {
if (frame == nullptr)
return;
const int slot = Event::Custom::Lookup(eventName);
const int slot = event.Slot();
if (slot < 0)
return;
@@ -229,7 +225,7 @@ void OnWorldTick() {
// event handler.
for (const auto &kv : g_currentTickPlates) {
if (g_seenPlates.insert(kv.second).second)
FireWithFrame(kEventCreated, const_cast<void *>(kv.second));
FireWithFrame(_evtCreated, const_cast<void *>(kv.second));
if (g_lastTickPlates.find(kv.first) == g_lastTickPlates.end()) {
const int slot = AssignSlot(kv.first);
// Watch this unit's fields so UNIT_HEALTH/UNIT_AURA/… fire with
@@ -237,7 +233,7 @@ void OnWorldTick() {
// target/party/raid tokens, not nameplates).
Unit::TokenObserver::Register(kv.first, &NamePlateFieldCb);
char tokenBuf[24];
FireWithString(kEventUnitAdded,
FireWithString(_evtUnitAdded,
FormatNamePlateToken(tokenBuf, sizeof tokenBuf, slot + 1));
}
}
@@ -254,7 +250,7 @@ void OnWorldTick() {
continue;
const int oneBased = static_cast<int>(it - g_slots.begin()) + 1;
char tokenBuf[24];
FireWithString(kEventUnitRemoved,
FireWithString(_evtUnitRemoved,
FormatNamePlateToken(tokenBuf, sizeof tokenBuf, oneBased));
Unit::TokenObserver::Unregister(kv.first, &NamePlateFieldCb);
*it = 0; // free the slot (no shift of survivors)
+2 -3
View File
@@ -109,8 +109,7 @@ int __fastcall EquipSlotChanged_cb(uint32_t fieldOffset, uint32_t /*size*/,
const int slot0 =
static_cast<int>(fieldOffset - Offsets::OFF_DESC_PLAYER_EQUIP_FIRST) >> 3;
if (slot0 >= 0 && slot0 < Offsets::DESC_PLAYER_EQUIP_SLOTS) {
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kEvtEquipmentChanged),
slot0 + 1,
Event::Custom::FireIdSuccess(_r.Slot(), slot0 + 1,
SlotHasItem(guidLo, guidHi, slot0));
// Gear swap changes item +healing and Spirit/Armor — invalidate the
// GetSpellBonusHealing cache.
@@ -225,7 +224,7 @@ void __fastcall AlertsRecompute_h() {
return;
}
if (changed)
Event::Custom::Fire(Event::Custom::Lookup(kEvtDurability), "");
Event::Custom::Fire(_r2.Slot(), "");
}
static const Game::HookAutoRegister _alertsHook{
+1 -2
View File
@@ -68,8 +68,7 @@ void __fastcall BindPointUpdateHandler_h(void *packetBuffer) {
static_cast<uintptr_t>(Offsets::VAR_IN_WORLD)) == 0)
return;
const int slot = Event::Custom::Lookup(kEventName);
Event::Custom::Fire(slot, "");
Event::Custom::Fire(_reserve.Slot(), "");
}
} // namespace
+12 -18
View File
@@ -43,12 +43,12 @@ namespace Player::InputEvents {
namespace {
constexpr const char *kStartedMovingEvent = "PLAYER_STARTED_MOVING";
constexpr const char *kStoppedMovingEvent = "PLAYER_STOPPED_MOVING";
constexpr const char *kStartedLookingEvent = "PLAYER_STARTED_LOOKING";
constexpr const char *kStoppedLookingEvent = "PLAYER_STOPPED_LOOKING";
constexpr const char *kStartedTurningEvent = "PLAYER_STARTED_TURNING";
constexpr const char *kStoppedTurningEvent = "PLAYER_STOPPED_TURNING";
const Event::Custom::AutoReserve _reserveStartedMoving{"PLAYER_STARTED_MOVING"};
const Event::Custom::AutoReserve _reserveStoppedMoving{"PLAYER_STOPPED_MOVING"};
const Event::Custom::AutoReserve _reserveStartedLooking{"PLAYER_STARTED_LOOKING"};
const Event::Custom::AutoReserve _reserveStoppedLooking{"PLAYER_STOPPED_LOOKING"};
const Event::Custom::AutoReserve _reserveStartedTurning{"PLAYER_STARTED_TURNING"};
const Event::Custom::AutoReserve _reserveStoppedTurning{"PLAYER_STOPPED_TURNING"};
// Below this per-frame delta, treat the yaw as stable. Picks up
// real rotation (smallest user-perceptible RMB-drag is ~0.5°/frame
@@ -85,12 +85,12 @@ const uint8_t *Camera() {
gameState, Offsets::OFF_GAME_STATE_CAMERA_PTR);
}
void FireTransition(bool now, bool &prev, const char *startedName,
const char *stoppedName) {
void FireTransition(bool now, bool &prev, const Event::Custom::AutoReserve &started,
const Event::Custom::AutoReserve &stopped) {
if (now == prev)
return;
prev = now;
const int slot = Event::Custom::Lookup(now ? startedName : stoppedName);
const int slot = (now ? started : stopped).Slot();
if (slot >= 0)
Event::Custom::Fire(slot, "");
}
@@ -172,19 +172,13 @@ void OnWorldTick() {
g_havePrevCameraYaw = true;
}
FireTransition(moving, g_wasMoving, kStartedMovingEvent, kStoppedMovingEvent);
FireTransition(turning, g_wasTurning, kStartedTurningEvent, kStoppedTurningEvent);
FireTransition(looking, g_wasLooking, kStartedLookingEvent, kStoppedLookingEvent);
FireTransition(moving, g_wasMoving, _reserveStartedMoving, _reserveStoppedMoving);
FireTransition(turning, g_wasTurning, _reserveStartedTurning, _reserveStoppedTurning);
FireTransition(looking, g_wasLooking, _reserveStartedLooking, _reserveStoppedLooking);
}
} // namespace
static const Event::Custom::AutoReserve _reserveStartedMoving{kStartedMovingEvent};
static const Event::Custom::AutoReserve _reserveStoppedMoving{kStoppedMovingEvent};
static const Event::Custom::AutoReserve _reserveStartedLooking{kStartedLookingEvent};
static const Event::Custom::AutoReserve _reserveStoppedLooking{kStoppedLookingEvent};
static const Event::Custom::AutoReserve _reserveStartedTurning{kStartedTurningEvent};
static const Event::Custom::AutoReserve _reserveStoppedTurning{kStoppedTurningEvent};
static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
} // namespace Player::InputEvents
+3 -3
View File
@@ -33,8 +33,8 @@ static const Event::Custom::AutoReserve _reserveQuestDataLoadResult{kQuestDataLo
static void __stdcall QuestLoadCallback(void *userData, int success) {
const auto questID =
static_cast<int>(reinterpret_cast<uintptr_t>(userData));
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kQuestDataLoadResult),
questID, success != 0);
Event::Custom::FireIdSuccess(_reserveQuestDataLoadResult.Slot(), questID,
success != 0);
}
static void RequestAndMaybeNotify(uint32_t questID) {
@@ -45,7 +45,7 @@ static void RequestAndMaybeNotify(uint32_t questID) {
// invoking our callback. Fire the event ourselves so addons get the
// notification regardless of cache state, matching modern semantics.
if (wasCached) {
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kQuestDataLoadResult),
Event::Custom::FireIdSuccess(_reserveQuestDataLoadResult.Slot(),
static_cast<int>(questID), true);
}
}
+2 -2
View File
@@ -157,7 +157,7 @@ void __fastcall QuestLogRebuild_h(int param_1) {
// (login or post-reload). Modern WoW's QUEST_ACCEPTED doesn't
// fire on initial sync either; we match that.
if (newCount == 1) {
const int slot = Event::Custom::Lookup(kAcceptedEventName);
const int slot = _reserveAccepted.Slot();
if (slot >= 0)
Event::Custom::Fire(slot, "%d%d", firstNewIdx, firstNewID);
}
@@ -168,7 +168,7 @@ void __fastcall QuestLogRebuild_h(int param_1) {
// all but one of the previous character's quests (1 removal, N
// additions in the same rebuild).
if (removedCount == 1 && newCount <= 1) {
const int slot = Event::Custom::Lookup(kRemovedEventName);
const int slot = _reserveRemoved.Slot();
if (slot >= 0)
Event::Custom::Fire(slot, "%d", firstRemovedID);
}
+1 -2
View File
@@ -77,8 +77,7 @@ int __fastcall Handler_h(void *thisPlayer, void *edx, void *packetStream) {
const int result = Handler_o(thisPlayer, edx, packetStream);
if (questID > 0) {
const int slot = Event::Custom::Lookup(kEventName);
Event::Custom::Fire(slot, "%u%u%u",
Event::Custom::Fire(_reserve.Slot(), "%u%u%u",
questID, xpReward, moneyReward);
}
+13 -9
View File
@@ -514,14 +514,18 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime,
now - g_cast.startMs < kCastStartDedupMs) {
// Confirming packet. Keep startMs (no visual restart), but SNAP the
// end time to the server's authoritative castTime. The client stamp
// came from FUN_006e3340, which folds in the caster's cast-speed /
// ranged-haste multiplier (descriptor +0x22c) — right for spells the
// server also hastes (Aimed/Multi-Shot), WRONG for ones it doesn't.
// Volley is the case that surfaced this: not channeled, not sped by
// Quick Shots server-side, so the hasted prediction ended the bar
// early and the player was "still casting after the bar finished."
// The server's castTime is reality; reconcile to it (preserving any
// pushback already accumulated). A no-op when client and server agree.
// came from FUN_006e3340, whose only caster factor is the cast-speed
// multiplier at descriptor +0x22c (UNIT_MOD_CAST_SPEED — spell
// haste); the server additionally scales ranged ABILITIES by ranged
// attack speed (SpellEntry::GetCastTime's IsRangedSpell branch,
// m_modAttackSpeedPct[RANGED_ATTACK] — quiver/Quick Shots), which
// the prediction can't see. Two real divergences so far: Volley
// (client +0x22c hastes it, server doesn't → bar ended early) and
// Steady/Aimed Shot (server ranged-hastes it, client doesn't → bar
// ran long, pfUI#43). The server's castTime is reality; reconcile
// to it (preserving accumulated pushback). A no-op when they agree;
// when they differ, PollPlayer sees the end move and fires
// UNIT_SPELLCAST_DELAYED so cast bars re-read the corrected times.
g_cast.endMs =
g_cast.startMs + static_cast<int>(castTime) + g_cast.delayMs;
g_castTargetGuid = targetGuid; // confirming packet carries the target
@@ -837,7 +841,7 @@ void OnWorldTick() {
// based so it needs no changes to the many stamp/clear sites; ~1 frame
// latency is imperceptible for a cast bar, and every fire is
// listener-gated (near-free when no addon uses these).
Spell::CastEvents::PollPlayer(g_cast.spellID, g_cast.startMs, g_cast.delayMs,
Spell::CastEvents::PollPlayer(g_cast.spellID, g_cast.startMs, g_cast.endMs,
g_channel.spellID, g_channel.startMs);
Spell::CastEvents::PollRemote();
Spell::CastEvents::PollReticle();
+35 -41
View File
@@ -31,33 +31,20 @@ namespace Spell::CastEvents {
namespace {
constexpr const char *kStart = "UNIT_SPELLCAST_START";
constexpr const char *kStop = "UNIT_SPELLCAST_STOP";
constexpr const char *kDelayed = "UNIT_SPELLCAST_DELAYED";
constexpr const char *kChannelStart = "UNIT_SPELLCAST_CHANNEL_START";
constexpr const char *kChannelStop = "UNIT_SPELLCAST_CHANNEL_STOP";
constexpr const char *kChannelUpdate = "UNIT_SPELLCAST_CHANNEL_UPDATE";
constexpr const char *kSucceeded = "UNIT_SPELLCAST_SUCCEEDED";
constexpr const char *kInterrupted = "UNIT_SPELLCAST_INTERRUPTED";
constexpr const char *kFailed = "UNIT_SPELLCAST_FAILED";
constexpr const char *kFailedQuiet = "UNIT_SPELLCAST_FAILED_QUIET";
constexpr const char *kSent = "UNIT_SPELLCAST_SENT";
constexpr const char *kReticleTarget = "UNIT_SPELLCAST_RETICLE_TARGET";
constexpr const char *kReticleClear = "UNIT_SPELLCAST_RETICLE_CLEAR";
const Event::Custom::AutoReserve _rStart{kStart};
const Event::Custom::AutoReserve _rStop{kStop};
const Event::Custom::AutoReserve _rDelayed{kDelayed};
const Event::Custom::AutoReserve _rChannelStart{kChannelStart};
const Event::Custom::AutoReserve _rChannelStop{kChannelStop};
const Event::Custom::AutoReserve _rChannelUpdate{kChannelUpdate};
const Event::Custom::AutoReserve _rSucceeded{kSucceeded};
const Event::Custom::AutoReserve _rInterrupted{kInterrupted};
const Event::Custom::AutoReserve _rFailed{kFailed};
const Event::Custom::AutoReserve _rFailedQuiet{kFailedQuiet};
const Event::Custom::AutoReserve _rSent{kSent};
const Event::Custom::AutoReserve _rReticleTarget{kReticleTarget};
const Event::Custom::AutoReserve _rReticleClear{kReticleClear};
// The reservations double as the fire handles (`k*.Slot()` — O(1)).
const Event::Custom::AutoReserve kStart{"UNIT_SPELLCAST_START"};
const Event::Custom::AutoReserve kStop{"UNIT_SPELLCAST_STOP"};
const Event::Custom::AutoReserve kDelayed{"UNIT_SPELLCAST_DELAYED"};
const Event::Custom::AutoReserve kChannelStart{"UNIT_SPELLCAST_CHANNEL_START"};
const Event::Custom::AutoReserve kChannelStop{"UNIT_SPELLCAST_CHANNEL_STOP"};
const Event::Custom::AutoReserve kChannelUpdate{"UNIT_SPELLCAST_CHANNEL_UPDATE"};
const Event::Custom::AutoReserve kSucceeded{"UNIT_SPELLCAST_SUCCEEDED"};
const Event::Custom::AutoReserve kInterrupted{"UNIT_SPELLCAST_INTERRUPTED"};
const Event::Custom::AutoReserve kFailed{"UNIT_SPELLCAST_FAILED"};
const Event::Custom::AutoReserve kFailedQuiet{"UNIT_SPELLCAST_FAILED_QUIET"};
const Event::Custom::AutoReserve kSent{"UNIT_SPELLCAST_SENT"};
const Event::Custom::AutoReserve kReticleTarget{"UNIT_SPELLCAST_RETICLE_TARGET"};
const Event::Custom::AutoReserve kReticleClear{"UNIT_SPELLCAST_RETICLE_CLEAR"};
// Autoshot spams client-side failures while ramping — nampower filters it
// out of its failure event, so do we.
@@ -108,8 +95,9 @@ void BuildCastGuid(char *out, size_t n, int type, int spellID, int castUID) {
// rank)`. Gated on a listener so an unwatched event does no DBC lookups or
// string formatting. A null name/rank pushes `nil` through the dispatcher's
// `lua_pushstring(NULL) → pushnil` tail-jump, which is fine.
void Fire(const char *eventName, int spellID, int guidNum, int type = 3) {
const int slot = Event::Custom::Lookup(eventName);
void Fire(const Event::Custom::AutoReserve &event, int spellID, int guidNum,
int type = 3) {
const int slot = event.Slot();
if (!Event::Custom::HasListeners(slot))
return;
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
@@ -127,7 +115,7 @@ void Fire(const char *eventName, int spellID, int guidNum, int type = 3) {
// inserted at arg2. `target` is a unit token (e.g. "target") resolved from
// the cast packet, or "" for self / no-target casts.
void FireSent(int spellID, int guidNum, const char *target) {
const int slot = Event::Custom::Lookup(kSent);
const int slot = kSent.Slot();
if (!Event::Custom::HasListeners(slot))
return;
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
@@ -148,8 +136,8 @@ void FireSent(int spellID, int guidNum, const char *target) {
// the load-bearing fields — are exact; only arg2 differs (`""` vs `nil`),
// which is inconsequential for a reticle (no cast to identify). spellName /
// rank are our usual extension tail.
void FireReticle(const char *eventName, int spellID) {
const int slot = Event::Custom::Lookup(eventName);
void FireReticle(const Event::Custom::AutoReserve &event, int spellID) {
const int slot = event.Slot();
if (!Event::Custom::HasListeners(slot))
return;
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
@@ -176,7 +164,7 @@ int g_guidCounter = 0;
int g_castSpell = 0; // 0 = not casting
int g_castStart = 0; // startMs of the tracked cast (detects same-spell recast)
int g_castGuid = 0; // castGUID counter value for the tracked cast
int g_castDelay = 0; // last-seen accumulated pushback (ms)
int g_castEnd = 0; // last-seen endMs (any movement fires DELAYED)
bool g_castSucceeded = false; // SPELL_GO seen for the tracked cast this cast
// UNIT_SPELLCAST_INTERRUPTED de-dup. SpellFailed_h and PollPlayer can both
@@ -382,9 +370,9 @@ const Net::SendObserver::AutoSubscribe _sendSub{&OnSend};
// the payload is fanned out per token (`target` / `focus` / `nameplateN` /
// `party` / `raid` / `mouseover`). Same `(unit, castGUID, spellID, spellName,
// rank)` shape as the player events, just with a non-"player" unit.
void FireRemote(const char *eventName, uint64_t casterGuid, int spellID,
int guidNum) {
const int slot = Event::Custom::Lookup(eventName);
void FireRemote(const Event::Custom::AutoReserve &event, uint64_t casterGuid,
int spellID, int guidNum) {
const int slot = event.Slot();
if (!Event::Custom::HasListeners(slot))
return;
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
@@ -441,7 +429,7 @@ RemoteEvt *AllocRemoteEvt(uint64_t guid) {
bool PlayerCastSucceeded() { return g_castSucceeded; }
void PollPlayer(int castSpellID, int castStartMs, int castDelayMs,
void PollPlayer(int castSpellID, int castStartMs, int castEndMs,
int channelSpellID, int channelStartMs) {
// ---- Regular cast -------------------------------------------------
// A "new cast" is a different spell OR the same spell re-stamped at a
@@ -474,12 +462,18 @@ void PollPlayer(int castSpellID, int castStartMs, int castDelayMs,
g_castGuid = NextCastGuid(castSpellID);
g_castSpell = castSpellID;
g_castStart = castStartMs;
g_castDelay = castDelayMs;
g_castEnd = castEndMs;
g_castSucceeded = false;
Fire(kStart, castSpellID, g_castGuid);
} else if (g_castSpell != 0 && castDelayMs > g_castDelay) {
// Pushback grew (SMSG_SPELL_DELAYED extended the cast).
g_castDelay = castDelayMs;
} else if (g_castSpell != 0 && castEndMs != g_castEnd) {
// The cast's end moved: pushback (SMSG_SPELL_DELAYED extends it) or
// the confirming SMSG_SPELL_START snapping the client-predicted cast
// time to the server's authoritative one — the server scales ranged
// abilities by ranged attack speed (Steady/Aimed Shot), which the
// client's prediction helper doesn't model, so the confirming packet
// shortens the bar ~RTT after START (pfUI#43). Either direction, the
// addon must re-read the times; DELAYED is the re-read trigger.
g_castEnd = castEndMs;
Fire(kDelayed, g_castSpell, g_castGuid);
}
+6 -2
View File
@@ -45,11 +45,15 @@ namespace Spell::CastEvents {
// after its own upkeep, passing the live `g_cast` / `g_channel` fields.
// Detects, against the previous snapshot:
// - regular cast start / stop / same-spell recast → UNIT_SPELLCAST_START / _STOP
// - accumulated pushback growth → UNIT_SPELLCAST_DELAYED
// - end-time movement (either direction) → UNIT_SPELLCAST_DELAYED
// - channel start / stop → UNIT_SPELLCAST_CHANNEL_START / _STOP
// End-time movement covers both pushback (SMSG_SPELL_DELAYED) and the
// confirming SMSG_SPELL_START reconciling the client-predicted cast time to
// the server's (ranged-haste-scaled Steady/Aimed Shot, pfUI#43) — DELAYED is
// the "re-read the times" trigger, whichever way the end moved.
// Channels never fire INTERRUPTED — retail emits only CHANNEL_STOP for a
// channel whether it ends naturally or is cut short.
void PollPlayer(int castSpellID, int castStartMs, int castDelayMs,
void PollPlayer(int castSpellID, int castStartMs, int castEndMs,
int channelSpellID, int channelStartMs);
// Derive the ground-target reticle events from the engine's targeting flags.
+187
View File
@@ -0,0 +1,187 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "spell/Lookup.h"
#include <cstdint>
// `GetNumTrackingTypes()` / `GetTrackingInfo(index)` / `SetTracking(index)` —
// backport of the minimap-tracking enumeration the modern client exposes.
//
// Vanilla 1.12 never enumerates the tracking abilities the player KNOWS — it
// only stores the single currently-active tracker as a plain spellID in
// `VAR_ACTIVE_TRACKING_SPELL`, surfaced through `GetTrackingTexture()` /
// `CancelTrackingBuff()` / `GameTooltip:SetTrackingSpell()`. 3.3.5's
// `GetTrackingInfo` iterates a spellbook-derived list of tracking spells and
// reports name/texture/active/category per entry; we rebuild that list here.
//
// A tracking spell is identified exactly the way the engine's own active-
// tracker updater `FUN_004E4170` identifies one: a Spell.dbc record with an
// `EffectApplyAuraName` of Track Creatures (44), Track Resources (45), or
// Track Stealthed (151). That effect-based test reproduces pfUI's hardcoded
// spellID/icon table (Find Herbs/Minerals/Treasure/Trees, every Hunter
// Track*, Sense Undead/Demons, Track Humanoids) with no table to maintain,
// and auto-covers server-custom trackers such as Turtle's Find Trees (52917).
namespace Spell::Tracking {
// Tracking aura effect types — SPELL_AURA_TRACK_* ApplyAura names, the same
// values `FUN_004E4170` scans a spell's three effects for.
static constexpr int32_t AURA_TRACK_CREATURES = 44;
static constexpr int32_t AURA_TRACK_RESOURCES = 45;
static constexpr int32_t AURA_TRACK_STEALTHED = 151;
// True iff `spellID`'s record has any of the three Track* effects.
static bool IsTrackingSpell(int spellID) {
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
if (record == nullptr)
return false;
auto *aura = Game::Ptr<const int32_t>(
record, Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
if (aura[i] == AURA_TRACK_CREATURES || aura[i] == AURA_TRACK_RESOURCES ||
aura[i] == AURA_TRACK_STEALTHED)
return true;
}
return false;
}
// Walks the player spellbook in slot order counting tracking spells. If
// `wantNth >= 1` and an Nth tracking spell exists, writes its spellID and
// 1-based spellbook slot to `*outSpellID` / `*outSlot`. Returns the total
// number of tracking spells found (so callers detect out-of-range by
// comparing the return against `wantNth`).
//
// The spellbook array is zero-padded past the populated count (see
// `Spell::Lookup::FindSpellbookSlot`), so a 0 entry is just an empty slot —
// skip it and keep scanning the full range rather than break, matching the
// existing spellbook walkers.
static int EnumerateTracking(int wantNth, int *outSpellID, int *outSlot) {
auto *book = reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELLBOOK));
int found = 0;
for (int i = 0; i < Offsets::SPELLBOOK_MAX_SLOTS; ++i) {
const int spellID = book[i];
if (spellID <= 0 || !IsTrackingSpell(spellID))
continue;
++found;
if (found == wantNth) {
if (outSpellID != nullptr)
*outSpellID = spellID;
if (outSlot != nullptr)
*outSlot = i + 1;
}
}
return found;
}
// `GetNumTrackingTypes()` -> number of tracking spells in the player's book.
static int __fastcall Script_GetNumTrackingTypes(void *L) {
Game::Lua::PushNumber(L,
static_cast<double>(EnumerateTracking(0, nullptr, nullptr)));
return 1;
}
// `GetTrackingInfo(index)` -> name, texture, active, category, spellID.
// - index is 1-based into the tracking list (spellbook order).
// - texture is the SpellIcon.dbc path (nil if the icon is missing).
// - active is a boolean: the tracker currently in effect.
// - category is always "spell" — vanilla has no non-spell ("other")
// trackers, unlike 3.3.5's class-masked table.
// - spellID is a ClassicAPI extension (the modern 5th return is `nested`,
// which has no meaning here); handy since selecting/among trackers is
// spellID-driven.
// Returns nil for an out-of-range index.
static int __fastcall Script_GetTrackingInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: GetTrackingInfo(index)");
return 0;
}
const int index = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (index < 1)
return 0;
int spellID = 0;
int slot = 0;
if (EnumerateTracking(index, &spellID, &slot) < index)
return 0; // index past the number of tracking types -> nil
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
const int locale = Game::Read<int>(static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
const char *name =
Game::Read<const char *>(record, Offsets::OFF_SPELL_NAMES + locale * 4);
const char *texture = nullptr;
const int iconID = Game::Read<int>(record, Offsets::OFF_SPELL_RECORD_ICON_ID);
if (auto *iconRec = DBC::Record(Offsets::VAR_SPELL_ICON_RECORDS,
Offsets::VAR_SPELL_ICON_COUNT,
static_cast<uint32_t>(iconID))) {
texture = Game::Read<const char *>(iconRec, Offsets::OFF_SPELLICON_PATH);
}
const bool active =
Game::Read<int>(static_cast<uintptr_t>(Offsets::VAR_ACTIVE_TRACKING_SPELL)) ==
spellID;
Game::Lua::PushString(L, name); // 1. name (PushString handles NULL -> nil)
Game::Lua::PushString(L, texture); // 2. texture (icon path) or nil
Game::Lua::PushBool(L, active); // 3. active
Game::Lua::PushString(L, "spell"); // 4. category
Game::Lua::PushNumber(L, static_cast<double>(spellID)); // 5. spellID (extension)
return 5;
}
// `SetTracking(index)` — selects (activates) the tracking spell at `index`.
// Vanilla has no "set tracking" primitive: choosing a tracker IS casting its
// spell (the tracking dropdown does exactly this). So we resolve the entry's
// spellbook slot and tail-call the engine's own `Script_CastSpell(slot,
// "spell")` — reusing its slot bounds-check, target resolution, and error
// handling rather than reconstructing the cast path. Returns nil for an
// out-of-range index.
static int __fastcall Script_SetTracking(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: SetTracking(index)");
return 0;
}
const int index = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (index < 1)
return 0;
int spellID = 0;
int slot = 0;
if (EnumerateTracking(index, &spellID, &slot) < index)
return 0;
// Rebuild the stack as CastSpell(slot, "spell") and hand off to the
// engine handler. `slot` is 1-based, matching what a Lua `CastSpell`
// caller passes.
Game::Lua::SetTop(L, 0);
Game::Lua::PushNumber(L, static_cast<double>(slot));
Game::Lua::PushString(L, "spell");
using CastSpell_t = int(__fastcall *)(void *);
auto castSpell = reinterpret_cast<CastSpell_t>(Offsets::FUN_SCRIPT_CAST_SPELL);
return castSpell(L);
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetNumTrackingTypes",
&Script_GetNumTrackingTypes);
Game::Lua::RegisterGlobalFunction("GetTrackingInfo", &Script_GetTrackingInfo);
Game::Lua::RegisterGlobalFunction("SetTracking", &Script_SetTracking);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Spell::Tracking
+133
View File
@@ -0,0 +1,133 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Turtle WoW "Stinging Nettle" hunter talent (talent 403; rank passives
// 51579 / 51580): Mongoose Bite and triggered fire traps apply the hunter's
// highest known Serpent Sting rank at 20% (R1) / 40% (R2) of its duration.
//
// Server-side (tortoise-wow spell_hunter.cpp ApplyStingingNettle) the sting
// is a direct `target->AddAura(...)` — no cast, no SMSG_SPELL_GO for the
// sting itself — so the client sees only the descriptor application and
// OnAuraAdded would seat it caster-less at the full 15s base. The TRIGGERS
// are visible, though: Mongoose Bite is a real player cast, and the
// fire-trap effect spells are cast by the trap's OWNER (GameObject::Use
// TYPE_TRAP → pOwner->CastSpell) with non-zero SpellVisual, so they pass
// IsNeedSendToClient and their SMSG_SPELL_GO broadcasts with the hunter as
// caster and the victim in the hit list. Registered as Aura::Source
// triggered-application rules: the trigger arms its hit targets, and the
// caster-less Serpent Sting application that follows consumes the arm
// (player attribution + the server's max(1, base × pct / 100)).
//
// The rank gates are the talent-granted passives, read live from the
// spell-knowledge bitmap at trigger time (respec-safe); R2 registers first
// so its 40% wins while known. The percents come from the rank spells' own
// EffectBasePoints[0] + 1 — the same DBC value the server's
// CalculateSimpleValue reads (19 → 20%, 39 → 40%), with the server's own
// 20/40 fallback — so a Turtle rebalance that patches the client DBC tracks
// automatically.
//
// Server guards that mirror for free: no talent → no arm (gate fails, and
// the server applies nothing); an existing LONGER sting → the server skips
// AddAura entirely → no application event → the arm just expires. A nettle
// re-apply over a SHORTER existing sting reuses the descriptor slot (no
// OnAuraAdded, verified in-game with chained Mongoose Bites), so the arm
// mechanism additionally refreshes the existing cached entry at trigger
// time, mirroring the server's keep-the-longer rule — see
// RefreshTriggeredExisting in aura/Source.cpp. Other hunters' nettle stings
// stay at base duration (their talents aren't client-knowable).
//
// Gated on Turtle::Detected() via a WorldTick latch, like DurationMods —
// the trigger spells exist on stock 1.12, but the mechanic is Turtle's.
#include "Offsets.h"
#include "aura/Source.h"
#include "spell/Lookup.h"
#include "tick/WorldTick.h"
#include "turtle/Detect.h"
#include <cstdint>
namespace Turtle::StingingNettle {
namespace {
constexpr uint32_t kHunter = 9; // SPELLFAMILY_HUNTER
// Serpent Sting's SpellFamilyFlags bit — all player ranks carry it (1978,
// 13549..13555, 25295, and Turtle's added Rank 9, 33459; client DBC
// verified).
constexpr uint64_t kSerpentStingFlag = 0x4000;
constexpr uint32_t kNettleR1 = 51579; // 20%
constexpr uint32_t kNettleR2 = 51580; // 40%
// The rank's duration percent: its record's EffectBasePoints[0] + 1 (the
// server's CalculateSimpleValue), else the server's hardcoded fallback.
int32_t NettlePct(uint32_t rankSpellId, int32_t fallbackPct) {
const uint8_t *rec =
Spell::Lookup::RecordForID(static_cast<int>(rankSpellId));
if (rec == nullptr)
return fallbackPct;
const int32_t pct =
*reinterpret_cast<const int32_t *>(
rec + Offsets::OFF_SPELL_RECORD_EFFECT_BASE_POINTS) +
1;
return pct > 0 ? pct : fallbackPct;
}
// Fire-trap effects' SpellFamilyFlags bit. Verified exhaustively against the
// client Spell.dbc: bit 0x4 in the hunter family selects EXACTLY the eight
// spells Turtle's script binding lists (Immolation Trap Effect r1-5,
// Explosive Trap Effect r1-3) and nothing else — so the mask form is
// rank-proof, covering any future trap rank for free. (Freezing Trap is the
// distinct bit 0x8.)
constexpr uint64_t kFireTrapEffectFlag = 0x4;
// Mongoose Bite ranks must stay EXACT IDs: their family bit (0x2) is shared
// with all eight Raptor Strike ranks — verified against the DBC — and Raptor
// Strike does NOT trigger nettle server-side. Mask-matching it would keep a
// melee-weaving hunter's target permanently armed.
constexpr uint32_t kMongooseBite[] = {1495, 14269, 14270, 14271};
bool g_registered = false;
void RegisterAll() {
const int32_t pctR2 = NettlePct(kNettleR2, 40);
const int32_t pctR1 = NettlePct(kNettleR1, 20);
// Higher rank first — rules matching the same trigger arm in
// registration order, first live gate wins.
Aura::Source::AddTriggeredApplicationByFamily(
kHunter, kFireTrapEffectFlag, kNettleR2, kHunter, kSerpentStingFlag,
pctR2);
Aura::Source::AddTriggeredApplicationByFamily(
kHunter, kFireTrapEffectFlag, kNettleR1, kHunter, kSerpentStingFlag,
pctR1);
for (uint32_t id : kMongooseBite) {
Aura::Source::AddTriggeredApplication(id, kNettleR2, kHunter,
kSerpentStingFlag, pctR2);
Aura::Source::AddTriggeredApplication(id, kNettleR1, kHunter,
kSerpentStingFlag, pctR1);
}
}
void OnTick() {
if (g_registered || !Turtle::Detected())
return;
RegisterAll();
g_registered = true;
}
const Tick::WorldTick::AutoSubscribe _tickSub{&OnTick};
} // namespace
} // namespace Turtle::StingingNettle
+1 -1
View File
@@ -284,7 +284,7 @@ void Set(uint64_t guid) {
TokenObserver::Register(guid, &FocusFieldCb);
g_focusObserved = true;
}
const int slot = Event::Custom::Lookup(kEventName);
const int slot = _reserve.Slot();
if (slot >= 0)
Event::Custom::Fire(slot, "");
}
+1 -2
View File
@@ -173,8 +173,7 @@ void __fastcall BonusActionBarUpdate_h() {
if (current < 0 || current == s_lastForm)
return;
s_lastForm = current;
const int slot = Event::Custom::Lookup(kEventName);
Event::Custom::Fire(slot, "");
Event::Custom::Fire(_reserve.Slot(), "");
}
} // namespace
+21 -4
View File
@@ -108,6 +108,19 @@ constexpr const char *kEvtPlaybackStarted = "VOICE_CHAT_TTS_PLAYBACK_STARTED";
constexpr const char *kEvtSpeakTextUpdate = "VOICE_CHAT_TTS_SPEAK_TEXT_UPDATE";
constexpr const char *kEvtVoicesUpdate = "VOICE_CHAT_TTS_VOICES_UPDATE";
// The lazily-created reservations, set by RegisterLuaFunctions. Null while
// standing down for VanillaTTS — but the fire helpers only run from our own
// registered surface, so they never see null in that mode; SlotOf's guard is
// belt-and-suspenders (`Fire(-1)` no-ops).
const Event::Custom::AutoReserve *g_evtPlaybackFailed = nullptr;
const Event::Custom::AutoReserve *g_evtPlaybackFinished = nullptr;
const Event::Custom::AutoReserve *g_evtPlaybackStarted = nullptr;
const Event::Custom::AutoReserve *g_evtVoicesUpdate = nullptr;
int SlotOf(const Event::Custom::AutoReserve *r) {
return r != nullptr ? r->Slot() : -1;
}
// ---------------------------------------------------------------------------
// CVar names + handles (registered in RegisterLuaFunctions)
// ---------------------------------------------------------------------------
@@ -186,18 +199,18 @@ std::wstring Utf8ToWide(const char *s) {
// Event fire helpers (engine dispatcher via Event::Custom)
// ---------------------------------------------------------------------------
void FireVoicesUpdate() {
Event::Custom::Fire(Event::Custom::Lookup(kEvtVoicesUpdate), "");
Event::Custom::Fire(SlotOf(g_evtVoicesUpdate), "");
}
void FirePlaybackStarted(int numConsumers, int utteranceID, int durationMS, int dest) {
Event::Custom::Fire(Event::Custom::Lookup(kEvtPlaybackStarted), "%d%d%d%d",
Event::Custom::Fire(SlotOf(g_evtPlaybackStarted), "%d%d%d%d",
numConsumers, utteranceID, durationMS, dest);
}
void FirePlaybackFinished(int numConsumers, int utteranceID, int dest) {
Event::Custom::Fire(Event::Custom::Lookup(kEvtPlaybackFinished), "%d%d%d",
Event::Custom::Fire(SlotOf(g_evtPlaybackFinished), "%d%d%d",
numConsumers, utteranceID, dest);
}
void FirePlaybackFailed(const char *status, int utteranceID, int dest) {
Event::Custom::Fire(Event::Custom::Lookup(kEvtPlaybackFailed), "%s%d%d",
Event::Custom::Fire(SlotOf(g_evtPlaybackFailed), "%s%d%d",
status, utteranceID, dest);
}
@@ -565,6 +578,10 @@ void RegisterLuaFunctions() {
static const Event::Custom::AutoReserve _r3{kEvtPlaybackStarted};
static const Event::Custom::AutoReserve _r4{kEvtSpeakTextUpdate};
static const Event::Custom::AutoReserve _r5{kEvtVoicesUpdate};
g_evtPlaybackFailed = &_r1;
g_evtPlaybackFinished = &_r2;
g_evtPlaybackStarted = &_r3;
g_evtVoicesUpdate = &_r5;
g_cvarVoice = CVar::Factory::Register(kCVarVoice, "0", 0, &OnVoiceChanged);
g_cvarSpeed = CVar::Factory::Register(kCVarSpeed, "0", 0, &OnSpeedChanged);