Files
ClassicAPI/src/item/Data.cpp
T
Brues 7f52afeef4 perf(event): fire custom events through O(1) AutoReserve::Slot()
Every custom-event fire resolved its name to a slot via
Event::Custom::Lookup - a linear strcmp scan over the ~46-entry
reservation table, on every single fire. The slot itself was already
cached per table build; only the name->reservation hop was repeated.
On a client that is purely CPU-bound, that is wasted work on paths
that burst exactly when frames are scarce (nameplate churn, cast spam,
loot rolls, mouse/modifier input).

AutoReserve now remembers its reservation index (the ctor's dedup
resolves duplicate names to the same entry) and exposes Slot() - two
loads. Reload safety is unchanged and stays centralized: Slot() reads
through the shared table PrepareForReload/RetryClaims maintain, so the
value can never go stale. Lookup is deleted; LookupByName remains for
engine-defined names.

All fire sites converted to the instance handle:
- CastEvents: the k* name constants become the AutoReserve instances,
  so all 19 fire sites stay textually identical; the Fire/FireReticle/
  FireRemote helpers take the reservation by reference.
- equipmentset: reservations move into Set.h as C++17 inline consts so
  the Data and Api TUs share one handle.
- Cache::QueryLoad::Register takes the AutoReserve pointer instead of
  an event-name string (creature/gameobject callers updated).
- Tts keeps its lazy VanillaTTS-stand-down reservations via
  null-guarded pointers set at registration.
- GlobalMouse/InputEvents/lossofcontrol instances moved above their
  fire sites (static-init list order is irrelevant).
2026-08-24 10:11:10 -05:00

651 lines
30 KiB
C++

// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "item/Arg.h"
#include "item/CGItem.h"
#include "item/Data.h"
#include "item/ID.h"
#include "item/Location.h"
#include "tick/WorldTick.h"
#include "time/Clock.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Item::Data {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
// Two events fire on item-data arrival; they are **mutually exclusive**
// based on what triggered the cache fill:
//
// `GET_ITEM_INFO_RECEIVED(itemID, success)` — added in 5.4. Fires for
// **implicit / transparent** cache fills: `GetItemInfo` warmup,
// hyperlink hover, chat-link resolution, our `SetItemByID` warmup,
// etc. This is the event most existing addons listen to.
//
// `ITEM_DATA_LOAD_RESULT(itemID, success)` — added in 8.0. Fires for
// **explicit** request paths only: `C_Item.RequestLoadItemData(ByID)`
// and friends. The completion event for callers who explicitly
// asked the modern Item-data API to load a specific item.
//
// Behavior verified empirically against 1.15.x: a single cache fill
// fires exactly one event, never both, depending on what initiated
// the request. We honor the split with two distinct callbacks.
//
// Names are reserved at static-init time via `Event::Custom::AutoReserve`;
// the `RebuildEventTable` hook appends them to the engine's input array
// at table-population time, so the engine itself allocates and tracks
// our entries identically to its own.
static constexpr const char *kItemDataLoadResult = "ITEM_DATA_LOAD_RESULT";
static constexpr const char *kGetItemInfoReceived = "GET_ITEM_INFO_RECEIVED";
static const Event::Custom::AutoReserve _reserveItemDataLoadResult{kItemDataLoadResult};
static const Event::Custom::AutoReserve _reserveGetItemInfoReceived{kGetItemInfoReceived};
static void FireGetItemInfoReceived(int itemID, bool success) {
Event::Custom::FireIdSuccess(_reserveGetItemInfoReceived.Slot(), itemID,
success);
}
static void FireItemDataLoadResult(int itemID, bool success) {
Event::Custom::FireIdSuccess(_reserveItemDataLoadResult.Slot(), itemID,
success);
}
// Item-load callbacks are __stdcall, 2 args / 8 bytes — matching the
// callback at 0x004FDC30 (used by 4 sites in the binary) which ends in
// `ret 8`. If the engine called us with a different convention it would
// crash, so stdcall is verified by induction.
using ItemLoadCallback_t = void(__stdcall *)(void *userData, int success);
// Calls DBCache_ItemStats_C_GetRecord. With `callback=nullptr`,
// performs only the hash-table lookup; returns the cached record or
// nullptr. With a non-null callback, also kicks off the
// SMSG_ITEM_QUERY_SINGLE if the item isn't cached — the engine fills
// the cache asynchronously and invokes the supplied callback with the
// itemID smuggled through `userData` (the engine just stores and
// replays it without dereferencing).
//
// Disassembly of `_GetRecord` at 0x55BAAC shows it branches on the
// callback arg (stack slot +0x10), not on the 6th `bool` arg that
// VanillaHelpers documented as `requestIfMissing` — that slot is reused
// as a local immediately after entry, never read. The trigger for the
// request path is `callback != NULL`.
//
// The 6th arg IS used elsewhere though: when non-zero, the engine
// walks the entry's existing pending-callback list and skips the
// append if a node with matching `(callback, userData)` is already
// queued. We always pass `1` so that bursts of `WarmCache` calls for
// the same itemID (e.g. Bagnon enumerating the same slot repeatedly
// while the SMSG_ITEM_QUERY_SINGLE_RESPONSE is in flight) collapse to
// a single callback registration — otherwise the response fires our
// `GET_ITEM_INFO_RECEIVED` once per call.
static const uint8_t *CacheFetch(uint32_t itemID, ItemLoadCallback_t callback) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
void *cb = reinterpret_cast<void *>(callback);
void *userData =
callback ? reinterpret_cast<void *>(static_cast<uintptr_t>(itemID)) : nullptr;
return fn(cache, itemID, &zeroGuid, cb, userData, 1);
}
// Resolves an item-location table at stack idx to the itemID by walking
// `Location::Resolve` -> CGItem -> instance block (+0x08) -> itemID (+0x0C).
// Returns 0 if the slot is empty or the table is malformed.
static int ResolveLocationToItemID(void *L, int idx) {
const uint8_t *item = Item::Location::Resolve(L, idx);
if (item == nullptr)
return 0;
auto *instance = Item::InstanceBlock(item);
if (instance == nullptr)
return 0;
return static_cast<int>(Game::Read<uint32_t>(
instance, Offsets::OFF_INSTANCE_BLOCK_ITEM_ID));
}
static int __fastcall Script_IsItemDataCachedByID(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
const bool cached =
(itemID > 0) && (CacheFetch(static_cast<uint32_t>(itemID), nullptr) != nullptr);
Game::Lua::PushBool(L, cached);
return 1;
}
static int __fastcall Script_IsItemDataCached(void *L) {
if (!Item::Location::IsLocationArg(L, 1)) {
Game::Lua::Error(L, "Usage: C_Item.IsItemDataCached(itemLocation)");
return 0;
}
const int itemID = ResolveLocationToItemID(L, 1);
const bool cached =
(itemID > 0) && (CacheFetch(static_cast<uint32_t>(itemID), nullptr) != nullptr);
Game::Lua::PushBool(L, cached);
return 1;
}
// 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`.
//
// 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), `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 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 = 512;
constexpr int kMaxWaitTicks = 1200;
// Wall-clock delay past world-live before we assume the engine's startup
// network/opcode-dispatch state has settled and eager queries are safe.
// Anchored to VAR_IN_WORLD (set to 1 by the enter-world initializer
// FUN_004908C0, cleared on every transition — the same flag
// bag/UpdateDelayed trusts), NOT a raw frame count: a fixed tick count is
// FPS-dependent, so it could fire before dispatch was ready on a fast
// machine (orphan risk — see the block comment above) yet leave bag icons
// as "?" far longer than intended on a slow login. World-live alone is
// still PLAYER_ENTERING_WORLD's first frame — inside the orphan window —
// so we wait this long past it. Also latched the instant the engine's own
// item-response handler fires (a response proves dispatch is live, so a
// warm WDB settles with no delay) — see `ItemStatsCacheResponse_h`.
// One-way latch; see the relog note in `WarmCache`.
constexpr uint32_t kSettleMs = 1000;
// Minimum spacing between outgoing *background* SMSG_ITEM_QUERY_SINGLE
// requests. A single /reload can make addons (pfQuest / Questie / Bagnon)
// look up hundreds of items in one frame. Unthrottled, every cache miss
// fired its own packet at once: a reported burst of 1,325 requests in
// ~14 ms exhausted a consumer router's NAT table and dropped the
// machine's internet connectivity (issue #25). 50 ms caps the sustained
// background rate at 20 requests / second. OnWorldTick is the single
// point that issues queries, so this one floor governs the bulk path.
constexpr uint32_t kMinRequestSpacingMs = 50;
// Priority tier. The player's carried items (equipment + backpack +
// equipped bags) are what the bag / paperdoll UI shows immediately on
// login; behind the background floor they showed as "unknown" for a
// second or two while a bulk addon scan drained ahead of them. These
// are issued on a separate, more generous budget so they resolve fast.
// The set is bounded (~120 items), so a small per-tick cap keeps them
// near-instant without re-introducing a burst: worst case is
// kOwnedPerTick + 1 packets per frame (~7), far below the flood that
// broke connectivity.
constexpr int kOwnedPerTick = 6;
constexpr int kMaxOwned = 256;
constexpr uint32_t kOwnedRefreshMs = 1000; // carried set changes slowly
static Pending g_pending[kMaxPending];
static int g_pendingCount = 0;
static bool g_settled = false;
// Tick timestamp when we first saw the world go live (VAR_IN_WORLD == 1);
// 0 = not yet. Anchors the kSettleMs delay. Only meaningful until the
// one-way g_settled latch flips, so it is never reset.
static uint32_t g_inWorldSinceMs = 0;
static uint32_t g_lastRequestMs = 0;
// Snapshot of the itemIDs the player currently carries. Rebuilt by a
// pure-C inventory walk (no Lua stack — safe at world-tick time) at most
// once per kOwnedRefreshMs, and only while there is queued work.
static uint32_t g_owned[kMaxOwned];
static int g_ownedCount = 0;
static uint32_t g_ownedBuiltMs = 0;
static bool g_ownedEverBuilt = false;
// One-shot latch: have we proactively seeded the queue with the player's
// carried items this enter-world session? Re-armed per in-game init (login
// or /reload) in RegisterLuaFunctions.
static bool g_ownedPrefetched = false;
static int FindPending(uint32_t itemID) {
for (int i = 0; i < g_pendingCount; ++i) {
if (g_pending[i].itemID == itemID)
return i;
}
return -1;
}
static void RemovePendingAt(int idx) {
--g_pendingCount;
if (idx != g_pendingCount)
g_pending[idx] = g_pending[g_pendingCount];
}
// 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 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) {
// 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;
}
if (g_pendingCount >= kMaxPending)
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
// `SMSG_ITEM_QUERY_SINGLE_RESPONSE` and walks the per-entry callback
// lists, we sweep `g_pending` for anything now-loaded and fire the
// event. This is the engine-aligned alternative to per-tick polling:
// we observe fills exactly when they happen, with zero added latency
// and no risk of racing the engine's own callback walk.
//
// `__thiscall` is mimicked as `__fastcall(ecx, edx_unused, ...)` per
// the convention used elsewhere in this codebase (see
// `KeyringDescChange_h`, `FrameRegisterEvent_h`).
using ItemStatsCacheResponse_t = void(__thiscall *)(void *cache, void *packetReader, int flag);
static ItemStatsCacheResponse_t ItemStatsCacheResponse_o = nullptr;
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 (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;) {
if (TryCompletePending(i))
continue;
++i;
}
}
static const Game::HookAutoRegister _hookCacheResponse{
Offsets::FUN_ITEMSTATS_CACHE_RESPONSE,
reinterpret_cast<void *>(&ItemStatsCacheResponse_h),
reinterpret_cast<void **>(&ItemStatsCacheResponse_o)};
// --- Owned-item priority set --------------------------------------------
//
// Determines which pending items are the player's carried inventory so
// OnWorldTick can issue them ahead of background bulk lookups. The walk
// reads the engine's GUID arrays directly (no Lua stack, no GetItemBySlot
// bank gate) — the same pure-C pattern `item/Count` uses — so it is safe
// to run at world-tick time.
static void AddOwned(uint32_t itemID) {
if (itemID == 0)
return;
for (int i = 0; i < g_ownedCount; ++i)
if (g_owned[i] == itemID)
return; // dedup — stacks / duplicates collapse to one entry
if (g_ownedCount < kMaxOwned)
g_owned[g_ownedCount++] = itemID;
}
// Collects itemIDs from linear slots [first, last] of an inventory
// manager's GUID array (invMgr+OFF_INVMGR_GUID_ARRAY). Empty slots hold a
// zero GUID and are skipped.
static void CollectOwnedFrom(const uint8_t *invMgr, int first, int last) {
if (invMgr == nullptr)
return;
auto *guids = Game::Read<const uint64_t *>(
invMgr, Offsets::OFF_INVMGR_GUID_ARRAY);
if (guids == nullptr)
return;
for (int slot = first; slot <= last; ++slot) {
const uint8_t *item = Item::Location::ResolveByGUID(guids[slot]);
if (item != nullptr)
AddOwned(static_cast<uint32_t>(Item::ID::FromCGItem(item)));
}
}
static void RebuildOwned() {
g_ownedCount = 0;
auto *invMgr = Unit::Identity::PlayerInventoryManager();
if (invMgr != nullptr) {
// Player invMgr linear slots 0..38: equipment + bag-slot items +
// backpack (bank starts at INVMGR_BANK_MAIN_FIRST_SLOT = 39).
CollectOwnedFrom(invMgr, 0,
Offsets::BACKPACK_LINEAR_BASE + Offsets::BACKPACK_NUM_SLOTS - 1);
// Contents of each equipped bag (its own invMgr, slot count at +0).
for (int bagID = 1; bagID <= 4; ++bagID) {
auto *bagInv =
static_cast<const uint8_t *>(Item::Location::EquippedBagInventory(bagID));
if (bagInv == nullptr)
continue;
const int count = static_cast<int>(
Game::Read<uint32_t>(bagInv, Offsets::OFF_INVMGR_SLOT_COUNT));
if (count > 0)
CollectOwnedFrom(bagInv, 0, count - 1);
}
}
g_ownedEverBuilt = true;
}
static bool IsOwned(uint32_t itemID) {
for (int i = 0; i < g_ownedCount; ++i)
if (g_owned[i] == itemID)
return true;
return false;
}
// Escalation + timeout — and the ONE place any item query is issued, so
// the rate limiter here governs every path into the cache. This tick is
// responsible for:
// 1. Latching the startup gate via a tick-count fallback, in case no
// engine response arrives early enough to latch it first.
// 2. Issuing queries — for items the engine doesn't naturally prefetch
// (quest rewards) AND for every `WarmCache` miss — but only once
// `g_settled` (don't race the engine's startup state machine). The
// player's carried items go out first, up to `kOwnedPerTick` per
// tick; everything else is background, one per `kMinRequestSpacingMs`
// (don't flood the network; issue #25). 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 whose query was already
// sent but never resolved (lost query, server silent).
static void OnWorldTick() {
const uint32_t now = Time::Clock::NowMs();
// Settle the startup gate a fixed wall-clock delay past world-live —
// or leave it to ItemStatsCacheResponse_h to latch the instant a real
// item response arrives, whichever comes first. See kSettleMs.
if (!g_settled) {
const bool inWorld = *reinterpret_cast<const volatile uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_IN_WORLD)) != 0;
if (inWorld && g_inWorldSinceMs == 0)
g_inWorldSinceMs = now;
if (g_inWorldSinceMs != 0 &&
Time::Clock::Elapsed(g_inWorldSinceMs, now) >= kSettleMs)
g_settled = true;
}
// Proactive local-inventory prefetch (once per enter-world session).
// The engine does NOT query bag item stats on enter-world or bag draw:
// GetContainerItemInfo and the icon getter both do a null-callback
// (pure lookup) _GetRecord, and an uncached bag item resolves
// displayInfoID = 0, which the icon path draws as INV_Misc_QuestionMark
// ("?"). (Equipped items are exempt — their icon comes from the
// server-pushed visible-items table.) So on a cold WDB the player's
// carried items stay "?" until something else queries them — a hover, a
// loot, or an addon's GetItemInfo. Seed the queue with every carried
// item so they issue on the owned priority lane ahead of any addon's
// background flood, giving complete carried-item coverage regardless of
// addon behavior. Bounded (<= kMaxOwned) and issued under the same owned
// budget as everything else, so it adds no burst (issue #25). Deferred
// to post-settle like every other query, so it can't orphan an entry
// before the engine's network/opcode dispatch is ready. Gate on a live
// inventory manager so a too-early settle retries next tick instead of
// latching on an empty walk.
if (g_settled && !g_ownedPrefetched &&
Unit::Identity::PlayerInventoryManager() != nullptr) {
RebuildOwned();
g_ownedBuiltMs = now;
for (int i = 0; i < g_ownedCount; ++i)
Track(g_owned[i], /*implicit=*/true);
g_ownedPrefetched = true;
}
// Refresh the carried-item set while there is queued work, at most
// once per kOwnedRefreshMs — the set changes slowly and the walk is
// pointer-chasing, not free.
if (g_settled && g_pendingCount > 0 &&
(!g_ownedEverBuilt || Time::Clock::Elapsed(g_ownedBuiltMs, now) >= kOwnedRefreshMs)) {
RebuildOwned();
g_ownedBuiltMs = now;
}
// Two independent issue budgets this tick:
// - owned (carried) items: up to kOwnedPerTick, no spacing floor —
// bounded and player-visible, so they resolve fast.
// - background items: one, and only once the spacing floor elapsed.
// World ticks fire once per frame (~10-16 ms), so the background floor
// is wall-clock, not a tick count, to bound its rate regardless of FPS.
int ownedBudget = g_settled ? kOwnedPerTick : 0;
bool bgAvailable =
g_settled && Time::Clock::Elapsed(g_lastRequestMs, now) >= kMinRequestSpacingMs;
for (int i = 0; i < g_pendingCount;) {
if (g_settled && !g_pending[i].requestIssued) {
const bool owned = IsOwned(g_pending[i].itemID);
bool issue = false;
if (owned && ownedBudget > 0) {
issue = true;
--ownedBudget;
} else if (!owned && bgAvailable) {
issue = true;
bgAvailable = false;
g_lastRequestMs = now;
}
if (issue) {
CacheFetch(g_pending[i].itemID, &ItemLoadCallback_Escalated);
g_pending[i].requestIssued = true;
}
}
// A fill (engine response) or a server rejection (escalation
// callback set `failed`) resolves the item either way.
if (TryCompletePending(i))
continue;
// Only time out items whose query is actually in flight. Counting
// from track-time would fire spurious failures for items still
// waiting their turn behind the rate limiter.
if (g_pending[i].requestIssued) {
if (g_pending[i].ticksWaiting >= kMaxWaitTicks) {
const uint32_t itemID = g_pending[i].itemID;
const bool implicit = g_pending[i].implicit;
RemovePendingAt(i);
FireByTag(itemID, false, implicit);
continue;
}
++g_pending[i].ticksWaiting;
}
++i;
}
}
static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
// Public C++ API — see Item/Data.h. Implicit (transparent) warmup path
// used by the `Script_GetItemInfo` hook and `SetItemByID`. Routes every
// call through the pending queue so OnWorldTick's single rate-limited
// issue point governs all outgoing item queries (issue #25). `Track`
// resolves an already-cached item for free (a pure lookup, no SMSG) and
// queues only a genuine miss; the response-handler sweep then fires
// `GET_ITEM_INFO_RECEIVED` (implicit), never `ITEM_DATA_LOAD_RESULT`.
void WarmCache(uint32_t itemID) {
// Do NOT issue the query here. Two reasons converge on the queue:
// - Network: a post-settle immediate CacheFetch let a reload-time
// lookup burst fire one SMSG per item in a single frame and flood
// the network (issue #25). The queue's rate limiter prevents it.
// - Startup race: eagerly creating the cache entry before the engine
// settles 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. This broke pfQuest/Questie
// scanning at PLAYER_ENTERING_WORLD. OnWorldTick issues only once
// `g_settled`, so deferring here fixes that too.
Track(itemID, /*implicit=*/true);
}
// Hook for `Script_GetItemInfo`. Pre-warms the cache from arg 1, then
// dispatches to the original. Effect: the original still returns nil
// for cache misses (vanilla behavior), but a query is now in flight,
// so subsequent calls will return valid data and
// GET_ITEM_INFO_RECEIVED will fire.
Script_GetItemInfo_t Script_GetItemInfo_o = nullptr;
int __fastcall Script_GetItemInfo_h(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
if (itemID > 0)
WarmCache(static_cast<uint32_t>(itemID));
return Script_GetItemInfo_o(L);
}
static int __fastcall Script_RequestLoadItemDataByID(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
if (itemID <= 0) {
Game::Lua::PushBoolean(L, 0);
return 1;
}
const bool accepted = RequestAndMaybeNotify(static_cast<uint32_t>(itemID));
Game::Lua::PushBoolean(L, accepted ? 1 : 0);
return 1;
}
static int __fastcall Script_RequestLoadItemData(void *L) {
if (!Item::Location::IsLocationArg(L, 1)) {
Game::Lua::Error(L, "Usage: C_Item.RequestLoadItemData(itemLocation)");
return 0;
}
const int itemID = ResolveLocationToItemID(L, 1);
if (itemID <= 0) {
Game::Lua::PushBoolean(L, 0);
return 1;
}
const bool accepted = RequestAndMaybeNotify(static_cast<uint32_t>(itemID));
Game::Lua::PushBoolean(L, accepted ? 1 : 0);
return 1;
}
static void RegisterLuaFunctions() {
// Re-arm the proactive carried-item prefetch for this enter-world
// session. Fires on login and /reload (this callback runs per in-game
// Lua init). g_settled is deliberately NOT reset — it is a one-way
// process latch (see WarmCache); on a /reload the cache is warm, so the
// re-run resolves as cache hits and does nothing.
g_ownedPrefetched = false;
Game::Lua::RegisterTableFunction("C_Item", "IsItemDataCachedByID",
&Script_IsItemDataCachedByID);
Game::Lua::RegisterTableFunction("C_Item", "IsItemDataCached", &Script_IsItemDataCached);
Game::Lua::RegisterTableFunction("C_Item", "RequestLoadItemDataByID",
&Script_RequestLoadItemDataByID);
Game::Lua::RegisterTableFunction("C_Item", "RequestLoadItemData", &Script_RequestLoadItemData);
// Event names are reserved at file scope via `AutoReserve`; nothing
// to do here for events.
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
// Auto-warm the item cache on `GetItemInfo(uncached_id)` calls, so
// subsequent calls return valid data and GET_ITEM_INFO_RECEIVED
// fires when the response arrives — matches modern WoW (5.x+)
// behavior. Without this, vanilla 1.12's `GetItemInfo` returns nil
// for misses and never fires a query, forcing addons to roll their
// own warmup hacks.
static const Game::HookAutoRegister _hookreg{
Offsets::FUN_SCRIPT_GET_ITEM_INFO,
reinterpret_cast<void *>(&Script_GetItemInfo_h),
reinterpret_cast<void **>(&Script_GetItemInfo_o)};
} // namespace Item::Data