mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-13 14:47:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19397782f7 | ||
|
|
284b951038 | ||
|
|
1df48772f4 | ||
|
|
939b52ae69 | ||
|
|
2403ff7233 |
@@ -27,6 +27,7 @@ Util\EventUtil.lua
|
||||
Util\ItemUtil.lua
|
||||
Util\AddOnCompat.lua
|
||||
Util\FunctionUtil.lua
|
||||
Util\CacheUtil.lua
|
||||
Util\Pools.lua
|
||||
Util\FrameWatcher.lua
|
||||
Util\TimedCallback.lua
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
-- On-demand, throttled cache warmer (/warmcache). A brute-force scan of the
|
||||
-- whole ID space floods the client's network buffers and panics in
|
||||
-- WDataStoreSmallBuffer ("not enough memory") — the engine can't hold that
|
||||
-- many pending queries. So this warms a *bounded* range, started/stopped
|
||||
-- manually, at a low fixed rate. Tune WARM_RATE / WARM_INTERVAL if needed,
|
||||
-- but keep it modest. Note: IDs the server doesn't know (gaps in the range)
|
||||
-- get no response, so their pending entries linger — warm ranges you expect
|
||||
-- to be mostly real, not arbitrary millions.
|
||||
--
|
||||
-- /warmcache item 1 40000 warm item IDs 1..40000
|
||||
-- /warmcache <type> <a> <b> type = item | quest | creature | object | all
|
||||
-- /warmcache stop abort an in-progress warm
|
||||
local WARM_RATE = 20 -- IDs queued per tick
|
||||
local WARM_INTERVAL = 0.2 -- seconds between ticks (-> ~20 IDs/sec)
|
||||
|
||||
local WARM_FN = {
|
||||
item = function(id) C_Item.RequestLoadItemDataByID(id) end,
|
||||
quest = function(id) C_QuestLog.RequestLoadQuestByID(id) end,
|
||||
creature = function(id) C_CreatureInfo.RequestLoadCreatureByID(id) end,
|
||||
object = function(id) C_GameObjectInfo.RequestLoadGameObjectByID(id) end,
|
||||
all = function(id)
|
||||
C_QuestLog.RequestLoadQuestByID(id)
|
||||
C_Item.RequestLoadItemDataByID(id)
|
||||
C_CreatureInfo.RequestLoadCreatureByID(id)
|
||||
C_GameObjectInfo.RequestLoadGameObjectByID(id)
|
||||
end,
|
||||
}
|
||||
|
||||
local warm = { running = false, cancel = false }
|
||||
|
||||
local function warm_msg(s)
|
||||
DEFAULT_CHAT_FRAME:AddMessage("|cff66ccff[WarmCache]|r " .. s)
|
||||
end
|
||||
|
||||
local function warm_step(fn, id, stop, lastMsg)
|
||||
if warm.cancel then
|
||||
warm.running = false
|
||||
warm_msg("stopped at " .. (id - 1))
|
||||
return
|
||||
end
|
||||
local n = 0
|
||||
while n < WARM_RATE and id <= stop do
|
||||
fn(id)
|
||||
n = n + 1
|
||||
id = id + 1
|
||||
end
|
||||
if id > stop then
|
||||
warm.running = false
|
||||
warm_msg("done (through " .. stop .. ")")
|
||||
else
|
||||
if (id - 1) - lastMsg >= 1000 then
|
||||
warm_msg((id - 1) .. " / " .. stop .. " ...")
|
||||
lastMsg = id - 1
|
||||
end
|
||||
C_Timer.After(WARM_INTERVAL, function() warm_step(fn, id, stop, lastMsg) end)
|
||||
end
|
||||
end
|
||||
|
||||
SLASH_WARMCACHE1 = "/warmcache"
|
||||
SlashCmdList["WARMCACHE"] = function(msg)
|
||||
local args = {}
|
||||
for tok in string.gmatch(msg or "", "%S+") do
|
||||
table.insert(args, string.lower(tok))
|
||||
end
|
||||
local cmd = args[1]
|
||||
|
||||
if cmd == "stop" then
|
||||
if warm.running then warm.cancel = true else warm_msg("not running") end
|
||||
return
|
||||
end
|
||||
if warm.running then
|
||||
warm_msg("already running - /warmcache stop first")
|
||||
return
|
||||
end
|
||||
|
||||
local fn = WARM_FN[cmd]
|
||||
local a, b = tonumber(args[2]), tonumber(args[3])
|
||||
if not fn or not a or not b or a < 1 or b < a then
|
||||
warm_msg("usage: /warmcache item|quest|creature|object|all <startID> <stopID>")
|
||||
warm_msg(" /warmcache stop")
|
||||
return
|
||||
end
|
||||
|
||||
warm.running, warm.cancel = true, false
|
||||
warm_msg(string.format("warming %s %d..%d (~%d/sec, /warmcache stop to abort)",
|
||||
cmd, a, b, math.floor(WARM_RATE / WARM_INTERVAL)))
|
||||
warm_step(fn, a, b, a - 1)
|
||||
end
|
||||
@@ -1,5 +1,4 @@
|
||||
local className = UnitClass('player')
|
||||
local classFile, classID = UnitClassBase('player')
|
||||
local className, classFile, classID = UnitClass('player'), UnitClassBase('player')
|
||||
|
||||
PlayerUtil = PlayerUtil or {}
|
||||
|
||||
@@ -32,12 +31,8 @@ function PlayerUtil.GetCurrentSpeed()
|
||||
return GetUnitSpeed("player") / 7 * 100
|
||||
end
|
||||
|
||||
local playerGuid
|
||||
function GetPlayerGuid()
|
||||
if not playerGuid then
|
||||
playerGuid = UnitGUID("player")
|
||||
end
|
||||
return playerGuid
|
||||
return UnitGUID("player")
|
||||
end
|
||||
|
||||
function IsPlayerGuid(guid)
|
||||
|
||||
+12
-4
@@ -7960,10 +7960,18 @@ so progress is `(GetTime()*1000 - startTimeMs) / (endTimeMs - startTimeMs)`.
|
||||
> another unit's cast with a server-authoritative cast time, cached per
|
||||
> caster GUID. So `C_Spell.UnitCastingInfo("target")` now backs
|
||||
> enemy/target/focus/nameplate cast bars. Instant casts return `nil` (no
|
||||
> cast bar). Best-effort for remote units: only casts that began while the
|
||||
> unit was in range are seen, and — since 1.12 has no per-unit interrupt
|
||||
> packet to hook — an *interrupted* remote cast lingers until its computed
|
||||
> `endTimeMs` rather than clearing instantly.
|
||||
> cast bar). Best-effort for remote units, in three ways: only casts that
|
||||
> began while the unit was in range are seen; since 1.12 has no per-unit
|
||||
> interrupt packet to hook, an *interrupted* remote cast lingers until its
|
||||
> computed `endTimeMs` rather than clearing instantly; and the remote
|
||||
> `startTimeMs`/`endTimeMs` are shifted later by roughly your latency.
|
||||
> (1.12's `SMSG_SPELL_START` carries only a single `castTime`, so we stamp
|
||||
> `start = now, end = now + castTime` on receipt. 3.3.5+ avoids the skew
|
||||
> because its packet carries *both* total and remaining cast time and
|
||||
> back-dates `start = now − elapsed`; the 1.12 packet has no field to
|
||||
> recover `elapsed` from.) The **local player is unaffected** by all of
|
||||
> this — its cast is detected client-side at the moment of cast, no packet
|
||||
> involved.
|
||||
>
|
||||
> `isTradeskill` is real — the spell's `SPELL_ATTR_TRADESPELL` flag, so
|
||||
> profession recipe casts (smelting, cooking/enchanting recipes, …)
|
||||
|
||||
@@ -706,6 +706,14 @@ enum Offsets {
|
||||
// nampower's SpellStartHandlerHook.
|
||||
FUN_SPELL_START_HANDLER = 0x006E7640,
|
||||
|
||||
// `SMSG_SPELL_DELAYED` handler — `int __stdcall(uint32_t *opCode,
|
||||
// CDataStore *packet)`. Body: `guid(u64), delayMs(u32)`. The server
|
||||
// only sends this to the affected caster, so in practice it's always
|
||||
// the local player's cast pushback (taking damage mid-cast). `Spell::
|
||||
// Cast` co-hooks it to extend the tracked cast's end time so the bar
|
||||
// reflects pushback. Mirrored from nampower's SpellDelayedHook.
|
||||
FUN_SPELL_DELAYED = 0x006E74F0,
|
||||
|
||||
// `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,
|
||||
@@ -725,6 +733,18 @@ enum Offsets {
|
||||
// re-stamp expiration so stacking-DoT refreshes are picked up.
|
||||
FUN_ON_AURA_STACKS_CHANGED = 0x00612450,
|
||||
|
||||
// `CGUnit_C::OnAuraRemoved` — same ABI as `OnAuraAdded`
|
||||
// (`__fastcall(unit /*ecx*/, edx_unused, slot, spellId)`; the disassembly
|
||||
// of the diff dispatcher `FUN_00604d00` at the call site `0x00604dc4`
|
||||
// shows `ECX = unit`, stack `[slot, spellId]`). The sibling of
|
||||
// `OnAuraAdded`: `FUN_00604d00` fires this when a descriptor aura slot
|
||||
// goes from occupied to empty — a genuine removal (fell off, dispelled,
|
||||
// or replaced by a higher rank, which fires Removed(old)+Added(new)).
|
||||
// `Aura::Source` co-hooks it to EVICT the cached entry so the
|
||||
// `GetAuraDataByIndex` fallback can't resurrect a superseded/dispelled
|
||||
// aura that's still inside its computed duration window.
|
||||
FUN_ON_AURA_REMOVED = 0x00612320,
|
||||
|
||||
// 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
|
||||
@@ -1631,6 +1651,19 @@ enum Offsets {
|
||||
OFF_UNIT_DESCRIPTOR_CLASS_BYTE = 0x79,
|
||||
OFF_UNIT_DESCRIPTOR_SEX_BYTE = 0x7A,
|
||||
|
||||
// The local player's UNIT_FIELD_BYTES_0 race/class/sex bytes, mirrored
|
||||
// into a login-time global that is populated at character-enter —
|
||||
// BEFORE the in-world CGUnit/descriptor exists. Script_UnitClass
|
||||
// (0x00518350) and Script_UnitRace special-case the literal "player"
|
||||
// token to read these (Script_UnitClass calls FUN_005abde0 →
|
||||
// `return DAT_00c27e81`), which is why UnitClass("player") resolves at
|
||||
// addon-load while a descriptor read returns nil until the player
|
||||
// object spawns. Getters: race FUN_005abdd0, class FUN_005abde0,
|
||||
// sex FUN_005abdf0.
|
||||
VAR_PLAYER_RACE_BYTE = 0x00C27E80,
|
||||
VAR_PLAYER_CLASS_BYTE = 0x00C27E81,
|
||||
VAR_PLAYER_SEX_BYTE = 0x00C27E82,
|
||||
|
||||
// SkillLineAbility.dbc — maps each (class, race) pair to its
|
||||
// learnable spell list with skill-rank gating. Indexed by record
|
||||
// ID; reads via the standard `records[id]` pattern. Used by
|
||||
@@ -2365,6 +2398,18 @@ enum Offsets {
|
||||
// `FUN_006E3D10`. Non-zero means "the cast bar is showing this
|
||||
// spell"; cleared to 0 when the cast finishes or is cancelled.
|
||||
VAR_CURRENT_CAST_SPELL = 0x00CECA88,
|
||||
// The cast-start state writer — `__fastcall(int spellID, int
|
||||
// targetState)`. Sets `VAR_CURRENT_CAST_SPELL` (pushing any current
|
||||
// spell into `VAR_QUEUED_CAST_SPELL`; `spellID == 0` restores the queued
|
||||
// one). `Spell::Cast` co-hooks it for lag-free, client-side detection of
|
||||
// NORMAL player casts (stamps from the spellID arg before the original).
|
||||
// It does NOT fire for a chained same-spell recast: the caller
|
||||
// `Spell_C_CastSpell` (`0x006E4B60`) short-circuits at `spellID ==
|
||||
// VAR_CURRENT_CAST_SPELL` *before* reaching here, so those never run the
|
||||
// client cast path — they're caught by the `SMSG_SPELL_START` backstop
|
||||
// instead. nampower hooks the higher Spell_C_CastSpell, not this inner
|
||||
// writer, so contention is low.
|
||||
FUN_CAST_START_SET = 0x006E4AD0,
|
||||
// Previous / queued cast — holds the spellID that was active
|
||||
// before the latest one superseded it (mid-GCD queueing in
|
||||
// vanilla). When the current cast ends, `FUN_006E4AD0(0, ...)`
|
||||
@@ -4173,6 +4218,29 @@ enum Offsets {
|
||||
VAR_GLUE_LOGIN_READY1 = 0x00B41DFC,
|
||||
VAR_GLUE_LOGIN_READY2 = 0x00B41E04,
|
||||
VAR_GLUE_LOGIN_INPROGRESS = 0x00B41DA0,
|
||||
// The same `VAR_GLUE_LOGIN_INPROGRESS` global doubles as a glue
|
||||
// state-code: `FUN_GLUE_ENTER_WORLD` sets it to `8` ("entering world")
|
||||
// on a successful commit, which `Unit::Identity`'s enter-world co-hook
|
||||
// gates on (the rename / char-locked bail paths return before it).
|
||||
GLUE_STATE_ENTERING_WORLD = 8,
|
||||
|
||||
// `FUN_0046B500` — the enter-world worker behind `Script_EnterWorld` (the
|
||||
// glue "Enter World" button: `Script_EnterWorld` is just `call this; ret`).
|
||||
// Computes the selected character's struct into VAR_GLUE_SELECTED_CHAR and,
|
||||
// on success, sets VAR_GLUE_LOGIN_INPROGRESS = 8. `Unit::Identity` co-hooks
|
||||
// it to capture the selected character's GUID *before* the engine creates
|
||||
// the in-world player object — so `UnitGUID("player")` / `PlayerGuid()`
|
||||
// answer from addon-load time instead of returning nil/0 until the
|
||||
// SMSG_UPDATE_OBJECT that populates the player object (≈PLAYER_LOGIN).
|
||||
FUN_GLUE_ENTER_WORLD = 0x0046B500,
|
||||
|
||||
// Selected-character struct pointer (glue): char-array base +
|
||||
// selectedIndex*0x120, written by FUN_GLUE_ENTER_WORLD. The character's
|
||||
// 64-bit login GUID is at offset 0x00 (name at +0x08, race +0x100, class
|
||||
// +0x101, level +0x108 — verified against `Script_GetCharacterInfo` at
|
||||
// `0x004732A0`). Entries are filled from SMSG_CHAR_ENUM.
|
||||
VAR_GLUE_SELECTED_CHAR = 0x00B41DF4,
|
||||
OFF_GLUE_CHAR_GUID = 0x00,
|
||||
|
||||
// Cursor state — single global type-tag at VAR_CURSOR_TYPE
|
||||
// dispatches reads through one of several payload slots depending
|
||||
|
||||
+10
-3
@@ -92,11 +92,16 @@ int PushAuraByIndex(void *L, const char *unitToken, int index,
|
||||
Data::Filter filter, bool playerOnly = false) {
|
||||
const uint8_t *unit = ResolveUnit(unitToken);
|
||||
const int slot = Data::FindNthSlot(unit, index, filter, playerOnly);
|
||||
if (slot < 0) {
|
||||
Game::Lua::PushNil(L);
|
||||
if (slot >= 0) {
|
||||
Data::Push(L, unit, slot);
|
||||
return 1;
|
||||
}
|
||||
Data::Push(L, unit, slot);
|
||||
// Descriptor exhausted — an aura the engine dropped from the slot array
|
||||
// (rogue stealth, party range fluctuation) may still be live in the
|
||||
// Aura::Source cache. Surface it after the descriptor entries.
|
||||
if (Data::PushNthCacheFallback(L, unit, index, filter, playerOnly))
|
||||
return 1;
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -218,6 +223,8 @@ void AppendRangeToArray(void *L, const uint8_t *unit, int outerIdx,
|
||||
Data::Push(L, unit, slot);
|
||||
Game::Lua::SetTable(L, outerIdx);
|
||||
}
|
||||
// Append auras the descriptor dropped but Aura::Source still has live.
|
||||
Data::AppendCacheFallbacks(L, unit, filter, playerOnly, outerIdx, nextKey);
|
||||
}
|
||||
|
||||
int __fastcall Script_GetUnitAuras(void *L) {
|
||||
|
||||
+187
-83
@@ -350,10 +350,13 @@ const char *DispelName(uint32_t dispelTypeID) {
|
||||
return (name == nullptr || *name == '\0') ? nullptr : name;
|
||||
}
|
||||
|
||||
void Push(void *L, const uint8_t *unit, int slot) {
|
||||
const uint32_t spellID = ReadSpellID(unit, slot);
|
||||
// Writes the full modern AuraData table on top of the Lua stack from
|
||||
// already-resolved values. Shared by the descriptor path (`Push`) and the
|
||||
// cache-fallback path (`PushFromCache`). Net stack effect: +1 (the table).
|
||||
static void BuildTable(void *L, uint32_t spellID, int applications,
|
||||
bool isHelpful, double duration, double expirationTime,
|
||||
uint64_t casterGuid) {
|
||||
const uint8_t *spellRecord = SpellRecord(spellID);
|
||||
const bool isHelpful = slot < Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
|
||||
uint32_t dispelTypeID = 0;
|
||||
if (spellRecord != nullptr) {
|
||||
@@ -370,87 +373,32 @@ void Push(void *L, const uint8_t *unit, int slot) {
|
||||
Game::Lua::SetFieldString(L, "name", name);
|
||||
Game::Lua::SetFieldString(L, "icon", icon);
|
||||
Game::Lua::SetFieldNumber(L, "applications",
|
||||
static_cast<double>(ReadStacks(unit, slot)));
|
||||
static_cast<double>(applications));
|
||||
Game::Lua::SetFieldNumber(L, "spellId", static_cast<double>(spellID));
|
||||
Game::Lua::SetFieldString(L, "dispelName", dispel);
|
||||
Game::Lua::SetFieldBool(L, "isHelpful", isHelpful);
|
||||
Game::Lua::SetFieldBool(L, "isHarmful", !isHelpful);
|
||||
|
||||
// Timing fields. Vanilla doesn't broadcast cast time / duration
|
||||
// for ANY unit's auras except the local player's — for everyone
|
||||
// else (target, party, raid, mouseover) `expirationTime` stays 0
|
||||
// and addons have to track expiry themselves via aura-add events.
|
||||
// For the player we read the engine's `VAR_PLAYER_BUFF_TABLE`
|
||||
// (same data `GetPlayerBuffTimeLeft` returns), which gives us a
|
||||
// real expiration timestamp.
|
||||
//
|
||||
// `duration` comes from Spell.dbc → SpellDuration.dbc with
|
||||
// level scaling. That's the *base* value; talent / glyph
|
||||
// duration-extension modifiers (Improved PW:F, etc.) are
|
||||
// already baked into the expiration timestamp on the
|
||||
// caster's side, so `expirationTime - GetTime()` reflects the
|
||||
// true remaining time even when `duration` doesn't include
|
||||
// the talent boost. We populate `duration` for any unit (the
|
||||
// base value is identical regardless of who's affected); only
|
||||
// `expirationTime` is player-gated.
|
||||
double duration = 0.0;
|
||||
double expirationTime = 0.0;
|
||||
const uint8_t *player = LocalPlayer();
|
||||
if (spellID != 0) {
|
||||
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
|
||||
duration = SpellBaseDurationSeconds(spellID, unitLevel);
|
||||
}
|
||||
if (unit != nullptr && unit == player) {
|
||||
const uint8_t *entry = FindPlayerBuffEntry(spellID);
|
||||
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);
|
||||
|
||||
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.
|
||||
char tokenBuf[32];
|
||||
char guidBuf[Guid::STRING_SIZE];
|
||||
const char *sourceUnit = Unit::Identity::TokenFromGUID(
|
||||
casterGuid, tokenBuf, sizeof tokenBuf);
|
||||
const char *sourceGUID =
|
||||
Guid::FormatAsString(casterGuid, guidBuf, sizeof guidBuf);
|
||||
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);
|
||||
@@ -466,11 +414,167 @@ void Push(void *L, const uint8_t *unit, int slot) {
|
||||
Game::Lua::SetFieldBool(L, "shouldConsolidate", false);
|
||||
Game::Lua::SetFieldBool(L, "isRaid", false);
|
||||
|
||||
// `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.
|
||||
// `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.
|
||||
}
|
||||
|
||||
void Push(void *L, const uint8_t *unit, int slot) {
|
||||
const uint32_t spellID = ReadSpellID(unit, slot);
|
||||
const bool isHelpful = slot < Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
|
||||
// Timing fields. Vanilla doesn't broadcast cast time / duration for ANY
|
||||
// unit's auras except the local player's — for everyone else (target,
|
||||
// party, raid, mouseover) `expirationTime` stays 0 unless the
|
||||
// `Aura::Source` cache observed the cast. For the player we read the
|
||||
// engine's `VAR_PLAYER_BUFF_TABLE` (same data `GetPlayerBuffTimeLeft`
|
||||
// returns), which gives a real expiration timestamp.
|
||||
//
|
||||
// `duration` comes from Spell.dbc → SpellDuration.dbc with level scaling
|
||||
// (the *base* value); talent / glyph duration-extension modifiers are
|
||||
// already baked into the expiration timestamp on the caster's side, so
|
||||
// `expirationTime - GetTime()` reflects true remaining time even when the
|
||||
// base `duration` doesn't include the talent boost.
|
||||
double duration = 0.0;
|
||||
double expirationTime = 0.0;
|
||||
uint64_t casterGuid = 0;
|
||||
const uint8_t *player = LocalPlayer();
|
||||
if (spellID != 0) {
|
||||
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
|
||||
duration = SpellBaseDurationSeconds(spellID, unitLevel);
|
||||
}
|
||||
if (unit != nullptr && unit == player) {
|
||||
const uint8_t *entry = FindPlayerBuffEntry(spellID);
|
||||
if (entry != nullptr)
|
||||
expirationTime = PlayerBuffExpirationSeconds(entry);
|
||||
}
|
||||
// Caster + non-player expiration from the SMSG_SPELL_GO cache, captured at
|
||||
// cast time (the descriptor has neither). The player-buff table above is
|
||||
// authoritative for the player's own expiration; the cache fills it for
|
||||
// everyone else. Prefer the applied (caster-modified) duration so it stays
|
||||
// consistent with `expirationTime` — otherwise a talented DoT (Improved
|
||||
// Shadow Word: Pain) would show remaining time exceeding the base.
|
||||
if (spellID != 0) {
|
||||
uint64_t c = 0;
|
||||
uint32_t expMs = 0;
|
||||
uint32_t durMs = 0;
|
||||
if (Aura::Source::Get(UnitGuid(unit), spellID, &c, &expMs, &durMs)) {
|
||||
if (expirationTime == 0.0 && expMs != 0)
|
||||
expirationTime = static_cast<double>(expMs) * 0.001;
|
||||
if (durMs != 0)
|
||||
duration = static_cast<double>(durMs) * 0.001;
|
||||
casterGuid = c;
|
||||
}
|
||||
}
|
||||
|
||||
BuildTable(L, spellID, ReadStacks(unit, slot), isHelpful, duration,
|
||||
expirationTime, casterGuid);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Builds an AuraData table from an `Aura::Source` cache entry — the fallback
|
||||
// when the descriptor has dropped the aura's slot. Stack count isn't broadcast
|
||||
// in SMSG_SPELL_GO, so `applications` defaults to 1. `duration` uses the
|
||||
// applied (caster-modified) ms when known, else the Spell.dbc base.
|
||||
void PushFromCache(void *L, const uint8_t *unit,
|
||||
const Aura::Source::CachedAura &c, bool isHelpful) {
|
||||
double duration = 0.0;
|
||||
if (c.durationMs != 0) {
|
||||
duration = static_cast<double>(c.durationMs) * 0.001;
|
||||
} else {
|
||||
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
|
||||
duration = SpellBaseDurationSeconds(c.spellId, unitLevel);
|
||||
}
|
||||
const double expirationTime =
|
||||
(c.expirationMs != 0) ? static_cast<double>(c.expirationMs) * 0.001 : 0.0;
|
||||
BuildTable(L, c.spellId, 1, isHelpful, duration, expirationTime,
|
||||
c.casterGuid);
|
||||
}
|
||||
|
||||
// Counts populated descriptor slots matching `filter` (and `playerOnly`).
|
||||
int CountSlots(const uint8_t *unit, Filter filter, bool playerOnly) {
|
||||
if (unit == nullptr)
|
||||
return 0;
|
||||
const int start = (filter == Filter::Harmful)
|
||||
? Offsets::UNIT_AURA_BUFF_COUNT
|
||||
: 0;
|
||||
const int end = (filter == Filter::Harmful) ? Offsets::UNIT_AURA_TOTAL
|
||||
: Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
int n = 0;
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
if (!IsSlotPopulated(unit, slot))
|
||||
continue;
|
||||
if (playerOnly && !IsPlayerCast(unit, slot))
|
||||
continue;
|
||||
++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
constexpr int kFallbackMax = Offsets::UNIT_AURA_TOTAL;
|
||||
|
||||
// Whether cache entry `c` should be surfaced as a fallback for `unit` under
|
||||
// `filter`/`playerOnly`: it must not already be in a populated descriptor slot
|
||||
// (no double-listing the same live aura) and, when player-filtered, must have
|
||||
// been cast by us. Superseded / dispelled auras are kept out of the cache by
|
||||
// `Aura::Source`'s removal eviction, not filtered here.
|
||||
bool FallbackEligible(const uint8_t *unit, const Aura::Source::CachedAura &c,
|
||||
Filter filter, bool playerOnly) {
|
||||
if (FindSlotBySpellID(unit, c.spellId, &filter, false) >= 0)
|
||||
return false; // still in the descriptor — returned via the slot path
|
||||
if (playerOnly && c.casterGuid != Unit::Identity::PlayerGuid())
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
|
||||
Filter filter, bool playerOnly) {
|
||||
if (unit == nullptr)
|
||||
return false;
|
||||
// The descriptor held `descCount` matches; the caller already found fewer
|
||||
// than `oneBasedIndex` there, so the cache supplies index
|
||||
// `oneBasedIndex - descCount` (1-based) of the entries it didn't.
|
||||
const int target = oneBasedIndex - CountSlots(unit, filter, playerOnly);
|
||||
if (target < 1)
|
||||
return false;
|
||||
const uint64_t guid = UnitGuid(unit);
|
||||
if (guid == 0)
|
||||
return false;
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
const int n = Aura::Source::Enumerate(
|
||||
guid, filter == Filter::Harmful, buf, kFallbackMax);
|
||||
int seen = 0;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (!FallbackEligible(unit, buf[i], filter, playerOnly))
|
||||
continue;
|
||||
if (++seen == target) {
|
||||
PushFromCache(L, unit, buf[i], filter == Filter::Helpful);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
|
||||
bool playerOnly, int outerIdx, int &nextKey) {
|
||||
if (unit == nullptr)
|
||||
return;
|
||||
const uint64_t guid = UnitGuid(unit);
|
||||
if (guid == 0)
|
||||
return;
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
const int n = Aura::Source::Enumerate(
|
||||
guid, filter == Filter::Harmful, buf, kFallbackMax);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (!FallbackEligible(unit, buf[i], filter, playerOnly))
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
|
||||
PushFromCache(L, unit, buf[i], filter == Filter::Helpful);
|
||||
Game::Lua::SetTable(L, outerIdx);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace Aura::Data
|
||||
|
||||
@@ -112,4 +112,24 @@ const char *DispelName(uint32_t dispelTypeID);
|
||||
// Net stack effect: +1 (the table).
|
||||
void Push(void *L, const uint8_t *unit, int slot);
|
||||
|
||||
// Fallback for the index path when the descriptor has dropped an aura's slot
|
||||
// (rogue stealth, party range fluctuation) but it's still active server-side.
|
||||
// `oneBasedIndex` is the same index the caller passed to `FindNthSlot`; this
|
||||
// resolves it against the `Aura::Source` cache *after* the descriptor matches
|
||||
// (so cache entries append after descriptor ones, preserving existing index
|
||||
// order). On a hit it pushes the AuraData table and returns true; on a miss it
|
||||
// pushes nothing and returns false (caller pushes nil). Only entries whose
|
||||
// helpful/harmful classification matches `filter` and that aren't already in a
|
||||
// populated descriptor slot are considered.
|
||||
bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
|
||||
Filter filter, bool playerOnly = false);
|
||||
|
||||
// Appends every eligible `Aura::Source` cache fallback for `unit` matching
|
||||
// `filter` into the array table at `outerIdx`, continuing from `nextKey`
|
||||
// (updated in place). The bulk-enumeration analog of `PushNthCacheFallback`,
|
||||
// for `GetUnitAuras`. Skips entries already present in a populated descriptor
|
||||
// slot so it never double-lists.
|
||||
void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
|
||||
bool playerOnly, int outerIdx, int &nextKey);
|
||||
|
||||
} // namespace Aura::Data
|
||||
|
||||
+90
-10
@@ -69,12 +69,19 @@ uint32_t SpellDurationMs(const uint8_t *spellRecord, bool casterIsPlayer) {
|
||||
|
||||
// ---- Cache ---------------------------------------------------------------
|
||||
|
||||
// Helpful/harmful classification, recorded from the aura's descriptor slot
|
||||
// when it's applied (the only place the buff/debuff split is known). SpellGo
|
||||
// gives no slot, so casts we only saw there stay Unknown until/unless an
|
||||
// application hook fires for the same aura.
|
||||
enum Kind : int8_t { KIND_UNKNOWN = -1, KIND_HELPFUL = 0, KIND_HARMFUL = 1 };
|
||||
|
||||
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
|
||||
int8_t kind; // Kind; descriptor-slot-derived, KIND_UNKNOWN if only seen via SpellGo
|
||||
bool used;
|
||||
};
|
||||
|
||||
@@ -88,13 +95,19 @@ int g_writeCursor = 0;
|
||||
// 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) {
|
||||
uint32_t expirationMs, uint32_t durationMs, bool fromCast,
|
||||
int8_t kind) {
|
||||
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) {
|
||||
// Learn the classification whenever a slot-derived kind arrives —
|
||||
// independent of caster/timing ownership, and never downgrade a
|
||||
// known kind back to unknown.
|
||||
if (kind != KIND_UNKNOWN)
|
||||
e.kind = kind;
|
||||
if (!fromCast && e.casterGuid != 0)
|
||||
return; // SpellGo owns this entry; keep its caster + timing
|
||||
if (casterGuid != 0)
|
||||
@@ -108,13 +121,32 @@ void Store(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
|
||||
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};
|
||||
e = {targetGuid, casterGuid, spellId, expirationMs, durationMs,
|
||||
kind, true};
|
||||
return;
|
||||
}
|
||||
}
|
||||
Entry &slot = g_cache[g_writeCursor];
|
||||
g_writeCursor = (g_writeCursor + 1) % kCacheSize;
|
||||
slot = {targetGuid, casterGuid, spellId, expirationMs, durationMs, true};
|
||||
slot = {targetGuid, casterGuid, spellId, expirationMs, durationMs, kind,
|
||||
true};
|
||||
}
|
||||
|
||||
// Evict the entry for an aura the engine reports gone. Keyed by (target,
|
||||
// spell) like the rest of the cache. Without this, the GetAuraDataByIndex
|
||||
// fallback would keep surfacing a dropped aura until its computed expiry —
|
||||
// e.g. a Rank 1 buff replaced by Rank 2 (engine drops Rank 1 from the
|
||||
// descriptor) would show as a phantom second aura, or a dispelled buff would
|
||||
// linger.
|
||||
void Evict(uint64_t targetGuid, uint32_t spellId) {
|
||||
if (targetGuid == 0 || spellId == 0)
|
||||
return;
|
||||
for (auto &e : g_cache) {
|
||||
if (e.used && e.targetGuid == targetGuid && e.spellId == spellId) {
|
||||
e.used = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop entries whose timed aura has elapsed so the table doesn't fill with
|
||||
@@ -176,11 +208,13 @@ void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
|
||||
|
||||
if (numTargets == 0) {
|
||||
// No explicit hit list (self-cast with caster-implicit target).
|
||||
Store(caster, spellId, caster, expirationMs, durationMs, true);
|
||||
Store(caster, spellId, caster, expirationMs, durationMs, true,
|
||||
KIND_UNKNOWN);
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < numTargets; ++i)
|
||||
Store(targets[i], spellId, caster, expirationMs, durationMs, true);
|
||||
Store(targets[i], spellId, caster, expirationMs, durationMs, true,
|
||||
KIND_UNKNOWN);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hook{Offsets::FUN_SPELL_GO,
|
||||
@@ -194,7 +228,7 @@ const Game::HookAutoRegister _hook{Offsets::FUN_SPELL_GO,
|
||||
// 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) {
|
||||
void StampApplication(void *unit, uint32_t spellId, int8_t kind) {
|
||||
if (spellId == 0)
|
||||
return;
|
||||
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellId));
|
||||
@@ -206,7 +240,13 @@ void StampApplication(void *unit, uint32_t spellId) {
|
||||
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);
|
||||
/*fromCast*/ false, kind);
|
||||
}
|
||||
|
||||
// Classify by the absolute aura slot: 0..BUFF_COUNT-1 = buff (helpful),
|
||||
// BUFF_COUNT..TOTAL-1 = debuff (harmful).
|
||||
int8_t KindForSlot(int slot) {
|
||||
return slot >= Offsets::UNIT_AURA_BUFF_COUNT ? KIND_HARMFUL : KIND_HELPFUL;
|
||||
}
|
||||
|
||||
// OnAuraAdded — a new aura occupies a slot (gives the spellId directly).
|
||||
@@ -217,7 +257,7 @@ 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);
|
||||
StampApplication(unit, spellId, KindForSlot(static_cast<int>(slot)));
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookAuraAdded{
|
||||
@@ -234,8 +274,10 @@ 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));
|
||||
StampApplication(
|
||||
unit,
|
||||
Aura::Data::ReadSpellID(static_cast<const uint8_t *>(unit), slot),
|
||||
KindForSlot(slot));
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookAuraStacks{
|
||||
@@ -243,6 +285,26 @@ const Game::HookAutoRegister _hookAuraStacks{
|
||||
reinterpret_cast<void *>(&OnAuraStacksChanged_h),
|
||||
reinterpret_cast<void **>(&g_origOnAuraStacksChanged)};
|
||||
|
||||
// OnAuraRemoved — a descriptor aura slot went empty: the aura fell off, was
|
||||
// dispelled, or was replaced by a higher rank (the diff dispatcher fires
|
||||
// Removed(old) + Added(new)). Evict the cache entry so the fallback stops
|
||||
// surfacing it. Same ABI as OnAuraAdded (unit in ecx, slot + spellId on the
|
||||
// stack); `slot` is unused here — we evict by spellId.
|
||||
using OnAuraRemoved_t = void(__fastcall *)(void *unit, void *edx, uint32_t slot,
|
||||
uint32_t spellId);
|
||||
OnAuraRemoved_t g_origOnAuraRemoved = nullptr;
|
||||
|
||||
void __fastcall OnAuraRemoved_h(void *unit, void *edx, uint32_t slot,
|
||||
uint32_t spellId) {
|
||||
g_origOnAuraRemoved(unit, edx, slot, spellId);
|
||||
(void)slot;
|
||||
Evict(Unit::Identity::GuidForObject(unit), spellId);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookAuraRemoved{
|
||||
Offsets::FUN_ON_AURA_REMOVED, reinterpret_cast<void *>(&OnAuraRemoved_h),
|
||||
reinterpret_cast<void **>(&g_origOnAuraRemoved)};
|
||||
|
||||
} // namespace
|
||||
|
||||
bool Get(uint64_t unitGuid, uint32_t spellId, uint64_t *outCaster,
|
||||
@@ -260,4 +322,22 @@ bool Get(uint64_t unitGuid, uint32_t spellId, uint64_t *outCaster,
|
||||
return false;
|
||||
}
|
||||
|
||||
int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut) {
|
||||
if (unitGuid == 0 || out == nullptr || maxOut <= 0)
|
||||
return 0;
|
||||
const int8_t want = harmful ? KIND_HARMFUL : KIND_HELPFUL;
|
||||
const uint32_t now = NowMs();
|
||||
int n = 0;
|
||||
for (const auto &e : g_cache) {
|
||||
if (n >= maxOut)
|
||||
break;
|
||||
if (!e.used || e.targetGuid != unitGuid || e.kind != want)
|
||||
continue;
|
||||
if (e.expirationMs != 0 && now >= e.expirationMs)
|
||||
continue; // expired (infinite-duration entries pass)
|
||||
out[n++] = {e.spellId, e.casterGuid, e.expirationMs, e.durationMs};
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
} // namespace Aura::Source
|
||||
|
||||
@@ -42,4 +42,24 @@ namespace Aura::Source {
|
||||
bool Get(uint64_t unitGuid, uint32_t spellId, uint64_t *outCaster,
|
||||
uint32_t *outExpirationMs, uint32_t *outDurationMs);
|
||||
|
||||
// One cached aura, as returned by `Enumerate`.
|
||||
struct CachedAura {
|
||||
uint32_t spellId;
|
||||
uint64_t casterGuid; // 0 = caster unknown (application hook, no SpellGo)
|
||||
uint32_t expirationMs; // 0 = infinite / unknown
|
||||
uint32_t durationMs; // applied duration (incl. caster mods); 0 = none
|
||||
};
|
||||
|
||||
// Fills `out` with up to `maxOut` cached, non-expired auras on `unitGuid`
|
||||
// whose helpful/harmful classification matches `harmful`. Returns the count
|
||||
// written. The classification is recorded from the aura's descriptor slot at
|
||||
// application time (`OnAuraAdded`/`OnAuraStacksChanged`); entries we only ever
|
||||
// saw via `SMSG_SPELL_GO` have no slot and so an unknown classification —
|
||||
// those are excluded (we can't tell which range they belong to).
|
||||
//
|
||||
// This is the fallback source for `Aura::Data` when the unit descriptor has
|
||||
// dropped an aura's slot (rogue stealth, party range fluctuation) even though
|
||||
// the aura is still active server-side: the cache survives descriptor clears.
|
||||
int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut);
|
||||
|
||||
} // namespace Aura::Source
|
||||
|
||||
+162
-61
@@ -17,15 +17,26 @@
|
||||
// Vanilla 1.12 stores NO cast start/end times anywhere readable (unlike
|
||||
// 3.3.5+, which keeps them per-unit on the CGUnit). The cast bar is
|
||||
// Lua-driven off `SPELLCAST_START(duration)` + `GetTime()`. So we
|
||||
// self-track the LOCAL PLAYER's cast/channel on `WorldTick`:
|
||||
// - cast: `VAR_CURRENT_CAST_SPELL` (the player cast-bar spellID
|
||||
// global). On a 0->nonzero transition we stamp startMs = now and
|
||||
// endMs = now + base cast time (SpellCastTimes.dbc). Instant casts
|
||||
// (cast time 0) and channels are filtered out here — a channel has
|
||||
// no cast time and surfaces through the channel field instead.
|
||||
// self-track the LOCAL PLAYER's cast/channel with a HYBRID of two sources:
|
||||
// - Normal casts (client path): co-hook the engine's cast-start writer
|
||||
// `FUN_CAST_START_SET` and stamp from its spellID argument the instant
|
||||
// the cast begins (startMs = now, endMs = now + effective cast time) —
|
||||
// zero latency. `g_castFromServer = false`; cleared on `WorldTick` when
|
||||
// `VAR_CURRENT_CAST_SPELL` returns to 0 (normal end / cancel / interrupt).
|
||||
// - Chained same-spell recasts (server path): the engine SHORT-CIRCUITS
|
||||
// `Spell_C_CastSpell` at `spellID == VAR_CURRENT_CAST_SPELL` — it never
|
||||
// runs the client cast path, never sets the global, never fires
|
||||
// SPELLCAST_START. So a chained recast is invisible client-side; the
|
||||
// ONLY witness is the server's `SMSG_SPELL_START` (= nampower's
|
||||
// SPELL_START_SELF). We stamp `g_cast` from that packet (server cast
|
||||
// time), dedup'd against a just-made client stamp so a normal cast's
|
||||
// confirming packet doesn't restart its bar. `g_castFromServer = true`;
|
||||
// since the global was never set for these, the WorldTick VAR==0 clear
|
||||
// must skip them — they expire on their computed endMs (and pfUI clears
|
||||
// on nampower's SPELL_FAILED_SELF for interrupts).
|
||||
// - channel: the player's broadcast `UNIT_FIELD_CHANNEL_SPELL`
|
||||
// (descriptor +0x228). endMs = now + base channel duration
|
||||
// (SpellDuration.dbc).
|
||||
// (descriptor +0x228), polled on `WorldTick`. endMs = now + effective
|
||||
// channel duration.
|
||||
// `now` is the engine ms clock (`FUN_OS_TICKCOUNT_MS`), the same source
|
||||
// Lua's `GetTime()` scales by 0.001 — so startMs/endMs are directly
|
||||
// comparable to `GetTime()*1000` in addon progress math.
|
||||
@@ -81,12 +92,17 @@ struct TrackedSpell {
|
||||
int spellID; // 0 = not casting / channeling
|
||||
int startMs;
|
||||
int endMs;
|
||||
int delayMs; // accumulated pushback (SMSG_SPELL_DELAYED), 0 = none
|
||||
};
|
||||
|
||||
TrackedSpell g_cast{0, 0, 0};
|
||||
TrackedSpell g_channel{0, 0, 0};
|
||||
int g_lastCast = 0;
|
||||
int g_lastChannel = 0;
|
||||
TrackedSpell g_cast{0, 0, 0, 0};
|
||||
TrackedSpell g_channel{0, 0, 0, 0};
|
||||
|
||||
// True when g_cast was stamped from SMSG_SPELL_START (a chained same-spell
|
||||
// recast the client cast path bailed on) rather than the client-side
|
||||
// FUN_CAST_START_SET hook. Such casts never set VAR_CURRENT_CAST_SPELL, so
|
||||
// the WorldTick VAR==0 clear must not touch them.
|
||||
bool g_castFromServer = false;
|
||||
|
||||
int NowMs() { return static_cast<int>(reinterpret_cast<TickMs_t>(Offsets::FUN_OS_TICKCOUNT_MS)()); }
|
||||
|
||||
@@ -116,18 +132,6 @@ int ChannelDurationMs(int spellID) {
|
||||
return ms > 0 ? ms : 0; // negative = "infinite"; channels are finite
|
||||
}
|
||||
|
||||
// The local player's currently-channeled spellID (broadcast +0x228), or 0.
|
||||
int PlayerChannelSpell() {
|
||||
void *p = Resolve("player");
|
||||
if (p == nullptr)
|
||||
return 0;
|
||||
auto *desc = *reinterpret_cast<const uint8_t *const *>(
|
||||
static_cast<const uint8_t *>(p) + Offsets::OFF_UNIT_DESCRIPTOR);
|
||||
if (desc == nullptr)
|
||||
return 0;
|
||||
return *reinterpret_cast<const int *>(desc + Offsets::OFF_UNIT_FIELD_CHANNEL_SPELL);
|
||||
}
|
||||
|
||||
const char *SpellName(const uint8_t *rec) {
|
||||
const int locale = *reinterpret_cast<int *>(Offsets::VAR_LOCALE_INDEX);
|
||||
return *reinterpret_cast<const char *const *>(rec + OFF_NAME + locale * 4);
|
||||
@@ -156,6 +160,12 @@ const char *SpellIconPath(const uint8_t *rec) {
|
||||
int PushCastInfo(void *L, const TrackedSpell &c) {
|
||||
if (c.spellID == 0)
|
||||
return 0;
|
||||
// Self-expire: once the cast window has elapsed, report nothing even if
|
||||
// the slot wasn't explicitly cleared. Keeps a stale cast (e.g. one whose
|
||||
// clear we missed) from lingering on an addon's cast bar, and lets a
|
||||
// fresh stamp for the next cast take over cleanly.
|
||||
if (NowMs() >= c.endMs)
|
||||
return 0;
|
||||
const uint8_t *rec = Spell::Lookup::RecordForID(c.spellID);
|
||||
if (rec == nullptr)
|
||||
return 0;
|
||||
@@ -170,7 +180,7 @@ int PushCastInfo(void *L, const TrackedSpell &c) {
|
||||
Game::Lua::PushBool(L, false); // 8 notInterruptible
|
||||
Game::Lua::PushNumber(L, static_cast<double>(c.spellID)); // 9 castingSpellID
|
||||
Game::Lua::PushNil(L); // 10 castBarID
|
||||
Game::Lua::PushNumber(L, 0); // 11 delayTimeMs
|
||||
Game::Lua::PushNumber(L, static_cast<double>(c.delayMs)); // 11 delayTimeMs
|
||||
return 11;
|
||||
}
|
||||
|
||||
@@ -179,6 +189,12 @@ int PushCastInfo(void *L, const TrackedSpell &c) {
|
||||
int PushChannelInfo(void *L, int spellID, int startMs, int endMs, bool haveTimes) {
|
||||
if (spellID == 0)
|
||||
return 0;
|
||||
// Self-expire a timed channel once its window elapses (mirrors
|
||||
// PushCastInfo) — this is how the player's channel clears, since we no
|
||||
// longer poll the +0x228 field. Remote callers gate on endMs before
|
||||
// passing haveTimes, so this is a no-op for them.
|
||||
if (haveTimes && endMs != 0 && NowMs() >= endMs)
|
||||
return 0;
|
||||
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
|
||||
if (rec == nullptr)
|
||||
return 0;
|
||||
@@ -199,17 +215,38 @@ int PushChannelInfo(void *L, int spellID, int startMs, int endMs, bool haveTimes
|
||||
return 8;
|
||||
}
|
||||
|
||||
// True if the unit at stack index 1 resolves to the local player.
|
||||
bool ArgIsPlayer(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1))
|
||||
return false;
|
||||
const char *token = Game::Lua::ToString(L, 1);
|
||||
if (token == nullptr)
|
||||
return false;
|
||||
void *u = Resolve(token);
|
||||
return u != nullptr && u == Resolve("player");
|
||||
// ---- Player cast detection (FUN_CAST_START_SET co-hook) ------------------
|
||||
|
||||
// The engine's cast-start writer — the client path for a NORMAL cast. We
|
||||
// stamp from its spellID argument the instant the cast begins (zero
|
||||
// latency), *before* the original so any SPELLCAST_START it triggers sees
|
||||
// fresh times. This does NOT fire for a chained same-spell recast — the
|
||||
// caller (Spell_C_CastSpell) bails at `spellID == VAR_CURRENT_CAST_SPELL`
|
||||
// before ever reaching this writer; those are caught by the SMSG_SPELL_START
|
||||
// path instead. The `spellID == 0` (queued-restore / clear) path is left to
|
||||
// the OnWorldTick VAR==0 poll so a restore doesn't prematurely wipe a bar.
|
||||
using CastStartSet_t = void(__fastcall *)(int spellID, int targetState);
|
||||
CastStartSet_t g_origCastStartSet = nullptr;
|
||||
|
||||
void __fastcall CastStartSet_h(int spellID, int targetState) {
|
||||
if (spellID != 0) {
|
||||
const int dur = CastTimeMs(spellID);
|
||||
if (dur > 0) {
|
||||
const int now = NowMs();
|
||||
g_cast = TrackedSpell{spellID, now, now + dur, 0};
|
||||
g_castFromServer = false; // client-tracked; VAR==0 clears it
|
||||
g_channel.spellID = 0; // a cast supersedes any channel
|
||||
}
|
||||
// dur == 0: instant (no bar) or channel (handled via +0x228 poll);
|
||||
// leave g_cast — don't clobber an unrelated active cast.
|
||||
}
|
||||
g_origCastStartSet(spellID, targetState);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _castStartHook{
|
||||
Offsets::FUN_CAST_START_SET, reinterpret_cast<void *>(&CastStartSet_h),
|
||||
reinterpret_cast<void **>(&g_origCastStartSet)};
|
||||
|
||||
// ---- Remote-unit cast tracking (SMSG_SPELL_START) ------------------------
|
||||
|
||||
// Vanilla stores no cast record for other units, so we capture their casts
|
||||
@@ -278,17 +315,58 @@ const RemoteCast *FindRemoteCast(uint64_t caster) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void RecordRemoteStart(uint64_t caster, int spellID, uint32_t castTime) {
|
||||
// How recently the client-side FUN_CAST_START_SET hook must have stamped the
|
||||
// same spell for an SMSG_SPELL_START to count as that cast's confirming
|
||||
// packet (skip — don't restart the bar) rather than a new chained recast
|
||||
// (stamp). Must exceed worst-case latency but stay under a GCD / min cast
|
||||
// time so genuine chained casts aren't wrongly deduped.
|
||||
constexpr int kCastStartDedupMs = 500;
|
||||
|
||||
void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
|
||||
if (caster == 0 || spellID == 0)
|
||||
return;
|
||||
// The local player uses the self-tracked g_cast / g_channel path.
|
||||
if (caster == Unit::Identity::PlayerGuid())
|
||||
return;
|
||||
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
|
||||
if (rec == nullptr)
|
||||
return;
|
||||
const int now = NowMs();
|
||||
const bool channel = IsChannelSpell(rec);
|
||||
|
||||
if (caster == Unit::Identity::PlayerGuid()) {
|
||||
if (channel) {
|
||||
// Player channel start. The broadcast +0x228 field that signals
|
||||
// "channeling" is server-pushed and lands ~1 tick (~1s) after the
|
||||
// channel actually begins, so a +0x228 poll shows the bar far too
|
||||
// late — the addon already polled nil at SPELLCAST_CHANNEL_START
|
||||
// and cleared. This packet IS the channel start: stamp g_channel
|
||||
// now with the engine-computed duration. We run before the
|
||||
// original handler that fires SPELLCAST_CHANNEL_START, so the data
|
||||
// is fresh when addons poll. Cleared by self-expiry (PushChannel-
|
||||
// Info) at endMs.
|
||||
const int dur = ChannelDurationMs(spellID);
|
||||
if (dur > 0) {
|
||||
g_channel = TrackedSpell{spellID, now, now + dur, 0};
|
||||
g_cast.spellID = 0; // a channel supersedes any cast
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (castTime == 0)
|
||||
return; // instant — no cast bar
|
||||
// Backstop for chained same-spell recasts: the engine short-circuits
|
||||
// Spell_C_CastSpell at `spellID == VAR_CURRENT_CAST_SPELL`, so the
|
||||
// client path (FUN_CAST_START_SET hook) never fires for them — this
|
||||
// server packet is the only signal. Dedup against a fresh client stamp
|
||||
// (same spell, started within ~latency) so a normal cast's confirming
|
||||
// packet doesn't restart its bar; otherwise it's a chained recast the
|
||||
// client bailed on — take it, and flag it so the VAR==0 poll (which
|
||||
// never saw it) won't clear it.
|
||||
if (g_cast.spellID == spellID && now < g_cast.endMs &&
|
||||
now - g_cast.startMs < kCastStartDedupMs)
|
||||
return;
|
||||
g_cast = TrackedSpell{spellID, now, now + static_cast<int>(castTime), 0};
|
||||
g_castFromServer = true;
|
||||
g_channel.spellID = 0; // a cast supersedes any channel
|
||||
return;
|
||||
}
|
||||
const int endMs = channel ? now + RemoteChannelDurationMs(rec)
|
||||
: now + static_cast<int>(castTime);
|
||||
StoreRemoteCast(caster, spellID, now, endMs, channel);
|
||||
@@ -312,7 +390,7 @@ int __fastcall SpellStartHandler_h(uint32_t unk, uint32_t opCode,
|
||||
Net::Read<uint16_t>(packet); // castFlags (unused)
|
||||
const uint32_t castTime = Net::Read<uint32_t>(packet);
|
||||
packet->m_read = saved; // restore before the engine re-parses
|
||||
RecordRemoteStart(caster, spellID, castTime);
|
||||
HandleSpellStart(caster, spellID, castTime);
|
||||
}
|
||||
return g_origSpellStart(unk, opCode, unk2, packet);
|
||||
}
|
||||
@@ -322,32 +400,55 @@ const Game::HookAutoRegister _spellStartHook{
|
||||
reinterpret_cast<void *>(&SpellStartHandler_h),
|
||||
reinterpret_cast<void **>(&g_origSpellStart)};
|
||||
|
||||
// Co-hook on the SMSG_SPELL_DELAYED handler — cast pushback. The server
|
||||
// only sends it to the affected caster, so it's effectively always the
|
||||
// local player; extend the tracked cast's end time (and report the
|
||||
// accumulated delay as delayTimeMs) so an addon's bar stretches on damage.
|
||||
using SpellDelayed_t = int(__stdcall *)(uint32_t *opCode,
|
||||
Net::CDataStore *packet);
|
||||
SpellDelayed_t g_origSpellDelayed = nullptr;
|
||||
|
||||
int __stdcall SpellDelayed_h(uint32_t *opCode, Net::CDataStore *packet) {
|
||||
if (packet != nullptr) {
|
||||
const uint32_t saved = packet->m_read;
|
||||
const uint64_t guid = Net::Read<uint64_t>(packet);
|
||||
const uint32_t delay = Net::Read<uint32_t>(packet);
|
||||
packet->m_read = saved;
|
||||
if (delay != 0 && g_cast.spellID != 0 &&
|
||||
guid == Unit::Identity::PlayerGuid()) {
|
||||
g_cast.endMs += static_cast<int>(delay);
|
||||
g_cast.delayMs += static_cast<int>(delay);
|
||||
}
|
||||
}
|
||||
return g_origSpellDelayed(opCode, packet);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _spellDelayedHook{
|
||||
Offsets::FUN_SPELL_DELAYED, reinterpret_cast<void *>(&SpellDelayed_h),
|
||||
reinterpret_cast<void **>(&g_origSpellDelayed)};
|
||||
|
||||
} // namespace
|
||||
|
||||
// Per-frame: detect the player's cast/channel start, stamp times.
|
||||
// Per-frame upkeep for the player. The regular cast is stamped by the
|
||||
// FUN_CAST_START_SET hook; here we clear it when the engine's cast global
|
||||
// drops to 0, and poll the broadcast +0x228 field for the channel (which
|
||||
// has no comparable client-side start hook).
|
||||
void OnWorldTick() {
|
||||
const int now = NowMs();
|
||||
// Clear a CLIENT-tracked cast when the cast global returns to 0 — covering
|
||||
// normal end, cancel, and interrupt. No grace window is needed: the
|
||||
// FUN_CAST_START_SET hook stamps g_cast in the same call that writes the
|
||||
// global non-zero, so it's never transiently 0 right after a stamp.
|
||||
// Server-stamped chained casts (g_castFromServer) never set the global,
|
||||
// so they're exempt — they expire on their computed endMs (self-expiry in
|
||||
// PushCastInfo).
|
||||
if (g_cast.spellID != 0 && !g_castFromServer &&
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_CURRENT_CAST_SPELL) == 0)
|
||||
g_cast.spellID = 0;
|
||||
|
||||
const int cast = *reinterpret_cast<const int *>(Offsets::VAR_CURRENT_CAST_SPELL);
|
||||
if (cast != g_lastCast) {
|
||||
const int dur = (cast != 0) ? CastTimeMs(cast) : 0;
|
||||
if (cast != 0 && dur > 0) {
|
||||
g_cast = {cast, now, now + dur};
|
||||
} else {
|
||||
g_cast.spellID = 0; // 0, instant, or channel — no cast bar
|
||||
}
|
||||
g_lastCast = cast;
|
||||
}
|
||||
|
||||
const int chan = PlayerChannelSpell();
|
||||
if (chan != g_lastChannel) {
|
||||
if (chan != 0) {
|
||||
g_channel = {chan, now, now + ChannelDurationMs(chan)};
|
||||
} else {
|
||||
g_channel.spellID = 0;
|
||||
}
|
||||
g_lastChannel = chan;
|
||||
}
|
||||
// Channels are stamped from SMSG_SPELL_START (HandleSpellStart) and expire
|
||||
// on their computed endMs (self-expiry in PushChannelInfo). We don't poll
|
||||
// the broadcast +0x228 field for the player — it lags the channel start by
|
||||
// ~1 tick, so the bar would show ~1s late.
|
||||
}
|
||||
|
||||
static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
|
||||
|
||||
+25
-14
@@ -11,6 +11,7 @@
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
@@ -71,21 +72,31 @@ int __fastcall Script_UnitClassBase(void *L) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
auto *unit = static_cast<const uint8_t *>(resolve(token));
|
||||
if (unit == nullptr) {
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNil(L);
|
||||
return 2;
|
||||
// Mirror Script_UnitClass: the literal "player" token reads the
|
||||
// login-time class byte global (populated at character-enter, before
|
||||
// the in-world descriptor exists), so this resolves at addon-load
|
||||
// instead of returning nil until the player object spawns. All other
|
||||
// tokens go through the unit descriptor.
|
||||
uint8_t classByte;
|
||||
if (Unit::Identity::IsPlayerToken(token)) {
|
||||
classByte = *reinterpret_cast<const uint8_t *>(Offsets::VAR_PLAYER_CLASS_BYTE);
|
||||
} else {
|
||||
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
auto *unit = static_cast<const uint8_t *>(resolve(token));
|
||||
if (unit == nullptr) {
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNil(L);
|
||||
return 2;
|
||||
}
|
||||
auto *desc = *reinterpret_cast<const uint8_t *const *>(
|
||||
unit + Offsets::OFF_UNIT_DESCRIPTOR);
|
||||
if (desc == nullptr) {
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNil(L);
|
||||
return 2;
|
||||
}
|
||||
classByte = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
|
||||
}
|
||||
auto *desc = *reinterpret_cast<const uint8_t *const *>(
|
||||
unit + Offsets::OFF_UNIT_DESCRIPTOR);
|
||||
if (desc == nullptr) {
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNil(L);
|
||||
return 2;
|
||||
}
|
||||
const uint8_t classByte = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
|
||||
if (classByte == 0) {
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNil(L);
|
||||
|
||||
+66
-13
@@ -26,6 +26,41 @@ namespace Unit::Identity {
|
||||
|
||||
using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
|
||||
|
||||
// The selected character's GUID, captured at the glue "Enter World" click
|
||||
// (the FUN_GLUE_ENTER_WORLD co-hook below) — i.e. before the engine has
|
||||
// created the in-world player object whose +0xC0 holds the live GUID.
|
||||
// `PlayerGuid()` serves this as a fallback during the early-login window so
|
||||
// `UnitGUID("player")` answers from addon-load time instead of returning nil
|
||||
// until SMSG_UPDATE_OBJECT lands (≈PLAYER_LOGIN). 0 = not captured yet.
|
||||
// Overwritten on every enter, so a character switch can't serve a stale GUID;
|
||||
// only ever read when the live engine value is still 0, and identical to it
|
||||
// once that's populated.
|
||||
static uint64_t g_charSelectGuid = 0;
|
||||
|
||||
// `FUN_GLUE_ENTER_WORLD` is `void(void)` (no args). Co-hook it to snapshot the
|
||||
// selected character's GUID once the entry is committed.
|
||||
using EnterWorld_t = void(__cdecl *)();
|
||||
static EnterWorld_t g_origEnterWorld = nullptr;
|
||||
|
||||
static void __cdecl EnterWorld_h() {
|
||||
g_origEnterWorld();
|
||||
// Only on a genuine commit — the rename / char-locked bail paths return
|
||||
// before the worker sets the state code to "entering world".
|
||||
if (*reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_GLUE_LOGIN_INPROGRESS)) !=
|
||||
Offsets::GLUE_STATE_ENTERING_WORLD)
|
||||
return;
|
||||
const uintptr_t charStruct = *reinterpret_cast<const uintptr_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_GLUE_SELECTED_CHAR));
|
||||
if (charStruct != 0)
|
||||
g_charSelectGuid = *reinterpret_cast<const uint64_t *>(
|
||||
charStruct + Offsets::OFF_GLUE_CHAR_GUID);
|
||||
}
|
||||
|
||||
static const Game::HookAutoRegister _enterWorldHook{
|
||||
Offsets::FUN_GLUE_ENTER_WORLD, reinterpret_cast<void *>(&EnterWorld_h),
|
||||
reinterpret_cast<void **>(&g_origEnterWorld)};
|
||||
|
||||
// FUN_TOKEN_TO_GUID's `"player"` path resolves the GUID by looking the
|
||||
// local player up in the engine's object manager: it calls FUN_00468550
|
||||
// to read `[VAR_LOCAL_PLAYER_PTR + 0xC0]`, then `FUN_00468460(type=0x10,
|
||||
@@ -38,21 +73,27 @@ using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
|
||||
// either. Suffixed tokens (`"playertarget"` and the like) still go
|
||||
// through the engine so the chained `.target` walk works.
|
||||
//
|
||||
// This does NOT help at addon file-load time on first login. The
|
||||
// engine populates `+0xC0` from SMSG_UPDATE_OBJECT processing in the
|
||||
// main loop, which happens AFTER `FrameScript_Initialize` (and the
|
||||
// addon load pass it triggers) finishes. The engine itself gates its
|
||||
// own post-init player setup at the tail of FrameScript_Initialize on
|
||||
// `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.
|
||||
// The engine populates `+0xC0` from SMSG_UPDATE_OBJECT processing in the
|
||||
// main loop, which happens AFTER `FrameScript_Initialize` (and the addon
|
||||
// load pass it triggers) finishes — so the engine value is 0 at addon
|
||||
// file-load on first login. We bridge that window with `g_charSelectGuid`,
|
||||
// captured at the glue "Enter World" click (see the co-hook above): the
|
||||
// selected character's GUID is known well before the player object exists,
|
||||
// and it's the same value `+0xC0` will hold once it's live. So
|
||||
// `UnitGUID("player")` / `PlayerGuid()` answer correctly from addon-load
|
||||
// time. (The player *object* still doesn't exist until `+0xC0` populates —
|
||||
// this only makes the GUID *value* available early, for keying SavedVariables,
|
||||
// comparisons, our aura/cast caster checks, etc.)
|
||||
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);
|
||||
if (player != nullptr) {
|
||||
const uint64_t guid = *reinterpret_cast<const uint64_t *>(
|
||||
player + Offsets::OFF_LOCAL_PLAYER_GUID);
|
||||
if (guid != 0)
|
||||
return guid; // live engine value — authoritative
|
||||
}
|
||||
return g_charSelectGuid; // early-login fallback (0 until char-select)
|
||||
}
|
||||
|
||||
uint64_t GuidForObject(const void *unitObject) {
|
||||
@@ -66,8 +107,20 @@ uint64_t GuidForObject(const void *unitObject) {
|
||||
return *reinterpret_cast<const uint64_t *>(block);
|
||||
}
|
||||
|
||||
bool IsPlayerToken(const char *token) {
|
||||
if (token == nullptr)
|
||||
return false;
|
||||
// Mirror Script_UnitClass/Script_UnitRace byte-for-byte: they compare the
|
||||
// token via Storm's SStrCmpI(token, "player", 0x7FFFFFFF) (the call at
|
||||
// 0x005183B0), not the CRT _stricmp. Same case-insensitive result for the
|
||||
// ASCII literal, but routed through the engine's own comparator.
|
||||
using SStrCmpI_t = int(__stdcall *)(const char *a, const char *b, int n);
|
||||
auto sstrcmpi = reinterpret_cast<SStrCmpI_t>(Offsets::FUN_SSTR_CMP_I);
|
||||
return sstrcmpi(token, "player", 0x7FFFFFFF) == 0;
|
||||
}
|
||||
|
||||
uint64_t GuidForToken(const char *token) {
|
||||
if (token != nullptr && _stricmp(token, "player") == 0)
|
||||
if (IsPlayerToken(token))
|
||||
return PlayerGuid();
|
||||
auto fn = reinterpret_cast<TokenToGUID_t>(Offsets::FUN_TOKEN_TO_GUID);
|
||||
return fn(token);
|
||||
|
||||
@@ -36,6 +36,13 @@ uint64_t PlayerGuid();
|
||||
// caster cache keys on it from both the lookup and the OnAuraAdded hook).
|
||||
uint64_t GuidForObject(const void *unitObject);
|
||||
|
||||
// Case-insensitive match against the literal `"player"` token, mirroring
|
||||
// the engine's `SStrCmpI(token, "player")` fast-path tests (e.g.
|
||||
// `Script_UnitClass`/`Script_UnitRace`, which special-case `"player"` to
|
||||
// read login-time globals available before the in-world descriptor spawns).
|
||||
// Returns false for a null pointer.
|
||||
bool IsPlayerToken(const char *token);
|
||||
|
||||
// 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
|
||||
|
||||
+25
-14
@@ -11,6 +11,7 @@
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
@@ -51,21 +52,31 @@ int __fastcall Script_UnitRaceBase(void *L) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
auto *unit = static_cast<const uint8_t *>(resolve(token));
|
||||
if (unit == nullptr) {
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNil(L);
|
||||
return 2;
|
||||
// Mirror Script_UnitRace: the literal "player" token reads the
|
||||
// login-time race byte global (populated at character-enter, before
|
||||
// the in-world descriptor exists), so this resolves at addon-load
|
||||
// instead of returning nil until the player object spawns. All other
|
||||
// tokens go through the unit descriptor.
|
||||
uint8_t raceByte;
|
||||
if (Unit::Identity::IsPlayerToken(token)) {
|
||||
raceByte = *reinterpret_cast<const uint8_t *>(Offsets::VAR_PLAYER_RACE_BYTE);
|
||||
} else {
|
||||
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
auto *unit = static_cast<const uint8_t *>(resolve(token));
|
||||
if (unit == nullptr) {
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNil(L);
|
||||
return 2;
|
||||
}
|
||||
auto *desc = *reinterpret_cast<const uint8_t *const *>(
|
||||
unit + Offsets::OFF_UNIT_DESCRIPTOR);
|
||||
if (desc == nullptr) {
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNil(L);
|
||||
return 2;
|
||||
}
|
||||
raceByte = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_RACE_BYTE);
|
||||
}
|
||||
auto *desc = *reinterpret_cast<const uint8_t *const *>(
|
||||
unit + Offsets::OFF_UNIT_DESCRIPTOR);
|
||||
if (desc == nullptr) {
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNil(L);
|
||||
return 2;
|
||||
}
|
||||
const uint8_t raceByte = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_RACE_BYTE);
|
||||
if (raceByte == 0) {
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNil(L);
|
||||
|
||||
Reference in New Issue
Block a user