Compare commits

...
26 Commits
Author SHA1 Message Date
Brues d786c607b8 C_Container.MoveItem: route count == srcStack through swap path
Vanilla's CMSG_SPLIT_ITEM rejects splits where count == srcStack
(EQUIP_ERR_COULDNT_SPLIT_ITEMS — would leave source empty). The
drag-and-drop UI's "drop whole stack on matching stack" goes through
CMSG_SWAP_ITEM, which the server merges for same-item destinations.

In MoveCount, read ITEM_FIELD_STACK_COUNT off the resolved source
CGItem and route count == srcStack to Item::Swap::Containers. Also
return false locally for count > srcStack (server would reject anyway,
fail fast). count < srcStack keeps the existing SPLIT path.
2026-06-06 09:41:48 -05:00
Brues 598c66894e Polyfill UNIT_FACTION fire-coverage on 5 paths the 1.12 engine skips
3.3.5+ fires UNIT_FACTION("player") on rep-state changes that vanilla
silently drops. Hook each path's inner setter / SMSG handler and fire
the event post-original via the engine's printf-style dispatcher:

- FactionToggleAtWar — fire if rep slot's flag byte changed
- SetFactionInactive / SetFactionActive — same shape
- SMSG_SET_FACTION_STANDINGS — fire if any of 64 flag bytes changed
  (catches AT_WAR side-effect flips on standing threshold crossings)
- SMSG_SET_FACTION_ATWAR — unconditional after byte write
- SMSG_INITIALIZE_FACTIONS — fire after initial sync at login

Hoist the event-table strcmp walk into Event::Custom::LookupByName
(sibling to Lookup, which only consults our reservation cache). Replaces
duplicated walks in event/Util.cpp::IsKnownEventName and the polyfill.
2026-06-05 20:50:51 -05:00
Brues 75b4ea3863 Update README.md 2026-06-04 17:38:23 -05:00
Brues 5f66e381d2 Restore Lua 5.0 coroutine.* library
Re-registers the standard five entries (create / resume / yield /
status / wrap) on the global `coroutine` table. The C-level
machinery (lua_resume, lua_yield, lua_newthread, threads as tag 8)
was always linked into the engine; only the script-side library
was stripped from the registration tables at build time.

Two WoW-specific quirks discovered along the way and worked
around:

- `lua_resume` returns 1 for BOTH yield and caught runtime error.
  Disambiguated by checking `co->ci > co->base_ci` — a yielded
  coroutine still has its body frame on the call stack; an
  errored one has been unwound to base_ci.

- The byte at L+0x31 is `allowhook` (saved/restored around
  protected calls), not `L->status` as it initially looked.
  Status reading is replaced by the ci comparison above.
2026-06-04 16:23:55 -05:00
Brues 4dd892297d removed unused GlobalStrings.lua 2026-06-04 15:43:19 -05:00
Brues 51cb7a5674 Add C_Item.GetItemData / GetItemDataByID
Single-call kitchen-sink reader for the cached `ItemStats_C` record.
Returns a table with ~50 fields covering everything the cache holds:
identity (name, description, icon, class/subclass/equipLoc strings),
quality + bind type + decoded flag bits (isConjured, isOpenable,
isLootable, isWrapper), stacking, economy, requirements, equipment
stats (sparse map keyed by statType), resistances, weapon damage
arrays, spell slots, and the misc fields (lockID, itemSet, pageText,
startQuest, area/map, etc.).

Returns nil for uncached / invalid input — passive reader, no cache
warming. Caller wanting to ensure load should `RequestLoadItemData`
and listen for `ITEM_DATA_LOAD_RESULT`.

Implements via existing offsets where available; adds the remaining
`ItemStats_C` fields to `Offsets.h` based on VanillaHelpers's full
struct layout, anchored against the offsets we'd already verified
(`m_stackable@+0x60`, `m_lockID@+0x1AC`, `m_bagFamily@+0x1D0`, etc.).
2026-06-04 14:39:01 -05:00
Brues cc0bf769c4 Dedup CacheFetch callbacks via _GetRecord's built-in walk
Bursts of `WarmCache` calls for the same uncached itemID (e.g. Bagnon
re-enumerating a slot while the SMSG_ITEM_QUERY_SINGLE_RESPONSE is in
flight) were each appending a fresh `Implicit` callback to the
entry's pending list. When the response landed the engine walked the
list and fired our callback N times, surfacing as duplicate
`GET_ITEM_INFO_RECEIVED` events for the same itemID.

`_GetRecord`'s 6th arg (`param_5` in the decompilation, previously
documented as the unused `requestIfMissing` bool) controls whether
the engine dedups appends. With `0` it always appends; with non-zero
it walks the existing list and skips the append on a matching
`(callback, userData)` node. We always have the same callback ptr and
encode the itemID in userData, so flipping the bit collapses repeat
warmups for the same item to a single registration.
2026-06-04 13:34:14 -05:00
Brues ad13440743 Item cache: hook response handler instead of racing the engine
`Item:CreateFromItemID(X):ContinueOnItemLoad(cb)` was leaving items
permanently uncached when pfQuest called it at addon-load /
PLAYER_ENTERING_WORLD against an empty WDB. Three intertwined bugs:

1. `GetItemInfoInstant` returned no values for uncached items, so
   `ItemMixin:GetItemID()` returned nil and `AddCallback(nil, cb)`
   crashed with "table index is nil".
2. `DoesItemExistByID` returned false for uncached items, which made
   `IsItemEmpty()` return true and erroneously gated `ContinueOnItemLoad`
   on cache state — modern semantic is "is this a valid ID", independent
   of cache.
3. Our `RequestLoadItemData` path eagerly called `CacheFetch` with our
   own callback at addon-load. That created a cache entry before the
   engine's natural inventory prefetch had a chance to run; the engine
   then saw the entry already existed and skipped its own SMSG, leaving
   the entry permanently pending. `GetItemInfo` retries didn't help —
   subsequent `_GetRecord` calls just appended callbacks to the dead
   list, no new query was sent.

Fixes:

- `GetItemInfoInstant` always pushes the itemID (echoed from input) and
  the remaining 6 fields when cached / nils when not. No cache touch.
- `DoesItemExistByID` returns `itemID > 0`. No cache touch.
- `RequestLoadItemData` tracks itemIDs in a pending set without touching
  the cache. We hook `FUN_ITEMSTATS_CACHE_RESPONSE` (the engine's
  `SMSG_ITEM_QUERY_SINGLE_RESPONSE` handler at `0x0055BDB0`) and sweep
  pending after the engine fills entries — observing the natural
  prefetch instead of racing it. `WorldTick` only handles escalation
  (after ~60 ticks, kick a query for items the engine doesn't prefetch
  itself, e.g. quest rewards) and the hard timeout.

Also fix the truthiness bug in modern (id, success) events:
`Event::Custom::Fire("%d%d", id, success)` was pushing `success` as a
Lua number — both 0 and 1 are truthy, so `if success then` fired the
success branch on failed loads. New `Event::Custom::FireIdSuccess`
pushes `1` for success and `nil` for failure (via the engine's
`lua_pushstring(NULL) → lua_pushnil` tail-jump). `ITEM_DATA_LOAD_RESULT`,
`GET_ITEM_INFO_RECEIVED`, and `QUEST_DATA_LOAD_RESULT` now all use this.
2026-06-04 13:15:59 -05:00
Brues 999a5846c4 Macro tagging: extract dispatch hub, add C_Spell.CastAtCursor pattern
Hoists the macro-parser hook + long-line guard + per-line walk out
of Spell::CastNoToggle into a new Spell::MacroPrimarySpell module
that dispatches to (prefix, extractor) pairs registered by feature
modules. The body walk now visits each line once and checks every
registered prefix at each position; extractors only parse args
after a matched prefix.

Registers a pattern for C_Spell.CastAtCursor(<spellID>) so macros
like `/run C_Spell.CastAtCursor(10)` get tagged with the spellID
the same way `/cast Blizzard` does, driving action-bar cooldown
overlays and auto-repeat highlight in pfUI etc.
2026-06-04 02:29:58 -05:00
Brues 9b5d12c8a1 Document C_Spell.CastAtCursor / C_Item.UseAtCursor 2026-06-03 14:55:00 -05:00
Brues d1bab7a3a4 Add C_Spell.CastAtCursor / C_Item.UseAtCursor
Vanilla 1.12.1 requires a manual click on the AoE reticle to land a
ground-target spell. This pair of functions fires the cast (or
on-use item spell) and immediately commits placement at the
cursor's current world position, matching modern `[@cursor]`
semantics:

  C_Spell.CastAtCursor(spellID)
  C_Item.UseAtCursor(itemInfo)

Chains the engine's own primitives — name-resolver to spellbook
slot, cast dispatcher, then a screen→world raycast via the existing
mouseover machinery and the placement-commit function. Cancels
cleanly when the spell isn't ground-target or the cursor is over
UI / off-screen.

Also extracts the bag-search pattern (used by `UseAtCursor` and
existing by-name action APIs) into `Item::Location::FindByArgInBags`,
alongside the existing `FindByItemID` / `FindByGUID` family.
2026-06-03 14:04:12 -05:00
Brues e657bac266 Suppress fatal-error code 10 so engine accepts ClassicAPI's file overrides
Vanilla 1.12.1 hashes FrameXML.toc + Bindings.xml + each addon's
contents and compares against signature blobs (Interface\FrameXML.sig
+ per-addon stored hash). ClassicAPI's file-read hook diverges those
in memory — Bindings.xml splice and the embedded !!!ClassicAPI addon
— so the engine fires FUN_00401560(10) and terminates with "Your
game interface files are corrupt".

Hook FUN_00401560 and drop calls with code 10. Other codes (login
failures, etc.) pass through unmodified. Stock vanilla binaries
(WoW.exe without a runtime patcher) now load ClassicAPI without
needing TLS-bootloader-style modifications.
2026-06-03 03:43:43 -05:00
Brues 0248661bb7 true localization 2026-06-03 03:33:00 -05:00
Brues 56fef8ec08 Spice section is no longer hardcoded 2026-06-03 00:41:39 -05:00
Brues 09ac68e674 No inline xml in c++ 2026-06-03 00:37:05 -05:00
Brues 19ad6e6514 update docs 2026-06-02 18:06:29 -05:00
Brues 41bb687608 Add FOCUSTARGET / TARGETFOCUS bindings under TARGETING
Splice into the engine's FrameXML Bindings.xml at load time via a
file-read hook so the new bindings receive display indices from the
same running counter as native bindings — landing visually inside the
TARGETING block instead of orphaned at the bottom (where addon-side
Bindings.xml entries always render, regardless of header="TARGETING").
2026-06-02 17:29:49 -05:00
Brues d9cc3f9d7e sort Items category 2026-06-02 16:51:15 -05:00
Brues 6b19c8bbb5 move useitembyname to Item section 2026-06-02 16:22:54 -05:00
Brues 09e57c3d97 UseItemByName now accepts a unit arg 2026-06-02 14:51:05 -05:00
Brues 2f3c67be1a Script_EquipmentSetContainsLockedItems: Missing items are not locked 2026-05-31 12:42:40 -05:00
Brues f94af6cb73 Fix C_Item.GetItemUniqueness* return shapes
The two functions have different signatures in modern (Classic Era
1.15.x) WoW, verified against BlizzardInterfaceCode's generated
ItemDocumentation.lua:

  GetItemUniqueness(itemLocation) → (limitCategory, limitMax)
  GetItemUniquenessByID(itemID)   →
      (isUnique, limitCategoryName, limitCategoryCount, limitCategoryID)

We were returning a 3-tuple in the wrong order for both. For a
"Unique (10)" item the byID variant now correctly returns
(true, nil, 10, nil) instead of (10, nil, 0).
2026-05-31 10:20:19 -05:00
Brues 1d370ada65 GetInventoryItemsForSlot: filter by player proficiency
Mail helms were showing up for cloth classes — the function only
checked invType→slot shape, not whether the player could actually
equip the item.

Adds a proficiency gate using the engine's per-itemClass mask table
at [0x00C4D4A0], populated by the SMSG_SET_PROFICIENCY handler at
0x005E7B70. `table[itemClass] & (1 << subclass)` is the same lookup
4.3.4's Script_GetInventoryItemsForSlot does (via FUN_00553AD0 ->
DAT_00DD4D38), and the same source the engine's own tooltip red-text
coloring and cursor-drop equip check read from. Covers both armor
and weapon proficiencies in one mask lookup.
2026-05-31 00:54:36 -05:00
Brues 35c78ea6be C_AddOns.GetAddOnName returns nil for addons that don't exist 2026-05-30 23:13:32 -05:00
Brues ad83eb9575 IsMouseButtonDown
Adds the modern (2.0.1+) query function backed by the same
WH_GETMESSAGE hook stream that drives GLOBAL_MOUSE_DOWN/UP — state
flips at the exact same point the events fire. Accepts the 1..5 ID
form, the "LeftButton".."Button5" name form, or no arg for "any
button held".
2026-05-30 22:36:30 -05:00
Brues 8995e82627 UseEquipmentSet: pre-snapshot locations, two-phase execution
Engine InvMgr state lags behind our atomic-swap packets (server has
to ACK before CGItem moves register locally), so mid-loop FindGUID
lookups returned stale locations and could undo prior swaps.

Mirrors 4.3.4's `Script_UseEquipmentSet` at 0x009A15D0, which walks
all 19 set slots up front to build an action list before any
execution. Combined with two-phase sequencing:

  1. paperdoll→paperdoll only — keeps bag state untouched, supports
     chained rotations via a virtual-paperdoll walk
  2. bag→paperdoll — atomic swap displaces current occupants into
     the source bag slot, never strands a future set item
  3. unequip slots the set wants empty into pre-snapshotted free
     bag slots

Eliminates the "trinket A in 14, B in bags" failure mode where an
earlier bag-to-paperdoll swap displaced an item later needed by the
set, then stale FindGUID returned the pre-swap paperdoll location.
2026-05-30 22:23:32 -05:00
51 changed files with 4434 additions and 1173 deletions
+2 -1
View File
@@ -4,7 +4,8 @@
## Version: DEV
## Author: Brues
GlobalStrings.lua
locales\locales.xml
Compat.lua
Util\Print.lua
Util\Constants.lua
+12
View File
@@ -0,0 +1,12 @@
if GetLocale() ~= "deDE" then return end
BINDING_NAME_FOCUSTARGET = "Fokus setzen";
BINDING_NAME_TARGETFOCUS = "Fokus anvisieren";
FOCUSTARGET = "Ziel als Fokus festlegen";
ITEMS_VARIABLE_QUANTITY = "%d |4IGegenstand:Gegenstände;";
ITEMS_EQUIPPED = "%d |4Gegenstand:Gegenstände; ausgerüstet";
ITEMS_IN_INVENTORY = "%d |4Gegenstand:Gegenstände; im Inventar";
ITEM_SLOTS_IGNORED = "%d |4Platz:Plätze; ignoriert";
ITEM_MISSING = "%s fehlt";
ITEMS_NOT_IN_INVENTORY = "%d |4Gegenstand fehlt:Gegenstände fehlen;";
TARGETFOCUS = "Fokussierte Einheit anvisieren";
@@ -1,6 +1,12 @@
if GetLocale() ~= "enUS" then return end
BINDING_NAME_FOCUSTARGET = "Focus Target";
BINDING_NAME_TARGETFOCUS = "Target Focus";
FOCUSTARGET = "Focus Target";
ITEMS_VARIABLE_QUANTITY = "%d |4Item:Items;";
ITEMS_EQUIPPED = "%d |4item:items; equipped";
ITEMS_IN_INVENTORY = "%d |4item:items; in inventory";
ITEM_SLOTS_IGNORED = "%d |4slot:slots; ignored";
ITEM_MISSING = "%s missing";
ITEMS_NOT_IN_INVENTORY = "%d |4item:items; missing";
TARGETFOCUS = "Target Focus";
+12
View File
@@ -0,0 +1,12 @@
if GetLocale() ~= "esES" then return end
BINDING_NAME_FOCUSTARGET = "Establecer objetivo";
BINDING_NAME_TARGETFOCUS = "Seleccionar enfocado";
FOCUSTARGET = "Establecer objetivo";
ITEMS_VARIABLE_QUANTITY = "%d |4objeto:objetos;";
ITEMS_EQUIPPED = "Equipado: %d |4objeto:objetos;";
ITEMS_IN_INVENTORY = "En inventario: %d |4objeto:objetos;";
ITEM_SLOTS_IGNORED = "Ignorado: %d |4casilla:casillas;";
ITEM_MISSING = "Falta: %s";
ITEMS_NOT_IN_INVENTORY = "Faltan: %d |4objeto:objetos;";
TARGETFOCUS = "Fijar unidad objetivo";
+12
View File
@@ -0,0 +1,12 @@
if GetLocale() ~= "frFR" then return end
BINDING_NAME_FOCUSTARGET = "Définir cible";
BINDING_NAME_TARGETFOCUS = "Reprendre cible";
FOCUSTARGET = "Définir la cible de focalisation";
ITEMS_VARIABLE_QUANTITY = "%d |4objet:objets;";
ITEMS_EQUIPPED = "%d |4objet:objets; équipés";
ITEMS_IN_INVENTORY = "%d |4objet:objets; dans l'inventaire";
ITEM_SLOTS_IGNORED = "%d |4emplacement ignoré:emplacements ignorés;";
ITEM_MISSING = "Objet manquant : %s";
ITEMS_NOT_IN_INVENTORY = "%d |4objet:objets; manquants";
TARGETFOCUS = "Cibler l'unité focalisée";
+9
View File
@@ -0,0 +1,9 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.blizzard.com/wow/ui/..\FrameXML\UI.xsd">
<Script file="deDE.lua"/>
<Script file="enUS.lua"/>
<Script file="esES.lua"/>
<Script file="frFR.lua"/>
<Script file="ruRU.lua"/>
<Script file="zhCN.lua"/>
<Script file="zhTW.lua"/>
</Ui>
+12
View File
@@ -0,0 +1,12 @@
if GetLocale() ~= "ruRU" then return end
BINDING_NAME_FOCUSTARGET = "Запомненная цель";
BINDING_NAME_TARGETFOCUS = "Восстановить цель";
FOCUSTARGET = "Запомнить цель";
ITEMS_VARIABLE_QUANTITY = "%d |4предмет:предмета:предметов;";
ITEMS_EQUIPPED = "%d |4предмет экипирован:предмета экипировано:предметов экипировано;";
ITEMS_IN_INVENTORY = "%d |4предмет:предмета:предметов; в сумках";
ITEM_SLOTS_IGNORED = "%d |4ячейка пропущена:ячейки пропущены:ячейки пропущены;";
ITEM_MISSING = "Предмет для ячейки %s отсутствует";
ITEMS_NOT_IN_INVENTORY = "%d |4предмет отсутствует:предмета отсутствует:предметов отсутствуют;";
TARGETFOCUS = "Восстановить цель";
+12
View File
@@ -0,0 +1,12 @@
if GetLocale() ~= "zhCN" then return end
BINDING_NAME_FOCUSTARGET = "焦点目标";
BINDING_NAME_TARGETFOCUS = "目标焦点";
FOCUSTARGET = "设定焦点目标";
ITEMS_VARIABLE_QUANTITY = "%d件物品";
ITEMS_EQUIPPED = "已装备%d件物品";
ITEMS_IN_INVENTORY = "背包中有%d件物品";
ITEM_SLOTS_IGNORED = "忽略%d个栏位";
ITEM_MISSING = "%s缺失";
ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
TARGETFOCUS = "选中焦点目标";
+12
View File
@@ -0,0 +1,12 @@
if GetLocale() ~= "zhTW" then return end
BINDING_NAME_FOCUSTARGET = "鎖定目標";
BINDING_NAME_TARGETFOCUS = "選定鎖定目標";
FOCUSTARGET = "設定鎖定目標";
ITEMS_VARIABLE_QUANTITY = "%d件物品";
ITEMS_EQUIPPED = "裝備%d件物品";
ITEMS_IN_INVENTORY = "%d件物品在物品欄中";
ITEM_SLOTS_IGNORED = "忽略%d個欄位";
ITEM_MISSING = "缺少%s物品";
ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
TARGETFOCUS = "選定鎖定單位";
+21 -1
View File
@@ -47,9 +47,29 @@ add_custom_command(
COMMENT "Embedding !!!ClassicAPI addon into ${EMBEDDED_CLASSICAPI_HEADER}"
VERBATIM)
# Bundle `src/bindings/Bindings.xml` (our TARGETING-group additions)
# into a generated header. `src/bindings/Inject.cpp` extracts the
# inner `<Binding>` elements from the embedded buffer and splices them
# into the engine's `Interface\FrameXML\Bindings.xml` at load time.
set(EMBEDDED_BINDINGS_SRC "${CMAKE_CURRENT_SOURCE_DIR}/src/bindings/Bindings.xml")
set(EMBEDDED_BINDINGS_HEADER "${CMAKE_BINARY_DIR}/generated/embedded_bindings.h")
add_custom_command(
OUTPUT "${EMBEDDED_BINDINGS_HEADER}"
COMMAND ${CMAKE_COMMAND}
-DINPUT_FILE=${EMBEDDED_BINDINGS_SRC}
-DOUT_HEADER=${EMBEDDED_BINDINGS_HEADER}
-DSYMBOL=kEmbeddedBindingsXml
-P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_file.cmake"
DEPENDS
"${EMBEDDED_BINDINGS_SRC}"
"${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_file.cmake"
COMMENT "Embedding bindings XML into ${EMBEDDED_BINDINGS_HEADER}"
VERBATIM)
add_library(${PROJECT_NAME} SHARED
${SOURCES} ${HEADERS} ${TINYCBOR_SOURCES}
${EMBEDDED_CLASSICAPI_HEADER})
${EMBEDDED_CLASSICAPI_HEADER}
${EMBEDDED_BINDINGS_HEADER})
# Dev-build sentinel — represents "newer than every plausible
# release". Encoded as v9999.99.99 = 9999 * 10000 + 99 * 100 + 99 =
+18 -4
View File
@@ -25,8 +25,9 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
| [Container](docs/API.md#container) | `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetItemCooldown`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
| [Coroutine](docs/API.md#coroutine) | `coroutine.create`, `coroutine.resume`, `coroutine.yield`, `coroutine.status`, `coroutine.wrap` |
| EncodingUtil | `C_EncodingUtil.CompressString`, `C_EncodingUtil.DecompressString`, `C_EncodingUtil.EncodeBase64`, `C_EncodingUtil.DecodeBase64`, `C_EncodingUtil.EncodeHex`, `C_EncodingUtil.DecodeHex`, `C_EncodingUtil.SerializeJSON`, `C_EncodingUtil.DeserializeJSON`, `C_EncodingUtil.SerializeCBOR`, `C_EncodingUtil.DeserializeCBOR` |
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` (sets persist to `WTF\Account\...\ClassicAPI_EquipmentSets.txt`) |
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` |
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid` |
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
@@ -35,16 +36,16 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex` |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura` |
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [Input](docs/API.md#input) | `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Input](docs/API.md#input) | `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [Macros](docs/API.md#macros) | `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
| [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` |
| [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.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` |
| [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` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsStealthed`, `IsSwimming` |
| [Table](docs/API.md#table) | `table.wipe` |
@@ -132,6 +133,19 @@ functions, just behavior the stock 1.12 engine didn't have. See the
| `nameplate1`..`nameplateN` | Unit behind the Nth visible nameplate, in creation-order. Works with every `UnitX` function — `UnitName`, `UnitGUID`, `UnitClass`, `UnitHealth`, etc. Suffix chains (`nameplate1target`, `nameplate1targettarget`) compose. See [NamePlate / Unit tokens](docs/API.md#unit-tokens-nameplaten). |
| `focus` / `focustarget` | Sticky target set via [`FocusUnit`](docs/API.md#focusunitunit), cleared via [`ClearFocus`](docs/API.md#clearfocus). Same `UnitX` coverage as `nameplateN`. Fires [`PLAYER_FOCUS_CHANGED`](docs/API.md#player_focus_changed-event) on transition. See [Focus](docs/API.md#focus). |
### Bindings
Injected into the engine's **Targeting Functions** group at FrameXML
Bindings.xml load time, so they appear in the keybind UI alongside
native targeting bindings instead of orphaned at the bottom.
| Binding | Action |
|---------|--------|
| `FOCUSTARGET` | `FocusUnit("target")` — pin current target as focus |
| `TARGETFOCUS` | `TargetUnit("focus")` — switch target to the focus |
See [Focus / Bindings](docs/API.md#bindings-focustarget--targetfocus) for the implementation note.
## Installation
Use [VanillaFixes](https://github.com/hannesmann/vanillafixes) to load the
+62
View File
@@ -0,0 +1,62 @@
# embed_file.cmake — bundle a single file into a generated C++ header.
#
# Invoked from CMakeLists.txt via `cmake -P` as a custom command. Reads
# INPUT_FILE and emits OUT_HEADER containing a constexpr byte array
# named SYMBOL plus a `SYMBOL ## Size` size constant. Both live at file
# scope; the header is meant to be included exactly once.
#
# Usage:
# cmake -DINPUT_FILE=... -DOUT_HEADER=... -DSYMBOL=... -P embed_file.cmake
if(NOT INPUT_FILE)
message(FATAL_ERROR "embed_file.cmake: INPUT_FILE required")
endif()
if(NOT OUT_HEADER)
message(FATAL_ERROR "embed_file.cmake: OUT_HEADER required")
endif()
if(NOT SYMBOL)
message(FATAL_ERROR "embed_file.cmake: SYMBOL required")
endif()
file(READ "${INPUT_FILE}" CONTENT_HEX HEX)
string(LENGTH "${CONTENT_HEX}" HEX_LEN)
math(EXPR FILE_SIZE "${HEX_LEN} / 2")
# Convert hex string to comma-separated byte literals. 16 bytes per
# line keeps the generated header readable.
set(BYTES "")
set(COL 0)
set(POS 0)
while(POS LESS HEX_LEN)
string(SUBSTRING "${CONTENT_HEX}" ${POS} 2 BYTE)
if(COL EQUAL 0)
string(APPEND BYTES " ")
endif()
string(APPEND BYTES "0x${BYTE},")
math(EXPR POS "${POS} + 2")
math(EXPR COL "${COL} + 1")
if(COL EQUAL 16)
string(APPEND BYTES "\n")
set(COL 0)
else()
string(APPEND BYTES " ")
endif()
endwhile()
if(NOT COL EQUAL 0)
string(APPEND BYTES "\n")
endif()
set(HEADER_CONTENT "// Auto-generated by embed_file.cmake — do not edit.
// Source: ${INPUT_FILE}
#pragma once
#include <cstddef>
static constexpr unsigned char ${SYMBOL}[] = {
${BYTES}};
static constexpr size_t ${SYMBOL}Size = sizeof(${SYMBOL});
")
file(WRITE "${OUT_HEADER}" "${HEADER_CONTENT}")
message(STATUS "embed_file: wrote ${OUT_HEADER} (${FILE_SIZE} bytes)")
+1280 -737
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -3,7 +3,9 @@
Catalogue of the Lua C API functions linked into the client. Vanilla WoW is
built against **Lua 5.0** (not 5.1), and the entire API plus the auxiliary
library lives in a contiguous block of `.text` from roughly `0x006F2000` to
`0x006F5100`. ~120 functions; the public C API is the core of that.
`0x006F5100`. ~120 functions; the public C API is the core of that. The
thread / coroutine machinery (`lua_resume`, `lua_yield`, the inner
`luaE_newthread`) lives slightly higher at `0x006F6000`–`0x006F6900`.
**Cross-verified against two binaries:**
@@ -75,6 +77,8 @@ context but not byte-by-byte verified.
| VA | Function | Conv. | Notes | Conf |
|--------------|-------------------------|----------|--------------------------------------------------------------|------|
| `0x006F2F80` | `lua_xmove` | fastcall | `(from, to, n)` — decr `from->top` by `n*0x10`, copy each 16-byte TValue, incr `to->top`. Used by `coroutine.*` to shuttle args/returns between threads | ★ |
| `0x006F3030` | `lua_newthread` | fastcall | GC check then calls inner `luaE_newthread` at `0x006F6B10`; pushes the new state onto L with tag 8 (LUA_TTHREAD). Returns `lua_State *` in eax — Ghidra reports `void` (decompiler artifact) | ★ |
| `0x006F3070` | `lua_gettop` | fastcall | `(top - base) >> 4` | ★ |
| `0x006F3080` | `lua_settop` | fastcall | | ✓ |
| `0x006F30D0` | `lua_remove` | fastcall | shift-down loop + decr_top; previously misidentified as `lua_pushvalue` | ★ |
@@ -155,7 +159,10 @@ behaviour the comment describes (`push 0; mov edx, 1; mov ecx, L`) is
| `0x006F4290` | *internal CFunction trampoline* | | called via `lua_cpcall` | ★ |
| `0x006F4320` | `lua_load` | fastcall | reader / loader entry | ★ |
| `0x006F4370` | `lua_dump` | fastcall | requires tag 6 + `Closure.isC == 0` | ★ |
| `0x006F4940` | `lua_error` | cdecl | `(L, msg)` — args on stack; reaches `luaD_throw` via wrappers | ✓ |
| `0x006F4940` | `lua_error` | **cdecl** varargs | `(L, fmt, ...)` — printf-style; engine prepends `luaL_where` then `lua_concat`. Single-string callers OK (cdecl tolerates extra-arg-less calls); user-controlled strings should use `Error(L, "%s", msg)` to avoid format-string interpretation | ✓ |
| `0x006F6620` | `lua_resume` | fastcall | `(L_co, nargs) → int`. Zero internal callers (coroutine lib was stripped from registration tables); linker kept the symbol. Stores nargs in a local, calls `luaD_rawrunprotected(L, &resume_inner, &nargs)` at `0x006F5DAC`; inner static helper at `0x006F67B0` | ★ |
| `0x006F6860` | `lua_yield` | fastcall | `(L, nresults) → int (-1)`. Sets `0x10` flag on CallInfo state field rather than directly writing `L->status` like later Lua versions. Errors with "attempt to yield across metamethod/C-call boundary" or "cannot yield a C function" when called from the wrong context | ★ |
| `0x006F6B10` | *internal* `luaE_newthread` | fastcall | Inner allocator called by `lua_newthread` at `0x006F3030`. Allocates the new state, sets tag 8 via the push helper at `0x006F7B20`, inits via `0x006F6C40`, copies global-state fields from the parent. Returns `lua_State *` | ★ |
### Misc / GC / version
+9 -1
View File
@@ -48,7 +48,15 @@ namespace Lua {
F(PCall, lua_pcall, LUA_PCALL) \
F(Next, lua_next, LUA_NEXT) \
F(Type, lua_type, LUA_TYPE) \
F(Error, lua_error, LUA_ERROR)
F(Error, lua_error, LUA_ERROR) \
F(ToPointer, lua_topointer, LUA_TO_POINTER) \
F(ToThread, lua_tothread, LUA_TO_THREAD) \
F(IsCFunction, lua_iscfunction, LUA_IS_C_FUNCTION)\
F(XMove, lua_xmove, LUA_X_MOVE) \
F(NewThread, lua_newthread, LUA_NEW_THREAD) \
F(Resume, lua_resume, LUA_RESUME) \
F(Yield, lua_yield, LUA_YIELD) \
F(ArgError, luaL_argerror, LUAL_ARG_ERROR)
#define CLASSICAPI_BIND_LUA(Name, Typedef, Offset) \
const Typedef##_t Name = reinterpret_cast<Typedef##_t>(Offsets::Offset);
+31 -1
View File
@@ -49,6 +49,8 @@ constexpr int TYPE_NUMBER = 3;
constexpr int TYPE_STRING = 4;
constexpr int TYPE_TABLE = 5;
constexpr int TYPE_FUNCTION = 6;
constexpr int TYPE_USERDATA = 7;
constexpr int TYPE_THREAD = 8;
using lua_isnumber_t = bool(__fastcall *)(void *L, int index);
using lua_isstring_t = bool(__fastcall *)(void *L, int index);
@@ -76,7 +78,27 @@ using lua_call_t = void(__fastcall *)(void *L, int nargs, int nresults);
using lua_pcall_t = int(__fastcall *)(void *L, int nargs, int nresults, int errfunc);
using lua_next_t = int(__fastcall *)(void *L, int idx);
using lua_type_t = int(__fastcall *)(void *L, int index);
using lua_error_t = void(__cdecl *)(void *L, const char *);
// The 1.12 `lua_error` wrapper at `0x6F4940` is actually variadic —
// it does `lua_pushvfstring(L, fmt, args)` then prepends `luaL_where`
// and throws. Existing callers pass a single literal (no `%`), which
// is fine because cdecl tolerates extra-arg-less calls. New callers
// forwarding user-supplied error strings should use `Error(L, "%s",
// userMsg)` to avoid format-string interpretation of `%` in the
// message body.
using lua_error_t = void(__cdecl *)(void *L, const char *fmt, ...);
using lua_topointer_t = const void *(__fastcall *)(void *L, int idx);
using lua_tothread_t = void *(__fastcall *)(void *L, int idx);
using lua_iscfunction_t = int(__fastcall *)(void *L, int idx);
using lua_xmove_t = void(__fastcall *)(void *from, void *to, int n);
// `lua_newthread` returns the new `lua_State *` in eax, but Ghidra
// infers `void` because the inner call's value just flows through.
// We declare the return type honestly here; callers that don't want
// to rely on the calling convention preserving eax can read the
// thread back from L's top via `ToPointer(L, -1)`.
using lua_newthread_t = void *(__fastcall *)(void *L);
using lua_resume_t = int(__fastcall *)(void *L, int nargs);
using lua_yield_t = int(__fastcall *)(void *L, int nresults);
using luaL_argerror_t = void(__fastcall *)(void *L, int narg, const char *msg);
extern const lua_isnumber_t IsNumber;
extern const lua_isstring_t IsString;
@@ -110,6 +132,14 @@ extern const lua_pcall_t PCall;
extern const lua_next_t Next;
extern const lua_type_t Type;
extern const lua_error_t Error;
extern const lua_topointer_t ToPointer;
extern const lua_tothread_t ToThread;
extern const lua_iscfunction_t IsCFunction;
extern const lua_xmove_t XMove;
extern const lua_newthread_t NewThread;
extern const lua_resume_t Resume;
extern const lua_yield_t Yield;
extern const luaL_argerror_t ArgError;
// Returns the global `lua_State *` (read on demand from the engine's global).
// Callable outside a Lua callback, e.g. during LoadScriptFunctions setup.
+321
View File
@@ -18,6 +18,18 @@ enum Offsets {
FUN_INVALID_FUNCTION_PTR_CHECK = 0x42A320,
FUN_LOAD_SCRIPT_FUNCTIONS = 0x490250,
// Fatal-error dispatcher. `__fastcall(uint code)`. Writes `code`
// to `DAT_00882738` and chains into the process-teardown path;
// `FUN_00403BC0` reads the code at exit and shows a localized
// popup keyed off it. Code `10` = "interface files are corrupt"
// — the engine fires this from FrameXML's `FUN_0048FBF0` hash
// mismatch and per-addon `FUN_0051F240` hash mismatch (plus
// other call sites we haven't pinned down by byte pattern).
// Hooked by `Security::FrameXMLBypass` to suppress code 10 so
// our in-memory file modifications (Bindings.xml splice +
// embedded `!!!ClassicAPI` addon) don't trigger termination.
FUN_FATAL_ERROR = 0x00401560,
// Master glue Lua init — clean linear caller of all 5 glue batch
// trampolines (60 main + 11 char-select + 24 char-create + 10 realm
// + 4 frame globals = 109 total). Body: 0x0046ABB0..0x0046ABD2,
@@ -1056,6 +1068,26 @@ enum Offsets {
// With `requestIfMissing=false`, returns NULL if not in cache (no server
// round-trip) — exactly the "instant" semantics we want.
FUN_DBCACHE_ITEMSTATS_GET_RECORD = 0x0055BA30,
// Item-cache response handler — the function called from
// `0x00555140` (success path for `SMSG_ITEM_QUERY_SINGLE_RESPONSE`)
// and `0x00555160` (failure path). Parses one or more itemIDs out
// of the packet, looks up (or creates) the cache entry per ID,
// copies the item data block via `FUN_007c9640` to `entry+0x18`,
// sets `[entry+0x1F0]=1` (the loaded byte), then walks the entry's
// pending callback list at `[entry+0x1FC]` and invokes each with
// `(userData, success)`. High-bit-on itemID in the packet signals
// a per-item failure for the batch.
//
// We hook this to observe any item-cache fill the engine performs
// — covers the engine's natural inventory prefetch and any
// addon-triggered queries without having to register our own
// engine callbacks. Lets `RequestLoadItemData` be purely passive:
// we track itemIDs, the engine (or whoever) issues the query, we
// see the fill via this hook and fire `ITEM_DATA_LOAD_RESULT`.
//
// Signature: `__thiscall(void *cache, void *packetReader, int flag)`.
// `cache` is the same `VAR_ITEMDB_CACHE` we already use.
FUN_ITEMSTATS_CACHE_RESPONSE = 0x0055BDB0,
// ItemStats_C field offsets we read. Full struct layout in
// VanillaHelpers's `Game.h` (`struct ItemStats_C`); we only need these.
OFF_ITEMSTATS_CLASS = 0x00,
@@ -1170,6 +1202,10 @@ enum Offsets {
// always at index 0 (some Turtle WoW custom items put it later).
OFF_ITEMSTATS_SPELL_ID = 0x11C, // u32[5] — spell ID per slot
OFF_ITEMSTATS_SPELL_TRIGGER = 0x130, // u32[5] — trigger code per slot
OFF_ITEMSTATS_SPELL_CHARGES = 0x144, // i32[5] — charges per slot (-1 = infinite)
OFF_ITEMSTATS_SPELL_COOLDOWN = 0x158, // u32[5] — cooldown ms per slot
OFF_ITEMSTATS_SPELL_CATEGORY = 0x16C, // u32[5] — category (groups shared cooldowns)
OFF_ITEMSTATS_SPELL_CATEGORY_CD = 0x180, // u32[5] — category cooldown ms
ITEMSTATS_SPELL_SLOT_COUNT = 5,
ITEM_SPELLTRIGGER_ON_USE = 0,
@@ -1181,6 +1217,63 @@ enum Offsets {
// members in VanillaHelpers's `ItemStats_C` definition.
OFF_ITEMSTATS_MAX_COUNT = 0x5C,
// Remaining `ItemStats_C` fields used by `C_Item.GetItemData(ByID)`.
// Offsets derived from VanillaHelpers's `struct ItemStats_C`
// definition (`C:\Git\VanillaHelpers\src\Game.h`), sized to match
// the verified anchors above (`+0x60 m_stackable`, `+0x1D0
// m_bagFamily`, `+0x1AC m_lockID`, etc.). Block follows the same
// ordering as the struct so it's easy to cross-reference.
OFF_ITEMSTATS_BUY_PRICE = 0x24, // u32
OFF_ITEMSTATS_ALLOWABLE_CLASS = 0x30, // u32 — class bitmask, -1 = all
OFF_ITEMSTATS_ALLOWABLE_RACE = 0x34, // u32 — race bitmask, -1 = all
OFF_ITEMSTATS_REQUIRED_LEVEL = 0x3C, // u32
OFF_ITEMSTATS_REQUIRED_SKILL = 0x40, // u32 — SkillLine.dbc row
OFF_ITEMSTATS_REQUIRED_SKILL_RANK = 0x44, // u32
OFF_ITEMSTATS_REQUIRED_SPELL = 0x48, // u32 — Spell.dbc row
OFF_ITEMSTATS_REQUIRED_HONOR_RANK = 0x4C, // u32
OFF_ITEMSTATS_REQUIRED_CITY_RANK = 0x50, // u32
OFF_ITEMSTATS_REQUIRED_FACTION = 0x54, // u32 — Faction.dbc row
OFF_ITEMSTATS_REQUIRED_FACTION_RANK = 0x58, // u32 — Friendly/Honored/etc.
OFF_ITEMSTATS_STAT_TYPE = 0x68, // u32[10] — ItemModType enum per slot
OFF_ITEMSTATS_STAT_VALUE = 0x90, // i32[10] — magnitude per slot
ITEMSTATS_STAT_SLOT_COUNT = 10,
OFF_ITEMSTATS_DAMAGE_MIN = 0xB8, // f32[5]
OFF_ITEMSTATS_DAMAGE_MAX = 0xCC, // f32[5]
OFF_ITEMSTATS_DAMAGE_TYPE = 0xE0, // u32[5] — schools per damage slot
ITEMSTATS_DAMAGE_SLOT_COUNT = 5,
OFF_ITEMSTATS_ARMOR = 0xF4, // u32
OFF_ITEMSTATS_RES_HOLY = 0xF8, // u32 (six resists contiguous)
OFF_ITEMSTATS_RES_FIRE = 0xFC,
OFF_ITEMSTATS_RES_NATURE = 0x100,
OFF_ITEMSTATS_RES_FROST = 0x104,
OFF_ITEMSTATS_RES_SHADOW = 0x108,
OFF_ITEMSTATS_RES_ARCANE = 0x10C,
OFF_ITEMSTATS_DELAY = 0x110, // u32 — weapon swing time (ms)
OFF_ITEMSTATS_AMMO_TYPE = 0x114, // u32 — ammo subclass for ranged
OFF_ITEMSTATS_RANGED_MOD_RANGE = 0x118, // f32 — range modifier on bows/guns
OFF_ITEMSTATS_BONDING = 0x194, // u32 — 0=none, 1=BoP, 2=BoE, 3=BoU, 4=Quest
OFF_ITEMSTATS_DESCRIPTION = 0x198, // char * — flavor text (locale-applied)
OFF_ITEMSTATS_PAGE_TEXT = 0x19C, // u32 — PageText.dbc row (readable books)
OFF_ITEMSTATS_LANGUAGE_ID = 0x1A0, // u32 — language for book text
OFF_ITEMSTATS_PAGE_MATERIAL = 0x1A4, // u32 — book material
OFF_ITEMSTATS_START_QUEST = 0x1A8, // u32 — questID started by right-click
OFF_ITEMSTATS_MATERIAL = 0x1B0, // i32 — material type
OFF_ITEMSTATS_SHEATH = 0x1B4, // u32 — weapon sheath style
OFF_ITEMSTATS_RANDOM_PROPERTY = 0x1B8, // i32 — random property template
OFF_ITEMSTATS_BLOCK = 0x1BC, // u32 — shield block value
OFF_ITEMSTATS_MAX_DURABILITY = 0x1C4, // u32
OFF_ITEMSTATS_AREA = 0x1C8, // u32 — bound area (AreaTable.dbc)
OFF_ITEMSTATS_MAP = 0x1CC, // u32 — bound map (Map.dbc)
// `m_flags` bits we surface as decoded booleans. The OPENABLE bit
// (0x4) is verified by the tooltip-builder's reader at 0x0052E323;
// the others are documented in the standard 1.12 protocol
// (CMaNGOS / TrinityCore `ITEM_FLAG_*`) and exposed as-is. Add
// verification anchors as consumers materialize.
ITEM_FLAG_CONJURED = 0x2,
ITEM_FLAG_LOOTABLE = 0x10,
ITEM_FLAG_WRAPPER = 0x200,
// `Spell.dbc` `Name[9]` field at record +0x1E0 (9-locale string
// array, indexed by `[VAR_LOCALE_INDEX]`). Used by `GetItemSpell`
// to push the spell name half of its return.
@@ -1488,6 +1581,35 @@ enum Offsets {
VAR_SKILL_LINE_COUNT = 0x00C0D930,
OFF_SKILL_LINE_NAME = 0x0C,
// Player's known-skills table — separate from the DBC. Populated
// by the engine on login from server data; entries are pointers
// to per-skill records. Each record's `+0x00` is the
// `SkillLine.dbc` row (or `0` for header / category rows), and
// `+0x10` is the current skill rank (other rank/modifier fields
// follow). Source: `Script_GetSkillLineInfo` at `0x004D3610`,
// which walks this array indexed by the Lua arg.
VAR_PLAYER_SKILL_LIST = 0x00B7318C,
VAR_PLAYER_SKILL_COUNT = 0x00B731B8,
OFF_PLAYER_SKILL_LINE_ID = 0x00,
OFF_PLAYER_SKILL_RANK = 0x10,
// Per-itemClass proficiency bitmap maintained by the engine.
// 17 u32 entries (indexed by Item.dbc `m_class`, 0..16). Each
// entry is a bitmask where bit N is set if the player has
// proficiency in subclass N of that item class — i.e.
// `table[4] & (1 << 3)` = "can wear Mail" (armor=class 4,
// mail=subclass 3).
//
// Populated by SMSG_SET_PROFICIENCY's handler at
// `FUN_005E7B70`, which writes `table[itemClass] = mask`
// verbatim from the packet. Same source the engine itself uses
// for equip eligibility (the tooltip "Mail" red-text logic
// and the cursor-drop-on-paperdoll check both read this). Note:
// covers **weapons too** (class 2), making it strictly more
// general than walking the per-skill-line table for individual
// armor proficiencies.
VAR_PROFICIENCY_TABLE = 0x00C4D4A0,
// Spell.dbc `BaseLevel` — the level a spell becomes available
// (trainer offers it, quest reward grants it, etc.). Distinct
// from `SpellLevel` at +0x74 which is the effective level used
@@ -1662,6 +1784,10 @@ enum Offsets {
// Note: the bit's MEANING is "can't toggle" — `canToggleAtWar` is
// its inverse, NOT its value.
REP_SLOT_FLAG_PEACE_FORCED = 0x10,
// INACTIVE flag — set by `Script_SetFactionInactive` (the "Inactive"
// collapsible category in FactionFrame). Toggles via
// `FUN_FACTION_SET_INACTIVE`; same byte as AT_WAR and PEACE_FORCED.
REP_SLOT_FLAG_INACTIVE = 0x20,
MAX_REP_SLOTS = 64,
// Static reaction-band threshold tables, indexed by reaction band
@@ -1681,6 +1807,81 @@ enum Offsets {
// against `FUN_004D5620(slot)`'s return.
OFF_CGPLAYER_INFO_WATCHED_REP_LIST_ID = 0x10C4,
// `FactionSetAtWar(factionID, newState)` — `__fastcall(ecx = factionID,
// edx = char newState)`. The sole worker behind
// `Script_FactionToggleAtWar` (`0x004D6950`); the script wrapper just
// reads the current at-war flag, negates it, and tail-calls here.
//
// Validates (only when turning OFF) that current standing ≥ -3000,
// resolves factionID → repListID, bails if the player is in a loot
// session (UNIT_FIELD_FLAGS bit `0x80000`, error code `0x1A1`),
// mutates the rep-slot flag byte (`AT_WAR` gated by `PEACE_FORCED`),
// and sends `CMSG_SET_FACTION_ATWAR` (opcode `0x125`).
//
// **Does NOT fire `UNIT_FACTION`** — a vanilla engine omission. 3.3.5
// added a `FUN_0071F8F0(player, 0)` call here, whose inner
// `FUN_0060BF10(playerGUID, UNIT_FACTION_id)` dispatches the event
// for every unit token referencing the player. We polyfill by
// hooking this function and firing `UNIT_FACTION("player")` post-
// original (see `src/faction/UnitFactionPolyfill.cpp`).
FUN_FACTION_SET_AT_WAR = 0x004D5FD0,
// `FactionSetInactive(factionID, newState)` — `__fastcall(ecx = factionID,
// edx = char newState)`. Inner setter behind `Script_SetFactionInactive`
// (`0x004D69B0`, sets INACTIVE bit) and `Script_SetFactionActive`
// (`0x004D6A00`, clears it) — both script wrappers tail-call here.
//
// Resolves factionID → repListID via `FUN_004D5600`, mutates the
// rep-slot flag byte (sets/clears `INACTIVE` = `0x20`), writes via
// `FUN_004D5FC0`, sends `CMSG_SET_FACTION_INACTIVE` (opcode `0x317`),
// then calls `FUN_004D5C40` (recompute displayed-list, fire
// `UPDATE_FACTION` event `0x174`).
//
// **Does NOT fire `UNIT_FACTION`** — same engine omission as the at-
// war setter. Polyfilled in `src/faction/UnitFactionPolyfill.cpp`.
FUN_FACTION_SET_INACTIVE = 0x004D60F0,
// SMSG_SET_FACTION_ATWAR handler — `__stdcall(uint32_t opcode,
// void *packet)`. Fires when the server force-changes the player's
// at-war state on a faction (e.g., a flag the player can't toggle
// changes server-side, or faction reset). Reads `(u32 repListID,
// u8 flags)`, mutates AT_WAR on the slot accordingly, writes via
// `FUN_004D5FC0`, then fires `UPDATE_FACTION` (event `0x174`).
//
// **Does NOT fire `UNIT_FACTION`** — 3.3.5 does (unconditionally
// after the byte write). Polyfilled in
// `src/faction/UnitFactionPolyfill.cpp`.
FUN_SMSG_SET_FACTION_ATWAR = 0x004D56B0,
// SMSG_INITIALIZE_FACTIONS handler — `__stdcall(uint32_t opcode,
// void *packet)`. Fires once at login from the SMSG dispatcher. Reads
// a count, then loops `(u8 flags, i32 standing)` pairs into the
// 64-entry rep slot array: `FUN_004D5FC0(slot, flags)` +
// `FUN_004D6330(slot, standing, 0 /*notify*/)`. Trailing
// `FUN_004D5430` rebuilds the displayed-list snapshot, then fires
// `UPDATE_FACTION` (event `0x174`).
//
// **Does NOT fire `UNIT_FACTION`** — modern WoW does. Addons that
// observe UNIT_FACTION miss the initial sync entirely in vanilla.
// Polyfilled in `src/faction/UnitFactionPolyfill.cpp`.
FUN_SMSG_INITIALIZE_FACTIONS = 0x004D5640,
// SMSG_SET_FACTION_STANDINGS handler — `__stdcall(uint32_t opcode,
// void *packet)`. Fires once per server-pushed rep change (kills,
// quest turn-ins, etc.). Reads count, loops `(flags, standing)`
// pairs, calling `FUN_004D6330(slot, standing, 1 /*notify*/)` which
// routes through `FUN_REPUTATION_FIRE_NOTIFY` for the
// `CHAT_MSG_COMBAT_FACTION_CHANGE` dispatch.
//
// Also mutates AT_WAR / VISIBLE flag bits as standing crosses
// thresholds (e.g., recovering to -2999 clears the force-set AT_WAR
// from "below Hated"). **Vanilla doesn't fire `UNIT_FACTION` for
// these side-effect flag flips**; modern 3.3.5 does. Polyfilled in
// `src/faction/UnitFactionPolyfill.cpp` — snapshots the 64-entry
// flag-byte array before/after and fires a single
// `UNIT_FACTION("player")` if any bit changed.
FUN_SMSG_SET_FACTION_STANDINGS = 0x004D5760,
// `__fastcall(ecx = factionID, edx = signedDelta)`. This is the
// engine's "reputation changed, fire the chat event" notify
// helper — it formats the localized chat message and dispatches
@@ -2012,6 +2213,67 @@ enum Offsets {
// to cast next", so we check this slot too.
VAR_QUEUED_CAST_SPELL = 0x00CECAA8,
// Spell-placement state bitmask. Non-zero when the engine is
// waiting on user input for an in-progress cast — the AoE reticle,
// unit-target cursor, trade-target cursor, etc. Bits encode what
// kind of input is needed; the engine's click handlers read this
// to decide whether a click was relevant. Read by
// `Script_SpellIsTargeting` via `FUN_006E48A0` (returns
// `state != 0`). Bits the engine OR-sets via `FUN_006E5250` when
// entering placement (subset relevant to us):
// - `0x20` source world location needed
// - `0x40` dest world location needed
// The `0x60` mask therefore identifies "ground-target placement"
// (vs. unit-target, trade-target, etc.). See `Spell::AtCursor`.
VAR_SPELL_PLACEMENT_STATE = 0x00CECAC0,
SPELL_PLACEMENT_GROUND_MASK = 0x60,
// `FUN_006E60F0(float *xyz)` — commits the in-flight ground-target
// placement at the given world position. Reads three contiguous
// floats from `xyz`; checks the placement-state mask
// (`VAR_SPELL_PLACEMENT_STATE`) to decide whether this is a
// source-coord (`0x20`) or dest-coord (`0x40`) commit; clears the
// matching bit and stores the coords into engine globals which the
// cast-finalize path then reads. Self-gates when neither bit is
// set — safe no-op for unit-target placement. Called by the engine
// itself when a player left-clicks ground while the reticle is up.
FUN_COMMIT_PLACEMENT_COORDS = 0x006E60F0,
// `FUN_006E4900()` — cancels in-flight placement mode. Internally
// checks `VAR_SPELL_PLACEMENT_STATE != 0` and, if so, fires the
// cast-cancel path via `FUN_006E4940(0x1C)`. Safe to call when no
// placement is active (no-ops). Wraps the action behind
// `Script_SpellStopTargeting`.
FUN_STOP_PLACEMENT = 0x006E4900,
// `*VAR_WORLDFRAME_CLICK_INFO` is a pointer to the engine's
// CWorldFrame "click info" struct — the data block where the input
// dispatcher stashes "what is the cursor pointed at right now"
// after every mouse motion. Allocated at world enter, populated
// continuously by the rendering pipeline (viewport bounds and
// camera position at fixed offsets) and the OS message pump
// (cursor screen pos). NULL when not in world.
// - `+0x350` (uint32) click type after last raycast:
// `0` = no hit
// `1` = world / terrain hit
// `2` = unit / object hit
// - `+0x360..+0x368` (3 floats) hit world position (when type == 1)
VAR_WORLDFRAME_CLICK_INFO = 0x00B4B2BC,
OFF_CLICK_INFO_TYPE = 0x350,
OFF_CLICK_INFO_COORDS = 0x360,
// `FUN_00481F00(void *clickInfo)` — refreshes the cursor's "what
// am I pointed at?" state on demand. Internally reads cursor
// screen pos from input state at `[clickInfo+0xA0]+0x1118/+0x111C`,
// calls the screen→world ray builder at `FUN_004813B0` (uses
// viewport bounds at `[clickInfo+0x390..+0x39C]` plus camera
// position at `[clickInfo+0x65B8]+8/+0xC/+0x10`), then raycasts
// via `FUN_0069BFF0` (terrain + WMO intersection). Writes click
// type to `[clickInfo+0x350]` and hit coords to
// `[clickInfo+0x360..+0x368]`. Safe to call from arbitrary code
// as long as the player is in-world.
FUN_REFRESH_CURSOR_RAYCAST = 0x00481F00,
// `Script_CastSpellByName` — engine's Lua wrapper for the
// `CastSpellByName(name [, onSelf])` global. `int __fastcall(void *L)`
// — standard Lua C function ABI. Reads `name` from stack[1] (string)
@@ -2021,6 +2283,16 @@ enum Offsets {
// results). Callable directly from C++ — push args on stack, call.
FUN_SCRIPT_CAST_SPELL_BY_NAME = 0x004B4AB0,
// Inner spell-cast dispatcher — `__fastcall(slot, bookType, targetGuidLo,
// targetGuidHi)`. `Script_CastSpellByName` calls this after resolving
// the spell name to a spellbook slot. For ground-target spells, the
// engine enters placement mode (sets `VAR_SPELL_PLACEMENT_STATE`)
// and returns; placement is resolved later by mouse-click or
// programmatically via `FUN_COMMIT_PLACEMENT_COORDS`. Passing (0, 0)
// for the GUID is the "no implicit target" form — engine falls back
// to the player's current selected target.
FUN_SPELL_CAST_DISPATCH = 0x004B3300,
// Macro "primary spell" parser — `__fastcall(MacroEntry *entry)`.
// Walks the macro body (stored at `entry + OFF_MACRO_BODY`) line
// by line, looking for `/cast <name>`, `/castsequence <name>`,
@@ -2253,6 +2525,55 @@ enum Offsets {
LUA_NEXT = 0x6F4450,
LUA_ERROR = 0x6F4940,
// `lua_xmove(from, to, n)` — moves the top `n` TValues from one
// thread to another. Decrements `from->top` by `n*16`, copies each
// 16-byte TValue, increments `to->top`. Needed by the `coroutine.*`
// trampolines to shuttle args/returns between the main state and a
// suspended coroutine.
LUA_X_MOVE = 0x6F2F80,
// `lua_newthread(L)` — does GC check, calls inner `luaE_newthread`
// at `0x006F6B10` to allocate a new state, pushes it onto L's stack
// with type tag 8 (LUA_TTHREAD). Ghidra reports the return as
// `void` (decompiler limitation — the inner call's eax flows
// through), so callers should retrieve the new state via
// `ToPointer(L, -1)` rather than relying on the return value.
LUA_NEW_THREAD = 0x6F3030,
// `lua_resume(L_co, nargs)` — runs a coroutine. Zero internal
// callers in the engine (coroutine library was stripped from the
// standard-library registration tables at build time); linker
// kept the symbol so it's callable from C. Goes through
// `luaD_rawrunprotected` at `0x006F5DAC` for setjmp/longjmp
// safety; inner static `resume` helper lives at `0x006F67B0`.
LUA_RESUME = 0x6F6620,
// `lua_yield(L, nresults)` — flags the current CallInfo to yield
// and returns -1. Sets a `0x10` bit on the CI->state field rather
// than writing `L->status` directly in this build. Zero internal
// callers (same reason as `lua_resume`). Errors with
// "attempt to yield across metamethod/C-call boundary" or
// "cannot yield a C function" when called from the wrong context.
LUA_YIELD = 0x6F6860,
// `lua_topointer(L, idx)` — returns the pointer value of a
// pointer-valued TValue: type 5 (table), 6 (function), 8 (thread)
// return `tvalue[2]` directly; type 2 (lightuserdata) returns its
// value; type 7 (userdata) returns the userdata payload (record +
// 0x10). Returns 0 for anything else. We use it as `lua_tothread`
// — for a known-thread TValue, returns the embedded `lua_State *`.
LUA_TO_POINTER = 0x6F3790,
// `luaL_argerror(L, narg, msg)` — formats "bad argument #%d to
// '%s' (%s)" (or "calling '%s' on bad self (%s)" for method-style
// calls) and calls `lua_error`. Doesn't return.
LUAL_ARG_ERROR = 0x6F4810,
// `lua_iscfunction(L, idx)` — returns 1 if value is a function
// closure with the `isC` byte set (`Closure.c.isC == 1`), 0
// otherwise. Used by `coroutine.create` to reject C functions
// (Lua 5.0 only allows Lua-defined functions as coroutine bodies).
LUA_IS_C_FUNCTION = 0x6F34A0,
// `lua_tothread(L, idx)` — returns the embedded `lua_State *` for
// type-8 (LUA_TTHREAD) TValues, NULL for anything else. Used by
// the `coroutine` library to extract the coroutine pointer from a
// stack slot.
LUA_TO_THREAD = 0x6F3770,
// Global `lua_State *`. The engine keeps one main thread state here; we
// read it on demand in helpers that run outside a Lua callback (e.g.
// RegisterTableFunction during LoadScriptFunctions).
+10 -1
View File
@@ -40,13 +40,14 @@
// Same pattern WeirdUtils.dll uses (we reverse-engineered it from
// `0x10015A74` while figuring this out). The addon load pass then
// reads each `.lua` file through the same hooked read path —
// `GlobalStrings.lua`, `Util\Print.lua`, etc. all surface from
// `Compat.lua`, `Util\Print.lua`, etc. all surface from
// embedded buffers.
#include "embedded_classicapi.h"
#include "Game.h"
#include "Offsets.h"
#include "bindings/Inject.h"
#include <cstdint>
#include <cstdio>
@@ -248,6 +249,14 @@ void DecideSource() {
int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
size_t *outSize, size_t extraBytes,
int flag1, int flag2) {
// FrameXML Bindings.xml gets an inline splice for our TARGETING
// additions — see `bindings/Inject.cpp`. Returns true with the
// patched buffer ready for the engine's binding parser to consume.
if (Bindings::Inject::TryHandle(unused, path, outBuf, outSize,
extraBytes, flag1, flag2, FileRead_o)) {
return 1;
}
const char *suffix = StripAddonPrefix(path);
if (suffix == nullptr) {
// Path isn't under `Interface\AddOns\!!!ClassicAPI\` — straight
+12 -1
View File
@@ -115,13 +115,24 @@ int PushFieldOrNil(void *L, FieldByName_t accessor) {
// `C_AddOns.GetAddOnName(indexOrName)` — the addon's directory name.
// For numeric input, returns the engine's canonical casing. For
// string input, returns the input verbatim once existence is
// confirmed.
// confirmed; nil for an unregistered name.
//
// String-input existence isn't free: `ResolveAddOnName` echoes the
// string for the field accessors below (`GetAddOnTitle`, etc.) which
// short-circuit cleanly on unknown names via the engine's own NULL-
// return paths. `GetAddOnName` doesn't call any engine accessor, so
// it'd otherwise echo any string. Probe the registry explicitly.
int __fastcall Script_GetAddOnName(void *L) {
const uint8_t *entry = ResolveAddOnName(L);
if (entry == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_STRING &&
!AddOnExistsByName(reinterpret_cast<const char *>(entry))) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushString(L, reinterpret_cast<const char *>(entry));
return 1;
}
+15
View File
@@ -0,0 +1,15 @@
<!-- Spliced into the engine's Interface\FrameXML\Bindings.xml at
load time by src/bindings/Inject.cpp. The `header` attribute on
the root tag tells the splicer which engine section to extend —
it picks the appropriate insertion point so the bindings appear
at the tail of that group in the keybind UI. The engine's own
XML parser ignores attributes on the root tag, so this is
metadata for the splicer only. -->
<Bindings header="TARGETING">
<Binding name="FOCUSTARGET">
FocusUnit("target");
</Binding>
<Binding name="TARGETFOCUS">
TargetUnit("focus");
</Binding>
</Bindings>
+297
View File
@@ -0,0 +1,297 @@
// 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/>.
// Inject ClassicAPI-owned bindings into a chosen engine section.
//
// Why a file-read hook rather than an addon-side Bindings.xml: vanilla
// stores every binding in a single linearly-indexed table, and the
// keybind UI walks that table in index order with `HEADER_*` pseudo-
// entries splitting groups. Addon Bindings.xml files get parsed
// AFTER FrameXML, so even with `header="TARGETING"` an addon binding
// receives a display index higher than every engine binding — it
// renders orphaned at the very bottom of the keybind list rather than
// under TARGETING.
//
// To land inside a specific section, the binding's display index has
// to fall between that section's `HEADER_*` entry and the next one.
// The cleanest way to arrange that is to splice the bindings into the
// FrameXML file the engine itself parses, so they take their indices
// from the same running counter every native binding does.
//
// What gets spliced is driven by `src/bindings/Bindings.xml`. The
// root `<Bindings header="X">` tag names the engine section to extend
// (e.g. `"TARGETING"`); the inner `<Binding>` children are spliced
// verbatim. The splicer locates `header="X"` in the engine file
// (which appears on the first binding of section X), then the next
// `header="..."` after that (start of the following section), and
// inserts our content just before that next-section marker — so our
// entries land at the tail of section X. If X is the last section in
// the file, the splice goes just before `</Bindings>`.
//
// The engine's binding parser is `FUN_004b6f70`. It calls
// `FUN_FILE_READ` (`0x00648620`) to load the file, then walks `<Binding>`
// elements assigning each the next display index from a running
// counter. By the time our injected bindings are processed, the
// engine has already emitted the section's `HEADER_*` and a sequence
// of native entries; ours append to that sequence and the next
// section's header follows.
#include "Inject.h"
#include "Offsets.h"
#include "embedded_bindings.h"
#include <cstdio>
#include <cstring>
namespace Bindings::Inject {
namespace {
constexpr const char *kFrameXMLBindingsPath = "Interface\\FrameXML\\Bindings.xml";
// Parsed view of `src/bindings/Bindings.xml`:
// - `header` is the value of `header="..."` on the root `<Bindings>`
// tag. Tells the splicer which engine section to extend.
// - `innerData / innerSize` point at the bytes between the
// `<Bindings ...>` opening and `</Bindings>` closing tags.
//
// All-zero fields mean "embedded buffer is malformed" — defensive,
// shouldn't happen since the source is build-time-validated. Splice
// is skipped (engine sees its original Bindings.xml unchanged).
struct EmbeddedConfig {
char header[64];
const char *innerData;
size_t innerSize;
};
// Advances `p` past any `<!-- ... -->` comment block starting at the
// current position. Returns the new position (past the closing `-->`)
// if a comment was skipped, or `p` unchanged if no comment was at
// the current position. Caps at `end` if the comment is unterminated.
const char *SkipCommentIfAny(const char *p, const char *end) {
if (p + 4 > end) return p;
if (std::memcmp(p, "<!--", 4) != 0) return p;
p += 4;
while (p + 3 <= end) {
if (std::memcmp(p, "-->", 3) == 0)
return p + 3;
++p;
}
return end;
}
// Linear scan for `marker` in `[start, end)`, skipping any `<!--...-->`
// comment blocks (where unrelated occurrences of XML tags might live).
const char *FindOutsideComment(const char *start, const char *end, const char *marker) {
const size_t markerLen = std::strlen(marker);
const char *p = start;
while (p + markerLen <= end) {
const char *afterComment = SkipCommentIfAny(p, end);
if (afterComment != p) {
p = afterComment;
continue;
}
if (std::memcmp(p, marker, markerLen) == 0)
return p;
++p;
}
return nullptr;
}
// Parses `header="X"` out of the byte range `[tagStart, tagEnd)` (the
// opening `<Bindings ...>` tag). Copies the value into `out` (size
// `outSize`) and returns true on success.
bool ExtractHeaderAttr(const char *tagStart, const char *tagEnd,
char *out, size_t outSize) {
static constexpr const char kAttr[] = "header=\"";
constexpr size_t kAttrLen = sizeof(kAttr) - 1;
for (const char *p = tagStart; p + kAttrLen < tagEnd; ++p) {
if (std::memcmp(p, kAttr, kAttrLen) != 0) continue;
const char *valStart = p + kAttrLen;
const char *valEnd = valStart;
while (valEnd < tagEnd && *valEnd != '"') ++valEnd;
const size_t len = static_cast<size_t>(valEnd - valStart);
if (len == 0 || len + 1 > outSize) return false;
std::memcpy(out, valStart, len);
out[len] = '\0';
return true;
}
return false;
}
EmbeddedConfig ResolveEmbeddedConfig() {
EmbeddedConfig out = {};
const char *content = reinterpret_cast<const char *>(kEmbeddedBindingsXml);
const char *end = content + kEmbeddedBindingsXmlSize;
// Locate `<Bindings` opening tag (skipping any comment blocks
// that mention the tag literally for documentation purposes).
const char *open = FindOutsideComment(content, end, "<Bindings");
if (open == nullptr) return out;
// Find the closing `>` of the opening tag.
const char *tagEnd = open;
while (tagEnd < end && *tagEnd != '>') ++tagEnd;
if (tagEnd == end) return out;
// Pull out the `header="..."` attribute. Required — this tells
// the splicer which engine section to extend.
if (!ExtractHeaderAttr(open, tagEnd, out.header, sizeof(out.header)))
return out;
const char *innerStart = tagEnd + 1;
const char *close = FindOutsideComment(innerStart, end, "</Bindings>");
if (close == nullptr || close < innerStart) {
out.header[0] = '\0';
return out;
}
out.innerData = innerStart;
out.innerSize = static_cast<size_t>(close - innerStart);
return out;
}
// Computes the byte offset in the engine's Bindings.xml buffer where
// our entries should be spliced — i.e., the tail of the section
// identified by `headerName`. Strategy:
//
// 1. Find `header="<HEADER>"` in the engine's buffer (skipping
// comments). This is the attribute the engine itself uses on
// the FIRST binding of each section, so this finds where that
// section *starts*.
// 2. Find the NEXT `header="` after that. That marks the start of
// the FOLLOWING section.
// 3. Back up from that next-header position to the `<` of the
// `<Binding` tag containing it — that's our splice point.
// 4. If there's no next header (last section in the file), splice
// just before `</Bindings>`.
//
// Returns `nullptr` if step 1 fails (header missing from engine file
// — e.g., a heavily-modified FrameXML). Caller falls back to passing
// the original buffer through unchanged.
const char *FindSplicePosition(const char *content, size_t size,
const char *headerName) {
const char *end = content + size;
char headerNeedle[80];
std::snprintf(headerNeedle, sizeof(headerNeedle),
"header=\"%s\"", headerName);
const char *sectionStart = FindOutsideComment(content, end, headerNeedle);
if (sectionStart == nullptr) return nullptr;
// Walk past the matched attribute, then look for the next
// `header="` anywhere later in the file.
const char *afterCurrent = sectionStart + std::strlen(headerNeedle);
const char *nextHeader = FindOutsideComment(afterCurrent, end, "header=\"");
if (nextHeader != nullptr) {
// Back up to the `<` of the `<Binding` tag that owns this
// next-header attribute — that `<` is where the next section
// visually starts, and we splice immediately before it.
const char *p = nextHeader;
while (p > content && *p != '<') --p;
return p;
}
// Last section in the file — splice just before `</Bindings>`.
return FindOutsideComment(content, end, "</Bindings>");
}
char NormalizeChar(char c) {
if (c == '/') return '\\';
if (c >= 'A' && c <= 'Z') return static_cast<char>(c + 32);
return c;
}
bool PathEqualsCI(const char *a, const char *b) {
while (*a != '\0' && *b != '\0') {
if (NormalizeChar(*a) != NormalizeChar(*b)) return false;
++a; ++b;
}
return *a == *b;
}
using SMemAlloc_t = void *(__stdcall *)(size_t size, const char *file, int line, int flags);
using SMemFree_t = void(__stdcall *)(void *buf, const char *file, int line, int flags);
} // namespace
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
size_t extraBytes, int flag1, int flag2, FileReadFn original) {
if (path == nullptr || !PathEqualsCI(path, kFrameXMLBindingsPath))
return false;
// Pull the real file through the original reader with the caller's
// requested `extraBytes` honored — the original allocates a Storm
// buffer of `fileSize + extraBytes` and zero-fills the tail.
void *origBuf = nullptr;
size_t origSize = 0;
const int readOk = original(unused, path, &origBuf, &origSize,
extraBytes, flag1, flag2);
if (readOk == 0 || origBuf == nullptr)
return false;
static const EmbeddedConfig config = ResolveEmbeddedConfig();
if (config.innerData == nullptr || config.innerSize == 0 ||
config.header[0] == '\0') {
// Embedded XML is missing the `<Bindings header="...">` root
// or the `</Bindings>` close tag. Pass the original through.
if (outBuf != nullptr) *outBuf = origBuf;
if (outSize != nullptr) *outSize = origSize;
return true;
}
const char *content = static_cast<const char *>(origBuf);
const char *splice = FindSplicePosition(content, origSize, config.header);
if (splice == nullptr) {
// Requested section isn't present in this engine file (heavily
// modified FrameXML or different build). Pass through unchanged
// rather than risking corruption.
if (outBuf != nullptr) *outBuf = origBuf;
if (outSize != nullptr) *outSize = origSize;
return true;
}
const size_t splicePos = static_cast<size_t>(splice - content);
const size_t newSize = origSize + config.innerSize;
auto SMemAlloc = reinterpret_cast<SMemAlloc_t>(Offsets::FUN_STORM_SMEM_ALLOC);
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
void *newBuf = SMemAlloc(newSize + extraBytes, __FILE__, __LINE__, 0);
if (newBuf == nullptr) {
// Allocation failed — fall back to the original buffer so the
// engine still loads its own bindings.
if (outBuf != nullptr) *outBuf = origBuf;
if (outSize != nullptr) *outSize = origSize;
return true;
}
auto *dest = static_cast<char *>(newBuf);
std::memcpy(dest, content, splicePos);
std::memcpy(dest + splicePos, config.innerData, config.innerSize);
std::memcpy(dest + splicePos + config.innerSize,
content + splicePos, origSize - splicePos);
if (extraBytes > 0)
std::memset(dest + newSize, 0, extraBytes);
SMemFree(origBuf, __FILE__, __LINE__, 0);
if (outBuf != nullptr) *outBuf = newBuf;
if (outSize != nullptr) *outSize = newSize;
return true;
}
} // namespace Bindings::Inject
+37
View File
@@ -0,0 +1,37 @@
// 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/>.
#pragma once
#include <cstddef>
namespace Bindings::Inject {
// `FUN_FILE_READ` shape — Storm-style: __stdcall, returns 1 on success
// with the buffer allocated via SMemAlloc into `*outBuf` (size in
// `*outSize`, plus `extraBytes` of trailing zero padding).
using FileReadFn = int(__stdcall *)(int unused, const char *path,
void **outBuf, size_t *outSize,
size_t extraBytes,
int flag1, int flag2);
// Path-matches `Interface\FrameXML\Bindings.xml`. On match: fetches
// the real file via `original`, splices our ClassicAPI bindings into
// the TARGETING block, and returns `true` with `*outBuf` / `*outSize`
// set to a fresh Storm buffer the caller can SMemFree normally.
// Returns `false` for any other path — caller should fall through to
// its own logic.
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
size_t extraBytes, int flag1, int flag2, FileReadFn original);
} // namespace Bindings::Inject
+216
View File
@@ -0,0 +1,216 @@
// 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/>.
//
// Restores Lua 5.0's stripped `coroutine.*` library to the in-game Lua
// state. WoW 1.12.1 ships with the C-level coroutine machinery linked
// in (`lua_resume`, `lua_yield`, `lua_newthread`, threads as a real
// type tag 8) but the `coroutine` global table was never registered.
// The five trampolines below mirror Lua 5.0's `lbaselib.c` shapes,
// cross-checked against 5.4.8's wired-up library via Ghidra (see the
// "Cross-binary reference" technique in CLAUDE.md).
#include "Game.h"
#include <cstdint>
namespace Coroutine {
// Lua 5.0 result codes returned by `lua_resume`.
static constexpr int LUA_OK = 0;
static constexpr int LUA_YIELD = 1;
// Field offsets in `lua_State`, confirmed from `lua_resume`'s error
// cleanup path (`L->ci = L->base_ci; L->base = base_ci->base`):
// *(L+0x14) = *(L+0x28)
// *(L+0x0C) = **(L+0x28)
static constexpr uintptr_t OFF_L_CI = 0x14;
static constexpr uintptr_t OFF_L_BASE_CI = 0x28;
// Reads `L->ci` (the current CallInfo). When greater than `L->base_ci`
// the coroutine's body function is mid-execution (paused at a yield);
// when equal, no Lua function is on this thread's call stack.
static void *LuaCI(void *L) {
return *reinterpret_cast<void **>(reinterpret_cast<uintptr_t>(L) + OFF_L_CI);
}
static void *LuaBaseCI(void *L) {
return *reinterpret_cast<void **>(reinterpret_cast<uintptr_t>(L) + OFF_L_BASE_CI);
}
// Coroutine status codes returned by `CoStatus` below — fed into the
// `kStatusNames` table for the `status` API. The dense 0..3 encoding
// also lets us label "unsuspended" states for resume's error path.
static constexpr int CO_RUNNING = 0;
static constexpr int CO_SUSPENDED = 1;
static constexpr int CO_NORMAL = 2;
static constexpr int CO_DEAD = 3;
static const char *const kStatusNames[] = {
"running",
"suspended",
"normal",
"dead",
};
// Inner status classifier. Equivalent to Lua 5.0's `auxstatus` from
// lbaselib.c, but we can't rely on `L->status` here: this build's
// `lua_yield` doesn't write status (it sets a CI state flag bit
// `0x10` instead), so the canonical "ttype(status) == LUA_YIELD"
// check fails. We use the `ci > base_ci` signal instead — true iff
// a Lua function is currently paused on this thread's call stack.
//
// State map:
// co->ci > co->base_ci → SUSPENDED (yielded; body paused)
// co->ci == co->base_ci, top==0 → DEAD (returned, all consumed)
// co->ci == co->base_ci, top>0 → SUSPENDED (initial: fn pushed,
// never resumed)
// After AuxResume's XMove cycle, both "returned" and "yielded" leave
// co's stack empty; the ci comparison is what distinguishes them.
// "Normal" (coroutine that resumed a deeper coroutine — appears as
// the parent in the call chain) is rare and would require walking
// the global thread list; we collapse it into "suspended".
static int CoStatus(void *L, void *co) {
if (co == L)
return CO_RUNNING;
if (LuaCI(co) > LuaBaseCI(co))
return CO_SUSPENDED;
return Game::Lua::GetTop(co) == 0 ? CO_DEAD : CO_SUSPENDED;
}
// Common back-end for resume / wrap. Returns nres ≥ 0 on success
// (with values already xmove'd onto L), or -1 on error (with the
// error message left on L's stack).
//
// WoW's modified Lua doesn't follow the standard `lua_resume` status
// contract: it returns `0` for normal completion and `1` for both
// "yielded" AND "errored mid-execution". We disambiguate the two
// `s==1` cases by checking the call-stack depth — a yielded
// coroutine still has its body paused (`ci > base_ci`), while an
// errored coroutine has been unwound back to `base_ci`. Errors hit
// from the "dead/already-resumed" early-out (e.g. the inner
// `cannot resume dead coroutine` path) also land in `s != 0` with
// `ci == base_ci`, so the same branch covers them.
static int AuxResume(void *L, void *co, int nargs) {
const int st = CoStatus(L, co);
if (st != CO_SUSPENDED) {
Game::Lua::PushString(L, st == CO_DEAD
? "cannot resume dead coroutine"
: "cannot resume non-suspended coroutine");
return -1;
}
Game::Lua::XMove(L, co, nargs);
const int s = Game::Lua::Resume(co, nargs);
const bool yielded = LuaCI(co) > LuaBaseCI(co);
if (s == LUA_OK || yielded) {
const int nres = Game::Lua::GetTop(co);
Game::Lua::XMove(co, L, nres);
return nres;
}
// Error path: engine left a single error message on co's stack.
Game::Lua::XMove(co, L, 1);
return -1;
}
static int __fastcall Script_Coroutine_Create(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_FUNCTION || Game::Lua::IsCFunction(L, 1)) {
Game::Lua::ArgError(L, 1, "Lua function expected");
return 0; // unreachable
}
Game::Lua::NewThread(L); // [arg1, NL]
void *NL = Game::Lua::ToThread(L, -1);
if (NL == nullptr) {
Game::Lua::Error(L, "coroutine.create: failed to allocate thread");
return 0; // unreachable
}
Game::Lua::PushValue(L, 1); // [arg1, NL, fn]
Game::Lua::XMove(L, NL, 1); // [arg1, NL]; NL has fn
return 1;
}
static int __fastcall Script_Coroutine_Yield(void *L) {
return Game::Lua::Yield(L, Game::Lua::GetTop(L));
}
static int __fastcall Script_Coroutine_Resume(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_THREAD) {
Game::Lua::ArgError(L, 1, "coroutine expected");
return 0; // unreachable
}
void *co = Game::Lua::ToThread(L, 1);
const int nargs = Game::Lua::GetTop(L) - 1;
const int r = AuxResume(L, co, nargs);
if (r < 0) {
// Error message is on top of L's stack. Push `false` and
// shuffle it below the message: [..., msg] -> [..., false, msg].
Game::Lua::PushBool(L, false);
Game::Lua::Insert(L, -2);
return 2;
}
// Success: `r` values on top of L's stack. Push `true` and slide
// it into position so returns are [..., true, v1, v2, ..., vr].
Game::Lua::PushBool(L, true);
Game::Lua::Insert(L, -(r + 1));
return r + 1;
}
static int __fastcall Script_Coroutine_Status(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_THREAD) {
Game::Lua::ArgError(L, 1, "coroutine expected");
return 0; // unreachable
}
void *co = Game::Lua::ToThread(L, 1);
Game::Lua::PushString(L, kStatusNames[CoStatus(L, co)]);
return 1;
}
// `coroutine.wrap`'s returned closure. The captured coroutine lives
// in upvalue 1; each invocation forwards args via resume and either
// returns the yielded/returned values directly (no { ok, ... }
// wrapping like resume does) or re-raises on error.
static int __fastcall WrapAux(void *L) {
void *co = Game::Lua::ToThread(L, Game::Lua::UpvalueIndex(1));
if (co == nullptr) {
Game::Lua::Error(L, "coroutine.wrap: missing thread upvalue");
return 0; // unreachable
}
const int nargs = Game::Lua::GetTop(L);
const int r = AuxResume(L, co, nargs);
if (r < 0) {
// The error message is at -1. `lua_error`'s 1.12 wrapper takes
// a printf-format string + va args; we pass `%s` so any `%`
// characters in the message are treated as literal.
const char *msg = Game::Lua::ToString(L, -1);
Game::Lua::Error(L, "%s", msg != nullptr ? msg : "coroutine error");
return 0; // unreachable
}
return r;
}
static int __fastcall Script_Coroutine_Wrap(void *L) {
// Reuse cocreate to build the thread (validates arg, allocates,
// moves fn into the new state). Leaves the thread on top of L.
Script_Coroutine_Create(L);
Game::Lua::PushCClosure(L, &WrapAux, 1); // captures thread as upvalue 1
return 1;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("coroutine", "create", &Script_Coroutine_Create);
Game::Lua::RegisterTableFunction("coroutine", "resume", &Script_Coroutine_Resume);
Game::Lua::RegisterTableFunction("coroutine", "yield", &Script_Coroutine_Yield);
Game::Lua::RegisterTableFunction("coroutine", "status", &Script_Coroutine_Status);
Game::Lua::RegisterTableFunction("coroutine", "wrap", &Script_Coroutine_Wrap);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Coroutine
+129 -38
View File
@@ -331,6 +331,19 @@ int __fastcall Script_ClearIgnoredSlotsForSave(void *L) {
// would race with). Reads the per-CGItem instance flag at
// `OFF_ITEM_CLIENT_LOCK` bit 0 — same bit `C_Item.IsLocked` checks
// and the engine's own pickup/unlock paths manipulate.
//
// Gates on `Locations::FindGUID` BEFORE consulting the lock flag:
// for items the player has deleted/mailed/traded away, the engine's
// CGItem cache often retains a stale entry that `ResolveByGUID`
// still hands back, but the inventory walk no longer finds the GUID
// anywhere. Reading the lock flag off the stale CGItem reflects
// whatever bits were last set on it (frequently the "locked"
// in-transaction state from when it was being deleted/swapped),
// which would falsely report the set as "contains locked items"
// and prevent `UseEquipmentSet` from running through addon
// wrappers that gate on this check. The inventory presence check
// matches modern semantics: a missing item is *missing*, not
// *locked*.
int __fastcall Script_EquipmentSetContainsLockedItems(void *L) {
const uint32_t setID = ArgSetID(L, 1);
const Set *s = Data::FindByID(setID);
@@ -341,6 +354,8 @@ int __fastcall Script_EquipmentSetContainsLockedItems(void *L) {
const uint64_t g = s->items[i];
if (g == GUID_EMPTY || g == GUID_IGNORED)
continue;
if (Locations::FindGUID(g) == 0)
continue; // not in inventory — treat as missing, not locked
const uint8_t *item = Locations::ResolveItemByGUID(g);
if (item == nullptr)
continue;
@@ -424,60 +439,136 @@ int __fastcall Script_UseEquipmentSet(void *L) {
}
int nextFreeSlot = 0;
// Pre-snapshot each set item's location against the ORIGINAL
// state, before any swap packets fly. This mirrors how 4.3.4's
// `Script_UseEquipmentSet` (`0x009A15D0`) builds its action list:
// resolve all 19 source locations up front, then execute. The
// engine's CGItem / InvMgr state lags behind our packet sends
// (waits for server ACK), so any `FindGUID` mid-execution would
// return stale answers and we'd re-resolve items into the wrong
// place.
int sourceLoc[SLOT_COUNT];
for (int i = 0; i < SLOT_COUNT; ++i) {
const uint64_t g = guids[i];
if (g == GUID_EMPTY || g == GUID_IGNORED) {
sourceLoc[i] = 0;
continue;
}
sourceLoc[i] = Locations::FindGUID(g);
}
// Virtual paperdoll occupant model — seeded from the engine's
// initial paperdoll GUID array, then updated in-place after each
// swap so phase 1 can chain rotations and phase 3 can spot
// already-empty slots.
uint64_t virtualPaperdoll[SLOT_COUNT];
Locations::SnapshotPaperdoll(virtualPaperdoll);
// Locates the current virtual slot of a GUID on the paperdoll,
// or 0 if it's not currently there. Used during phase 1 so
// chained paperdoll-to-paperdoll moves operate on the *current*
// virtual position, not the stale pre-snapshot.
auto currentPaperdollSlot = [&](uint64_t g) -> int {
if (g == 0)
return 0;
for (int slot = 1; slot <= SLOT_COUNT; ++slot) {
if (virtualPaperdoll[slot - 1] == g)
return slot;
}
return 0;
};
// ── Phase 1: paperdoll → paperdoll ─────────────────────────────
// Move items already on the paperdoll into their target slots.
// Source and destination are both paperdoll, so atomic swaps
// never touch bag state — phase 2's bag lookups stay accurate.
for (int i = 0; i < SLOT_COUNT; ++i) {
const int targetSlot = i + 1;
const uint64_t g = guids[i];
// GUID_IGNORED: leave whatever the player has in this slot
// as-is. Distinct from GUID_EMPTY (= 0), which means "this
// slot should be empty after the swap" — handled below.
if (g == GUID_IGNORED)
if (g == GUID_EMPTY || g == GUID_IGNORED)
continue;
if (g == GUID_EMPTY) {
// Set wants this slot empty. If it already is, no-op.
// Otherwise unequip whatever's there into the next bag
// slot we reserved during the pre-loop snapshot. If
// we've exhausted the snapshot, silently skip — matches
// the modern API which surfaces no per-slot reason.
const uint8_t *equipped =
Item::Location::ResolveEquipmentSlot(targetSlot);
if (equipped == nullptr)
continue;
if (nextFreeSlot >= freeSlotCount)
continue;
const FreeSlot &fs = freeSlots[nextFreeSlot++];
Item::Swap::ToBag(equipped, targetSlot, fs.bag, fs.slot);
continue;
}
const int loc = Locations::FindGUID(g);
const int loc = sourceLoc[i];
if (loc == 0)
continue; // missing — nothing to do
// Already in its target slot? Skip.
if ((loc & LOC_BAGS) == 0 && (loc & LOC_BANK) == 0 &&
(loc & LOC_SLOT_MASK) == targetSlot)
continue;
// In bank (main or bank-bag) — server rejects equip-from-bank.
if (loc & LOC_BANK)
continue;
continue; // server rejects equip-from-bank
if (loc & LOC_BAGS)
continue; // phase 2
const int srcSlot = currentPaperdollSlot(g);
if (srcSlot == 0 || srcSlot == targetSlot)
continue; // missing or already there
const uint8_t *item = Locations::ResolveItemByGUID(g);
if (item == nullptr)
continue;
if (loc & LOC_BAGS) {
const int srcBag = (loc >> LOC_BAG_SHIFT) & 0xFF;
const int srcSlot = loc & LOC_SLOT_MASK;
Item::Swap::FromBag(item, srcBag, srcSlot, targetSlot);
} else {
const int srcSlot = loc & LOC_SLOT_MASK;
Item::Swap::FromPaperdoll(item, srcSlot, targetSlot);
const uint64_t displaced = virtualPaperdoll[targetSlot - 1];
if (Item::Swap::FromPaperdoll(item, srcSlot, targetSlot)) {
virtualPaperdoll[targetSlot - 1] = g;
virtualPaperdoll[srcSlot - 1] = displaced;
}
}
// ── Phase 2: bag → paperdoll ───────────────────────────────────
// Move items from bag slots onto the paperdoll. Each atomic swap
// displaces the current paperdoll occupant into the bag slot the
// incoming item vacated. Anything we still wanted on the paperdoll
// was already placed by phase 1, so phase 2 displacements can't
// strand a future set item in a bag.
for (int i = 0; i < SLOT_COUNT; ++i) {
const int targetSlot = i + 1;
const uint64_t g = guids[i];
if (g == GUID_EMPTY || g == GUID_IGNORED)
continue;
if (virtualPaperdoll[targetSlot - 1] == g)
continue; // already at target (probably by phase 1)
const int loc = sourceLoc[i];
if (loc == 0)
continue;
if (loc & LOC_BANK)
continue;
if ((loc & LOC_BAGS) == 0)
continue; // not from a bag — phase 1 handled, or item missing
const uint8_t *item = Locations::ResolveItemByGUID(g);
if (item == nullptr)
continue;
const int srcBag = (loc >> LOC_BAG_SHIFT) & 0xFF;
const int srcSlot = loc & LOC_SLOT_MASK;
if (Item::Swap::FromBag(item, srcBag, srcSlot, targetSlot)) {
virtualPaperdoll[targetSlot - 1] = g;
// The displaced item lands in (srcBag, srcSlot). We
// don't track bag-side virtual state — by phase 3 we
// only care about the paperdoll occupant.
}
}
// ── Phase 3: unequip slots the set wants empty ─────────────────
// For each `GUID_EMPTY` slot, send the current paperdoll occupant
// (if any) to a pre-snapshotted free bag slot. Runs last so any
// displaced items from phases 1/2 that landed in the slot get
// unequipped here too.
for (int i = 0; i < SLOT_COUNT; ++i) {
const int targetSlot = i + 1;
if (guids[i] != GUID_EMPTY)
continue;
const uint64_t occupant = virtualPaperdoll[targetSlot - 1];
if (occupant == 0)
continue;
if (nextFreeSlot >= freeSlotCount)
continue;
const uint8_t *equipped = Locations::ResolveItemByGUID(occupant);
if (equipped == nullptr)
continue;
const FreeSlot &fs = freeSlots[nextFreeSlot++];
if (Item::Swap::ToBag(equipped, targetSlot, fs.bag, fs.slot))
virtualPaperdoll[targetSlot - 1] = 0;
}
const int evt = Event::Custom::Lookup(kSwapFinishedEvent);
if (evt >= 0)
Event::Custom::Fire(evt, "%d%d", 1, static_cast<int>(setID));
+11
View File
@@ -177,4 +177,15 @@ const uint8_t *ResolveItemByGUID(uint64_t guid) {
return Item::Location::ResolveByGUID(guid);
}
void SnapshotPaperdoll(uint64_t *out) {
for (int i = 0; i < SLOT_COUNT; ++i)
out[i] = 0;
auto *invMgr = ResolvePlayerInvMgr();
auto *guids = InvMgrGuidArray(invMgr);
if (guids == nullptr)
return;
for (int i = 0; i < SLOT_COUNT; ++i)
out[i] = guids[i];
}
} // namespace EquipmentSet::Locations
+12
View File
@@ -42,4 +42,16 @@ int FindGUID(uint64_t targetGuid);
// isn't known to the client. Side-effect free.
const uint8_t *ResolveItemByGUID(uint64_t guid);
// Copies the current paperdoll GUIDs into `out[0..SLOT_COUNT-1]`,
// where `out[i]` is the GUID at 1-based equipment slot `i+1`.
// Empty / unequipped slots write 0. Reads from the player invMgr's
// flat GUID array directly — no Lua stack, no engine state changes.
//
// Used by `UseEquipmentSet` to seed a "virtual paperdoll" model that
// stays current across in-flight swaps. The engine's CGItem state
// lags behind packet sends (server has to ACK each swap), so any
// subsequent `FindGUID` after the first packet would return stale
// pre-swap locations. Tracking moves locally bypasses that lag.
void SnapshotPaperdoll(uint64_t *out);
} // namespace EquipmentSet::Locations
+17
View File
@@ -105,6 +105,23 @@ int Lookup(const char *name) {
return -1;
}
int LookupByName(const char *name) {
if (name == nullptr)
return -1;
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
if (base == nullptr || count <= 0)
return -1;
for (int i = 0; i < count; ++i) {
const char *entryName = *reinterpret_cast<const char *const *>(
base + i * Offsets::EVENT_ENTRY_STRIDE +
Offsets::OFF_EVENT_ENTRY_NAME);
if (entryName != nullptr && std::strcmp(entryName, name) == 0)
return i;
}
return -1;
}
void RetryClaims() {
for (int i = 0; i < g_reservedCount; ++i) {
if (g_reserved[i].slot < 0)
+30
View File
@@ -47,8 +47,22 @@ struct AutoReserve {
// claimed (e.g. before the first Lua-side `RegisterEvent` has
// triggered `RetryClaims`). Slot indices may change across `/reload`,
// so call this at fire time rather than caching the value.
//
// Only consults our reservation cache — for engine-defined events
// (UNIT_FACTION, BAG_UPDATE, etc.) use `LookupByName`.
int Lookup(const char *name);
// Walks the live engine event table at `[VAR_EVENT_TABLE_BASE_PTR]`
// looking for an entry whose name strcmps equal to `name`. Returns
// the slot index, or -1 if no entry matches.
//
// Works for both engine-defined events AND our `AutoReserve`-claimed
// custom events (both populate the same table). Slightly slower than
// `Lookup` (O(table size) vs O(reservations)) — prefer `Lookup` for
// names we've reserved. Use this for engine-defined events we want to
// fire ourselves (e.g., polyfilling missing event dispatches).
int LookupByName(const char *name);
// Dispatches a custom event via the engine's printf-style event
// dispatcher at `FUN_FIRE_EVENT` (`0x00703F50`). `format` is a
// concatenation of `%d` (int), `%u` (uint), `%f` (double), `%s`
@@ -74,6 +88,22 @@ inline void Fire(int eventID, const char *format, Args... args) {
fn(eventID, format, args...);
}
// Convenience for the modern WoW `(id: number, success: bool)` event
// shape (ITEM_DATA_LOAD_RESULT, GET_ITEM_INFO_RECEIVED,
// QUEST_DATA_LOAD_RESULT, …). The engine's printf-style dispatcher has
// no `%b`, so a naive `%d%d` with `0` for failure surfaces `arg2 = 0` —
// truthy in Lua, breaking the canonical `if success then` branch. We
// push `1` for success and `nil` for failure instead, leaning on the
// engine's `lua_pushstring(L, NULL) → lua_pushnil` tail-jump so the
// dispatcher itself emits the nil without any pre-staging. Lua handlers
// can then use the natural idiom: nil is falsy, `1` is truthy.
inline void FireIdSuccess(int eventID, int id, bool success) {
if (success)
Fire(eventID, "%d%d", id, 1);
else
Fire(eventID, "%d%s", id, static_cast<const char *>(nullptr));
}
// Internal: try to claim a slot for every reservation that's still
// unclaimed (`slot < 0`). Called from the `Frame::RegisterEvent` hook
// in DllMain — every time Lua calls `frame:RegisterEvent(...)`, we
+2 -28
View File
@@ -12,36 +12,10 @@
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
#include <cstring>
#include "event/Custom.h"
namespace Event::Util {
// Walks the same `EventEntry` array that `Frame::RegisterEvent` strcmps
// against (base at `[VAR_EVENT_TABLE_BASE_PTR]`, count at
// `[VAR_EVENT_TABLE_COUNT]`, stride 0x10, name field at +0x00). This is
// the source of truth for "events the engine will accept registration
// for", so it picks up anything we've injected via `Event::Custom::Register`
// in addition to all built-in events.
static bool IsKnownEventName(const char *needle) {
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
if (base == nullptr || count <= 0)
return false;
for (int i = 0; i < count; i++) {
const uint8_t *entry = base + i * Offsets::EVENT_ENTRY_STRIDE;
const char *name = *reinterpret_cast<const char *const *>(
entry + Offsets::OFF_EVENT_ENTRY_NAME);
if (name == nullptr)
continue;
if (std::strcmp(name, needle) == 0)
return true;
}
return false;
}
static int __fastcall Script_IsEventValid(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_STRING) {
Game::Lua::Error(L, "Usage: C_EventUtils.IsEventValid(eventName)");
@@ -52,7 +26,7 @@ static int __fastcall Script_IsEventValid(void *L) {
Game::Lua::PushBool(L, 0);
return 1;
}
Game::Lua::PushBoolean(L, IsKnownEventName(needle));
Game::Lua::PushBoolean(L, Event::Custom::LookupByName(needle) >= 0);
return 1;
}
+248
View File
@@ -0,0 +1,248 @@
// 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/>.
// Restores `UNIT_FACTION("player")` event semantics that vanilla 1.12.1
// silently dropped from four engine paths. In 3.3.5 each of these calls
// `FUN_0071F8F0(player, 0) → FUN_0060BF10(playerGUID, UNIT_FACTION_id)`
// which broadcasts the event for every unit token referencing the
// local player; vanilla just returns. We hook each path and fire
// `UNIT_FACTION("player")` post-original via the engine's printf-style
// dispatcher.
//
// Why one file and not four: every hook does the same shape work
// (snapshot rep-slot state, run original, compare, fire). The shared
// `LookupEngineEvent` + `FireUnitFactionPlayer` helpers are the value;
// splitting four 30-line hooks into four files would just spread that
// helper around.
//
// Why hook the inner setters (vs. `Script_*` wrappers): same coverage,
// and any future C++ caller of the setter inherits the polyfill.
//
// Coverage map:
//
// 1. `FUN_FACTION_SET_AT_WAR` — `FactionToggleAtWar` toggles
// AT_WAR. Hook fires UNIT_FACTION if the flag byte changed.
//
// 2. `FUN_FACTION_SET_INACTIVE` — `SetFactionInactive` /
// `SetFactionActive` toggle INACTIVE. Same change-detection
// shape as at-war.
//
// 3. `FUN_SMSG_SET_FACTION_STANDINGS` — server rep update.
// The handler mutates AT_WAR / VISIBLE bits as standing crosses
// thresholds (recovery from below-Hated clears the force-set
// AT_WAR). Snapshots the 64-entry flag-byte array before/after
// and fires once if any byte changed.
//
// 4. `FUN_SMSG_INITIALIZE_FACTIONS` — server initial sync at login.
// Fires unconditionally after the engine populates the rep table.
// Lets addons that observe UNIT_FACTION (rather than
// PLAYER_LOGIN / VARIABLES_LOADED) see the initial state.
//
// 5. `FUN_SMSG_SET_FACTION_ATWAR` — server force-change of AT_WAR
// (e.g., faction reset, server-side state change). Fires
// unconditionally after the engine writes the slot byte, matching
// 3.3.5's behavior — server-pushed packets are infrequent and
// always indicate a state change worth signaling.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "event/Custom.h"
#include <cstdint>
#include <cstring>
namespace Faction::UnitFactionPolyfill {
namespace {
// Helpers ────────────────────────────────────────────────────────────
// `UNIT_FACTION`'s event-table slot. The engine's `RebuildEventTable`
// preserves slot order across reloads and other DLLs claim only NULL
// gaps, so this is stable once resolved. Lazy-init on first fire.
int s_unitFactionSlot = -1;
void FireUnitFactionPlayer() {
if (s_unitFactionSlot < 0)
s_unitFactionSlot = Event::Custom::LookupByName("UNIT_FACTION");
if (s_unitFactionSlot < 0)
return;
using FireEventFn_t = void(__cdecl *)(int eventID, const char *format, ...);
auto fire = reinterpret_cast<FireEventFn_t>(Offsets::FUN_FIRE_EVENT);
fire(s_unitFactionSlot, "%s", "player");
}
uint8_t ReadRepSlotFlags(int repListID) {
return *reinterpret_cast<uint8_t *>(
Offsets::VAR_PLAYER_REP_SLOTS +
repListID * Offsets::REP_SLOT_STRIDE +
Offsets::OFF_REP_SLOT_FLAGS);
}
// Resolves `factionID → repListID` (0..63 rep-slot index in
// `VAR_PLAYER_REP_SLOTS`) by reading the `+0x04` field on the
// `Faction.dbc` record directly. Same value the engine's helper at
// `0x004D5600` returns — it just dereferences the same DBC field after
// its own bounds check. Returns -1 for factionIDs that don't resolve
// to a record (out of range / empty slot) or are header categories
// (record stores -1 in the field).
int FactionIDToRepListID(int factionID) {
if (factionID <= 0)
return -1;
const auto *record = DBC::Record(Offsets::VAR_FACTION_DBC_RECORDS,
Offsets::VAR_FACTION_DBC_COUNT,
static_cast<uint32_t>(factionID));
if (record == nullptr)
return -1;
return *reinterpret_cast<const int32_t *>(
record + Offsets::OFF_FACTION_REP_LIST_INDEX);
}
// Hook 1 — at-war setter ──────────────────────────────────────────────
using SetAtWar_t = void(__fastcall *)(int factionID, int newState);
SetAtWar_t s_setAtWar_o = nullptr;
void __fastcall SetAtWar_h(int factionID, int newState) {
const int repListID = FactionIDToRepListID(factionID);
const bool slotValid = repListID >= 0 && repListID < Offsets::MAX_REP_SLOTS;
const uint8_t beforeFlags = slotValid ? ReadRepSlotFlags(repListID) : 0;
s_setAtWar_o(factionID, newState);
if (!slotValid)
return;
if (ReadRepSlotFlags(repListID) == beforeFlags)
return; // engine bailed (looting / PEACE_FORCED no-op / standing block)
FireUnitFactionPlayer();
}
const Game::HookAutoRegister _hookAtWar{
Offsets::FUN_FACTION_SET_AT_WAR,
reinterpret_cast<void *>(&SetAtWar_h),
reinterpret_cast<void **>(&s_setAtWar_o)};
// Hook 2 — inactive setter ────────────────────────────────────────────
using SetInactive_t = void(__fastcall *)(int factionID, int newState);
SetInactive_t s_setInactive_o = nullptr;
void __fastcall SetInactive_h(int factionID, int newState) {
const int repListID = FactionIDToRepListID(factionID);
const bool slotValid = repListID >= 0 && repListID < Offsets::MAX_REP_SLOTS;
const uint8_t beforeFlags = slotValid ? ReadRepSlotFlags(repListID) : 0;
s_setInactive_o(factionID, newState);
if (!slotValid)
return;
if (ReadRepSlotFlags(repListID) == beforeFlags)
return; // no-op (already in requested state)
FireUnitFactionPlayer();
}
const Game::HookAutoRegister _hookInactive{
Offsets::FUN_FACTION_SET_INACTIVE,
reinterpret_cast<void *>(&SetInactive_h),
reinterpret_cast<void **>(&s_setInactive_o)};
// Hook 3 — SMSG_SET_FACTION_STANDINGS ─────────────────────────────────
// Snapshot all 64 rep-slot flag bytes into a packed buffer. The engine's
// per-rep-change handler mutates AT_WAR / VISIBLE bits as standing
// crosses thresholds, so we need a delta-detect across the whole table
// (single rep change may flip flags on an unrelated slot — and the SMSG
// can carry multiple rep updates in one packet).
struct RepFlagsSnapshot {
uint8_t flags[Offsets::MAX_REP_SLOTS];
};
void TakeSnapshot(RepFlagsSnapshot *out) {
for (int i = 0; i < Offsets::MAX_REP_SLOTS; ++i)
out->flags[i] = ReadRepSlotFlags(i);
}
bool SnapshotsDiffer(const RepFlagsSnapshot *a, const RepFlagsSnapshot *b) {
return std::memcmp(a->flags, b->flags, sizeof(a->flags)) != 0;
}
using SmsgHandler_t = int(__stdcall *)(uint32_t opcode, void *packet);
SmsgHandler_t s_smsgSetStandings_o = nullptr;
int __stdcall SmsgSetStandings_h(uint32_t opcode, void *packet) {
RepFlagsSnapshot before;
TakeSnapshot(&before);
const int result = s_smsgSetStandings_o(opcode, packet);
RepFlagsSnapshot after;
TakeSnapshot(&after);
if (SnapshotsDiffer(&before, &after))
FireUnitFactionPlayer();
return result;
}
const Game::HookAutoRegister _hookSetStandings{
Offsets::FUN_SMSG_SET_FACTION_STANDINGS,
reinterpret_cast<void *>(&SmsgSetStandings_h),
reinterpret_cast<void **>(&s_smsgSetStandings_o)};
// Hook 4 — SMSG_INITIALIZE_FACTIONS ──────────────────────────────────
SmsgHandler_t s_smsgInitFactions_o = nullptr;
int __stdcall SmsgInitFactions_h(uint32_t opcode, void *packet) {
const int result = s_smsgInitFactions_o(opcode, packet);
// Initial sync — engine has just populated all 64 rep slots from
// server state. Always fire so addons listening for UNIT_FACTION
// (rather than PLAYER_LOGIN / VARIABLES_LOADED) see the initial
// load. Modern WoW's behavior.
FireUnitFactionPlayer();
return result;
}
const Game::HookAutoRegister _hookInitFactions{
Offsets::FUN_SMSG_INITIALIZE_FACTIONS,
reinterpret_cast<void *>(&SmsgInitFactions_h),
reinterpret_cast<void **>(&s_smsgInitFactions_o)};
// Hook 5 — SMSG_SET_FACTION_ATWAR ─────────────────────────────────────
SmsgHandler_t s_smsgSetAtWar_o = nullptr;
int __stdcall SmsgSetAtWar_h(uint32_t opcode, void *packet) {
const int result = s_smsgSetAtWar_o(opcode, packet);
// Server-pushed AT_WAR change — packet is rare and always carries
// intent to update. Match 3.3.5's behavior: fire unconditionally
// after the byte write, without delta-detecting against the slot
// (the engine writes the byte regardless of whether it differs, so
// a delta check would conflate "value didn't change" with "server
// had nothing to say" — but the server only sends this when
// something did).
FireUnitFactionPlayer();
return result;
}
const Game::HookAutoRegister _hookSetAtWar{
Offsets::FUN_SMSG_SET_FACTION_ATWAR,
reinterpret_cast<void *>(&SmsgSetAtWar_h),
reinterpret_cast<void **>(&s_smsgSetAtWar_o)};
} // namespace
} // namespace Faction::UnitFactionPolyfill
+71 -1
View File
@@ -18,6 +18,12 @@
// per-frame `OnMouseDown` scripts — these fire even when the click
// misses any UI frame.
//
// `IsMouseButtonDown([button])` — query the current held state of a
// specific button by name or 1-based ID, or "any button held" when
// called with no args. State is maintained from the same hook stream
// as the events (single source of truth — events firing and the
// state being readable happen at the same point in the pipeline).
//
// Same `WH_GETMESSAGE` thread-message hook pattern as
// [Input::Modifier](Modifier.cpp): we intercept the WM_*BUTTON*
// messages on the engine's main thread *before* dispatch. The hook
@@ -28,6 +34,9 @@
#include "Game.h"
#include "event/Custom.h"
#include <cstdint>
#include <cstring>
#include <windows.h>
namespace Input::GlobalMouse {
@@ -37,6 +46,19 @@ namespace {
constexpr const char *kDownEvent = "GLOBAL_MOUSE_DOWN";
constexpr const char *kUpEvent = "GLOBAL_MOUSE_UP";
// Held-state bitmap — bit `i` set means the button with 1-based
// `IsMouseButtonDown` ID `i+1` is currently held. Modern API IDs:
// 1 = LeftButton, 2 = RightButton, 3 = MiddleButton,
// 4 = Button4 (XBUTTON1), 5 = Button5 (XBUTTON2).
constexpr int BIT_LEFT_BUTTON = 0;
constexpr int BIT_RIGHT_BUTTON = 1;
constexpr int BIT_MIDDLE_BUTTON = 2;
constexpr int BIT_BUTTON4 = 3;
constexpr int BIT_BUTTON5 = 4;
constexpr uint32_t MASK_ANY_BUTTON = 0x1F;
uint32_t g_mouseButtonMask = 0;
HHOOK g_msgHook = nullptr;
// Maps WM_*BUTTON{DOWN,UP} → ("LeftButton"|..., isDown). Returns
@@ -60,16 +82,60 @@ const char *DecodeButton(UINT msg, WPARAM wParam, bool *outDown) {
}
}
// Maps a "LeftButton"/.../"Button5" name to its bit index. -1 for
// any other string.
int NameToBitIdx(const char *name) {
if (name == nullptr)
return -1;
if (std::strcmp(name, "LeftButton") == 0) return BIT_LEFT_BUTTON;
if (std::strcmp(name, "RightButton") == 0) return BIT_RIGHT_BUTTON;
if (std::strcmp(name, "MiddleButton") == 0) return BIT_MIDDLE_BUTTON;
if (std::strcmp(name, "Button4") == 0) return BIT_BUTTON4;
if (std::strcmp(name, "Button5") == 0) return BIT_BUTTON5;
return -1;
}
void ProcessMouseMessage(UINT msg, WPARAM wParam) {
bool down = false;
const char *name = DecodeButton(msg, wParam, &down);
if (name == nullptr)
return;
const int bitIdx = NameToBitIdx(name);
if (bitIdx >= 0) {
const uint32_t bit = 1u << bitIdx;
if (down) g_mouseButtonMask |= bit;
else g_mouseButtonMask &= ~bit;
}
const int slot = Event::Custom::Lookup(down ? kDownEvent : kUpEvent);
if (slot >= 0)
Event::Custom::Fire(slot, "%s", name);
}
int __fastcall Script_IsMouseButtonDown(void *L) {
bool down;
if (Game::Lua::GetTop(L) == 0) {
// No-arg form: any button held.
down = (g_mouseButtonMask & MASK_ANY_BUTTON) != 0;
} else {
int bitIdx = -1;
if (Game::Lua::IsNumber(L, 1)) {
const int id = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (id >= 1 && id <= 5)
bitIdx = id - 1;
} else if (Game::Lua::IsString(L, 1)) {
bitIdx = NameToBitIdx(Game::Lua::ToString(L, 1));
}
// Unrecognized button (bad id, unknown name) → false, matching
// the modern API's "did this specific button happen to be held"
// semantic. Bad input doesn't promote to the any-button check.
down = (bitIdx >= 0) && (g_mouseButtonMask & (1u << bitIdx)) != 0;
}
Game::Lua::PushBool(L, down);
return 1;
}
LRESULT CALLBACK GetMsgHook(int code, WPARAM wParam, LPARAM lParam) {
// Only process when the message is being removed from the queue
// (`PM_REMOVE` / `GetMessage`); `PM_NOREMOVE` peeks would deliver
@@ -88,7 +154,11 @@ void InstallHook() {
GetCurrentThreadId());
}
void RegisterLuaFunctions() { InstallHook(); }
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("IsMouseButtonDown",
&Script_IsMouseButtonDown);
InstallHook();
}
const Event::Custom::AutoReserve _reserveDown{kDownEvent};
const Event::Custom::AutoReserve _reserveUp{kUpEvent};
+153 -29
View File
@@ -17,6 +17,7 @@
#include "item/Arg.h"
#include "item/Data.h"
#include "item/Location.h"
#include "tick/WorldTick.h"
#include <cstdint>
@@ -52,16 +53,14 @@ 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, int success) {
const int slot = Event::Custom::Lookup(kGetItemInfoReceived);
if (slot >= 0)
Event::Custom::Fire(slot, "%d%d", itemID, success);
static void FireGetItemInfoReceived(int itemID, bool success) {
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kGetItemInfoReceived),
itemID, success);
}
static void FireItemDataLoadResult(int itemID, int success) {
const int slot = Event::Custom::Lookup(kItemDataLoadResult);
if (slot >= 0)
Event::Custom::Fire(slot, "%d%d", itemID, success);
static void FireItemDataLoadResult(int itemID, bool success) {
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kItemDataLoadResult),
itemID, success);
}
// Engine callback for **implicit** cache fills (transparent warmup
@@ -76,16 +75,7 @@ static void FireItemDataLoadResult(int itemID, int success) {
// callback would crash the engine, so stdcall is verified by induction.
static void __stdcall ItemLoadCallback_Implicit(void *userData, int success) {
const auto itemID = static_cast<int>(reinterpret_cast<uintptr_t>(userData));
FireGetItemInfoReceived(itemID, static_cast<int>(success != 0));
}
// Engine callback for **explicit** cache fills (player-side
// `C_Item.RequestLoadItemData(ByID)` calls). Fires ITEM_DATA_LOAD_RESULT
// only — the implicit GET_ITEM_INFO_RECEIVED path is for ambient cache
// fills, not for callers who explicitly asked.
static void __stdcall ItemLoadCallback_Explicit(void *userData, int success) {
const auto itemID = static_cast<int>(reinterpret_cast<uintptr_t>(userData));
FireItemDataLoadResult(itemID, static_cast<int>(success != 0));
FireGetItemInfoReceived(itemID, success != 0);
}
using ItemLoadCallback_t = void(__stdcall *)(void *userData, int success);
@@ -103,6 +93,15 @@ using ItemLoadCallback_t = void(__stdcall *)(void *userData, int success);
// 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);
@@ -110,7 +109,7 @@ static const uint8_t *CacheFetch(uint32_t itemID, ItemLoadCallback_t callback) {
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, 0);
return fn(cache, itemID, &zeroGuid, cb, userData, 1);
}
// Resolves an item-location table at stack idx to the itemID by walking
@@ -148,18 +147,143 @@ static int __fastcall Script_IsItemDataCached(void *L) {
return 1;
}
// Explicit-request path: kicks off the cache fill via the explicit
// callback (which fires ITEM_DATA_LOAD_RESULT). If the item is already
// cached, the engine won't invoke our callback — synthesize the event
// so addons get the same notification regardless of cache state,
// matching modern `C_Item.RequestLoadItemData(ByID)` semantics.
static void RequestAndMaybeNotify(uint32_t itemID) {
const bool wasCached = (CacheFetch(itemID, nullptr) != nullptr);
CacheFetch(itemID, &ItemLoadCallback_Explicit);
if (wasCached)
FireItemDataLoadResult(static_cast<int>(itemID), 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.
//
// 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.
//
// 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
// 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)`.
struct Pending {
uint32_t itemID;
int ticksWaiting;
bool requestIssued;
};
constexpr int kMaxPending = 64;
constexpr int kEscalateTicks = 60;
constexpr int kMaxWaitTicks = 1200;
static Pending g_pending[kMaxPending];
static int g_pendingCount = 0;
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];
}
// 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*/) {}
static void RequestAndMaybeNotify(uint32_t itemID) {
// Already loaded — fire success synchronously.
if (CacheFetch(itemID, nullptr) != nullptr) {
FireItemDataLoadResult(static_cast<int>(itemID), true);
return;
}
// Already tracking — second request just dedups.
if (FindPending(itemID) >= 0)
return;
if (g_pendingCount >= kMaxPending)
return;
g_pending[g_pendingCount].itemID = itemID;
g_pending[g_pendingCount].ticksWaiting = 0;
g_pending[g_pendingCount].requestIssued = false;
++g_pendingCount;
}
// 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);
// Sweep pending — the engine just finished filling any matching
// entries. Peek (no callback) returns non-null iff the entry's
// loaded flag is set.
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);
continue;
}
++i;
}
}
static const Game::HookAutoRegister _hookCacheResponse{
Offsets::FUN_ITEMSTATS_CACHE_RESPONSE,
reinterpret_cast<void *>(&ItemStatsCacheResponse_h),
reinterpret_cast<void **>(&ItemStatsCacheResponse_o)};
// 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.
static void OnWorldTick() {
for (int i = 0; i < g_pendingCount;) {
if (!g_pending[i].requestIssued && g_pending[i].ticksWaiting >= kEscalateTicks) {
CacheFetch(g_pending[i].itemID, &ItemLoadCallback_NoOp);
g_pending[i].requestIssued = true;
}
if (g_pending[i].ticksWaiting >= kMaxWaitTicks) {
const uint32_t itemID = g_pending[i].itemID;
RemovePendingAt(i);
FireItemDataLoadResult(static_cast<int>(itemID), false);
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`. Fires
// the cache request only if the item isn't already cached; uses the
+5 -49
View File
@@ -166,49 +166,6 @@ int __fastcall Script_C_Item_IsEquippedItem(void *L) {
return 1;
}
// Walks bags 0..4 looking for an item matching `arg`. On hit, sets
// `*outBag` / `*outSlot` / `*outItem` and returns true.
//
// itemID match is direct; name match peeks the item-cache record's
// `m_name[0]` and compares case-insensitively. Stomps the Lua stack
// per the helpers it calls.
bool FindItemInBags(void *L, const Item::Arg::Resolved &arg, int *outBag, int *outSlot,
const uint8_t **outItem) {
for (int bag = 0; bag <= 4; ++bag) {
const int slotCount = Item::Location::GetBagSlotCount(bag);
for (int slot = 1; slot <= slotCount; ++slot) {
const uint8_t *item = Item::Location::ResolveBag(L, bag, slot);
if (item == nullptr) continue;
const int id = Item::ID::FromCGItem(item);
if (id == 0) continue;
if (arg.itemID > 0) {
if (id == arg.itemID) {
*outBag = bag;
*outSlot = slot;
*outItem = item;
return true;
}
continue;
}
auto *record = PeekItemRecord(static_cast<uint32_t>(id));
if (record == nullptr) continue;
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (name == nullptr) continue;
if (_stricmp(name, arg.name) == 0) {
*outBag = bag;
*outSlot = slot;
*outItem = item;
return true;
}
}
}
return false;
}
// `C_Item.EquipItemByName(itemInfo [, dstSlot])` — finds the first
// item in the player's bags matching `itemInfo` (itemID, link, or
// name) and equips it.
@@ -255,14 +212,13 @@ int __fastcall Script_C_Item_EquipItemByName(void *L) {
reinterpret_cast<int(__fastcall *)(void *)>(
Offsets::FUN_SCRIPT_CLEAR_CURSOR)(L);
int bag = -1, slot = -1;
const uint8_t *cgItem = nullptr;
if (!FindItemInBags(L, arg, &bag, &slot, &cgItem)) {
Item::Location::ByGUIDResult found;
if (!Item::Location::FindByArgInBags(L, arg, &found)) {
return 0;
}
if (hasDstSlot) {
Item::Swap::FromBag(cgItem, bag, slot, dstSlot);
Item::Swap::FromBag(found.item, found.bagID, found.slotIndex, dstSlot);
return 0;
}
@@ -273,8 +229,8 @@ int __fastcall Script_C_Item_EquipItemByName(void *L) {
// — `Script_AutoEquipCursorItem` is a thin wrapper that just
// resolves the local player and calls this with flag=0.
Game::Lua::SetTop(L, 0);
Game::Lua::PushNumber(L, static_cast<double>(bag));
Game::Lua::PushNumber(L, static_cast<double>(slot));
Game::Lua::PushNumber(L, static_cast<double>(found.bagID));
Game::Lua::PushNumber(L, static_cast<double>(found.slotIndex));
auto pickup = reinterpret_cast<ScriptFn_t>(Offsets::FUN_SCRIPT_PICKUP_CONTAINER_ITEM);
pickup(L);
+22 -33
View File
@@ -14,28 +14,12 @@
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include "item/Data.h"
#include "item/Location.h"
#include <cstdint>
namespace Item::Exists {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
const uint8_t *PeekItemRecord(uint32_t itemID) {
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;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
} // namespace
// `C_Item.DoesItemExist(itemLocation)` — true iff the location resolves
// to a populated inventory slot on the active player. Equipment-slot
// locations check the character pane; bag-slot locations walk the
@@ -51,27 +35,32 @@ static int __fastcall Script_C_Item_DoesItemExist(void *L) {
return 1;
}
// `C_Item.DoesItemExistByID(itemInfo)` — true iff the cache currently
// has data for this itemID (i.e. `GetItemInfo` would return non-nil
// right now). Same semantics as the modern function and the existing
// Lua shim: cache miss → false, but we kick off the network query
// so a subsequent call after `GET_ITEM_INFO_RECEIVED` will succeed.
// `C_Item.DoesItemExistByID(itemInfo)` — true if `itemID` looks like a
// valid item identifier. Modern WoW answers from the client-side item
// database (Item-sparse.db2) and is independent of whether the network
// cache has been warmed yet. 1.12 has no client-side DB, so we
// optimistically accept any positive itemID; callers needing the
// "is the cache populated?" check should use `IsItemDataCachedByID`.
//
// This split matters for `ItemMixin:IsItemEmpty` (FrameXML ItemUtil),
// which uses `DoesItemExistByID` to gate `ContinueOnItemLoad` — the
// whole point of `ContinueOnItemLoad` is to wait for an uncached item
// to load, so the existence check must succeed for items that aren't
// in the cache yet.
//
// We deliberately do NOT auto-warm the cache here. Existence is a pure
// query about the ID, not a load request — and during early-login
// (cold WDB, pfQuest's PLAYER_ENTERING_WORLD path) a spurious cache
// touch can race the engine's natural inventory prefetch and leave the
// item stuck. Callers wanting to actually load data should use
// `RequestLoadItemDataByID`; passive readers (`GetItemInfo`,
// `GetItemNameByID`) already warm on their own miss path.
//
// Accepts numeric itemID or `"item:NNN..."` string (including full
// chat links). Returns false for any input we can't resolve to an
// itemID; never raises.
// chat links); never raises.
static int __fastcall Script_C_Item_DoesItemExistByID(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
if (itemID <= 0) {
Game::Lua::PushBoolean(L, 0);
return 1;
}
if (PeekItemRecord(static_cast<uint32_t>(itemID)) == nullptr) {
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
Game::Lua::PushBoolean(L, 0);
return 1;
}
Game::Lua::PushBoolean(L, 1);
Game::Lua::PushBoolean(L, itemID > 0);
return 1;
}
+417
View File
@@ -0,0 +1,417 @@
// 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/>.
// `C_Item.GetItemDataByID(itemInfo)` / `C_Item.GetItemData(itemLocation)`
// — return a single table containing every field we can extract from
// the cached `ItemStats_C` record. Convenience for addons that want
// the kitchen sink without making 15 separate `C_Item.*` calls.
//
// Returns `nil` if the item isn't cached (caller should
// `RequestLoadItemData` and listen for `ITEM_DATA_LOAD_RESULT`).
// Does not warm the cache itself — this is a passive reader, same as
// `GetItemInfoInstant`'s contract.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "item/Arg.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Spell.h"
#include <cstdint>
#include <cstdio>
namespace Item::GetData {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, bool requestIfMissing);
const uint8_t *FetchItemRecord(uint32_t itemID) {
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;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, false);
}
int CurrentLocaleIndex() {
return *reinterpret_cast<int *>(Offsets::VAR_LOCALE_INDEX);
}
// Mirrors the lookup in `Item::Info`. ItemClass.dbc record `+0x0C` is
// the 9-locale name array; null/empty slots fall back to "".
const char *LookupClassName(uint32_t classID) {
const char *s = DBC::LocalizedField(
Offsets::VAR_ITEMCLASS_RECORDS, Offsets::VAR_ITEMCLASS_COUNT,
classID, Offsets::OFF_ITEMCLASS_NAMES);
return (s != nullptr && s[0] != '\0') ? s : "";
}
// ItemSubClass.dbc walker — same fallback chain as Script_GetItemInfo
// (verbose name first, short name on miss).
const char *LookupSubClassName(uint32_t classID, uint32_t subClassID) {
const int count = *reinterpret_cast<int *>(Offsets::VAR_ITEMSUBCLASS_COUNT);
if (count <= 0)
return "";
auto *records = *reinterpret_cast<const uint8_t *const *>(Offsets::VAR_ITEMSUBCLASS_RECORDS);
if (records == nullptr)
return "";
const int locale = CurrentLocaleIndex();
for (int i = 0; i < count; ++i) {
const uint8_t *record = records + i * Offsets::OFF_ITEMSUBCLASS_RECORD_STRIDE;
if (*reinterpret_cast<const uint32_t *>(record + 0x00) != classID)
continue;
if (*reinterpret_cast<const uint32_t *>(record + 0x04) != subClassID)
continue;
const char *verbose = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSUBCLASS_DISPLAY_NAME + locale * 4);
if (verbose != nullptr && verbose[0] != '\0')
return verbose;
const char *shortName = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSUBCLASS_NAME + locale * 4);
return (shortName != nullptr && shortName[0] != '\0') ? shortName : "";
}
return "";
}
const char *LookupInvType(uint32_t invType) {
if (invType > Offsets::INVTYPE_TABLE_MAX_INDEX)
return "";
auto **table = reinterpret_cast<const char **>(Offsets::VAR_INVTYPE_STRING_TABLE);
const char *s = table[invType];
return (s != nullptr && s[0] != '\0') ? s : "";
}
bool BuildIconPath(uint32_t displayInfoID, char *out, size_t outSize) {
if (out == nullptr || outSize == 0)
return false;
out[0] = '\0';
const char *iconName = DBC::StringField(
Offsets::VAR_ITEMDISPLAYINFO_RECORDS, Offsets::VAR_ITEMDISPLAYINFO_COUNT,
displayInfoID, Offsets::OFF_ITEMDISPLAYINFO_ICON);
if (iconName == nullptr || iconName[0] == '\0')
return false;
std::snprintf(out, outSize, "Interface\\Icons\\%s", iconName);
return true;
}
// Wrath flipped the bagFamily encoding from raw 1-based IDs to a
// bitmask. We surface the modern shape so backported addons compile
// cleanly. See `Item::Info::BagFamilyIdToBitmask` for the full story.
uint32_t BagFamilyIdToBitmask(uint32_t rawId) {
return (rawId == 0) ? 0 : (1u << (rawId - 1));
}
// Shorthand: read a u32 field from the record at `offset`, push it on
// the stack as a Lua number, and set `t[key] = value` on the table at
// stack[-3]. `SetFieldNumber` already handles the push/set; this is
// just the field-fetch convenience.
void SetU32(void *L, const uint8_t *record, const char *key, int offset) {
const uint32_t v = *reinterpret_cast<const uint32_t *>(record + offset);
Game::Lua::SetFieldNumber(L, key, static_cast<double>(v));
}
void SetI32(void *L, const uint8_t *record, const char *key, int offset) {
const int32_t v = *reinterpret_cast<const int32_t *>(record + offset);
Game::Lua::SetFieldNumber(L, key, static_cast<double>(v));
}
void SetF32(void *L, const uint8_t *record, const char *key, int offset) {
const float v = *reinterpret_cast<const float *>(record + offset);
Game::Lua::SetFieldNumber(L, key, static_cast<double>(v));
}
// Sets `t[key]` to a sub-array of N consecutive u32 values from the
// record. Pushes 1-indexed (Lua array convention).
void SetU32Array(void *L, const uint8_t *record, const char *key,
int offset, int count) {
Game::Lua::PushString(L, key);
Game::Lua::NewTable(L);
auto *arr = reinterpret_cast<const uint32_t *>(record + offset);
for (int i = 0; i < count; ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::PushNumber(L, static_cast<double>(arr[i]));
Game::Lua::SetTable(L, -3);
}
Game::Lua::SetTable(L, -3);
}
void SetI32Array(void *L, const uint8_t *record, const char *key,
int offset, int count) {
Game::Lua::PushString(L, key);
Game::Lua::NewTable(L);
auto *arr = reinterpret_cast<const int32_t *>(record + offset);
for (int i = 0; i < count; ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::PushNumber(L, static_cast<double>(arr[i]));
Game::Lua::SetTable(L, -3);
}
Game::Lua::SetTable(L, -3);
}
void SetF32Array(void *L, const uint8_t *record, const char *key,
int offset, int count) {
Game::Lua::PushString(L, key);
Game::Lua::NewTable(L);
auto *arr = reinterpret_cast<const float *>(record + offset);
for (int i = 0; i < count; ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::PushNumber(L, static_cast<double>(arr[i]));
Game::Lua::SetTable(L, -3);
}
Game::Lua::SetTable(L, -3);
}
// Sparse stats: walks the 10 (statType, statValue) slots and pushes
// only the populated ones into a map keyed by statType. Modern addons
// expect `stats[STAT_AGI] = 5`-style access; matching that here avoids
// callers having to walk the fixed-10 array themselves.
void SetStatsMap(void *L, const uint8_t *record) {
Game::Lua::PushString(L, "stats");
Game::Lua::NewTable(L);
auto *types = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_STAT_TYPE);
auto *values = reinterpret_cast<const int32_t *>(
record + Offsets::OFF_ITEMSTATS_STAT_VALUE);
for (int i = 0; i < Offsets::ITEMSTATS_STAT_SLOT_COUNT; ++i) {
if (types[i] == 0 && values[i] == 0)
continue;
Game::Lua::PushNumber(L, static_cast<double>(types[i]));
Game::Lua::PushNumber(L, static_cast<double>(values[i]));
Game::Lua::SetTable(L, -3);
}
Game::Lua::SetTable(L, -3);
}
// Spell slots: walks all 5 slots, pushes only entries with a non-zero
// spellID as `{ id, trigger, charges, cooldown, category,
// categoryCooldown }` records. Sparse but 1-indexed in declaration
// order so the slot index is preserved.
void SetSpellsArray(void *L, const uint8_t *record) {
Game::Lua::PushString(L, "spells");
Game::Lua::NewTable(L);
auto *ids = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_ID);
auto *triggers = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_TRIGGER);
auto *charges = reinterpret_cast<const int32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_CHARGES);
auto *cooldowns = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_COOLDOWN);
auto *categories = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_CATEGORY);
auto *catCooldowns = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_CATEGORY_CD);
int written = 0;
for (int i = 0; i < Offsets::ITEMSTATS_SPELL_SLOT_COUNT; ++i) {
if (ids[i] == 0)
continue;
++written;
Game::Lua::PushNumber(L, static_cast<double>(written));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "id", static_cast<double>(ids[i]));
Game::Lua::SetFieldNumber(L, "trigger", static_cast<double>(triggers[i]));
Game::Lua::SetFieldNumber(L, "charges", static_cast<double>(charges[i]));
Game::Lua::SetFieldNumber(L, "cooldown", static_cast<double>(cooldowns[i]));
Game::Lua::SetFieldNumber(L, "category", static_cast<double>(categories[i]));
Game::Lua::SetFieldNumber(L, "categoryCooldown",
static_cast<double>(catCooldowns[i]));
Game::Lua::SetTable(L, -3);
}
Game::Lua::SetTable(L, -3);
}
// Pushes a single Lua table for `record` onto the stack, populated
// with every field we can read out of `ItemStats_C`. Caller is
// responsible for verifying `record` is non-null and pushing the
// itemID as a setup step.
void PushItemDataTable(void *L, uint32_t itemID, const uint8_t *record) {
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "itemID", static_cast<double>(itemID));
// Identity strings
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
Game::Lua::SetFieldString(L, "name", name);
const char *description = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_DESCRIPTION);
if (description != nullptr && description[0] != '\0')
Game::Lua::SetFieldString(L, "description", description);
// Display
const uint32_t displayInfoID = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID);
Game::Lua::SetFieldNumber(L, "displayInfoID", static_cast<double>(displayInfoID));
char iconPath[260];
if (BuildIconPath(displayInfoID, iconPath, sizeof(iconPath)))
Game::Lua::SetFieldString(L, "icon", iconPath);
// Quality / classification
const uint32_t quality = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_QUALITY);
Game::Lua::SetFieldNumber(L, "quality", static_cast<double>(quality));
const uint32_t classID = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CLASS);
const uint32_t subClassID = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SUBCLASS);
Game::Lua::SetFieldNumber(L, "classID", static_cast<double>(classID));
Game::Lua::SetFieldNumber(L, "subclassID", static_cast<double>(subClassID));
Game::Lua::SetFieldString(L, "className", LookupClassName(classID));
Game::Lua::SetFieldString(L, "subclassName", LookupSubClassName(classID, subClassID));
const uint32_t invType = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_INVENTORY_TYPE);
Game::Lua::SetFieldNumber(L, "inventoryType", static_cast<double>(invType));
Game::Lua::SetFieldString(L, "equipLoc", LookupInvType(invType));
// Bind
SetU32(L, record, "bindType", Offsets::OFF_ITEMSTATS_BONDING);
// Flags: raw + decoded bits.
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_FLAGS);
Game::Lua::SetFieldNumber(L, "flags", static_cast<double>(flags));
Game::Lua::SetFieldBool(L, "isConjured",
(flags & Offsets::ITEM_FLAG_CONJURED) != 0);
Game::Lua::SetFieldBool(L, "isOpenable",
(flags & Offsets::ITEMSTATS_FLAG_OPENABLE) != 0);
Game::Lua::SetFieldBool(L, "isLootable",
(flags & Offsets::ITEM_FLAG_LOOTABLE) != 0);
Game::Lua::SetFieldBool(L, "isWrapper",
(flags & Offsets::ITEM_FLAG_WRAPPER) != 0);
// Stacking / inventory
SetI32(L, record, "maxStackSize", Offsets::OFF_ITEMSTATS_STACKABLE);
SetI32(L, record, "maxCount", Offsets::OFF_ITEMSTATS_MAX_COUNT);
SetU32(L, record, "containerSlots", Offsets::OFF_ITEMSTATS_CONTAINER_SLOTS);
const uint32_t rawBagFamily = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_BAG_FAMILY);
Game::Lua::SetFieldNumber(L, "bagFamily",
static_cast<double>(BagFamilyIdToBitmask(rawBagFamily)));
// Economy
SetU32(L, record, "buyPrice", Offsets::OFF_ITEMSTATS_BUY_PRICE);
SetU32(L, record, "sellPrice", Offsets::OFF_ITEMSTATS_SELL_PRICE);
// Level / requirements
SetU32(L, record, "itemLevel", Offsets::OFF_ITEMSTATS_ITEM_LEVEL);
SetU32(L, record, "requiredLevel", Offsets::OFF_ITEMSTATS_REQUIRED_LEVEL);
SetU32(L, record, "requiredSkill", Offsets::OFF_ITEMSTATS_REQUIRED_SKILL);
SetU32(L, record, "requiredSkillRank", Offsets::OFF_ITEMSTATS_REQUIRED_SKILL_RANK);
SetU32(L, record, "requiredSpell", Offsets::OFF_ITEMSTATS_REQUIRED_SPELL);
SetU32(L, record, "requiredHonorRank", Offsets::OFF_ITEMSTATS_REQUIRED_HONOR_RANK);
SetU32(L, record, "requiredCityRank", Offsets::OFF_ITEMSTATS_REQUIRED_CITY_RANK);
SetU32(L, record, "requiredFaction", Offsets::OFF_ITEMSTATS_REQUIRED_FACTION);
SetU32(L, record, "requiredFactionRank", Offsets::OFF_ITEMSTATS_REQUIRED_FACTION_RANK);
SetU32(L, record, "allowableClass", Offsets::OFF_ITEMSTATS_ALLOWABLE_CLASS);
SetU32(L, record, "allowableRace", Offsets::OFF_ITEMSTATS_ALLOWABLE_RACE);
// Equipment defenses
SetU32(L, record, "armor", Offsets::OFF_ITEMSTATS_ARMOR);
SetU32(L, record, "block", Offsets::OFF_ITEMSTATS_BLOCK);
SetU32(L, record, "maxDurability", Offsets::OFF_ITEMSTATS_MAX_DURABILITY);
// Sparse stats map (statType -> statValue, only populated entries)
SetStatsMap(L, record);
// Resistances
SetU32(L, record, "resistanceHoly", Offsets::OFF_ITEMSTATS_RES_HOLY);
SetU32(L, record, "resistanceFire", Offsets::OFF_ITEMSTATS_RES_FIRE);
SetU32(L, record, "resistanceNature", Offsets::OFF_ITEMSTATS_RES_NATURE);
SetU32(L, record, "resistanceFrost", Offsets::OFF_ITEMSTATS_RES_FROST);
SetU32(L, record, "resistanceShadow", Offsets::OFF_ITEMSTATS_RES_SHADOW);
SetU32(L, record, "resistanceArcane", Offsets::OFF_ITEMSTATS_RES_ARCANE);
// Weapon
SetF32Array(L, record, "damageMin", Offsets::OFF_ITEMSTATS_DAMAGE_MIN,
Offsets::ITEMSTATS_DAMAGE_SLOT_COUNT);
SetF32Array(L, record, "damageMax", Offsets::OFF_ITEMSTATS_DAMAGE_MAX,
Offsets::ITEMSTATS_DAMAGE_SLOT_COUNT);
SetU32Array(L, record, "damageType", Offsets::OFF_ITEMSTATS_DAMAGE_TYPE,
Offsets::ITEMSTATS_DAMAGE_SLOT_COUNT);
SetU32(L, record, "delay", Offsets::OFF_ITEMSTATS_DELAY);
SetU32(L, record, "ammoType", Offsets::OFF_ITEMSTATS_AMMO_TYPE);
SetF32(L, record, "rangedModRange", Offsets::OFF_ITEMSTATS_RANGED_MOD_RANGE);
// Spell slots (sparse array of records) + ON_USE convenience
SetSpellsArray(L, record);
const int useSpellID = Item::Spell::OnUseSpellIDForItemID(itemID);
if (useSpellID > 0)
Game::Lua::SetFieldNumber(L, "useSpellID", static_cast<double>(useSpellID));
// Lock / item set
SetU32(L, record, "lockID", Offsets::OFF_ITEMSTATS_LOCK_ID);
SetU32(L, record, "itemSet", Offsets::OFF_ITEMSTATS_ITEM_SET);
// Books / quest / binding
SetU32(L, record, "pageText", Offsets::OFF_ITEMSTATS_PAGE_TEXT);
SetU32(L, record, "pageMaterial", Offsets::OFF_ITEMSTATS_PAGE_MATERIAL);
SetU32(L, record, "languageID", Offsets::OFF_ITEMSTATS_LANGUAGE_ID);
SetU32(L, record, "startQuest", Offsets::OFF_ITEMSTATS_START_QUEST);
SetI32(L, record, "material", Offsets::OFF_ITEMSTATS_MATERIAL);
SetU32(L, record, "sheath", Offsets::OFF_ITEMSTATS_SHEATH);
SetI32(L, record, "randomProperty", Offsets::OFF_ITEMSTATS_RANDOM_PROPERTY);
SetU32(L, record, "area", Offsets::OFF_ITEMSTATS_AREA);
SetU32(L, record, "map", Offsets::OFF_ITEMSTATS_MAP);
}
// Common path for both entry points: take an itemID, peek the cache,
// build & push the data table or push nil for uncached.
int PushDataForItemID(void *L, int itemID) {
if (itemID <= 0) {
Game::Lua::PushNil(L);
return 1;
}
const uint8_t *record = FetchItemRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
PushItemDataTable(L, static_cast<uint32_t>(itemID), record);
return 1;
}
// `C_Item.GetItemDataByID(itemInfo)` — accepts numeric itemID,
// `"item:NNN..."` string, or full chat link. Returns the data table
// or nil for invalid input / uncached items.
int __fastcall Script_GetItemDataByID(void *L) {
return PushDataForItemID(L, Item::Arg::ResolveItemID(L, 1));
}
// `C_Item.GetItemData(itemLocation)` — resolves the equipment-slot /
// bag-slot location to its CGItem, extracts the itemID, then peeks
// the cache. Returns nil for empty slots, invalid locations, or
// uncached items.
int __fastcall Script_GetItemData(void *L) {
if (!Item::Location::IsLocationArg(L, 1)) {
Game::Lua::Error(L, "Usage: C_Item.GetItemData(itemLocation)");
return 0;
}
return PushDataForItemID(L, Item::ID::FromCGItem(Item::Location::Resolve(L, 1)));
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Item", "GetItemDataByID", &Script_GetItemDataByID);
Game::Lua::RegisterTableFunction("C_Item", "GetItemData", &Script_GetItemData);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Item::GetData
+20 -3
View File
@@ -93,13 +93,31 @@ static bool BuildIconPath(uint32_t displayInfoID, char *out, size_t outSize) {
return true;
}
// `GetItemInfoInstant` is the modern "synchronous, never blocks" item
// info call. In modern WoW the trailing 6 fields come from client-side
// `Item-sparse.db2` and so the call always returns the full 7-tuple.
// 1.12 has no client-side item DB — those fields live only in the
// network-fed `DBCache_ItemStats`. We mirror the contract by:
// - always returning the itemID (echoed from input) so callers using
// `GetItemInfoInstant(link_or_id)` as an ID extractor never see nil
// - returning the remaining 6 fields from the cache when present,
// nil when not (preserving the 7-tuple shape)
// - not warming the cache; "Instant" implies no side effects. Callers
// that need the full info should use `GetItemInfo`, which already
// triggers the warmup via the `Script_GetItemInfo` hook.
static int __fastcall Script_GetItemInfoInstant(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
if (itemID <= 0)
return 0;
Game::Lua::PushNumber(L, static_cast<double>(itemID));
const uint8_t *record = FetchItemRecord(static_cast<uint32_t>(itemID));
if (record == nullptr)
return 0;
if (record == nullptr) {
for (int i = 0; i < 6; ++i)
Game::Lua::PushNil(L);
return 7;
}
const uint32_t classID =
*reinterpret_cast<const uint32_t *>(record + Offsets::OFF_ITEMSTATS_CLASS);
@@ -114,7 +132,6 @@ static int __fastcall Script_GetItemInfoInstant(void *L) {
if (!BuildIconPath(displayInfoID, iconPath, sizeof(iconPath)))
iconPath[0] = 0;
Game::Lua::PushNumber(L, static_cast<double>(itemID));
Game::Lua::PushString(L, LookupItemClassName(classID));
Game::Lua::PushString(L, LookupItemSubClassName(classID, subClassID));
Game::Lua::PushString(L, LookupInvType(invType));
+51 -4
View File
@@ -105,6 +105,47 @@ bool FitsSlot(int slot1Based, uint32_t invType) {
return (kSlotMaskByInvType[invType] & (1u << (slot1Based - 1))) != 0;
}
// ── Proficiency gate ──────────────────────────────────────────────
// `FitsSlot` only checks invType→slot shape compatibility; without
// also gating on the player's proficiencies this would return (e.g.)
// mail helms for a warlock since both are `invType=HEAD`. Modern
// WoW's equipment-set picker filters by what the player can actually
// equip; we mirror that.
//
// Source of truth: the engine's per-itemClass proficiency bitmap at
// `[VAR_PROFICIENCY_TABLE]` (17 u32 entries indexed by `m_class`).
// SMSG_SET_PROFICIENCY's handler at `0x005E7B70` writes the bitmask
// for each class verbatim from the wire. Bit N of `table[class]` =
// "player can equip subclass N of this class". Same data the engine
// itself reads for the tooltip "Mail/Leather/Plate" red-text and the
// cursor-drop-on-paperdoll-slot check — covers both armor and weapon
// proficiencies in one lookup.
//
// 4.3.4's `Script_GetInventoryItemsForSlot` does the equivalent check
// via `FUN_00553AD0(itemClass) & (1 << subclass)`, reading from
// `DAT_00DD4D38` (its own copy of the same packet-fed table).
bool PlayerHasProficiency(uint32_t itemClass, uint32_t subclass) {
if (itemClass > 16)
return true; // table only covers 0..16; be permissive past that
const uint32_t mask = *reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_PROFICIENCY_TABLE) + itemClass * 4);
if (mask == 0)
return true; // table not populated yet (pre-SMSG_SET_PROFICIENCY)
// — fail open so empty-mask doesn't blank the list
return (mask & (1u << subclass)) != 0;
}
bool PlayerCanEquip(const uint8_t *record) {
if (record == nullptr)
return true; // missing record — be permissive, don't hide the item
const uint32_t classCode = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CLASS);
const uint32_t subclass = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SUBCLASS);
return PlayerHasProficiency(classCode, subclass);
}
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
@@ -112,11 +153,14 @@ const uint8_t *PeekItemRecord(uint32_t itemID) {
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
uint32_t ReadInventoryType(const uint8_t *cgItem) {
const uint8_t *ItemRecordForCGItem(const uint8_t *cgItem) {
const int itemID = Item::ID::FromCGItem(cgItem);
if (itemID <= 0)
return 0;
auto *record = PeekItemRecord(static_cast<uint32_t>(itemID));
return nullptr;
return PeekItemRecord(static_cast<uint32_t>(itemID));
}
uint32_t ReadInventoryType(const uint8_t *record) {
if (record == nullptr)
return 0;
return *reinterpret_cast<const uint32_t *>(
@@ -132,7 +176,10 @@ void CollectIfFits(int slot, const uint8_t *cgItem, int location,
std::vector<Hit> &out) {
if (cgItem == nullptr)
return;
if (!FitsSlot(slot, ReadInventoryType(cgItem)))
const uint8_t *record = ItemRecordForCGItem(cgItem);
if (!FitsSlot(slot, ReadInventoryType(record)))
return;
if (!PlayerCanEquip(record))
return;
out.push_back({location, cgItem});
}
+41
View File
@@ -16,8 +16,11 @@
#include "../Game.h"
#include "../Offsets.h"
#include "../guid/Guid.h"
#include "Arg.h"
#include "ID.h"
#include <cstring>
namespace Item::Location {
namespace {
@@ -183,6 +186,44 @@ bool FindByItemID(void *L, int itemID, ByGUIDResult *out) {
return false;
}
bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out) {
if (arg.itemID <= 0 && arg.name == nullptr)
return false;
for (int bagID = 0; bagID <= 4; ++bagID) {
const int slotCount = GetBagSlotCount(bagID);
for (int slotIndex = 1; slotIndex <= slotCount; ++slotIndex) {
auto *item = ResolveBag(L, bagID, slotIndex);
if (item == nullptr)
continue;
const int id = Item::ID::FromCGItem(item);
if (id == 0)
continue;
if (arg.itemID > 0) {
if (id != arg.itemID)
continue;
} else {
auto *record = PeekItemRecord(static_cast<uint32_t>(id));
if (record == nullptr)
continue;
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (name == nullptr || _stricmp(name, arg.name) != 0)
continue;
}
out->equipmentSlotIndex = 0;
out->bagID = bagID;
out->slotIndex = slotIndex;
out->item = item;
return true;
}
}
return false;
}
bool FindByGUID(void *L, uint64_t guid, ByGUIDResult *out) {
if (guid == 0)
return false;
+20
View File
@@ -96,6 +96,26 @@ bool FindByGUID(void *L, uint64_t guid, ByGUIDResult *out);
// for link-decoration purposes. Stomps the Lua stack.
bool FindByItemID(void *L, int itemID, ByGUIDResult *out);
} // namespace Item::Location
// Forward-declare for `FindByArgInBags` below.
namespace Item::Arg { struct Resolved; }
namespace Item::Location {
// Walks the player's bags (0..4 only — no equipment) looking for the
// first item matching `arg`. `arg.itemID > 0` ⇒ direct ID compare;
// otherwise `arg.name` is matched case-insensitively against the
// cached `ItemStats_C.m_name[0]` of each candidate. On hit, populates
// `out->bagID` / `out->slotIndex` / `out->item` and sets
// `equipmentSlotIndex = 0`. Stomps the Lua stack.
//
// Used by the by-name action APIs (`C_Item.EquipItemByName`,
// `C_Item.UseItemByName`, `C_Item.UseAtCursor`) — none of them want
// equipment in the candidate pool (the engine doesn't equip-from-
// equipped or use-from-equipped; the item has to be in bags first).
bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out);
// Parses the `"0xHHHHHHHHLLLLLLLL"` GUID string format
// `C_Item.GetItemGUID` returns. Strict: requires exactly `0x` prefix
// + 16 case-insensitive hex digits. Returns false (and leaves `*out`
+18
View File
@@ -254,6 +254,24 @@ bool MoveCount(void *L, int srcBag, int srcSlot, int dstBag, int dstSlot, int co
if (srcItem == nullptr)
return false;
// Vanilla server rejects `CMSG_SPLIT_ITEM` when count == srcStack
// (`EQUIP_ERR_COULDNT_SPLIT_ITEMS`) — conceptually "split N off,
// leave residual", and splitting the entire stack would leave
// source empty. The drag-and-drop UI's "drop whole stack on
// matching stack" goes through CMSG_SWAP_ITEM instead, which the
// server treats as a merge for same-item destinations (up to
// maxStack). Route there for count == srcStack so callers don't
// have to special-case "move everything".
auto *srcDescriptor = *reinterpret_cast<const uint8_t *const *>(
srcItem + Offsets::OFF_ITEM_DESCRIPTOR);
const int srcStack = srcDescriptor == nullptr ? 0 :
static_cast<int>(*reinterpret_cast<const uint32_t *>(
srcDescriptor + Offsets::OFF_DESCRIPTOR_STACK_COUNT));
if (count > srcStack)
return false; // server would reject anyway; fail fast and locally
if (count == srcStack)
return Containers(L, srcBag, srcSlot, dstBag, dstSlot);
void *player = ResolvePlayer();
if (player == nullptr)
return false;
+9 -3
View File
@@ -82,9 +82,15 @@ bool Containers(void *L, int srcBag, int srcSlot, int dstBag, int dstSlot);
// - `dst` same item & fits maxStack → merges `count` into dst
// - `dst` different item / overflow → server rejects (no partial)
//
// Source must be occupied with at least `count` items; server rejects
// if not. Returns false locally only on bad args or unresolvable bags.
// All other validation is server-side — same as `Containers` above.
// Source must be occupied with at least `count` items. Locally
// validates `count <= srcStack` (server would reject otherwise with
// `EQUIP_ERR_COULDNT_SPLIT_ITEMS` — fail fast). When `count ==
// srcStack` ("move the whole stack"), routes through `Containers`
// instead — vanilla's `CMSG_SPLIT_ITEM` rejects splits that would
// leave the source empty, but `CMSG_SWAP_ITEM` handles the case
// (merges into a matching destination stack, swaps otherwise).
// Returns false locally on bad args, unresolvable bags, or
// `count > srcStack`; all other validation is server-side.
//
// Requires Lua state for the same reason as `Containers`: the source
// CGItem lookup goes through `Item::Location::ResolveBag`. Stomps the
+62 -45
View File
@@ -11,18 +11,31 @@
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Item.GetItemUniqueness(itemLocation)` — uniqueness cap. Reads
// `ItemStats_C.m_maxCount` from the item's cached record; that's the
// only uniqueness signal vanilla's client carries. `1` = unique
// ("Held In Off-hand" trinkets, special quest items, etc.); `0` =
// unlimited; higher values cap the inventory count.
// `C_Item.GetItemUniqueness(itemLocation)` and the `*ByID` variant
// — uniqueness cap. Both read `ItemStats_C.m_maxCount` from the
// item's cached record; that's the only uniqueness signal vanilla
// carries. `1` = unique-equipped (one at a time); higher values
// cap the inventory count; `0` = unlimited.
//
// Modern WoW returns a 3-tuple `(maxEquipped, category, equippedCount)`
// keyed off the post-TBC category system (e.g. "Unique-Equipped:
// Brewfest Mug"). Vanilla has no category system, so we surface
// `m_maxCount` as the first return and leave the category nil and
// equipped count `0` — matching modern's "field present but inapplicable"
// convention.
// Modern WoW (Classic Era 1.15.x) gives the two functions **different
// signatures**, verified against `_classic_` BlizzardInterfaceCode's
// `ItemDocumentation.lua`:
//
// GetItemUniqueness(itemLocation) →
// limitCategory (number, non-nilable),
// limitMax (number, non-nilable)
//
// GetItemUniquenessByID(itemInfo) →
// isUnique (bool, non-nilable),
// limitCategoryName (string, nilable),
// limitCategoryCount (number, nilable),
// limitCategoryID (number, nilable)
//
// Vanilla has no `LimitCategory.dbc` system (TBC+ addition for
// "Unique-Equipped: Brewfest Mug"-style cross-item category caps),
// so the category fields are always nil/0. `isUnique` collapses to
// `m_maxCount > 0`; `limitMax` and `limitCategoryCount` both surface
// the raw `m_maxCount`.
#include "Game.h"
#include "Offsets.h"
@@ -50,54 +63,58 @@ const uint8_t *PeekItemRecord(uint32_t itemID) {
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
} // namespace
// Pushes the 3-tuple `(maxAllowed, nil, 0)` derived from the
// ItemStats_C record, or nil + early-return on cache miss.
int PushFromItemID(void *L, uint32_t itemID) {
// Returns the item's `m_maxCount` or -1 if the record isn't cached.
// `-1` is a "warm-and-bail" sentinel; caller pushes nothing on -1
// so the Lua side sees the modern "MayReturnNothing" behaviour.
int ReadMaxCount(uint32_t itemID) {
const uint8_t *record = PeekItemRecord(itemID);
if (record == nullptr) {
Item::Data::WarmCache(itemID);
Game::Lua::PushNil(L);
return 1;
return -1;
}
const int maxCount = *reinterpret_cast<const int *>(
return *reinterpret_cast<const int *>(
record + Offsets::OFF_ITEMSTATS_MAX_COUNT);
Game::Lua::PushNumber(L, static_cast<double>(maxCount));
Game::Lua::PushNil(L); // category (vanilla has none)
Game::Lua::PushNumber(L, 0.0); // equippedCount (not tracked)
return 3;
}
} // namespace
// `GetItemUniqueness(itemLocation)` shape: `(limitCategory, limitMax)`.
static int __fastcall Script_GetItemUniqueness(void *L) {
if (!Item::Location::IsLocationArg(L, 1)) {
Game::Lua::PushNil(L);
return 1;
}
if (!Item::Location::IsLocationArg(L, 1))
return 0;
auto *item = Item::Location::Resolve(L, 1);
if (item == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
if (item == nullptr)
return 0;
const int itemID = Item::ID::FromCGItem(item);
if (itemID <= 0) {
Game::Lua::PushNil(L);
return 1;
}
return PushFromItemID(L, static_cast<uint32_t>(itemID));
if (itemID <= 0)
return 0;
const int maxCount = ReadMaxCount(static_cast<uint32_t>(itemID));
if (maxCount < 0)
return 0; // cache miss — modern API documents "MayReturnNothing"
Game::Lua::PushNumber(L, 0.0); // limitCategory (vanilla has no LimitCategory.dbc)
Game::Lua::PushNumber(L, static_cast<double>(maxCount));
return 2;
}
// `GetItemUniquenessByID(itemID)` shape:
// `(isUnique, limitCategoryName, limitCategoryCount, limitCategoryID)`.
static int __fastcall Script_GetItemUniquenessByID(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::PushNil(L);
return 1;
}
if (!Game::Lua::IsNumber(L, 1))
return 0;
const int itemID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (itemID <= 0) {
Game::Lua::PushNil(L);
return 1;
}
return PushFromItemID(L, static_cast<uint32_t>(itemID));
if (itemID <= 0)
return 0;
const int maxCount = ReadMaxCount(static_cast<uint32_t>(itemID));
if (maxCount < 0)
return 0;
Game::Lua::PushBool(L, maxCount > 0);
Game::Lua::PushNil(L); // limitCategoryName (no categories in vanilla)
if (maxCount > 0)
Game::Lua::PushNumber(L, static_cast<double>(maxCount));
else
Game::Lua::PushNil(L); // limitCategoryCount — nil when not limited
Game::Lua::PushNil(L); // limitCategoryID
return 4;
}
static void RegisterLuaFunctions() {
+82 -54
View File
@@ -11,9 +11,12 @@
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Item.UseItemByName(itemInfo)` — finds the first item in the
// player's bags matching `itemInfo` (itemID, link, or name) and uses
// it via the engine's `CGItem::UseItem` primitive.
// `C_Item.UseItemByName(itemInfo [, unit])` — finds the first item in
// the player's bags matching `itemInfo` (itemID, link, or name) and
// uses it via the engine's `CGItem::UseItem` primitive. Optional
// `unit` is a unit token (`"player"`, `"target"`, `"focus"`, …)
// passed to spell-cast items (scrolls, traps, on-use targeted
// effects) as the cast target.
//
// Same structure 3.3.5's `Script_UseItemByName` uses: locate the item
// directly, then hand it to the engine's by-item-pointer use call.
@@ -25,91 +28,116 @@
// food / potion / hearthstone / scroll / quiver all route through
// the same entry.
//
// Target arg: we pass nullptr / a zero GUID. The engine substitutes
// the player itself for self-use items (hearthstone, food, potion);
// for target-required items (scrolls, traps) the engine reads
// cursor-target state at call time, which a Lua-side caller can
// pre-set with `TargetUnit("...")`.
// Target arg: `FUN_ITEM_USE`'s `param_1` is read as a `uint64_t *`
// pointing at the target's GUID. The engine writes it through to the
// "use item" event (`DAT_00c4c1d0`/`DAT_00c4c1d4` + event 0x122) for
// spell-cast items, and into the cast-targeting helper at
// `FUN_006e5a90`. For self-use items (hearthstone, food, potion) the
// engine overwrites with the item's own GUID before dispatch, so a
// stray target is harmless. We default to a zero GUID when the caller
// omits `unit` — matches existing call sites.
//
// Returns nothing. Silently no-ops on bad input, item not found in
// bags, item locked, on cooldown, etc.
// bags, item locked, on cooldown, etc. An unrecognized `unit` string
// is treated as "no target" rather than raising, matching arg-1's
// "silently no-ops on bad input" contract.
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include "item/ID.h"
#include "item/Location.h"
#include <cstdint>
#include <string.h>
#include <cstring>
namespace Item::Use {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
using UseItem_t = unsigned(__thiscall *)(const void *item, const uint64_t *targetGuid,
int flag);
const uint8_t *PeekItemRecord(uint32_t itemID) {
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;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
bool HasPrefix(const char *s, const char *prefix) {
return std::strncmp(s, prefix, std::strlen(prefix)) == 0;
}
// Returns the matching CGItem* in `*outItem` on hit; mirrors the
// `Item::Equipment::FindItemInBags` walk but skips bag/slot output
// since `FUN_ITEM_USE` operates directly on the CGItem pointer.
bool FindItemInBags(void *L, const Item::Arg::Resolved &arg, const uint8_t **outItem) {
for (int bag = 0; bag <= 4; ++bag) {
const int slotCount = Item::Location::GetBagSlotCount(bag);
for (int slot = 1; slot <= slotCount; ++slot) {
const uint8_t *item = Item::Location::ResolveBag(L, bag, slot);
if (item == nullptr) continue;
// Walk past any "target" suffixes (`targettarget`, `focustargettarget`,
// …); returns a pointer at the first non-"target" character.
const char *SkipTargetSuffixes(const char *s) {
while (HasPrefix(s, "target"))
s += 6;
return s;
}
const int id = Item::ID::FromCGItem(item);
if (id == 0) continue;
if (arg.itemID > 0) {
if (id == arg.itemID) {
*outItem = item;
return true;
}
continue;
}
auto *record = PeekItemRecord(static_cast<uint32_t>(id));
if (record == nullptr) continue;
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (name == nullptr) continue;
if (_stricmp(name, arg.name) == 0) {
*outItem = item;
return true;
}
}
// Predicate mirror of the engine's token grammar inside
// `FUN_TOKEN_TO_GUID` (`0x00515970`) plus the families our own
// `Unit::TokenExtensions` hook adds (`focus`, `nameplateN`). The
// engine raises a Lua error on unknown input, so we gate before
// dispatching to keep our "silently no-ops on bad input" contract.
//
// Every base token may be followed by zero or more `target` suffixes
// (`playertarget`, `party1targettarget`, …) which the engine's
// resolver walks via `LAB_005159d3..0x00515A2C`.
bool MatchBase(const char *s, const char *base, bool requireDigits) {
if (!HasPrefix(s, base))
return false;
const char *rest = s + std::strlen(base);
if (requireDigits) {
const char *p = rest;
while (*p >= '0' && *p <= '9')
++p;
if (p == rest)
return false;
rest = p;
}
return *SkipTargetSuffixes(rest) == '\0';
}
bool LooksLikeToken(const char *s) {
if (s == nullptr || *s == '\0')
return false;
// Order matters for shared roots: longer prefix first
// (`partypet`/`raidpet` before `party`/`raid`).
if (MatchBase(s, "partypet", true)) return true;
if (MatchBase(s, "raidpet", true)) return true;
if (MatchBase(s, "party", true)) return true;
if (MatchBase(s, "raid", true)) return true;
if (MatchBase(s, "nameplate", true)) return true;
if (MatchBase(s, "mouseover", false)) return true;
if (MatchBase(s, "player", false)) return true;
if (MatchBase(s, "target", false)) return true;
if (MatchBase(s, "focus", false)) return true;
if (MatchBase(s, "npc", false)) return true;
if (MatchBase(s, "pet", false)) return true;
return false;
}
uint64_t ResolveUnitGuid(void *L, int idx) {
if (!Game::Lua::IsString(L, idx))
return 0;
const char *token = Game::Lua::ToString(L, idx);
if (!LooksLikeToken(token))
return 0;
auto fn = reinterpret_cast<TokenToGUID_t>(Offsets::FUN_TOKEN_TO_GUID);
return fn(token);
}
int __fastcall Script_C_Item_UseItemByName(void *L) {
const auto arg = Item::Arg::Resolve(L, 1);
if (arg.itemID <= 0 && arg.name == nullptr) {
return 0;
}
const uint8_t *cgItem = nullptr;
if (!FindItemInBags(L, arg, &cgItem)) {
Item::Location::ByGUIDResult found;
if (!Item::Location::FindByArgInBags(L, arg, &found)) {
return 0;
}
const uint64_t zeroTarget = 0;
const uint64_t targetGuid = ResolveUnitGuid(L, 2);
auto useItem = reinterpret_cast<UseItem_t>(Offsets::FUN_ITEM_USE);
useItem(cgItem, &zeroTarget, 0);
useItem(found.item, &targetGuid, 0);
return 0;
}
+76
View File
@@ -0,0 +1,76 @@
// 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/>.
// `C_Item.UseAtCursor(itemInfo)` — use the named item at the player's
// current cursor world position. For items whose on-use spell is
// ground-target (Iron Grenade, demolition charges, etc.), bypasses
// the manual click on the AoE reticle that the engine would
// otherwise require.
//
// Mirrors `C_Item.UseItemByName`'s bag-walk + `FUN_ITEM_USE` dispatch
// — the engine's item-use path eventually hands the on-use spell to
// the same cast dispatcher that ground-target spells go through, so
// placement-mode activation works identically. After firing the
// item, `Spell::AtCursor::Resolve()` commits the placement at cursor.
//
// Returns `true` when the cursor-placement leg landed; `false` for
// items that aren't ground-target (the item still fires, just with
// no implicit target), unparseable input, item-not-in-bags, etc.
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include "item/Location.h"
#include "spell/AtCursor.h"
#include <cstdint>
namespace Item::UseAtCursor {
namespace {
using UseItem_t = unsigned(__thiscall *)(const void *item, const uint64_t *targetGuid,
int flag);
int __fastcall Script_C_Item_UseAtCursor(void *L) {
const auto arg = Item::Arg::Resolve(L, 1);
if (arg.itemID <= 0 && arg.name == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
Item::Location::ByGUIDResult found;
if (!Item::Location::FindByArgInBags(L, arg, &found)) {
Game::Lua::PushBool(L, false);
return 1;
}
const uint64_t zeroTarget = 0;
auto useItem = reinterpret_cast<UseItem_t>(Offsets::FUN_ITEM_USE);
useItem(found.item, &zeroTarget, 0);
const bool placed = Spell::AtCursor::Resolve();
Game::Lua::PushBool(L, placed);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Item", "UseAtCursor",
&Script_C_Item_UseAtCursor);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Item::UseAtCursor
+4 -7
View File
@@ -31,12 +31,10 @@ static const Event::Custom::AutoReserve _reserveQuestDataLoadResult{kQuestDataLo
// `ret 8` cleans both args. We encode the questID into `userData` at
// request time so the callback knows which quest completed.
static void __stdcall QuestLoadCallback(void *userData, int success) {
const int slot = Event::Custom::Lookup(kQuestDataLoadResult);
if (slot < 0)
return;
const auto questID =
static_cast<int>(reinterpret_cast<uintptr_t>(userData));
Event::Custom::Fire(slot, "%d%d", questID, static_cast<int>(success != 0));
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kQuestDataLoadResult),
questID, success != 0);
}
static void RequestAndMaybeNotify(uint32_t questID) {
@@ -47,9 +45,8 @@ static void RequestAndMaybeNotify(uint32_t questID) {
// invoking our callback. Fire the event ourselves so addons get the
// notification regardless of cache state, matching modern semantics.
if (wasCached) {
const int slot = Event::Custom::Lookup(kQuestDataLoadResult);
if (slot >= 0)
Event::Custom::Fire(slot, "%d%d", static_cast<int>(questID), 1);
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kQuestDataLoadResult),
static_cast<int>(questID), true);
}
}
+59
View File
@@ -0,0 +1,59 @@
// 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/>.
// Suppress vanilla 1.12.1's "Your game interface files are corrupt"
// fatal error so the engine accepts the in-memory file modifications
// ClassicAPI makes via the file-read hook:
//
// - `src/bindings/Inject.cpp` splices into FrameXML's `Bindings.xml`
// - `src/addons/Embedded.cpp` surfaces the embedded `!!!ClassicAPI`
// addon
//
// Both diverge from what's on disk, so the cumulative hashes the
// engine computes against `Interface\FrameXML.sig` (global) and each
// addon entry's stored signature (per-addon) no longer match. The
// engine calls `FUN_00401560(10)` and tears down the process.
//
// We hook `FUN_00401560` (the fatal-error dispatcher) and drop calls
// with code `10`. Other codes pass through to the original handler
// unmolested — we don't want to mask legitimate fatal conditions
// (login failures, connection drops, etc., which use other codes).
//
// Modded clients (Octo + TLS-bootloader, SuperWoW-style) usually
// neutralize the integrity checks at startup; the engine never
// reaches `FUN_00401560(10)` there and our hook is a harmless no-op.
#include "Game.h"
#include "Offsets.h"
namespace Security::FrameXMLBypass {
namespace {
using FatalError_t = void(__fastcall *)(unsigned code);
FatalError_t FatalError_o = nullptr;
void __fastcall FatalError_h(unsigned code) {
if (code == 10)
return;
FatalError_o(code);
}
const Game::HookAutoRegister _hookreg{
Offsets::FUN_FATAL_ERROR,
reinterpret_cast<void *>(&FatalError_h),
reinterpret_cast<void **>(&FatalError_o)};
} // namespace
} // namespace Security::FrameXMLBypass
+199
View File
@@ -0,0 +1,199 @@
// 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/>.
// `C_Spell.CastAtCursor(spellID)` — cast a ground-target spell at
// the player's current cursor world position, bypassing the manual
// click on the AoE reticle.
//
// Implementation chains the engine's existing primitives:
//
// 1. Initiate the cast the same way `Script_CastSpellByName` does:
// resolve `spellID` (via our `Spell::CastByID` numeric-name hook)
// to a spellbook slot through `FUN_RESOLVE_SPELL_NAME_TO_SLOT`,
// then dispatch through `FUN_SPELL_CAST_DISPATCH`. For a ground-
// target spell, the engine enters placement mode at this point
// (sets `VAR_SPELL_PLACEMENT_STATE`); for a normal cast, it
// fires immediately.
//
// 2. `Spell::AtCursor::Resolve()` then refreshes the cursor's world
// raycast via `FUN_REFRESH_CURSOR_RAYCAST` and commits the
// placement at the resulting world coords via
// `FUN_COMMIT_PLACEMENT_COORDS`. Self-cancelling when the spell
// isn't ground-target (unit-target placement → cancel) or when
// the cursor isn't on terrain (over UI / off-screen → cancel).
//
// Returns `true` from `Resolve()` when we successfully placed at
// cursor; `false` otherwise (no placement was needed, or cursor was
// invalid). The cast itself proceeds either way — `Resolve()`'s
// return tells the caller whether the cursor-placement leg landed.
#include "AtCursor.h"
#include "Game.h"
#include "Offsets.h"
#include "spell/MacroPrimarySpell.h"
#include <cstdint>
#include <cstdio>
#include <cstring>
namespace Spell::AtCursor {
namespace {
using RefreshCursorRaycast_t = void(__fastcall *)(void *clickInfo);
using CommitPlacementCoords_t = unsigned(__fastcall *)(const float *xyz);
using StopPlacement_t = void(__fastcall *)();
bool InWorld() {
// Player resolves only when in-world. Same gate other modules
// (e.g. `Item::Location::ResolveBag`) use to avoid touching
// engine state during loading screens or glue.
using ResolveUnitToken_t = void *(__fastcall *)(const char *);
auto fn = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
return fn("player") != nullptr;
}
void CancelPlacement() {
auto fn = reinterpret_cast<StopPlacement_t>(Offsets::FUN_STOP_PLACEMENT);
fn();
}
} // namespace
bool Resolve() {
const uint32_t state = *reinterpret_cast<const uint32_t *>(
Offsets::VAR_SPELL_PLACEMENT_STATE);
if (state == 0)
return false; // no placement active — nothing to commit
if ((state & Offsets::SPELL_PLACEMENT_GROUND_MASK) == 0) {
// Spell wants a unit / item / corpse / etc. — not ground.
// Cancel so the cursor recovers; caller learns via the false
// return that no cursor-placement happened.
CancelPlacement();
return false;
}
if (!InWorld())
return false;
void *clickInfo = *reinterpret_cast<void *const *>(
Offsets::VAR_WORLDFRAME_CLICK_INFO);
if (clickInfo == nullptr)
return false;
// Re-raycast at the current cursor screen position. Writes the
// new click type to `[clickInfo + OFF_CLICK_INFO_TYPE]` and the
// hit world coords to `[clickInfo + OFF_CLICK_INFO_COORDS]`.
auto refresh = reinterpret_cast<RefreshCursorRaycast_t>(
Offsets::FUN_REFRESH_CURSOR_RAYCAST);
refresh(clickInfo);
const uint32_t hitType = *reinterpret_cast<const uint32_t *>(
static_cast<const uint8_t *>(clickInfo) + Offsets::OFF_CLICK_INFO_TYPE);
if (hitType != 1) {
// Cursor wasn't on terrain (over UI, off-screen, on a unit
// we didn't ask for). Modern `[@cursor]` would error; we
// chose silent cancel-and-return-false.
CancelPlacement();
return false;
}
const float *coords = reinterpret_cast<const float *>(
static_cast<const uint8_t *>(clickInfo) + Offsets::OFF_CLICK_INFO_COORDS);
auto commit = reinterpret_cast<CommitPlacementCoords_t>(
Offsets::FUN_COMMIT_PLACEMENT_COORDS);
commit(coords);
return true;
}
namespace {
using NameToSlot_t = int(__fastcall *)(const char *name, void *out);
using CastDispatch_t = void(__fastcall *)(unsigned slot, int bookType,
unsigned targetGuidLo, float targetGuidHi);
int __fastcall Script_C_Spell_CastAtCursor(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::PushBool(L, false);
return 1;
}
const int spellID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (spellID <= 0) {
Game::Lua::PushBool(L, false);
return 1;
}
// Hand the spellID off as a numeric string — our `Spell::CastByID`
// hook on `FUN_RESOLVE_SPELL_NAME_TO_SLOT` translates pure-numeric
// input into the locale-resolved spell name internally, so the
// engine treats it just like `CastSpellByName("Blizzard")`.
char numBuf[16];
std::snprintf(numBuf, sizeof(numBuf), "%d", spellID);
auto nameToSlot = reinterpret_cast<NameToSlot_t>(
Offsets::FUN_RESOLVE_SPELL_NAME_TO_SLOT);
int bookType = 0;
const int slot = nameToSlot(numBuf, &bookType);
if (slot < 0) {
// Spell not in the player's spellbook — can't cast it from
// here either way. Match `CastSpellByName`'s silent failure.
Game::Lua::PushBool(L, false);
return 1;
}
auto dispatch = reinterpret_cast<CastDispatch_t>(
Offsets::FUN_SPELL_CAST_DISPATCH);
dispatch(static_cast<unsigned>(slot), bookType,
0, 0.0f); // no implicit target unit; engine handles placement
const bool placed = Resolve();
Game::Lua::PushBool(L, placed);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Spell", "CastAtCursor",
&Script_C_Spell_CastAtCursor);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
// Macro-parser pattern: tag macros that call
// `C_Spell.CastAtCursor(<spellID>)` with the spellID, so action-bar
// UIs highlight the slot the same way they do for `/cast SpellName`.
// The Lua call only accepts a numeric spellID, so the extractor just
// reads a numeric literal — no name resolution needed.
int ExtractCastAtCursorArg(const char *p, const char *end) {
while (p < end && (*p == ' ' || *p == '\t'))
++p;
int value = 0;
const char *digitStart = p;
while (p < end && *p >= '0' && *p <= '9') {
if (value > 100000000) // sanity cap before overflow
return 0;
value = value * 10 + (*p - '0');
++p;
}
return (p > digitStart && value > 0) ? value : 0;
}
const Spell::MacroPrimarySpell::PatternAutoRegister _patreg{
"C_Spell.CastAtCursor(", &ExtractCastAtCursorArg};
} // namespace
} // namespace Spell::AtCursor
+33
View File
@@ -0,0 +1,33 @@
// 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/>.
#pragma once
namespace Spell::AtCursor {
// Resolves the engine's in-flight ground-target placement at the
// player's current cursor world position. Intended to be called
// immediately after initiating a ground-target cast (spell or item)
// to skip the manual click. Decision tree:
//
// 1. No placement active → return false (nothing to do)
// 2. Placement is unit-target, not ground → cancel placement, return false
// 3. Cursor not pointed at terrain → cancel placement, return false
// 4. Cursor on terrain → commit at those world coords, return true
//
// Shared by `C_Spell.CastAtCursor` and `C_Item.UseAtCursor`. Safe to
// call from any in-world context — internally gates on a non-NULL
// click-info struct pointer.
bool Resolve();
} // namespace Spell::AtCursor
+27 -130
View File
@@ -42,6 +42,7 @@
#include "Offsets.h"
#include "spell/Arg.h"
#include "spell/Lookup.h"
#include "spell/MacroPrimarySpell.h"
#include <cstdint>
#include <cstdio>
@@ -208,141 +209,37 @@ void RegisterLuaFunctions() {
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
// ---------------------------------------------------------------------------
// Macro parser hook
// Macro parser pattern
//
// Without this hook, a macro whose only cast statement is
// `CastSpellNoToggle("Shoot")` doesn't get tagged in the spell-state
// cache with the resolved spellID, because the engine's parser
// (`FUN_004EFE00`) only knows to look for `/cast` slash-aliases and
// `CastSpellByName("...")`. `IsAutoRepeatAction(macroSlot)` then has
// nothing to match against the active auto-repeat spell, so the slot
// fails to highlight in action-bar UIs (pfUI etc.).
//
// We hook the parser, let the original run, and if it didn't recognize
// any pattern (cache field stays 0), scan the body ourselves for our
// function name and use the engine's own name→spellID resolver to
// fill in the field.
// Tags macros containing `CastSpellNoToggle("Name")` with the
// resolved spellID, so `IsAutoRepeatAction(macroSlot)` reports
// correctly and action-bar UIs (pfUI etc.) highlight the slot for
// auto-repeat spells. `Spell::MacroPrimarySpell` owns the parser
// hook and the line walk; we just declare our `(prefix, extractor)`.
// ---------------------------------------------------------------------------
using MacroParse_t = void(__fastcall *)(int macroEntry);
MacroParse_t MacroParse_o = nullptr;
// Scan a macro body for `CastSpellNoToggle("<name>")` and return the
// resolved spellID, or 0 if no recognized invocation is present.
// Mirrors the engine parser's "first hit wins, line-at-a-time" walk
// order so behavior is consistent with how `/cast` and
// `CastSpellByName` are matched.
int ScanMacroBodyForOurPattern(const char *body) {
static constexpr char kPattern[] = "CastSpellNoToggle(";
static constexpr size_t kPatternLen = sizeof(kPattern) - 1;
const char *line = body;
while (*line != '\0') {
const char *eol = line;
while (*eol != '\0' && *eol != '\n')
++eol;
const char *p = line;
const char *end = eol;
while (p + kPatternLen <= end) {
if (std::memcmp(p, kPattern, kPatternLen) == 0) {
p += kPatternLen;
while (p < end && (*p == ' ' || *p == '\t'))
++p;
if (p < end && *p == '"') {
++p;
const char *q = p;
while (q < end && *q != '"')
++q;
if (q < end) {
const size_t n = static_cast<size_t>(q - p);
if (n > 0 && n < 128) {
char name[128];
std::memcpy(name, p, n);
name[n] = '\0';
const int spellID = Spell::Arg::NameToSpellID(name);
if (spellID > 0)
return spellID;
}
}
}
break; // matched our prefix on this line; don't double-match
}
++p;
}
line = (*eol == '\n') ? eol + 1 : eol;
}
return 0;
int ExtractCastSpellNoToggleArg(const char *p, const char *end) {
while (p < end && (*p == ' ' || *p == '\t'))
++p;
if (p >= end || *p != '"')
return 0;
++p;
const char *q = p;
while (q < end && *q != '"')
++q;
if (q >= end)
return 0;
const size_t n = static_cast<size_t>(q - p);
if (n == 0 || n >= 128)
return 0;
char name[128];
std::memcpy(name, p, n);
name[n] = '\0';
return Spell::Arg::NameToSpellID(name);
}
// The vanilla parser's per-line stack buffer is 256 bytes. When a
// macro line exceeds that, the parser's local at `[EBP-4]` (the saved
// entry pointer) and the saved return address get clobbered with body
// bytes — observed in a crash with `entry@[EBP-4]` reading as ASCII
// `"onTi"` (fragment of `"expirationTime"` from a long `/run` line).
// The bound-check on the line-read function reads correctly in
// Ghidra, so the overflow path is somewhere we couldn't isolate from
// static analysis — we settle for a conservative pre-check that
// skips the original entirely when any line is long enough to risk
// the bug.
//
// 240 (not 256) leaves a safety margin: the parser writes a NUL
// terminator and may walk one or two bytes past in its loop logic,
// so we keep clear of the boundary. Macros that hit this branch
// can't have a `/cast` line anyway (`/cast SpellName(Rank N)` is
// well under 100 bytes), so losing the engine's `/cast` /
// `CastSpellByName` tagging is no loss. We still run our own
// `CastSpellNoToggle` pattern scan since it bound-checks its own
// extraction.
bool BodyHasLongLine(const char *body) {
if (body == nullptr)
return false;
constexpr int kMaxSafeLineBytes = 240;
int lineLen = 0;
for (; *body != '\0'; ++body) {
if (*body == '\n') {
lineLen = 0;
} else if (++lineLen > kMaxSafeLineBytes) {
return true;
}
}
return false;
}
void __fastcall MacroParse_h(int macroEntry) {
const char *body = reinterpret_cast<const char *>(
macroEntry + Offsets::OFF_MACRO_BODY);
int *cacheField = reinterpret_cast<int *>(
macroEntry + Offsets::OFF_MACRO_PRIMARY_SPELL);
if (BodyHasLongLine(body)) {
// Skip the engine parser entirely — it overflows on lines
// longer than its 256-byte buffer. Mark the cache as "no
// recognized cast" (0); our own scan below may still tag it
// if it's a `CastSpellNoToggle("...")` macro.
*cacheField = 0;
const int spellID = ScanMacroBodyForOurPattern(body);
if (spellID > 0)
*cacheField = spellID;
return;
}
MacroParse_o(macroEntry);
if (*cacheField != 0)
return;
const int spellID = ScanMacroBodyForOurPattern(body);
if (spellID > 0)
*cacheField = spellID;
}
const Game::HookAutoRegister _hookreg{
Offsets::FUN_MACRO_PARSE_PRIMARY_SPELL,
reinterpret_cast<void *>(&MacroParse_h),
reinterpret_cast<void **>(&MacroParse_o)};
const Spell::MacroPrimarySpell::PatternAutoRegister _patreg{
"CastSpellNoToggle(", &ExtractCastSpellNoToggleArg};
} // namespace
+145
View File
@@ -0,0 +1,145 @@
// 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/>.
// Owns the hook on `FUN_MACRO_PARSE_PRIMARY_SPELL` and dispatches to
// feature-module-registered `(prefix, extractor)` pairs. The engine's
// parser only recognizes `/cast SpellName` and `CastSpellByName(...)`
// — addons that define new cast wrappers (`CastSpellNoToggle`,
// `C_Spell.CastAtCursor`, etc.) need their macro slots tagged with
// the resolved spellID too, otherwise action-bar UIs lose
// auto-repeat highlight and cooldown overlay.
//
// Each feature module owns an extractor that knows how to read the
// arg shape for its specific function (quoted string, numeric
// literal, etc.). Patterns auto-register at static-init via
// `PatternAutoRegister`. The hook walks each macro line ONCE,
// checking every registered prefix at every position; on a prefix
// match it calls the extractor to resolve a spellID.
//
// Long-line overflow guard: the vanilla parser has a 256-byte
// per-line stack buffer that overflows on `/run` lines longer than
// that. We pre-check and skip the original entirely when any line
// exceeds the safe threshold — the original's tagging is lost in
// that case (no `/cast` on a 240+ byte line anyway) but our
// patterns still run since they bound-check their own extraction.
#include "MacroPrimarySpell.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
#include <cstring>
namespace Spell::MacroPrimarySpell {
namespace {
PatternAutoRegister *g_patternHead = nullptr;
using MacroParse_t = void(__fastcall *)(int macroEntry);
MacroParse_t MacroParse_o = nullptr;
// Vanilla's parser uses a 256-byte stack buffer per line; bytes past
// the buffer clobber the saved entry pointer at `[EBP-4]` and the
// return address. 240 leaves a safety margin.
constexpr int kMaxSafeLineBytes = 240;
bool BodyHasLongLine(const char *body) {
if (body == nullptr)
return false;
int lineLen = 0;
for (; *body != '\0'; ++body) {
if (*body == '\n') {
lineLen = 0;
} else if (++lineLen > kMaxSafeLineBytes) {
return true;
}
}
return false;
}
// Walks each line once. At every byte position, checks every
// registered prefix; on first match, invokes the extractor. First
// extractor that returns a positive spellID wins for the whole macro
// — matches the engine parser's "first hit, line-at-a-time" walk.
int ScanBody(const char *body) {
if (body == nullptr)
return 0;
const char *line = body;
while (*line != '\0') {
const char *eol = line;
while (*eol != '\0' && *eol != '\n')
++eol;
for (const char *p = line; p < eol; ++p) {
for (auto *node = g_patternHead; node != nullptr; node = node->next) {
if (p + node->prefixLen > eol)
continue;
if (std::memcmp(p, node->prefix, node->prefixLen) != 0)
continue;
const int spellID = node->extract(p + node->prefixLen, eol);
if (spellID > 0)
return spellID;
// Prefix matched but extraction failed (malformed
// args). Don't keep checking other prefixes at this
// position — they couldn't possibly also match here.
break;
}
}
line = (*eol == '\n') ? eol + 1 : eol;
}
return 0;
}
void __fastcall MacroParse_h(int macroEntry) {
const char *body = reinterpret_cast<const char *>(
macroEntry + Offsets::OFF_MACRO_BODY);
int *cacheField = reinterpret_cast<int *>(
macroEntry + Offsets::OFF_MACRO_PRIMARY_SPELL);
if (BodyHasLongLine(body)) {
*cacheField = 0;
const int spellID = ScanBody(body);
if (spellID > 0)
*cacheField = spellID;
return;
}
MacroParse_o(macroEntry);
if (*cacheField != 0)
return;
const int spellID = ScanBody(body);
if (spellID > 0)
*cacheField = spellID;
}
const Game::HookAutoRegister _hookreg{
Offsets::FUN_MACRO_PARSE_PRIMARY_SPELL,
reinterpret_cast<void *>(&MacroParse_h),
reinterpret_cast<void **>(&MacroParse_o)};
} // namespace
PatternAutoRegister::PatternAutoRegister(const char *p, ArgExtractor e)
: prefix(p),
prefixLen(static_cast<unsigned>(std::strlen(p))),
extract(e),
next(g_patternHead) {
g_patternHead = this;
}
} // namespace Spell::MacroPrimarySpell
+44
View File
@@ -0,0 +1,44 @@
// 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/>.
#pragma once
namespace Spell::MacroPrimarySpell {
// Parses the argument(s) immediately after a known function-call
// prefix. `p` points to the first byte past the closing `(` of the
// prefix (so for `CastSpellNoToggle("Shoot")`, `p` would be at the
// `"`). `end` is end-of-line. Returns the resolved spellID, or 0 if
// extraction failed (malformed args, unknown spell, etc.).
using ArgExtractor = int(*)(const char *p, const char *end);
// File-scope static auto-registers a `(prefix, extractor)` pair. Each
// feature module that wants its Lua wrapper to tag macros declares:
//
// static const Spell::MacroPrimarySpell::PatternAutoRegister _r{
// "CastSpellNoToggle(", &MyExtractor};
//
// The macro-parser hook walks every line once, checks each registered
// prefix at each position, and on match invokes the extractor to
// resolve a spellID. The resolved ID is written to the macro's
// primary-spell cache field — drives auto-repeat highlight, cooldown
// overlay, etc. in action-bar UIs (pfUI).
struct PatternAutoRegister {
PatternAutoRegister(const char *prefix, ArgExtractor extract);
const char *prefix;
unsigned prefixLen;
ArgExtractor extract;
PatternAutoRegister *next;
};
} // namespace Spell::MacroPrimarySpell