Compare commits

...
3 Commits
Author SHA1 Message Date
Brues 19397782f7 CacheUtil 2026-06-29 14:30:12 -05:00
Brues 284b951038 Resolve UnitClassBase/UnitRaceBase "player" at addon-load
UnitClassBase("player") and UnitRaceBase("player") returned nil until
~PLAYER_ENTERING_WORLD because they always read the class/race byte from
the in-world unit descriptor ([unit+0x110]+0x79/+0x78), which doesn't
exist until the player object spawns.

Vanilla's Script_UnitClass (0x00518350) avoids this: for the literal
"player" token it reads a login-time global (FUN_005abde0 ->
DAT_00c27e81) instead of the descriptor. That global mirrors the local
player's UNIT_FIELD_BYTES_0 race/class/sex and is populated at
character-enter, so UnitClass("player") resolves at addon-load.

Mirror that fast path: the "player" token now reads the login-time byte
globals (race 0x00C27E80, class 0x00C27E81, sex 0x00C27E82); all other
tokens keep the descriptor path, so remote-unit behavior is unchanged.

The "player" test is factored into Unit::Identity::IsPlayerToken, shared
by both modules (and GuidForToken). It routes through the engine's own
SStrCmpI (FUN_SSTR_CMP_I) to match Script_UnitClass's comparator exactly,
rather than the CRT _stricmp.
2026-06-29 13:53:48 -05:00
Brues 1df48772f4 Serve player GUID from char-select before the world loads
Vanilla doesn't populate the local player object (and its +0xC0 GUID) until
SMSG_UPDATE_OBJECT in the main loop, after FrameScript_Initialize and the
addon load pass — so UnitGUID("player") / PlayerGuid() returned nil/0 at
addon file-load on first login.

Co-hook the glue enter-world worker (FUN_0046B500, behind Script_EnterWorld)
to snapshot the selected character's GUID at the moment of entry: read from
the selected-character struct (VAR_GLUE_SELECTED_CHAR; GUID at offset 0,
verified against Script_GetCharacterInfo), gated on the "entering world"
state code so the rename/locked bail paths don't capture. PlayerGuid()
returns the live engine value when present and falls back to the captured
GUID otherwise — the same value, just available from addon-load time.
Captured fresh per entry, so a character switch can't serve a stale GUID.

This makes the GUID *value* available early (keying SavedVariables,
comparisons, the aura/cast caster checks); the player *object* still doesn't
exist until +0xC0 populates.

Also simplify GetPlayerGuid() to a passthrough now that UnitGUID("player")
is reliably available early and cheap — the memo saved only a trivial
C-call + snprintf and the GUID is session-stable.

Add FUN_GLUE_ENTER_WORLD / VAR_GLUE_SELECTED_CHAR offsets.
2026-06-29 01:21:24 -05:00
8 changed files with 250 additions and 48 deletions
+1
View File
@@ -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
+88
View File
@@ -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
+2 -7
View File
@@ -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)
+36
View File
@@ -1651,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
@@ -4205,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
+25 -14
View File
@@ -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
View File
@@ -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);
+7
View File
@@ -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
View File
@@ -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);