Compare commits

...
9 Commits
Author SHA1 Message Date
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
32 changed files with 1450 additions and 352 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";
+12
View File
@@ -0,0 +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 =
+15 -2
View File
@@ -37,14 +37,14 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [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 +132,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)")
+97
View File
@@ -106,6 +106,7 @@ build instructions.
- [`FocusUnit(unit)`](#focusunitunit)
- [`ClearFocus()`](#clearfocus)
- [Unit token (`focus` / `focustarget`)](#unit-token-focus--focustarget)
- [Bindings (`FOCUSTARGET` / `TARGETFOCUS`)](#bindings-focustarget--targetfocus)
- [FriendList](#friendlist)
- [`C_FriendList.SendWhoQueryByName(name)`](#c_friendlistsendwhoquerybynamename)
@@ -188,6 +189,7 @@ build instructions.
- [`C_Item.RequestLoadItemDataByID(item)` / `C_Item.RequestLoadItemData(itemLocation)`](#c_itemrequestloaditemdatabyiditem--c_itemrequestloaditemdataitemlocation)
- [`C_Item.UnlockAllItems()`](#c_itemunlockallitems)
- [`C_Item.UnlockItem(itemLocation)`](#c_itemunlockitemitemlocation)
- [`C_Item.UseAtCursor(itemInfo)`](#c_itemuseatcursoriteminfo)
- [`C_Item.UseItemByName(itemInfo [, unit])`](#c_itemuseitembynameiteminfo--unit)
- [`Get*ItemID` — companions to the engine's `Get*ItemLink` family](#getitemid--companions-to-the-engines-getitemlink-family)
- [`GetAverageItemLevel()`](#getaverageitemlevel)
@@ -271,6 +273,7 @@ build instructions.
- [`C_Spell.IsSpellHarmful(spellID)` / `C_Spell.IsSpellHelpful(spellID)`](#c_spellisspellharmfulspellid--c_spellisspellhelpfulspellid)
- [`GetSpellSchool(spellID)`](#getspellschoolspellid)
- [`CastSpellNoToggle(name | spellID)`](#castspellnotogglename--spellid)
- [`C_Spell.CastAtCursor(spellID)`](#c_spellcastatcursorspellid)
- [`C_Spell.CancelSpellByID(spellID)` / `CancelSpellByName(name)`](#c_spellcancelspellbyidspellid--cancelspellbynamename)
- [SpellBook](#spellbook)
@@ -2437,6 +2440,29 @@ after `"target"` (matching retail order), so a focused unit's GUID
reverse-resolves to `"focus"` only if it isn't already addressable
as `"player"` / `"party*"` / `"raid*"` / `"nameplate*"` / `"target"`.
### Bindings (`FOCUSTARGET` / `TARGETFOCUS`)
Two key bindings appear in the keybind UI (`Esc` → Key Bindings)
under the **Targeting Functions** group, between `PETATTACK` and the
Interface section:
| Binding | Lua action | Use |
|---------|------------|-----|
| `FOCUSTARGET` | `FocusUnit("target")` | Pin current target as focus |
| `TARGETFOCUS` | `TargetUnit("focus")` | Switch target to the focus |
No default key — assign one in the keybind UI like any other binding.
The two together give you a quick "set focus → swap to focus" loop
common in PvP / heal-tank-while-killing-add scenarios.
Implementation note: addon-side `Bindings.xml` files always render at
the bottom of the keybind list (orphaned from any `header="..."` they
declare) because the engine's binding registry is linearly indexed by
insertion order and addons load after FrameXML. To land inside the
TARGETING block, the DLL splices the two `<Binding>` entries into the
engine's `Interface\FrameXML\Bindings.xml` at file-read time via a
hook on `FUN_FILE_READ` — see [`src/bindings/Inject.cpp`](../src/bindings/Inject.cpp).
## FriendList
### `C_FriendList.SendWhoQueryByName(name)`
@@ -4266,6 +4292,37 @@ sweep is logout). This call gives you an in-session escape hatch.
> update will set the lock right back. For cursor-cancel semantics,
> pair with vanilla-native `ClearCursor()`.
### `C_Item.UseAtCursor(itemInfo)`
Uses `itemInfo` at the player's current cursor world position —
ClassicAPI's `[@cursor]` analog for ground-target on-use items
(Iron Grenade, Bombling, demolition charges, etc.). Returns `true`
when the cursor-placement leg landed (the item fires at terrain);
`false` for items that aren't ground-target (the item still fires
normally with no implicit target), unparseable input, items not in
bags, cursor over UI / off-screen, etc.
`itemInfo` accepts the same shapes as
[`C_Item.UseItemByName`](#c_itemuseitembynameiteminfo--unit) — itemID,
bare `"item:N"`, full chat link, or localized name.
```lua
C_Item.UseAtCursor(4068) -- Iron Grenade at cursor
C_Item.UseAtCursor("Iron Grenade")
```
Implementation chains the existing item-use path
(`Item::Location::FindByArgInBags` + `FUN_ITEM_USE`) with
[`Spell::AtCursor::Resolve`](#c_spellcastatcursorspellid) — same
cursor-resolution helper `C_Spell.CastAtCursor` uses. When the item
fires a non-ground-target spell, the cursor leg no-ops and returns
`false`; the item still uses normally (any implicit target — current
selection, etc. — applies).
Cancels placement automatically when the cursor isn't on terrain —
the item-use packet is never sent, so an off-screen click doesn't
waste the grenade.
### `C_Item.UseItemByName(itemInfo [, unit])`
Finds the first item in the player's bags matching `itemInfo` and
@@ -6589,6 +6646,46 @@ Using this from inside a macro action slot? See
[`CastSpellNoToggle` as a macro cast line](#castspellnotoggle-as-a-macro-cast-line) below for the additional
parser support that makes the slot tag correctly for action-bar UIs.
### `C_Spell.CastAtCursor(spellID)`
Casts a ground-target spell at the player's current cursor world
position, bypassing the manual click on the AoE reticle the engine
would otherwise require. ClassicAPI's analog of modern's
`/cast [@cursor] Blizzard`. Returns `true` when the cursor-placement
leg landed; `false` for non-ground-target spells (cast still fires
normally on the current target), unknown spellIDs, cursor over UI /
off-screen, etc.
```lua
C_Spell.CastAtCursor(10) -- Blizzard rank 1 at cursor
C_Spell.CastAtCursor(2120) -- Flamestrike rank 1 at cursor
```
Two-stage internally:
1. Initiates the cast through the same path
[`CastSpellByName`](docs/API.md#numeric-spellids-in-cast-and-castspellbyname)
uses (`FUN_RESOLVE_SPELL_NAME_TO_SLOT` + the engine's cast
dispatcher). If the spell needs a ground target, the engine
enters placement mode and arms the AoE reticle.
2. Refreshes the cursor's screen→world raycast via
`FUN_REFRESH_CURSOR_RAYCAST` (the same internal the engine fires
on every mouse move when placement is active), confirms the
cursor hit terrain (not a UI frame or a unit), and commits
placement via `FUN_COMMIT_PLACEMENT_COORDS` with the resolved
`(x, y, z)`. Cancels placement cleanly via `FUN_STOP_PLACEMENT`
when the cursor isn't on terrain or when the spell wanted a
unit-target rather than ground.
For non-placement spells (regular target casts, self-buffs, etc.)
the cast fires normally; the placement-resolve no-ops since the
engine never set the placement flag, and we return `false`.
The companion item version is
[`C_Item.UseAtCursor`](#c_itemuseatcursoriteminfo) — same chain via
the item-use path for grenades / on-use ground-target items.
### `C_Spell.CancelSpellByID(spellID)` / `CancelSpellByName(name)`
Cancel a buff on the player by spellID or by spell name. Modern WoW's
+103
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,
@@ -2041,6 +2073,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)
@@ -2050,6 +2143,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>`,
+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>
+185 -47
View File
@@ -11,7 +11,7 @@
// 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 the engine's TARGETING group.
// 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
@@ -22,30 +22,36 @@
// renders orphaned at the very bottom of the keybind list rather than
// under TARGETING.
//
// To land inside TARGETING, the binding's display index has to fall
// between HEADER_TARGETING and HEADER_INTERFACE. 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.
// 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.
//
// Splice site: just before `<Binding name="TOGGLECHARACTER0"` (the
// first INTERFACE-section entry). Our injected bindings inherit
// TARGETING from the preceding `header="TARGETING"` declaration on
// `TARGETNEARESTENEMY` (the engine never re-asserts a header on each
// binding — group membership is positional). They appear at the tail
// of TARGETING, just after PETATTACK / ATTACKTARGET.
// 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 HEADER_TARGETING and a sequence of
// TARGETING entries; ours append to that sequence and HEADER_INTERFACE
// follows.
// 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 {
@@ -54,21 +60,154 @@ namespace {
constexpr const char *kFrameXMLBindingsPath = "Interface\\FrameXML\\Bindings.xml";
// First INTERFACE-section binding. Splicing immediately before this
// puts our entries at the tail of the TARGETING block.
constexpr const char *kSpliceMarker = "<Binding name=\"TOGGLECHARACTER0\"";
// 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;
};
// LF-only line endings — vanilla's XML parser is whitespace-tolerant
// and accepts either LF or CRLF freely. Indentation matches the
// surrounding file (one leading tab per `<Binding>`).
constexpr const char *kInjectedXml =
"<Binding name=\"FOCUSTARGET\">\n"
"\t\tFocusUnit(\"target\");\n"
"\t</Binding>\n"
"\t<Binding name=\"TARGETFOCUS\">\n"
"\t\tTargetUnit(\"focus\");\n"
"\t</Binding>\n"
"\t";
// 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 '\\';
@@ -87,16 +226,6 @@ bool PathEqualsCI(const char *a, const char *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);
const char *FindMarker(const char *content, size_t size, const char *marker) {
const size_t markerLen = std::strlen(marker);
if (size < markerLen) return nullptr;
for (size_t i = 0; i + markerLen <= size; ++i) {
if (std::memcmp(content + i, marker, markerLen) == 0)
return content + i;
}
return nullptr;
}
} // namespace
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
@@ -114,20 +243,29 @@ bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
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 = FindMarker(content, origSize, kSpliceMarker);
const char *splice = FindSplicePosition(content, origSize, config.header);
if (splice == nullptr) {
// Marker missing — likely a modified or non-vanilla FrameXML
// (Turtle patch level, custom build). Pass the original
// through unchanged rather than corrupting it.
// 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 injectLen = std::strlen(kInjectedXml);
const size_t splicePos = static_cast<size_t>(splice - content);
const size_t newSize = origSize + injectLen;
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);
@@ -143,8 +281,8 @@ bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
auto *dest = static_cast<char *>(newBuf);
std::memcpy(dest, content, splicePos);
std::memcpy(dest + splicePos, kInjectedXml, injectLen);
std::memcpy(dest + splicePos + injectLen,
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);
+16
View File
@@ -74,6 +74,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
+143 -28
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);
@@ -148,18 +138,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;
}
+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));
+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`
+3 -51
View File
@@ -45,21 +45,15 @@
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include "item/ID.h"
#include "item/Location.h"
#include <cstdint>
#include <cstring>
#include <string.h>
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);
@@ -130,62 +124,20 @@ uint64_t ResolveUnitGuid(void *L, int idx) {
return fn(token);
}
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);
}
// 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;
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;
}
}
}
return false;
}
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 targetGuid = ResolveUnitGuid(L, 2);
auto useItem = reinterpret_cast<UseItem_t>(Offsets::FUN_ITEM_USE);
useItem(cgItem, &targetGuid, 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