Compare commits

...
5 Commits
Author SHA1 Message Date
Brues ee2b4d22ac GetItemInfo failure fix
commit 52064f025188267ab44dae41af93261ae1bcfc03
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Thu Jun 18 23:33:09 2026 -0500

    Item cache: fast-fail server rejections; report pending overflow

    Two follow-ups to the WarmCache startup-latch fix.

    Failure fast-path: the escalation path used a no-op callback, so a
    server "no such item" reply (high-bit itemID — the FUN_0055BDB0 failure
    branch does NOT set the entry loaded flag) went undetected and the item
    waited out the full kMaxWaitTicks (~20s) before reporting failure.
    Replace it with ItemLoadCallback_Escalated, which marks the pending
    entry failed when the engine reports !success. A shared TryCompletePending
    helper resolves each entry either way (loaded -> success, failed ->
    failure) and is now used by both the response-handler sweep and the
    tick, so rejections fire in the same response. Genuinely lost queries
    (server silent) still fall back to the timeout.

    Overflow: Track now returns false when the pending set is full instead
    of silently dropping, and the explicit RequestLoadItemData/ByID Lua
    functions propagate that as their boolean return. Bump kMaxPending
    256 -> 512 since the unsettled window now also holds implicit warmups.

commit 797a73de44e6a4ceb0ac434944a137dcd47c18f3
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Thu Jun 18 23:24:04 2026 -0500

    Item cache: gate WarmCache behind a startup-settled latch

    The previous fix (ad13440) deferred only the explicit
    RequestLoadItemData path out of the engine startup window. The implicit
    WarmCache path — the GetItemInfo hook plus every GetItem* getter cache-
    miss warmup — still eagerly called CacheFetch(callback) with no gate, so
    it orphaned cache entries during the exact PLAYER_ENTERING_WORLD /
    addon-load window the original bug was about. pfQuest/Questie scan item
    data there, so those items stayed permanently nil for the session.

    WarmCache now gates on a one-way g_settled latch:
    - Pre-settle (startup): defer through the same pending set as
      RequestLoadItemData, tagged implicit so the response-handler sweep
      fires GET_ITEM_INFO_RECEIVED (not ITEM_DATA_LOAD_RESULT).
    - Post-settle: eager CacheFetch as before, for low-latency mid-session
      lookups.

    The latch flips true the moment the engine response handler fires (proof
    dispatch is live) or after kSettleTicks world ticks, whichever first.
    Per-process, not re-armed on relog (cold-WDB race is initial-login only).

    Unify both paths through Track(itemID, implicit); sweep and timeout fire
    the correct event by tag. Escalation now gates on g_settled instead of a
    fixed per-item tick count, draining the startup backlog as soon as it is
    safe. Bump kMaxPending 64 -> 256 to absorb the larger startup burst.
2026-06-18 23:34:37 -05:00
Brues 59a603f9b2 AoE Loot binding 2026-06-18 23:23:26 -05:00
Brues 0332e49ac2 UnitNameFromGUID: resolve via object manager + FUN_OBJECT_GET_NAME
The original implementation only consulted the SMSG_NAME_QUERY_RESPONSE-
fed player NameCache, which only populates for GUIDs the engine has
explicitly queried — so currently-synced players (just-targeted, in
view, etc.) typically miss until the engine sends a name query.

Switch to vanilla's own `Script_UnitName` path: resolve the GUID via
`FUN_CLNT_OBJ_MGR_OBJECT_PTR` with TYPEMASK_OBJECT, then call the
engine's polymorphic name getter `FUN_OBJECT_GET_NAME`. That routes
through the player NameCache AND the creature cache, so both player
and NPC GUIDs return real names for any currently-synced unit. Gate
on the "UNKNOWNOBJECT" / "Unknown Being" sentinels so non-unit
objects fall through. Persistent NameCache fallback kept for ex-units
that have left the engine's sync window.
2026-06-17 20:52:30 -05:00
Brues b1b564a533 Add UnitSubName(unit) — NPC subtitle / title text
Modern WoW returns the subtitle as UnitName's second return; vanilla
1.12's UnitName returns only one value, leaving no direct route to
the subtitle. UnitSubName reads it straight off the engine's creature
cache (`[unit + 0xB30] → [+0x10]`), no tooltip scrape required.

Returns nil for players, unsynced NPCs, and NPCs with no subtitle.
2026-06-17 20:34:53 -05:00
Brues 444875ae38 Add UnitNameFromGUID(guid) → (name, realm)
Modern backport that reuses GetPlayerInfoByGUID's lookup chain
(engine NameCache → persistent on-disk fallback) but pushes only
(name, realm) — narrower return shape for callers that only need
the name. realm is always "" in vanilla (single-realm, engine
doesn't populate per-player realm).
2026-06-17 20:16:25 -05:00
8 changed files with 420 additions and 58 deletions
+1
View File
@@ -1,3 +1,4 @@
AOELOOT = "AoE Loot"
BINDING_NAME_FOCUSTARGET = "Focus Target";
BINDING_NAME_TARGETFOCUS = "Target Focus";
FOCUSTARGET = "Focus Target";
+2 -2
View File
@@ -45,7 +45,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Map](docs/API.md#map) | `C_Map.GetBestMapForUnit` |
| [MerchantFrame](docs/API.md#merchantframe) | `C_MerchantFrame.GetBuybackItemID`, `C_MerchantFrame.GetItemInfo`, `C_MerchantFrame.GetNumJunkItems`, `C_MerchantFrame.IsMerchantItemRefundable`, `C_MerchantFrame.IsSellAllJunkEnabled`, `C_MerchantFrame.SellAllJunkItems` |
| [NamePlate](docs/API.md#nameplate) | `C_NamePlate.GetNamePlateForGUID`, `C_NamePlate.GetNamePlateForUnit`, `C_NamePlate.GetNamePlateGUIDs`, `C_NamePlate.GetNamePlates` |
| [NameCache](docs/API.md#namecache) | `C_CreatureInfo.GetCreatureID`, `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `GetPlayerInfoByGUID` |
| [NameCache](docs/API.md#namecache) | `C_CreatureInfo.GetCreatureID`, `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `GetPlayerInfoByGUID`, `UnitNameFromGUID` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellName`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
@@ -54,7 +54,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [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"]`) |
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitGUID`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitTokenFromGUID` |
| [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` |
### GlueXML calls
+58
View File
@@ -249,6 +249,7 @@ build instructions.
- [NameCache](#namecache)
- [`GetPlayerInfoByGUID(guid)`](#getplayerinfobyguidguid)
- [`UnitNameFromGUID(guid)`](#unitnamefromguidguid)
- [`C_PlayerCache.GetPlayerInfoByName(name)`](#c_playercachegetplayerinfobynamename)
- [`C_PlayerInfo.GUIDIsPlayer(guid)` / `GUIDIsCreature` / `GUIDIsPet` / `GUIDIsGameObject`](#c_playerinfoguidisplayerguid--guidiscreature--guidispet--guidisgameobject)
- [`C_CreatureInfo.GetCreatureID(guid)`](#c_creatureinfogetcreatureidguid)
@@ -346,6 +347,7 @@ build instructions.
- [Unit](#unit)
- [`UnitGUID(unit)`](#unitguidunit)
- [`UnitTokenFromGUID(guid)`](#unittokenfromguidguid)
- [`UnitSubName(unit)`](#unitsubnameunit)
- [`GetUnitSpeed(unit)`](#getunitspeedunit)
- [`UnitClassBase(unit)`](#unitclassbaseunit)
- [`UnitRaceBase(unit)`](#unitracebaseunit)
@@ -6003,6 +6005,35 @@ instance lives at `0x00C0E228`; entry layout (name, realm, race,
sex, class) was reverse-engineered from the
`SMSG_NAME_QUERY_RESPONSE` write path at `0x0055F310`.
### `UnitNameFromGUID(guid)`
Returns `name, realm` for the player identified by `guid`, or `nil`
if no player with that GUID has been encountered yet. Same lookup
chain as [`GetPlayerInfoByGUID`](#getplayerinfobyguidguid) (engine
NameCache, persistent on-disk fallback when enabled) — just narrower
return shape for callers that only need the name.
```lua
local name, realm = UnitNameFromGUID(UnitGUID("target"))
if name then
ChatFrame1:AddMessage("Target: " .. name)
end
-- Resolving a chat link
local name, realm = UnitNameFromGUID("0x0000000000000777")
```
`realm` is always `""` in vanilla — the engine doesn't populate
per-player realm names and 1.12 has no cross-realm interaction.
We push the empty string rather than `nil` to match the convention
of the other player-info accessors; addons can gate on `realm == ""`.
Doesn't trigger a network query on miss. To populate an unknown
GUID, either let the engine receive a `SMSG_NAME_QUERY_RESPONSE`
through normal chat/target interaction, or call
[`C_PlayerCache.RememberPlayer`](#c_playercacherememberplayerguid-name-classtoken)
explicitly.
### `C_PlayerCache.GetPlayerInfoByName(name)`
Returns
@@ -8251,6 +8282,33 @@ end
> ex-targets, ex-mouseover units, distant players seen in the chat
> log, etc. The engine simply doesn't address those by token.
### `UnitSubName(unit)`
Returns the NPC's subtitle / title string (the small italic text
under their name in the tooltip): `"Innkeeper"`, `"Stable Master"`,
`"<Master Engineer>"`, etc. Returns `nil` for players, unsynced
NPCs, and NPCs with no subtitle.
```lua
/dump UnitSubName("target")
-- "Innkeeper" (Stormwind innkeep)
-- "<Auction House>" (auctioneer)
-- nil (a player, or a wolf in the woods)
```
Modern WoW returns the subtitle as `UnitName`'s second return;
vanilla 1.12's `UnitName` returns only one value, leaving no
direct route to the subtitle. Addons that wanted it had to scrape
`GameTooltip` text, which is fragile and required the unit to be
hovered. `UnitSubName` reads it straight off the engine's creature
cache (`[unit + 0xB30] → [+0x10]`), no tooltip round-trip.
Coverage caveat: the creature cache row is populated when an NPC
becomes visible to the client AND the server has sent its
`CMSG_CREATURE_QUERY_RESPONSE`. The first time a fresh NPC appears
the row may be briefly NULL — usually only one or two frames.
Subsequent calls succeed once the response lands.
### `GetUnitSpeed(unit)`
Returns `currentSpeed, runSpeed, flightSpeed, swimSpeed` — all four
+17
View File
@@ -405,6 +405,23 @@ enum Offsets {
// descriptor at +0x114 — these are sibling classes under CGObject
// with class-specific descriptor offsets.
OFF_UNIT_DESCRIPTOR = 0x110,
// CGCreature client-side creature-data cache row. Populated by the
// engine when an NPC GUID becomes visible (the same row the
// `CreatureCache` SMSG fills with the templated NPC's name,
// subname, etc.). NULL for unsynced units or for player CGUnits
// (players don't have a row in this cache — their name comes from
// the SMSG_NAME_QUERY_RESPONSE-fed name cache at `0x00C0E228`).
//
// Layout of the cache row:
// +0x00 ?
// +0x0C name C-string ptr (alias of UNIT_NAME for creatures)
// +0x10 subName C-string ptr ("Innkeeper", "<Master Trainer>", etc.)
//
// Used by `Script_UnitName`'s creature path and by VanillaMinimapTracking's
// subname-based blip filter (`src/Blips.cpp::ExtractUnitSubName`).
OFF_UNIT_CREATURE_CACHE_ROW = 0xB30,
OFF_CREATURE_CACHE_SUB_NAME = 0x10,
// Pointer to the CGUnit's 8-byte GUID at `*(CGUnit + 0x08)`. Verified
// in `Script_GetInventoryItemLink` at `0x004C8CB0`-`0x004C8CB5`:
// `mov eax, [esi+8]; mov edi, [eax]; mov ecx, [eax+4]` reads the
+5
View File
@@ -12,4 +12,9 @@
<Binding name="TARGETFOCUS">
TargetUnit("focus");
</Binding>
<Binding name="AOELOOT">
if not C_Loot.IsScanInProgress() then
C_Loot.ScanNearbyLoot()
end
</Binding>
</Bindings>
+163 -56
View File
@@ -147,41 +147,54 @@ static int __fastcall Script_IsItemDataCached(void *L) {
return 1;
}
// Pending explicit-request tracking. We deliberately do NOT eagerly
// create cache entries when an addon calls `C_Item.RequestLoadItemData`
// at addon-load / PLAYER_ENTERING_WORLD time — empirically (pfQuest),
// pre-creating the entry blocks the engine's natural prefetch path
// from issuing a fresh `SMSG_ITEM_QUERY_SINGLE`. The entry stays in a
// permanently-pending state because nothing ever sends the query.
// Pending-request tracking. We deliberately do NOT eagerly create cache
// entries during the startup window — empirically (pfQuest), pre-creating
// the entry at addon-load / PLAYER_ENTERING_WORLD time, before the
// engine's network/opcode dispatch has settled, blocks the engine's
// natural prefetch from issuing a fresh `SMSG_ITEM_QUERY_SINGLE`. The
// entry stays permanently pending because nothing ever sends the query.
// This applies to BOTH paths into the cache:
// - explicit `C_Item.RequestLoadItemData(ByID)`
// - implicit `WarmCache` (the `GetItemInfo` hook + every `GetItem*`
// getter's cache-miss warmup) — see `WarmCache`.
//
// Instead we hook the engine's item-cache response handler at
// `FUN_ITEMSTATS_CACHE_RESPONSE` (the function that fills cache
// entries on `SMSG_ITEM_QUERY_SINGLE_RESPONSE`). After the engine
// finishes filling any entries and walking its own callback lists,
// we sweep our pending set and fire `ITEM_DATA_LOAD_RESULT(itemID, 1)`
// for any tracked items that now resolve. The engine's natural
// prefetch (for player-inventory items) takes care of the actual
// `SMSG_ITEM_QUERY_SINGLE` for us.
// Both route through `Track`, which records the item without touching
// the cache while `g_settled` is false. We hook the engine's item-cache
// response handler at `FUN_ITEMSTATS_CACHE_RESPONSE` (the function that
// fills cache entries on `SMSG_ITEM_QUERY_SINGLE_RESPONSE`); after the
// engine finishes filling entries and walking its own callback lists, we
// sweep our pending set and fire the per-item completion event for any
// tracked item that now resolves. The engine's natural prefetch (for
// player-inventory items) takes care of the actual query for us.
//
// For items the engine *doesn't* naturally prefetch (e.g. quest
// reward IDs the player has never owned), we escalate after
// `kEscalateTicks` by issuing our own `CacheFetch` with a no-op
// For items the engine *doesn't* naturally prefetch (e.g. quest reward
// IDs the player has never owned), `OnWorldTick` escalates once the
// startup gate has settled by issuing our own `CacheFetch` with a no-op
// callback — that creates the entry and queues the query, and the
// response handler hook still fires our event when the data lands.
// `kMaxWaitTicks` is the hard timeout that gives up and fires
// `ITEM_DATA_LOAD_RESULT(itemID, nil)`.
// `kMaxWaitTicks` is the hard timeout that gives up and fires the
// completion event with failure.
struct Pending {
uint32_t itemID;
int ticksWaiting;
bool requestIssued;
bool implicit; // true: fire GET_ITEM_INFO_RECEIVED; false: ITEM_DATA_LOAD_RESULT
bool failed; // set by the escalation callback on a server "no such item" reply
};
constexpr int kMaxPending = 64;
constexpr int kEscalateTicks = 60;
constexpr int kMaxPending = 512;
constexpr int kMaxWaitTicks = 1200;
// World ticks after which we assume the engine's startup
// network/opcode-dispatch state has settled and eager queries are safe.
// Also latched the moment the engine's own response handler fires (a
// response proves dispatch is live) — see `ItemStatsCacheResponse_h`.
// One-way latch; see the relog note in `WarmCache`.
constexpr int kSettleTicks = 60;
static Pending g_pending[kMaxPending];
static int g_pendingCount = 0;
static bool g_settled = false;
static int g_worldTicks = 0;
static int FindPending(uint32_t itemID) {
for (int i = 0; i < g_pendingCount; ++i) {
@@ -197,27 +210,92 @@ static void RemovePendingAt(int idx) {
g_pending[idx] = g_pending[g_pendingCount];
}
// No-op callback for the escalation path — we just need a non-null
// callback pointer so the engine's `_GetRecord` queues the query.
// The response handler hook will detect the fill and fire the event;
// this callback doesn't need to do anything.
static void __stdcall ItemLoadCallback_NoOp(void * /*userData*/, int /*success*/) {}
// Callback for the escalation path. The engine queues the query when we
// pass a non-null callback to `_GetRecord`; it then invokes this with the
// itemID smuggled through `userData`. On a successful fill the engine has
// already set the entry's loaded flag, so the response-handler sweep
// reports success — we do nothing here. On a server failure (high-bit
// itemID in `SMSG_ITEM_QUERY_SINGLE_RESPONSE`) the engine does NOT set the
// loaded flag (verified in `FUN_0055BDB0`), so the sweep can't detect it;
// we mark the pending entry failed so the sweep / tick fires failure
// immediately instead of waiting out `kMaxWaitTicks`.
static void __stdcall ItemLoadCallback_Escalated(void *userData, int success) {
if (success)
return;
const auto itemID = static_cast<uint32_t>(reinterpret_cast<uintptr_t>(userData));
const int idx = FindPending(itemID);
if (idx >= 0)
g_pending[idx].failed = true;
}
static void RequestAndMaybeNotify(uint32_t itemID) {
// Already loaded — fire success synchronously.
static void FireByTag(uint32_t itemID, bool success, bool implicit) {
if (implicit)
FireGetItemInfoReceived(static_cast<int>(itemID), success);
else
FireItemDataLoadResult(static_cast<int>(itemID), success);
}
// Records an item we owe a completion event for, WITHOUT eagerly
// creating the cache entry. During the startup window (before
// `g_settled`) this is the only safe way to "request" an item: calling
// `CacheFetch` with a callback now would create an orphan entry the
// engine's prefetch then skips, leaving it permanently pending (see the
// block comment above). `OnWorldTick` issues the actual query once
// settled, and the response-handler sweep fires the event — tagged
// implicit (GET_ITEM_INFO_RECEIVED) or explicit (ITEM_DATA_LOAD_RESULT).
// Returns false only when the request was dropped because the pending
// set is full (the caller is still tracking nothing). All other outcomes
// — already-loaded, already-tracking, newly-tracked — return true.
static bool Track(uint32_t itemID, bool implicit) {
if (CacheFetch(itemID, nullptr) != nullptr) {
FireItemDataLoadResult(static_cast<int>(itemID), true);
return;
// Already loaded. Explicit RequestLoadItemData fires its
// completion event synchronously (modern API contract). Implicit
// warmup mirrors the engine's already-loaded shortcut, which does
// not re-invoke the callback — so a cache hit fires nothing.
if (!implicit)
FireItemDataLoadResult(static_cast<int>(itemID), true);
return true;
}
const int idx = FindPending(itemID);
if (idx >= 0) {
// Already tracking. An explicit request supersedes a pending
// implicit one so the explicit caller's ITEM_DATA_LOAD_RESULT is
// honored (the two events are mutually exclusive per fill).
if (!implicit)
g_pending[idx].implicit = false;
return true;
}
// Already tracking — second request just dedups.
if (FindPending(itemID) >= 0)
return;
if (g_pendingCount >= kMaxPending)
return;
return false;
g_pending[g_pendingCount].itemID = itemID;
g_pending[g_pendingCount].ticksWaiting = 0;
g_pending[g_pendingCount].requestIssued = false;
g_pending[g_pendingCount].implicit = implicit;
g_pending[g_pendingCount].failed = false;
++g_pendingCount;
return true;
}
static bool RequestAndMaybeNotify(uint32_t itemID) { return Track(itemID, /*implicit=*/false); }
// Fires + removes a pending entry if it has resolved either way: loaded
// (engine set the flag) → success, or `failed` (escalation callback saw a
// server rejection) → failure. Returns true if it fired and removed the
// entry at `i`, so callers must NOT advance their index when it does.
static bool TryCompletePending(int i) {
const uint32_t itemID = g_pending[i].itemID;
const bool implicit = g_pending[i].implicit;
if (CacheFetch(itemID, nullptr) != nullptr) {
RemovePendingAt(i);
FireByTag(itemID, true, implicit);
return true;
}
if (g_pending[i].failed) {
RemovePendingAt(i);
FireByTag(itemID, false, implicit);
return true;
}
return false;
}
// Response handler hook. After the engine fills cache entries from
@@ -237,16 +315,17 @@ static void __fastcall ItemStatsCacheResponse_h(void *cache, void * /*edx_unused
void *packetReader, int flag) {
using Trampoline_t = void(__fastcall *)(void *cache, void *edx, void *packetReader, int flag);
reinterpret_cast<Trampoline_t>(ItemStatsCacheResponse_o)(cache, nullptr, packetReader, flag);
// The engine just processed an item-query response, which proves its
// network/opcode dispatch is live — eager queries are safe from here
// on. Latch the startup gate so `WarmCache` stops deferring.
g_settled = true;
// Sweep pending — the engine just finished filling any matching
// entries. Peek (no callback) returns non-null iff the entry's
// loaded flag is set.
// entries (and, on the failure branch, invoked our escalation
// callback which marked any rejected items `failed`). Fire whatever
// resolved this response, success or failure.
for (int i = 0; i < g_pendingCount;) {
const uint32_t itemID = g_pending[i].itemID;
if (CacheFetch(itemID, nullptr) != nullptr) {
RemovePendingAt(i);
FireItemDataLoadResult(static_cast<int>(itemID), true);
if (TryCompletePending(i))
continue;
}
++i;
}
}
@@ -258,23 +337,34 @@ static const Game::HookAutoRegister _hookCacheResponse{
// Escalation + timeout. The hook covers the happy path (engine fills
// cache → we fire). This tick is only responsible for:
// 1. Issuing a query for items the engine doesn't naturally prefetch
// (e.g. quest rewards) — wait `kEscalateTicks` so we don't race
// the engine's startup-time state machine, then call CacheFetch
// with a no-op callback. The engine sends the SMSG, the response
// handler hook fires the event when the response lands.
// 2. Giving up after `kMaxWaitTicks` for items that never resolve
// (bad itemID, server doesn't respond, etc.) — fire failure.
// 1. Latching the startup gate via a tick-count fallback, in case no
// engine response arrives early enough to latch it first.
// 2. Issuing a query for items the engine doesn't naturally prefetch
// (e.g. quest rewards) — but only once `g_settled`, so we don't
// race the engine's startup-time state machine. The engine sends
// the SMSG, the response handler hook fires the event when the
// response lands.
// 3. Firing failure immediately for items the server rejected (the
// escalation callback marked them `failed`), and as a last resort
// giving up after `kMaxWaitTicks` for items that never resolve at
// all (lost query, server silent) — fire failure.
static void OnWorldTick() {
if (!g_settled && ++g_worldTicks >= kSettleTicks)
g_settled = true;
for (int i = 0; i < g_pendingCount;) {
if (!g_pending[i].requestIssued && g_pending[i].ticksWaiting >= kEscalateTicks) {
CacheFetch(g_pending[i].itemID, &ItemLoadCallback_NoOp);
if (!g_pending[i].requestIssued && g_settled) {
CacheFetch(g_pending[i].itemID, &ItemLoadCallback_Escalated);
g_pending[i].requestIssued = true;
}
// A server rejection (marked by the escalation callback) resolves
// the item as failure without waiting out the timeout.
if (TryCompletePending(i))
continue;
if (g_pending[i].ticksWaiting >= kMaxWaitTicks) {
const uint32_t itemID = g_pending[i].itemID;
const bool implicit = g_pending[i].implicit;
RemovePendingAt(i);
FireItemDataLoadResult(static_cast<int>(itemID), false);
FireByTag(itemID, false, implicit);
continue;
}
++g_pending[i].ticksWaiting;
@@ -291,7 +381,24 @@ static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
// `ITEM_DATA_LOAD_RESULT`), matching modern semantics for ambient
// cache fills.
void WarmCache(uint32_t itemID) {
CacheFetch(itemID, &ItemLoadCallback_Implicit);
// Post-settle (steady state): query immediately for lowest latency.
// The engine invokes our implicit callback when the data lands.
if (g_settled) {
CacheFetch(itemID, &ItemLoadCallback_Implicit);
return;
}
// Startup window: do NOT eagerly create the cache entry — that
// orphans it (the engine's prefetch skips already-existing entries,
// and our SMSG goes into a hole while dispatch isn't ready), leaving
// the item permanently nil for the session. This is the exact race
// that broke pfQuest/Questie scanning at PLAYER_ENTERING_WORLD. Defer
// through the pending set instead; OnWorldTick issues the query once
// settled and the response-handler sweep fires GET_ITEM_INFO_RECEIVED.
//
// NOTE: g_settled is a one-way latch per process. The race this
// guards is the initial-login cold-WDB window. On relog the WDB is
// already warm so cold-fetch pressure is minimal; we don't re-arm it.
Track(itemID, /*implicit=*/true);
}
// Hook for `Script_GetItemInfo`. Pre-warms the cache from arg 1, then
@@ -314,8 +421,8 @@ static int __fastcall Script_RequestLoadItemDataByID(void *L) {
Game::Lua::PushBoolean(L, 0);
return 1;
}
RequestAndMaybeNotify(static_cast<uint32_t>(itemID));
Game::Lua::PushBoolean(L, 1);
const bool accepted = RequestAndMaybeNotify(static_cast<uint32_t>(itemID));
Game::Lua::PushBoolean(L, accepted ? 1 : 0);
return 1;
}
@@ -329,8 +436,8 @@ static int __fastcall Script_RequestLoadItemData(void *L) {
Game::Lua::PushBoolean(L, 0);
return 1;
}
RequestAndMaybeNotify(static_cast<uint32_t>(itemID));
Game::Lua::PushBoolean(L, 1);
const bool accepted = RequestAndMaybeNotify(static_cast<uint32_t>(itemID));
Game::Lua::PushBoolean(L, accepted ? 1 : 0);
return 1;
}
+89
View File
@@ -41,6 +41,7 @@
#include "guid/Guid.h"
#include <cstdint>
#include <cstring>
namespace Player::Info {
@@ -182,6 +183,92 @@ int PushFromNameCacheEntry(void *L, const char *name,
return 7;
}
// `UnitNameFromGUID(guid)` → `(name, realm)`. Modern WoW backport;
// vanilla 1.12 doesn't expose any GUID → name accessor at the Lua
// surface, so addons that need to resolve a chat link or combat-log
// GUID can't get a name without round-tripping through a unit token.
//
// Resolution order (matches vanilla's own `Script_UnitName` at
// `0x00517020`):
// 1. Resolve the GUID through the object manager. Any currently-
// synced unit — player you can see, NPC in interact range,
// target / mouseover / party / raid member — gets a `CGObject *`
// back. Call the engine's canonical polymorphic name getter
// `FUN_OBJECT_GET_NAME` on it; this routes through the player
// NameCache for players AND the creature cache for NPCs, so it
// works for both with a single call.
// 2. If the unit isn't currently synced (ex-target who logged off,
// player encountered earlier in chat but no longer visible),
// fall back to the persistent NameCache (when enabled).
//
// `realm` is always `""` in vanilla — the engine doesn't populate
// per-player realm names and 1.12 has no cross-realm interaction.
// We push the empty string rather than nil so callers don't have to
// special-case the second return; modern WoW returns nil, but
// `realm == ""` is just as easy to gate on.
//
// Returns nothing on:
// - Missing or non-string arg (raises an error rather than return).
// - Unparseable GUID string.
// - Zero / NULL GUID.
// - GUID not resolvable in the object manager AND not in persistent
// NameCache (i.e., we've never seen this unit).
//
// Doesn't trigger a network query on miss — passive read, same as
// `GetPlayerInfoByGUID`. Use `C_PlayerCache.RememberPlayer` or let
// the engine populate via normal interaction.
int __fastcall Script_UnitNameFromGUID(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitNameFromGUID(\"0x...\")");
return 0;
}
const char *guidStr = Game::Lua::ToString(L, 1);
uint32_t hi, lo;
if (!ParseGUID(guidStr, hi, lo))
return 0;
if (hi == 0 && lo == 0)
return 0;
// Object-manager path — TYPEMASK_OBJECT (0x01) is permissive; the
// name getter at `FUN_OBJECT_GET_NAME` does its own type check and
// safely returns the "UNKNOWNOBJECT" sentinel for non-unit
// objects (gameobjects etc.), so we just gate on the sentinel
// rather than pre-filtering by typemask.
using ObjectPtr_t = void *(__fastcall *)(uint32_t typeMask,
const char *debugMsg,
uint32_t guidLo,
uint32_t guidHi,
int debugCode);
using GetName_t = const char *(__thiscall *)(void *obj, int *outFlags);
auto objectPtr = reinterpret_cast<ObjectPtr_t>(
Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
void *obj = objectPtr(Offsets::TYPEMASK_OBJECT, nullptr, lo, hi, 0);
if (obj != nullptr) {
auto getName = reinterpret_cast<GetName_t>(Offsets::FUN_OBJECT_GET_NAME);
const char *name = getName(obj, nullptr);
if (name != nullptr && *name != '\0' &&
std::strcmp(name, "UNKNOWNOBJECT") != 0 &&
std::strcmp(name, "Unknown Being") != 0) {
Game::Lua::PushString(L, name);
Game::Lua::PushString(L, "");
return 2;
}
}
// Object-manager miss / sentinel — fall back to the persistent
// NameCache (when enabled). Covers ex-units no longer in the
// engine's sync window.
const std::string *cachedName = nullptr;
const NameCache::Entry *cached = NameCache::Lookup(
(static_cast<uint64_t>(hi) << 32) | lo, &cachedName);
if (cached == nullptr || cachedName == nullptr || cachedName->empty())
return 0;
Game::Lua::PushString(L, cachedName->c_str());
Game::Lua::PushString(L, "");
return 2;
}
// `C_PlayerCache.GetPlayerInfoByName(name)` — name-keyed lookup over
// the persistent NameCache. Returns `(localizedClass, englishClass,
// localizedRace, englishRace, sex, name, realm, guid)` or nothing if
@@ -212,6 +299,8 @@ int __fastcall Script_C_PlayerCache_GetPlayerInfoByName(void *L) {
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetPlayerInfoByGUID",
&Script_GetPlayerInfoByGUID);
Game::Lua::RegisterGlobalFunction("UnitNameFromGUID",
&Script_UnitNameFromGUID);
Game::Lua::RegisterTableFunction("C_PlayerCache", "GetPlayerInfoByName",
&Script_C_PlayerCache_GetPlayerInfoByName);
}
+85
View File
@@ -0,0 +1,85 @@
// 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.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `UnitSubName(unit)` — returns the NPC's subname / title (the small
// italic text under their name in the tooltip): "Innkeeper",
// "Stable Master", "<Master Engineer>", etc. Modern WoW exposes this
// via `UnitName`'s second return; vanilla 1.12's `UnitName` returns
// only one value, so addons that want the subtitle have to roll their
// own — until now.
//
// Returns `nil` for:
// - Unresolvable unit token (`target` with nothing targeted, empty
// `partyN` slot, etc.).
// - Player units — they don't have a row in the creature cache.
// - NPCs whose creature data hasn't synced yet (rare; happens when
// the unit comes into view but the SMSG_CREATURE_QUERY_RESPONSE
// hasn't arrived).
// - NPCs with no subname (most non-vendor / non-trainer creatures).
//
// Raises a Lua error on missing / non-string `unit` argument — same
// semantics as the engine's `UnitName`.
//
// Implementation: walks the creature cache row at `[unit + 0xB30]` →
// `[+0x10]` (subname C string). Verified path; same one
// VanillaMinimapTracking's blip subname filter uses.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Unit::SubName {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
int __fastcall Script_UnitSubName(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitSubName(\"unit\")");
return 0;
}
const char *token = Game::Lua::ToString(L, 1);
if (token == nullptr)
return 0;
auto resolve = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *unit = static_cast<const uint8_t *>(resolve(token));
if (unit == nullptr)
return 0;
auto *cacheRow = *reinterpret_cast<const uint8_t *const *>(
unit + Offsets::OFF_UNIT_CREATURE_CACHE_ROW);
if (cacheRow == nullptr)
return 0;
auto *subName = *reinterpret_cast<const char *const *>(
cacheRow + Offsets::OFF_CREATURE_CACHE_SUB_NAME);
if (subName == nullptr || *subName == '\0')
return 0;
Game::Lua::PushString(L, subName);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitSubName", &Script_UnitSubName);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Unit::SubName