Compare commits

...
6 Commits
Author SHA1 Message Date
Brues 5550cb02b7 Fill AuraData.expirationTime for proc / triggered auras
SpellGo only sees directly-cast auras, so proc-applied debuffs (Shadow
Weaving etc.) had no expiration. Co-hook two more engine functions that
fire for every aura application:

- CGUnit_C::OnAuraAdded (0x006123F0) - any aura newly occupying a slot,
  procs included; stamps now + base duration (no caster).
- CGUnit_C::OnAuraStacksChanged (0x00612450) - re-stamps on a stack-count
  change so a climbing stacking debuff (Shadow Weaving 1->5) refreshes.

Both pass fromCast=false to Store, which skips entries SpellGo already owns,
so directly-cast auras keep their talented timing + caster regardless of
hook order. Consolidate the object-GUID read into Unit::Identity::
GuidForObject so the lookup and the new hooks key the cache identically.

Known limitation (documented): a refresh at MAX stacks (5->5) changes no
client-visible aura field, so the server sends no UpdateField and no
callback fires - there's no packet to hook. The countdown runs out and
expirationTime falls back to 0 (unknown) while the aura still shows.
2026-06-24 17:36:56 -05:00
Brues 492c70d156 Use applied (caster-modified) duration for AuraData.duration
duration came from a base Spell.dbc/SpellDuration.dbc lookup while
expirationTime came from the Aura::Source cache computed with the caster's
duration modifiers. For a talented DoT (e.g. 2/2 Improved Shadow Word: Pain,
18s -> 24s) the two disagreed, so remaining (expirationTime - GetTime())
exceeded duration -- impossible for a single aura.

Cache the applied durationMs alongside expirationMs in Aura::Source and have
Push prefer it for the duration field, keeping the two consistent
(remaining <= duration). Falls back to the base DBC value on a cache miss.
2026-06-24 16:57:34 -05:00
Brues c68ec17e0c Honor the PLAYER aura filter in C_UnitAuras
With the Aura::Source caster cache in place, the PLAYER filter token is now
derivable: restrict results to auras whose cached caster GUID matches the
local player (Unit::Identity::PlayerGuid). Wired into every getter that
takes a filter — GetAuraDataByIndex, GetUnitAuraBySpellID,
GetAuraDataBySpellName, and GetUnitAuras — via a playerOnly flag threaded
into the slot finders and the new Data::IsPlayerCast predicate.

Auras whose cast we didn't observe (predate login / no SMSG_SPELL_GO) count
as not-player-cast and are excluded, matching the best-effort caster model.

Also fix GetUnitAuras range selection so a bare "PLAYER" (no HELPFUL/HARMFUL)
returns both ranges restricted to player casts, instead of defaulting to
helpful-only.

RAID / CANCELABLE / nameplate filter tokens remain accepted-but-no-op.
2026-06-24 15:21:33 -05:00
Brues b17aeda203 Add aura caster + non-player expiration to C_UnitAuras
Populate AuraData sourceUnit/sourceGUID and non-player expirationTime by
co-hooking SMSG_SPELL_GO (FUN_SPELL_GO) and caching (targetGuid, spellId)
-> caster + expiration. The vanilla unit aura array stores only spell IDs;
the SpellGo packet is the only place the client sees an aura's caster and a
server-authoritative duration. We parse the engine packet ourselves, so
this is self-sufficient (no nampower dependency) -- when nampower is loaded
we simply co-hook the same site.

sourceGUID is a ClassicAPI extension (raw "0x..." string), set whenever a
caster is known including when sourceUnit is nil; stable across the session,
unlike the volatile nameplate token, and usable as a unit token under
SuperWoW.

Also consolidate the local-player-GUID read into Unit::Identity::PlayerGuid()
(was duplicated inline in four files) and expose Unit::Identity GuidForToken
/ TokenFromGUID for reuse.
2026-06-24 15:10:53 -05:00
Brues 23d2fe4bbb Update README.md 2026-06-24 12:14:56 -05:00
Brues f6a8b850e6 Point install instructions at the latest release
Lead with downloading the prebuilt ClassicAPI.dll from
/releases/latest (build-from-source is the alternative), and note the
bundled !!!ClassicAPI addon ships inside the DLL so there's no separate
addon install.
2026-06-24 12:10:02 -05:00
13 changed files with 712 additions and 110 deletions
+8 -3
View File
@@ -56,7 +56,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Table](docs/API.md#table) | `table.wipe` |
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` (backports `GlobalColor.dbc`; companion addon wraps each row with `ColorMixin` and assigns `_G[baseTag]` + `_G[baseTag.."_CODE"]`) |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitGUID`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras` |
@@ -166,14 +166,19 @@ See [Focus / Bindings](docs/API.md#bindings-focustarget--targetfocus) for the im
## Installation
Use [VanillaFixes](https://github.com/hannesmann/vanillafixes) to load the
DLL.
Download the prebuilt `ClassicAPI.dll` from the
[latest release](https://github.com/brues-code/ClassicAPI/releases/latest)
(or [build it yourself](#building)). It's loaded with
[VanillaFixes](https://github.com/hannesmann/vanillafixes):
1. Install VanillaFixes if it isn't already.
2. Copy `ClassicAPI.dll` into your game directory.
3. Add `ClassicAPI.dll` to `dlls.txt`.
4. Launch the game with `VanillaFixes.exe`.
The bundled `!!!ClassicAPI` addon ships *inside* the DLL — no separate
addon download or install step needed.
## Bundled addon: !!!ClassicAPI
The Lua-side companion library lives in
+65 -7
View File
@@ -9270,21 +9270,79 @@ selects.
| `dispelName` | string | `"Magic"` / `"Curse"` / `"Disease"` / `"Poison"` (from `SpellDispelType.dbc`), or `""` if non-dispellable |
| `isHelpful` | boolean | true for slot < 32 |
| `isHarmful` | boolean | true for slot >= 32 |
| `duration` | number | base applied duration in seconds, looked up via `Spell.dbc → SpellDuration.dbc` with level scaling. Talent / glyph duration extensions (Improved PW:F, etc.) aren't reflected here — those are baked into `expirationTime` on the caster's side. Returns 0 for spells flagged "no duration" (passives, paladin auras, infinite buffs) |
| `expirationTime` | number | populated for `unit == "player"` (read from the engine's player-buff table at `0x00BC6040`; same data `GetPlayerBuffTimeLeft` returns). Always `0` for all other units — vanilla server only broadcasts cast/duration for the local player's own auras. `expirationTime - GetTime()` gives the true remaining time (including any talent extensions) |
| `duration` | number | applied duration in seconds. When the aura's cast was observed (in the `Aura::Source` cache), this is the caster-modified duration — talent/glyph extensions like Improved Shadow Word: Pain included — so it stays consistent with `expirationTime` (`remaining ≤ duration`). On a cache miss it falls back to the base `Spell.dbc → SpellDuration.dbc` value with level scaling. Returns 0 for spells flagged "no duration" (passives, paladin auras, infinite buffs) |
| `expirationTime` | number | for `unit == "player"`, read from the engine's player-buff table at `0x00BC6040` (same data `GetPlayerBuffTimeLeft` returns). For any other unit, taken from the `Aura::Source` cache (cast time + duration captured from `SMSG_SPELL_GO`; see below). `0` when neither source has it. `expirationTime - GetTime()` gives the true remaining time |
| `sourceUnit` | string | unit token of the caster (`"player"`, `"raid7"`, `"nameplate1"`, …), resolved from the `Aura::Source` cache. `nil` if the cast wasn't observed or the caster maps to no current token |
| `sourceGUID` | string | caster's `"0x…"` GUID string from the same cache. **ClassicAPI extension — not a retail `AuraData` field.** Set whenever a caster is known, including when `sourceUnit` is `nil` (caster left token range). Stable for the session, unlike the volatile nameplate token; doubles as a unit token under SuperWoW. `nil` on a cache miss |
| `charges` / `maxCharges` | number | always `0` — vanilla has stacks, not charges |
| `timeMod` | number | always `1` — vanilla has no haste-affected auras |
| `isStealable`, `isBossAura`, `isFromPlayerOrPlayerPet`, `isNameplateOnly`, `nameplateShowAll`, `nameplateShowPersonal`, `canApplyAura`, `shouldConsolidate`, `isRaid` | boolean | always `false` — modern UI concepts vanilla doesn't have |
| `sourceUnit`, `auraInstanceID`, `points` | (absent) | omitted from the table — Lua read yields nil, matching modern semantics for "field doesn't apply" |
| `auraInstanceID`, `points` | (absent) | omitted from the table — Lua read yields nil, matching modern semantics for "field doesn't apply" |
### Filter parsing
The optional `filter` string is a pipe-separated set of upper-case
tokens, matching modern syntax (`"HELPFUL"`, `"HARMFUL"`,
`"HELPFUL|PLAYER"`, etc.). Only `HELPFUL` (default) and `HARMFUL`
are honored on vanilla; other tokens (`PLAYER` / `RAID` /
`CANCELABLE` / `INCLUDE_NAME_PLATE_ONLY`) are accepted but no-op —
they'd need source-GUID tracking or systems vanilla doesn't have.
`"HELPFUL|PLAYER"`, etc.). Honored on vanilla:
- **`HELPFUL`** (default) / **`HARMFUL`** — pick the buff or debuff range.
In `GetUnitAuras`, supplying neither returns both ranges.
- **`PLAYER`** — restrict to auras the local player cast, via the
`Aura::Source` caster cache (`sourceGUID == ` player GUID). Combines with
the range tokens (`"HARMFUL|PLAYER"` = your debuffs only). Because the
caster is best-effort, an aura whose cast we didn't observe is treated as
not-player-cast and excluded — so `PLAYER` can under-report auras that
predate login.
Other tokens (`RAID` / `CANCELABLE` / `INCLUDE_NAME_PLATE_ONLY`) are
accepted but no-op — they need engine systems (raid-dispel relevance,
nameplate visibility flags) vanilla doesn't have.
### Caster & timing (`Aura::Source`)
`sourceUnit`, `sourceGUID`, and non-player `expirationTime` come from a
client-side cache that vanilla itself can't provide: the unit aura array
stores only spell IDs — never the caster, and no cast/expiration timing for
anyone but the local player. `Aura::Source` fills the gap by co-hooking
three engine functions and caching `(targetGuid, spellId) → { casterGuid,
expirationMs, durationMs }`; `Push` keys into it by the unit's GUID + the
aura's spell ID.
- **`SpellGo`** (`FUN_SPELL_GO`, `0x006E7A70`) — the cast packet, the only
place the client sees an aura's **caster** + a server-authoritative,
caster-modified duration. Fills `sourceUnit`/`sourceGUID` + timing for
directly-cast auras.
- **`OnAuraAdded`** (`0x006123F0`) — fires when any aura first occupies a
slot, including proc/triggered auras that emit no `SMSG_SPELL_GO` (Shadow
Weaving etc.). Stamps `expirationMs = now + base duration` (no caster).
- **`OnAuraStacksChanged`** (`0x00612450`) — fires when an existing aura's
stack count changes; re-stamps expiration so a climbing stacking debuff
(e.g. Shadow Weaving 1→5) refreshes.
Application hooks never overwrite an entry `SpellGo` already owns, so a
directly-cast aura keeps its talented timing + caster regardless of hook
order.
Implications:
- **Best-effort.** Only auras observed *after* login carry data; auras
already up when you logged in leave the defaults (`sourceUnit`/`sourceGUID`
nil, non-player `expirationTime` 0).
- **Max-stack refresh is a blind spot.** When a stacking debuff is refreshed
at *max* stacks (e.g. Shadow Weaving 5→5), no client-visible aura field
changes (spellID, stacks, flags, level all identical), so the server sends
no UpdateField and the engine fires no callback — there is no packet to
hook (verified: the stack-change dispatcher `FUN_00604ea0` calls
`OnAuraStacksChanged` unconditionally, so its silence proves nothing
arrived). The countdown therefore runs to 0 and the entry evicts; once
evicted, `expirationTime` falls back to 0 (unknown) while the aura is still
shown. Climbing stacks and single-stack refreshes (re-cast → fresh
`SpellGo`) are unaffected.
- **No nampower dependency.** We parse the engine functions ourselves;
nampower (which hooks the same three) need not be loaded — when it is, we
co-hook the sites.
- Entries are evicted once their timed aura elapses, so the cache stays
bounded; infinite-duration auras persist until overwritten under load.
### `C_UnitAuras.GetAuraDataByIndex(unit, index [, filter])`
+32
View File
@@ -682,6 +682,38 @@ enum Offsets {
OFF_SPELLDURATION_PER_LEVEL_MS = 0x08,
OFF_SPELLDURATION_MAX_MS = 0x0C,
// `SMSG_SPELL_GO` inner handler — `void __fastcall(uint64_t *itemGUID,
// uint64_t *casterGUID, uint32_t spellId, CDataStore *packet)`. The
// engine has already decoded item/caster/spell into args; the packet
// read cursor sits at the post-header body (castFlags, hit/missed
// target lists, target mask). This is the ONLY place the client sees
// an aura's *caster* — the unit aura array stores spell IDs but never
// who applied them. `Aura::Source` co-hooks this (alongside nampower,
// which detours the same function) to cache `(targetGuid, spellId) →
// casterGuid` for `C_UnitAuras` sourceUnit + non-player expiration.
// Packet layout mirrored from nampower's SpellGoHook (verified on the
// same Octo/Turtle 1.12.1 client). Per-cast frequency, not per-frame.
FUN_SPELL_GO = 0x006E7A70,
// `CGUnit_C::OnAuraAdded` — `__thiscall void(CGUnit *unit, uint32_t
// slot, uint32_t spellId)` (i.e. `__fastcall(unit /*ecx*/, edx_unused,
// slot, spellId)`). Fires for EVERY aura that lands on a tracked unit,
// including proc/triggered auras that never produce an `SMSG_SPELL_GO`
// (Shadow Weaving, etc.). `Aura::Source` co-hooks it to stamp
// expiration for those — it carries no caster, so it fills timing only
// and never overwrites an entry `SpellGo` already owns. Per-aura-change
// frequency. nampower hooks the same function (its BUFF/DEBUFF events).
FUN_ON_AURA_ADDED = 0x006123F0,
// `CGUnit_C::OnAuraStacksChanged` — `__thiscall void(CGUnit *unit, int
// slot, uint8_t stackCount)` (`__fastcall(unit, edx_unused, slot,
// stackCount)`). Fires when an existing aura's stack count changes
// without a fresh add — e.g. Shadow Weaving climbing 2→3. The slot's
// spellID is read from the unit aura array (`OnAuraAdded` gets it as an
// arg; here only the slot is given). `Aura::Source` co-hooks it to
// re-stamp expiration so stacking-DoT refreshes are picked up.
FUN_ON_AURA_STACKS_CHANGED = 0x00612450,
// CGPlayer-side sub-struct, allocated for any player-controlled
// unit (local self, target, party, raid, inspect targets — all of
// them). Holds player-specific data that's *not* in the broadcast
+30 -13
View File
@@ -60,6 +60,13 @@ Data::Filter ParseFilter(const char *filter) {
return Data::Filter::Helpful;
}
// The `PLAYER` filter token restricts results to auras the local player
// cast. No other modern token contains the substring "PLAYER", so a plain
// substring test is safe.
bool HasPlayerFilter(const char *filter) {
return filter != nullptr && strstr(filter, "PLAYER") != nullptr;
}
const char *ArgUnit(void *L, int idx) {
if (!Game::Lua::IsString(L, idx))
return nullptr;
@@ -82,9 +89,9 @@ const char *ArgOptString(void *L, int idx) {
// or nil if no such aura. Used by `GetAuraDataByIndex` and the
// filter-locked aliases.
int PushAuraByIndex(void *L, const char *unitToken, int index,
Data::Filter filter) {
Data::Filter filter, bool playerOnly = false) {
const uint8_t *unit = ResolveUnit(unitToken);
const int slot = Data::FindNthSlot(unit, index, filter);
const int slot = Data::FindNthSlot(unit, index, filter, playerOnly);
if (slot < 0) {
Game::Lua::PushNil(L);
return 1;
@@ -101,7 +108,8 @@ int __fastcall Script_GetAuraDataByIndex(void *L) {
Game::Lua::PushNil(L);
return 1;
}
return PushAuraByIndex(L, unit, index, ParseFilter(filterStr));
return PushAuraByIndex(L, unit, index, ParseFilter(filterStr),
HasPlayerFilter(filterStr));
}
int __fastcall Script_GetBuffDataByIndex(void *L) {
@@ -139,7 +147,8 @@ int __fastcall Script_GetUnitAuraBySpellID(void *L) {
f = ParseFilter(filterStr);
fp = &f;
}
const int slot = Data::FindSlotBySpellID(unit, static_cast<uint32_t>(spellID), fp);
const int slot = Data::FindSlotBySpellID(unit, static_cast<uint32_t>(spellID),
fp, HasPlayerFilter(filterStr));
if (slot < 0) {
Game::Lua::PushNil(L);
return 1;
@@ -163,7 +172,8 @@ int __fastcall Script_GetAuraDataBySpellName(void *L) {
f = ParseFilter(filterStr);
fp = &f;
}
const int slot = Data::FindSlotBySpellName(unit, spellName, fp);
const int slot = Data::FindSlotBySpellName(unit, spellName, fp,
HasPlayerFilter(filterStr));
if (slot < 0) {
Game::Lua::PushNil(L);
return 1;
@@ -192,7 +202,7 @@ int __fastcall Script_GetPlayerAuraBySpellID(void *L) {
// sequential keys starting from `nextKey`. Updates `nextKey` so a
// follow-up call can append to the same outer table.
void AppendRangeToArray(void *L, const uint8_t *unit, int outerIdx,
Data::Filter filter, int &nextKey) {
Data::Filter filter, int &nextKey, bool playerOnly) {
const int start = (filter == Data::Filter::Harmful)
? Offsets::UNIT_AURA_BUFF_COUNT
: 0;
@@ -202,6 +212,8 @@ void AppendRangeToArray(void *L, const uint8_t *unit, int outerIdx,
for (int slot = start; slot < end; ++slot) {
if (!Data::IsSlotPopulated(unit, slot))
continue;
if (playerOnly && !Data::IsPlayerCast(unit, slot))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
Data::Push(L, unit, slot);
Game::Lua::SetTable(L, outerIdx);
@@ -218,14 +230,19 @@ int __fastcall Script_GetUnitAuras(void *L) {
if (unit == nullptr)
return 1;
// Range tokens are independent of PLAYER: an explicit HELPFUL/HARMFUL
// selects that range, neither selects both. So `"PLAYER"` alone returns
// both ranges restricted to player-cast auras (matching retail).
const bool playerOnly = HasPlayerFilter(filterStr);
const bool hasHelpful = filterStr != nullptr && strstr(filterStr, "HELPFUL") != nullptr;
const bool hasHarmful = filterStr != nullptr && strstr(filterStr, "HARMFUL") != nullptr;
const bool both = !hasHelpful && !hasHarmful;
int nextKey = 1;
if (filterStr == nullptr) {
// No filter — return both ranges.
AppendRangeToArray(L, unit, 1, Data::Filter::Helpful, nextKey);
AppendRangeToArray(L, unit, 1, Data::Filter::Harmful, nextKey);
} else {
AppendRangeToArray(L, unit, 1, ParseFilter(filterStr), nextKey);
}
if (hasHelpful || both)
AppendRangeToArray(L, unit, 1, Data::Filter::Helpful, nextKey, playerOnly);
if (hasHarmful || both)
AppendRangeToArray(L, unit, 1, Data::Filter::Harmful, nextKey, playerOnly);
return 1;
}
+87 -11
View File
@@ -15,7 +15,10 @@
#include "Game.h"
#include "Offsets.h"
#include "aura/Source.h"
#include "dbc/Lookup.h"
#include "guid/Guid.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstring>
@@ -38,6 +41,13 @@ const uint8_t *Descriptor(const uint8_t *unit) {
unit + Offsets::OFF_CGUNIT_OBJECT_FIELDS);
}
// Keys into the `Aura::Source` caster/expiration cache. Thin alias for the
// shared `Unit::Identity::GuidForObject` so the lookup here and the
// OnAuraAdded hook in `Aura::Source` read the GUID the same way.
uint64_t UnitGuid(const uint8_t *unit) {
return Unit::Identity::GuidForObject(unit);
}
const uint8_t *SpellRecord(uint32_t spellID) {
return DBC::Record(Offsets::VAR_SPELL_RECORDS,
Offsets::VAR_SPELL_RECORD_COUNT, spellID);
@@ -219,7 +229,20 @@ bool IsSlotPopulated(const uint8_t *unit, int slot) {
return IsVisible(SpellRecord(spellID));
}
int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter) {
bool IsPlayerCast(const uint8_t *unit, int slot) {
const uint32_t spellID = ReadSpellID(unit, slot);
if (spellID == 0)
return false;
uint64_t casterGuid = 0;
uint32_t expMs = 0;
uint32_t durMs = 0;
if (!Aura::Source::Get(UnitGuid(unit), spellID, &casterGuid, &expMs, &durMs))
return false;
return casterGuid != 0 && casterGuid == Unit::Identity::PlayerGuid();
}
int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
bool playerOnly) {
if (unit == nullptr || oneBasedIndex < 1)
return -1;
const int start = (filter == Filter::Harmful)
@@ -232,6 +255,8 @@ int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter) {
for (int slot = start; slot < end; ++slot) {
if (!IsSlotPopulated(unit, slot))
continue;
if (playerOnly && !IsPlayerCast(unit, slot))
continue;
if (++matches == oneBasedIndex)
return slot;
}
@@ -239,7 +264,7 @@ int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter) {
}
int FindSlotBySpellID(const uint8_t *unit, uint32_t spellID,
const Filter *filter) {
const Filter *filter, bool playerOnly) {
if (unit == nullptr || spellID == 0)
return -1;
const int start = (filter != nullptr && *filter == Filter::Harmful)
@@ -251,14 +276,17 @@ int FindSlotBySpellID(const uint8_t *unit, uint32_t spellID,
for (int slot = start; slot < end; ++slot) {
if (!IsSlotPopulated(unit, slot))
continue;
if (ReadSpellID(unit, slot) == spellID)
return slot;
if (ReadSpellID(unit, slot) != spellID)
continue;
if (playerOnly && !IsPlayerCast(unit, slot))
continue;
return slot;
}
return -1;
}
int FindSlotBySpellName(const uint8_t *unit, const char *spellName,
const Filter *filter) {
const Filter *filter, bool playerOnly) {
if (unit == nullptr || spellName == nullptr || *spellName == '\0')
return -1;
const int start = (filter != nullptr && *filter == Filter::Harmful)
@@ -271,8 +299,11 @@ int FindSlotBySpellName(const uint8_t *unit, const char *spellName,
if (!IsSlotPopulated(unit, slot))
continue;
const char *name = LocalizedSpellName(SpellRecord(ReadSpellID(unit, slot)));
if (name != nullptr && std::strcmp(name, spellName) == 0)
return slot;
if (name == nullptr || std::strcmp(name, spellName) != 0)
continue;
if (playerOnly && !IsPlayerCast(unit, slot))
continue;
return slot;
}
return -1;
}
@@ -374,8 +405,52 @@ void Push(void *L, const uint8_t *unit, int slot) {
if (entry != nullptr)
expirationTime = PlayerBuffExpirationSeconds(entry);
}
// Caster + non-player expiration from the SMSG_SPELL_GO cache
// (`Aura::Source`), captured at cast time. The vanilla descriptor has
// neither. `sourceUnit` applies to any unit; `expirationTime` only
// fills in here when the player-buff table above didn't — that table is
// authoritative for the player's own auras, the cache covers everyone
// else. Both are best-effort: a miss (aura predates login, or the cast
// wasn't observed) leaves the modern-truthful defaults untouched.
const char *sourceUnit = nullptr;
const char *sourceGUID = nullptr;
char tokenBuf[32];
char guidBuf[Guid::STRING_SIZE];
if (spellID != 0) {
uint64_t casterGuid = 0;
uint32_t expMs = 0;
uint32_t durMs = 0;
if (Aura::Source::Get(UnitGuid(unit), spellID, &casterGuid, &expMs,
&durMs)) {
if (expirationTime == 0.0 && expMs != 0)
expirationTime = static_cast<double>(expMs) * 0.001;
// Prefer the applied (caster-modified) duration so `duration`
// stays consistent with `expirationTime` — otherwise a talented
// DoT (e.g. Improved Shadow Word: Pain) shows remaining time
// exceeding the base `duration`.
if (durMs != 0)
duration = static_cast<double>(durMs) * 0.001;
if (casterGuid != 0) {
// `sourceUnit` is a token (nil when the caster maps to no
// current token); `sourceGUID` is the raw "0x..." GUID and
// is set whenever we have a caster — a ClassicAPI extension
// (not in retail AuraData) that survives the caster leaving
// token range and doubles as a unit token under SuperWoW.
sourceUnit = Unit::Identity::TokenFromGUID(
casterGuid, tokenBuf, sizeof tokenBuf);
sourceGUID = Guid::FormatAsString(casterGuid, guidBuf,
sizeof guidBuf);
}
}
}
Game::Lua::SetFieldNumber(L, "duration", duration);
Game::Lua::SetFieldNumber(L, "expirationTime", expirationTime);
if (sourceUnit != nullptr)
Game::Lua::SetFieldString(L, "sourceUnit", sourceUnit);
if (sourceGUID != nullptr)
Game::Lua::SetFieldString(L, "sourceGUID", sourceGUID);
Game::Lua::SetFieldNumber(L, "charges", 0);
Game::Lua::SetFieldNumber(L, "maxCharges", 0);
Game::Lua::SetFieldNumber(L, "timeMod", 1);
@@ -391,10 +466,11 @@ void Push(void *L, const uint8_t *unit, int slot) {
Game::Lua::SetFieldBool(L, "shouldConsolidate", false);
Game::Lua::SetFieldBool(L, "isRaid", false);
// `sourceUnit`, `auraInstanceID`, `points` deliberately omitted
// — modern returns nil for those when they don't apply, and
// Lua reading a missing key yields nil, so the table doesn't
// need an explicit entry.
// `sourceUnit` / `sourceGUID` are set above when the `Aura::Source`
// cache has a caster for this aura; left unset (→ nil) on a miss.
// `auraInstanceID` and `points` are deliberately omitted — modern
// returns nil for those when they don't apply, and Lua reading a
// missing key yields nil, so the table doesn't need an explicit entry.
}
} // namespace Aura::Data
+21 -8
View File
@@ -47,22 +47,29 @@ bool IsSlotPopulated(const uint8_t *unit, int slot);
// Finds the absolute slot of the `oneBasedIndex`-th populated aura
// matching `filter`. Returns -1 if no such aura. Walks
// 0..UNIT_AURA_BUFF_COUNT-1 for Helpful, UNIT_AURA_BUFF_COUNT..
// UNIT_AURA_TOTAL-1 for Harmful.
int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter);
// UNIT_AURA_TOTAL-1 for Harmful. When `playerOnly` is true, only auras
// the local player cast (per `IsPlayerCast`) are counted/returned.
int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
bool playerOnly = false);
// Finds the absolute slot of a populated aura with the given spell
// ID, optionally restricted to one filter range. `filter` of
// nullptr searches both ranges (helpful first, then harmful).
// Returns -1 if not found.
// `playerOnly` restricts to player-cast auras. Returns -1 if not found.
int FindSlotBySpellID(const uint8_t *unit, uint32_t spellID,
const Filter *filter);
const Filter *filter, bool playerOnly = false);
// Finds the absolute slot of a populated aura whose locale-resolved
// spell name exactly matches `spellName`. Case-sensitive. Otherwise
// same semantics as `FindSlotBySpellID`. Returns -1 if not found
// or if `spellName` is null/empty.
int FindSlotBySpellName(const uint8_t *unit, const char *spellName,
const Filter *filter);
const Filter *filter, bool playerOnly = false);
// True iff the aura at `slot` was cast by the local player, per the
// `Aura::Source` cache. False on a cache miss (caster unknown) — so a
// PLAYER-filtered query excludes auras whose cast we didn't observe.
bool IsPlayerCast(const uint8_t *unit, int slot);
// Display stack count for the given slot (engine stores stacks-1,
// we add 1). Returns 0 if the unit is null.
@@ -85,12 +92,18 @@ const char *DispelName(uint32_t dispelTypeID);
//
// Populates these fields with real data:
// name, icon, applications, spellId, dispelName,
// isHelpful, isHarmful, timeMod
// isHelpful, isHarmful, timeMod, duration
//
// And these from the `Aura::Source` SMSG_SPELL_GO cache when available
// (else their inapplicable default):
// expirationTime (player: engine buff table; others: cache; 0 on miss),
// sourceUnit (caster token from the cache; nil on miss),
// sourceGUID (caster "0x..." GUID string; nil on miss — a ClassicAPI
// extension, not in retail AuraData)
//
// And these with vanilla-truthful defaults (matches modern's
// "field present but inapplicable" semantics):
// duration=0, expirationTime=0, charges=0, maxCharges=0,
// sourceUnit=nil, auraInstanceID=nil, points=nil,
// charges=0, maxCharges=0, auraInstanceID=nil, points=nil,
// isStealable=false, isBossAura=false, isFromPlayerOrPlayerPet=false,
// isNameplateOnly=false, nameplateShowAll=false,
// nameplateShowPersonal=false, canApplyAura=false,
+294
View File
@@ -0,0 +1,294 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser 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 Lesser General Public License for more details.
// See `Source.h` for the contract. This is the backend: a co-hook on
// `FUN_SPELL_GO` that mirrors nampower's `SpellGoHook` parse to capture the
// caster + duration of every aura-applying cast, and a small fixed-size
// cache `Aura::Data::Push` reads from.
#include "Source.h"
#include "Data.h"
#include "Game.h"
#include "Offsets.h"
#include "spell/Lookup.h"
#include "tick/WorldTick.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Aura::Source {
namespace {
// Minimal view of the engine's CDataStore packet reader. Only the fields
// the read path touches; layout matches nampower's `CDataStore`. Incoming
// SMSG packets are a single contiguous buffer (`m_base == 0`,
// `m_alloc == size`), so the simple `m_buffer - m_base + m_read` read with
// an `m_size` bound is sufficient — same critical guard as the engine's
// `CDataStore::Get`.
//
// CDataStore is POLYMORPHIC in the engine (virtual Internal*/Reset/etc.),
// so a vtable pointer occupies offset 0 and the data members start at
// +0x04. Omitting `vtable` here would shift every field by 4 and make
// `m_buffer` read the vptr — yielding a garbage read address (the cause of
// an early ACCESS_VIOLATION in the SpellGo hook).
struct CDataStore {
void *vtable; // +0x00
uint8_t *m_buffer; // +0x04
uint32_t m_base; // +0x08
uint32_t m_alloc; // +0x0C
uint32_t m_size; // +0x10
uint32_t m_read; // +0x14
};
template <typename T>
T ReadField(CDataStore *p) {
T val{};
const uint32_t end = p->m_read + sizeof(T);
if (end > p->m_size) {
p->m_read = p->m_size + 1; // poison: further reads short-circuit
return val;
}
val = *reinterpret_cast<const T *>(p->m_buffer - p->m_base + p->m_read);
p->m_read += sizeof(T);
return val;
}
// A cast applies an aura iff any of its three effects names an aura. The
// EffectApplyAuraName[3] int32 array sits at `+0x16C` (already used by the
// shapeshift / mechanic-immunity code). Gating on this keeps pure-damage
// and utility casts out of the cache.
bool SpellAppliesAura(const uint8_t *spellRecord) {
if (spellRecord == nullptr)
return false;
const auto *auraNames = reinterpret_cast<const int32_t *>(
spellRecord + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
if (auraNames[i] != 0)
return true;
}
return false;
}
uint32_t NowMs() {
using TickCount_t = uint32_t(__fastcall *)();
return reinterpret_cast<TickCount_t>(
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
}
// Server-authoritative duration (ms). When the local player is the caster
// we let the engine fold in the player's duration modifiers (skipMod = 0);
// for any other caster we take the unmodified base (skipMod = 1) since we
// only have the local player's mod tables. `unit = 0` selects the player
// mod context per FUN_GET_SPELL_DURATION's signature.
uint32_t SpellDurationMs(const uint8_t *spellRecord, bool casterIsPlayer) {
using GetDuration_t = int(__fastcall *)(const uint8_t *spellRecord,
int unit, char skipMod);
const int ms = reinterpret_cast<GetDuration_t>(
static_cast<uintptr_t>(Offsets::FUN_GET_SPELL_DURATION))(
spellRecord, 0, casterIsPlayer ? 0 : 1);
return ms > 0 ? static_cast<uint32_t>(ms) : 0;
}
// ---- Cache ---------------------------------------------------------------
struct Entry {
uint64_t targetGuid;
uint64_t casterGuid;
uint32_t spellId;
uint32_t expirationMs; // 0 = infinite / unknown duration
uint32_t durationMs; // applied duration (incl. caster mods); 0 = none
bool used;
};
constexpr int kCacheSize = 256;
Entry g_cache[kCacheSize];
int g_writeCursor = 0;
// `fromCast` true: the SpellGo hook — authoritative caster + caster-modified
// (talented) timing. False: the OnAuraAdded application hook — timing only,
// no caster, and it must not clobber an entry SpellGo already owns (that
// would replace talented timing with the unmodified base), so it skips
// entries that already carry a caster.
void Store(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
uint32_t expirationMs, uint32_t durationMs, bool fromCast) {
if (targetGuid == 0 || spellId == 0)
return;
// Update an existing entry for this exact aura instance.
for (auto &e : g_cache) {
if (e.used && e.targetGuid == targetGuid && e.spellId == spellId) {
if (!fromCast && e.casterGuid != 0)
return; // SpellGo owns this entry; keep its caster + timing
if (casterGuid != 0)
e.casterGuid = casterGuid;
e.expirationMs = expirationMs;
e.durationMs = durationMs;
return;
}
}
// Take a free slot, else an expired one, else evict round-robin.
const uint32_t now = NowMs();
for (auto &e : g_cache) {
if (!e.used || (e.expirationMs != 0 && now >= e.expirationMs)) {
e = {targetGuid, casterGuid, spellId, expirationMs, durationMs, true};
return;
}
}
Entry &slot = g_cache[g_writeCursor];
g_writeCursor = (g_writeCursor + 1) % kCacheSize;
slot = {targetGuid, casterGuid, spellId, expirationMs, durationMs, true};
}
// Drop entries whose timed aura has elapsed so the table doesn't fill with
// stale combat debuffs. Infinite-duration entries (expirationMs == 0) stay
// until overwritten.
void OnWorldTick() {
const uint32_t now = NowMs();
for (auto &e : g_cache) {
if (e.used && e.expirationMs != 0 && now >= e.expirationMs)
e.used = false;
}
}
const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
// ---- SMSG_SPELL_GO co-hook ----------------------------------------------
// The hit-target list is all we need: each entry is a unit the cast landed
// on, which is exactly where an aura gets applied. We deliberately stop
// before the missed-target list / target mask — the post-mask "intended"
// target is redundant with the hit list for aura purposes, and skipping it
// keeps the parse short and robust.
constexpr int kMaxTargets = 16;
using SpellGo_t = void(__fastcall *)(uint64_t *itemGUID, uint64_t *casterGUID,
uint32_t spellId, CDataStore *packet);
SpellGo_t g_origSpellGo = nullptr;
void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
uint32_t spellId, CDataStore *packet) {
uint64_t targets[kMaxTargets];
int numTargets = 0;
if (packet != nullptr) {
const uint32_t savedRead = packet->m_read;
ReadField<int16_t>(packet); // castFlags (unused)
const uint8_t numHit = ReadField<uint8_t>(packet);
for (int i = 0; i < numHit; ++i) {
const uint64_t guid = ReadField<uint64_t>(packet);
if (numTargets < kMaxTargets)
targets[numTargets++] = guid;
}
packet->m_read = savedRead; // restore so the engine re-parses cleanly
}
g_origSpellGo(itemGUID, casterGUID, spellId, packet);
const uint64_t caster = (casterGUID != nullptr) ? *casterGUID : 0;
if (caster == 0 || spellId == 0)
return;
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellId));
if (!SpellAppliesAura(rec))
return;
const bool casterIsPlayer = (caster == Unit::Identity::PlayerGuid());
const uint32_t durationMs = SpellDurationMs(rec, casterIsPlayer);
const uint32_t expirationMs = durationMs > 0 ? NowMs() + durationMs : 0;
if (numTargets == 0) {
// No explicit hit list (self-cast with caster-implicit target).
Store(caster, spellId, caster, expirationMs, durationMs, true);
return;
}
for (int i = 0; i < numTargets; ++i)
Store(targets[i], spellId, caster, expirationMs, durationMs, true);
}
const Game::HookAutoRegister _hook{Offsets::FUN_SPELL_GO,
reinterpret_cast<void *>(&SpellGo_h),
reinterpret_cast<void **>(&g_origSpellGo)};
// ---- Aura-application co-hooks (timing for proc / triggered auras) -------
// Stamp expiration for an aura that just landed/refreshed on `unit`. Used by
// both the add and stack-change hooks. No caster is available from these
// paths, so it stamps timing only with `fromCast=false` — Store skips any
// entry SpellGo already owns, so a directly-cast aura keeps its talented
// timing. Base (unmodified) duration is the best estimate without a caster.
void StampApplication(void *unit, uint32_t spellId) {
if (spellId == 0)
return;
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellId));
if (!SpellAppliesAura(rec))
return;
const uint64_t unitGuid = Unit::Identity::GuidForObject(unit);
if (unitGuid == 0)
return;
const uint32_t durationMs = SpellDurationMs(rec, /*casterIsPlayer*/ false);
const uint32_t expirationMs = durationMs > 0 ? NowMs() + durationMs : 0;
Store(unitGuid, spellId, /*casterGuid*/ 0, expirationMs, durationMs,
/*fromCast*/ false);
}
// OnAuraAdded — a new aura occupies a slot (gives the spellId directly).
using OnAuraAdded_t = void(__fastcall *)(void *unit, void *edx, uint32_t slot,
uint32_t spellId);
OnAuraAdded_t g_origOnAuraAdded = nullptr;
void __fastcall OnAuraAdded_h(void *unit, void *edx, uint32_t slot,
uint32_t spellId) {
g_origOnAuraAdded(unit, edx, slot, spellId);
StampApplication(unit, spellId);
}
const Game::HookAutoRegister _hookAuraAdded{
Offsets::FUN_ON_AURA_ADDED, reinterpret_cast<void *>(&OnAuraAdded_h),
reinterpret_cast<void **>(&g_origOnAuraAdded)};
// OnAuraStacksChanged — an existing aura's stack count changed (e.g. Shadow
// Weaving climbing). Only the slot is given, so read the spellID back from
// the unit's aura array. Re-stamps expiration so stacking refreshes count.
using OnAuraStacksChanged_t = void(__fastcall *)(void *unit, void *edx,
int slot, uint8_t stackCount);
OnAuraStacksChanged_t g_origOnAuraStacksChanged = nullptr;
void __fastcall OnAuraStacksChanged_h(void *unit, void *edx, int slot,
uint8_t stackCount) {
g_origOnAuraStacksChanged(unit, edx, slot, stackCount);
StampApplication(unit, Aura::Data::ReadSpellID(
static_cast<const uint8_t *>(unit), slot));
}
const Game::HookAutoRegister _hookAuraStacks{
Offsets::FUN_ON_AURA_STACKS_CHANGED,
reinterpret_cast<void *>(&OnAuraStacksChanged_h),
reinterpret_cast<void **>(&g_origOnAuraStacksChanged)};
} // namespace
bool Get(uint64_t unitGuid, uint32_t spellId, uint64_t *outCaster,
uint32_t *outExpirationMs, uint32_t *outDurationMs) {
if (unitGuid == 0 || spellId == 0)
return false;
for (const auto &e : g_cache) {
if (e.used && e.targetGuid == unitGuid && e.spellId == spellId) {
*outCaster = e.casterGuid;
*outExpirationMs = e.expirationMs;
*outDurationMs = e.durationMs;
return true;
}
}
return false;
}
} // namespace Aura::Source
+45
View File
@@ -0,0 +1,45 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser 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 Lesser General Public License for more details.
#pragma once
#include <cstdint>
// Aura caster / duration cache, fed by a co-hook on `SMSG_SPELL_GO`
// (`FUN_SPELL_GO`). The vanilla unit aura array (`UNIT_FIELD_AURA`) stores
// only spell IDs — it has no record of *who* cast an aura, and no
// cast/expiration timing for any unit but the local player. The one place
// the client ever sees the caster + a server-authoritative duration is the
// `SMSG_SPELL_GO` packet at cast time. We parse it (the same packet
// nampower parses for its `AURA_CAST_ON_*` events) and remember, per
// `(targetGuid, spellId)`, who cast it and when it should expire.
//
// `Aura::Data::Push` consults this to fill `sourceUnit` (caster, resolved
// to a unit token) and `expirationTime` for non-player units. The cache is
// inherently best-effort: it only knows about casts observed since login,
// and an entry survives until it expires (combat-duration auras) or is
// evicted under pressure. Misses leave the modern-truthful defaults
// (`sourceUnit` nil, `expirationTime` 0) in place — same outcome as before
// this module existed.
namespace Aura::Source {
// Looks up the cached caster + timing for the aura `spellId` currently on
// the unit identified by `unitGuid`. Returns true and fills the out params
// on a hit. `*outExpirationMs` is an absolute `GetTickCount`-epoch timestamp
// (0 = unknown / infinite-duration aura); `*outDurationMs` is the applied
// duration including the caster's modifiers (talents etc.; 0 = none) — use
// it for the `duration` field so it stays consistent with `expirationTime`;
// `*outCaster` is the caster's 64-bit GUID (never 0 on a hit). Returns false
// on a miss or for zero inputs.
bool Get(uint64_t unitGuid, uint32_t spellId, uint64_t *outCaster,
uint32_t *outExpirationMs, uint32_t *outDurationMs);
} // namespace Aura::Source
+2 -8
View File
@@ -13,6 +13,7 @@
#include "Game.h"
#include "Offsets.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstring>
@@ -74,14 +75,7 @@ uint16_t LocalPlayerArea() {
static_cast<uintptr_t>(Offsets::VAR_PLAYER_AREA_ID)));
}
uint64_t LocalPlayerGUID() {
auto *player = *reinterpret_cast<uint8_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_LOCAL_PLAYER_PTR));
if (player == nullptr)
return 0;
return *reinterpret_cast<const uint64_t *>(
player + Offsets::OFF_LOCAL_PLAYER_GUID);
}
uint64_t LocalPlayerGUID() { return Unit::Identity::PlayerGuid(); }
// Pushes `area` as a number, or nil if `area == 0`. Vanilla zone 0
+2 -10
View File
@@ -37,7 +37,7 @@
// player object is ready, and stay stable across zone transitions.
#include "Game.h"
#include "Offsets.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -46,15 +46,7 @@ namespace Player::LoginStatus {
namespace {
int __fastcall Script_IsLoggedIn(void *L) {
auto *player = *reinterpret_cast<const uint8_t *const *>(
Offsets::VAR_LOCAL_PLAYER_PTR);
if (player == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
const uint64_t guid = *reinterpret_cast<const uint64_t *>(
player + Offsets::OFF_LOCAL_PLAYER_GUID);
Game::Lua::PushBool(L, guid != 0);
Game::Lua::PushBool(L, Unit::Identity::PlayerGuid() != 0);
return 1;
}
+2 -8
View File
@@ -62,6 +62,7 @@
#include "Offsets.h"
#include "guid/Guid.h"
#include "settings/Account.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstdio>
@@ -467,14 +468,7 @@ UnitBytes0 ReadBytes0(void *unit) {
// on the local CGPlayer_C type, or it is but returns 0x1 to mean
// "no remote name; ask the local source." Either way we don't need
// to cache ourselves — we have the data via every other path.
uint64_t LocalPlayerGUID() {
auto *player = *reinterpret_cast<uint8_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_LOCAL_PLAYER_PTR));
if (player == nullptr)
return 0;
return *reinterpret_cast<const uint64_t *>(
player + Offsets::OFF_LOCAL_PLAYER_GUID);
}
uint64_t LocalPlayerGUID() { return Unit::Identity::PlayerGuid(); }
// Pointer sanity check before dereferencing. The CGObject vftable
// GetName slot has at least one known case where it returns 0x1
+69 -42
View File
@@ -11,6 +11,8 @@
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "unit/Identity.h"
#include "Game.h"
#include "Offsets.h"
#include "guid/Guid.h"
@@ -44,15 +46,29 @@ using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
// `FUN_00468550() != 0` for the same reason — the GUID isn't ready
// yet. Addons that need the player GUID at boot must wait for
// `PLAYER_LOGIN`. See the trace notes in commit history.
static uint64_t ResolveTokenGUID(const char *token) {
if (token != nullptr && _stricmp(token, "player") == 0) {
auto *player = *reinterpret_cast<uint8_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_LOCAL_PLAYER_PTR));
if (player == nullptr)
return 0;
return *reinterpret_cast<const uint64_t *>(
player + Offsets::OFF_LOCAL_PLAYER_GUID);
}
uint64_t PlayerGuid() {
auto *player = *reinterpret_cast<uint8_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_LOCAL_PLAYER_PTR));
if (player == nullptr)
return 0;
return *reinterpret_cast<const uint64_t *>(
player + Offsets::OFF_LOCAL_PLAYER_GUID);
}
uint64_t GuidForObject(const void *unitObject) {
if (unitObject == nullptr)
return 0;
const auto *unit = static_cast<const uint8_t *>(unitObject);
const uint8_t *block = *reinterpret_cast<const uint8_t *const *>(
unit + Offsets::OFF_UNIT_GUID_PTR);
if (block == nullptr)
return 0;
return *reinterpret_cast<const uint64_t *>(block);
}
uint64_t GuidForToken(const char *token) {
if (token != nullptr && _stricmp(token, "player") == 0)
return PlayerGuid();
auto fn = reinterpret_cast<TokenToGUID_t>(Offsets::FUN_TOKEN_TO_GUID);
return fn(token);
}
@@ -92,7 +108,7 @@ static int __fastcall Script_UnitGUID(void *L) {
if (token == nullptr)
return 0;
const uint64_t guid = ResolveTokenGUID(token);
const uint64_t guid = GuidForToken(token);
if (guid == 0)
return 0;
@@ -127,25 +143,20 @@ static int __fastcall Script_UnitGUID(void *L) {
//
// Returns `nil` if the input string isn't a parseable GUID, or the
// GUID is zero, or no token currently maps to it.
static int __fastcall Script_UnitTokenFromGUID(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitTokenFromGUID(\"unitGUID\")");
return 0;
}
const char *guidStr = Game::Lua::ToString(L, 1);
uint64_t target = 0;
if (guidStr == nullptr || !Guid::Parse(guidStr, &target) || target == 0)
return 0;
const char *TokenFromGUID(uint64_t target, char *buf, size_t bufSize) {
if (target == 0)
return nullptr;
auto set = [&](const char *token) -> const char * {
std::snprintf(buf, bufSize, "%s", token);
return buf;
};
auto check = [&](const char *token) -> bool {
if (ResolveTokenGUID(token) != target)
return false;
Game::Lua::PushString(L, token);
return true;
return GuidForToken(token) == target;
};
if (check("player")) return 1;
if (check("pet")) return 1;
if (check("player")) return set("player");
if (check("pet")) return set("pet");
// Cap each indexed-token scan at the actual populated slot
// count, read straight out of engine memory. Solo skips all 88
@@ -170,22 +181,21 @@ static int __fastcall Script_UnitTokenFromGUID(void *L) {
if (raidCount > Offsets::RAID_MAX_SLOTS)
raidCount = Offsets::RAID_MAX_SLOTS;
char buf[16];
for (int i = 1; i <= partyCount; ++i) {
std::snprintf(buf, sizeof buf, "party%d", i);
if (check(buf)) return 1;
std::snprintf(buf, bufSize, "party%d", i);
if (GuidForToken(buf) == target) return buf;
}
for (int i = 1; i <= partyCount; ++i) {
std::snprintf(buf, sizeof buf, "partypet%d", i);
if (check(buf)) return 1;
std::snprintf(buf, bufSize, "partypet%d", i);
if (GuidForToken(buf) == target) return buf;
}
for (int i = 1; i <= raidCount; ++i) {
std::snprintf(buf, sizeof buf, "raid%d", i);
if (check(buf)) return 1;
std::snprintf(buf, bufSize, "raid%d", i);
if (GuidForToken(buf) == target) return buf;
}
for (int i = 1; i <= raidCount; ++i) {
std::snprintf(buf, sizeof buf, "raidpet%d", i);
if (check(buf)) return 1;
std::snprintf(buf, bufSize, "raidpet%d", i);
if (GuidForToken(buf) == target) return buf;
}
// Visible nameplates. The ordered list (see
@@ -198,18 +208,35 @@ static int __fastcall Script_UnitTokenFromGUID(void *L) {
if (plateGuid == 0)
break;
if (plateGuid == target) {
std::snprintf(buf, sizeof buf, "nameplate%d", i);
Game::Lua::PushString(L, buf);
return 1;
std::snprintf(buf, bufSize, "nameplate%d", i);
return buf;
}
}
if (check("target")) return 1;
if (check("focus")) return 1;
if (check("npc")) return 1;
if (check("mouseover")) return 1;
if (check("target")) return set("target");
if (check("focus")) return set("focus");
if (check("npc")) return set("npc");
if (check("mouseover")) return set("mouseover");
return 0;
return nullptr;
}
static int __fastcall Script_UnitTokenFromGUID(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitTokenFromGUID(\"unitGUID\")");
return 0;
}
const char *guidStr = Game::Lua::ToString(L, 1);
uint64_t target = 0;
if (guidStr == nullptr || !Guid::Parse(guidStr, &target) || target == 0)
return 0;
char buf[32];
const char *token = TokenFromGUID(target, buf, sizeof buf);
if (token == nullptr)
return 0;
Game::Lua::PushString(L, token);
return 1;
}
static void RegisterLuaFunctions() {
+55
View File
@@ -0,0 +1,55 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser 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 Lesser General Public License for more details.
#pragma once
#include <cstddef>
#include <cstdint>
// Unit identity helpers shared across modules. The Lua surface
// (`UnitGUID` / `UnitTokenFromGUID`) lives in `Identity.cpp`; these are the
// reusable C++ cores other modules call directly (e.g. `Aura::Data`
// resolving an aura's caster GUID to a `sourceUnit` token).
namespace Unit::Identity {
// The local player's 64-bit GUID, read from the canonical local-player
// pointer (`VAR_LOCAL_PLAYER_PTR + OFF_LOCAL_PLAYER_GUID`). Independent of
// object-manager state, so it stays valid across zone transitions / brief
// CGPlayer rebuilds. Returns 0 before login (pointer null) or before the
// GUID is populated. This is the canonical "who am I" read — prefer it over
// re-dereferencing the globals inline.
uint64_t PlayerGuid();
// The 64-bit GUID of a resolved CGUnit/CGObject pointer, read through the
// pointer at `+0x08` to an instance block whose first 8 bytes are the GUID
// (verified in `Script_GetInventoryItemLink`; see `OFF_UNIT_GUID_PTR`).
// Returns 0 if `unitObject` or the block pointer is null. Use this for any
// "GUID of this object pointer" read so callers stay consistent (the aura
// caster cache keys on it from both the lookup and the OnAuraAdded hook).
uint64_t GuidForObject(const void *unitObject);
// Resolves a unit token (`"player"`, `"target"`, `"party3"`, …) to its
// 64-bit GUID, or 0 if the token currently maps to nothing. `"player"` is
// read from the canonical local-player pointer so it works regardless of
// object-manager state; everything else goes through the engine's
// token→GUID resolver (so out-of-range party/raid members resolve too).
// NOTE: raises a Lua error for tokens the engine doesn't recognize — only
// pass known unit tokens.
uint64_t GuidForToken(const char *token);
// Best-effort reverse lookup: given a unit GUID, writes the first unit
// token currently mapped to it into `buf` and returns `buf`, or returns
// nullptr if no known token points at it (or `guid` is 0). The mapping is
// unstable across frames — callers should treat the result as a snapshot.
// `buf` should be at least 32 bytes.
const char *TokenFromGUID(uint64_t guid, char *buf, size_t bufSize);
} // namespace Unit::Identity