mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-15 07:30:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e793f80f6b | ||
|
|
ded767bf19 | ||
|
|
70a69b64fd | ||
|
|
0f5b50ea14 |
+61
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+13
-9
@@ -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();
|
||||
|
||||
@@ -164,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
|
||||
@@ -429,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
|
||||
@@ -462,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user