Compare commits

..
17 Commits
Author SHA1 Message Date
Brues 9b5d12c8a1 Document C_Spell.CastAtCursor / C_Item.UseAtCursor 2026-06-03 14:55:00 -05:00
Brues d1bab7a3a4 Add C_Spell.CastAtCursor / C_Item.UseAtCursor
Vanilla 1.12.1 requires a manual click on the AoE reticle to land a
ground-target spell. This pair of functions fires the cast (or
on-use item spell) and immediately commits placement at the
cursor's current world position, matching modern `[@cursor]`
semantics:

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

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

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

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

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

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

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

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

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

Eliminates the "trinket A in 14, B in bags" failure mode where an
earlier bag-to-paperdoll swap displaced an item later needed by the
set, then stale FindGUID returned the pre-swap paperdoll location.
2026-05-30 22:23:32 -05:00
34 changed files with 2409 additions and 951 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
+4
View File
@@ -1,6 +1,10 @@
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() ~= "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 =
+16 -3
View File
@@ -35,16 +35,16 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex` |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura` |
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [Input](docs/API.md#input) | `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Input](docs/API.md#input) | `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [Macros](docs/API.md#macros) | `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
| [Map](docs/API.md#map) | `C_Map.GetBestMapForUnit` |
| [MerchantFrame](docs/API.md#merchantframe) | `C_MerchantFrame.GetBuybackItemID`, `C_MerchantFrame.GetItemInfo`, `C_MerchantFrame.GetNumJunkItems`, `C_MerchantFrame.IsMerchantItemRefundable`, `C_MerchantFrame.IsSellAllJunkEnabled`, `C_MerchantFrame.SellAllJunkItems` |
| [NamePlate](docs/API.md#nameplate) | `C_NamePlate.GetNamePlateForGUID`, `C_NamePlate.GetNamePlateForUnit`, `C_NamePlate.GetNamePlateGUIDs`, `C_NamePlate.GetNamePlates` |
| [NameCache](docs/API.md#namecache) | `C_CreatureInfo.GetCreatureID`, `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `GetPlayerInfoByGUID` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellName`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellName`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsStealthed`, `IsSwimming` |
| [Table](docs/API.md#table) | `table.wipe` |
@@ -132,6 +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)")
+902 -754
View File
File diff suppressed because it is too large Load Diff
+112
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,
@@ -1488,6 +1500,35 @@ enum Offsets {
VAR_SKILL_LINE_COUNT = 0x00C0D930,
OFF_SKILL_LINE_NAME = 0x0C,
// Player's known-skills table — separate from the DBC. Populated
// by the engine on login from server data; entries are pointers
// to per-skill records. Each record's `+0x00` is the
// `SkillLine.dbc` row (or `0` for header / category rows), and
// `+0x10` is the current skill rank (other rank/modifier fields
// follow). Source: `Script_GetSkillLineInfo` at `0x004D3610`,
// which walks this array indexed by the Lua arg.
VAR_PLAYER_SKILL_LIST = 0x00B7318C,
VAR_PLAYER_SKILL_COUNT = 0x00B731B8,
OFF_PLAYER_SKILL_LINE_ID = 0x00,
OFF_PLAYER_SKILL_RANK = 0x10,
// Per-itemClass proficiency bitmap maintained by the engine.
// 17 u32 entries (indexed by Item.dbc `m_class`, 0..16). Each
// entry is a bitmask where bit N is set if the player has
// proficiency in subclass N of that item class — i.e.
// `table[4] & (1 << 3)` = "can wear Mail" (armor=class 4,
// mail=subclass 3).
//
// Populated by SMSG_SET_PROFICIENCY's handler at
// `FUN_005E7B70`, which writes `table[itemClass] = mask`
// verbatim from the packet. Same source the engine itself uses
// for equip eligibility (the tooltip "Mail" red-text logic
// and the cursor-drop-on-paperdoll check both read this). Note:
// covers **weapons too** (class 2), making it strictly more
// general than walking the per-skill-line table for individual
// armor proficiencies.
VAR_PROFICIENCY_TABLE = 0x00C4D4A0,
// Spell.dbc `BaseLevel` — the level a spell becomes available
// (trainer offers it, quest reward grants it, etc.). Distinct
// from `SpellLevel` at +0x74 which is the effective level used
@@ -2012,6 +2053,67 @@ enum Offsets {
// to cast next", so we check this slot too.
VAR_QUEUED_CAST_SPELL = 0x00CECAA8,
// Spell-placement state bitmask. Non-zero when the engine is
// waiting on user input for an in-progress cast — the AoE reticle,
// unit-target cursor, trade-target cursor, etc. Bits encode what
// kind of input is needed; the engine's click handlers read this
// to decide whether a click was relevant. Read by
// `Script_SpellIsTargeting` via `FUN_006E48A0` (returns
// `state != 0`). Bits the engine OR-sets via `FUN_006E5250` when
// entering placement (subset relevant to us):
// - `0x20` source world location needed
// - `0x40` dest world location needed
// The `0x60` mask therefore identifies "ground-target placement"
// (vs. unit-target, trade-target, etc.). See `Spell::AtCursor`.
VAR_SPELL_PLACEMENT_STATE = 0x00CECAC0,
SPELL_PLACEMENT_GROUND_MASK = 0x60,
// `FUN_006E60F0(float *xyz)` — commits the in-flight ground-target
// placement at the given world position. Reads three contiguous
// floats from `xyz`; checks the placement-state mask
// (`VAR_SPELL_PLACEMENT_STATE`) to decide whether this is a
// source-coord (`0x20`) or dest-coord (`0x40`) commit; clears the
// matching bit and stores the coords into engine globals which the
// cast-finalize path then reads. Self-gates when neither bit is
// set — safe no-op for unit-target placement. Called by the engine
// itself when a player left-clicks ground while the reticle is up.
FUN_COMMIT_PLACEMENT_COORDS = 0x006E60F0,
// `FUN_006E4900()` — cancels in-flight placement mode. Internally
// checks `VAR_SPELL_PLACEMENT_STATE != 0` and, if so, fires the
// cast-cancel path via `FUN_006E4940(0x1C)`. Safe to call when no
// placement is active (no-ops). Wraps the action behind
// `Script_SpellStopTargeting`.
FUN_STOP_PLACEMENT = 0x006E4900,
// `*VAR_WORLDFRAME_CLICK_INFO` is a pointer to the engine's
// CWorldFrame "click info" struct — the data block where the input
// dispatcher stashes "what is the cursor pointed at right now"
// after every mouse motion. Allocated at world enter, populated
// continuously by the rendering pipeline (viewport bounds and
// camera position at fixed offsets) and the OS message pump
// (cursor screen pos). NULL when not in world.
// - `+0x350` (uint32) click type after last raycast:
// `0` = no hit
// `1` = world / terrain hit
// `2` = unit / object hit
// - `+0x360..+0x368` (3 floats) hit world position (when type == 1)
VAR_WORLDFRAME_CLICK_INFO = 0x00B4B2BC,
OFF_CLICK_INFO_TYPE = 0x350,
OFF_CLICK_INFO_COORDS = 0x360,
// `FUN_00481F00(void *clickInfo)` — refreshes the cursor's "what
// am I pointed at?" state on demand. Internally reads cursor
// screen pos from input state at `[clickInfo+0xA0]+0x1118/+0x111C`,
// calls the screen→world ray builder at `FUN_004813B0` (uses
// viewport bounds at `[clickInfo+0x390..+0x39C]` plus camera
// position at `[clickInfo+0x65B8]+8/+0xC/+0x10`), then raycasts
// via `FUN_0069BFF0` (terrain + WMO intersection). Writes click
// type to `[clickInfo+0x350]` and hit coords to
// `[clickInfo+0x360..+0x368]`. Safe to call from arbitrary code
// as long as the player is in-world.
FUN_REFRESH_CURSOR_RAYCAST = 0x00481F00,
// `Script_CastSpellByName` — engine's Lua wrapper for the
// `CastSpellByName(name [, onSelf])` global. `int __fastcall(void *L)`
// — standard Lua C function ABI. Reads `name` from stack[1] (string)
@@ -2021,6 +2123,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>`,
+9
View File
@@ -47,6 +47,7 @@
#include "Game.h"
#include "Offsets.h"
#include "bindings/Inject.h"
#include <cstdint>
#include <cstdio>
@@ -248,6 +249,14 @@ void DecideSource() {
int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
size_t *outSize, size_t extraBytes,
int flag1, int flag2) {
// FrameXML Bindings.xml gets an inline splice for our TARGETING
// additions — see `bindings/Inject.cpp`. Returns true with the
// patched buffer ready for the engine's binding parser to consume.
if (Bindings::Inject::TryHandle(unused, path, outBuf, outSize,
extraBytes, flag1, flag2, FileRead_o)) {
return 1;
}
const char *suffix = StripAddonPrefix(path);
if (suffix == nullptr) {
// Path isn't under `Interface\AddOns\!!!ClassicAPI\` — straight
+12 -1
View File
@@ -115,13 +115,24 @@ int PushFieldOrNil(void *L, FieldByName_t accessor) {
// `C_AddOns.GetAddOnName(indexOrName)` — the addon's directory name.
// For numeric input, returns the engine's canonical casing. For
// string input, returns the input verbatim once existence is
// confirmed.
// confirmed; nil for an unregistered name.
//
// String-input existence isn't free: `ResolveAddOnName` echoes the
// string for the field accessors below (`GetAddOnTitle`, etc.) which
// short-circuit cleanly on unknown names via the engine's own NULL-
// return paths. `GetAddOnName` doesn't call any engine accessor, so
// it'd otherwise echo any string. Probe the registry explicitly.
int __fastcall Script_GetAddOnName(void *L) {
const uint8_t *entry = ResolveAddOnName(L);
if (entry == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_STRING &&
!AddOnExistsByName(reinterpret_cast<const char *>(entry))) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushString(L, reinterpret_cast<const char *>(entry));
return 1;
}
+15
View File
@@ -0,0 +1,15 @@
<!-- Spliced into the engine's Interface\FrameXML\Bindings.xml at
load time by src/bindings/Inject.cpp. The `header` attribute on
the root tag tells the splicer which engine section to extend —
it picks the appropriate insertion point so the bindings appear
at the tail of that group in the keybind UI. The engine's own
XML parser ignores attributes on the root tag, so this is
metadata for the splicer only. -->
<Bindings header="TARGETING">
<Binding name="FOCUSTARGET">
FocusUnit("target");
</Binding>
<Binding name="TARGETFOCUS">
TargetUnit("focus");
</Binding>
</Bindings>
+297
View File
@@ -0,0 +1,297 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Inject ClassicAPI-owned bindings into a chosen engine section.
//
// Why a file-read hook rather than an addon-side Bindings.xml: vanilla
// stores every binding in a single linearly-indexed table, and the
// keybind UI walks that table in index order with `HEADER_*` pseudo-
// entries splitting groups. Addon Bindings.xml files get parsed
// AFTER FrameXML, so even with `header="TARGETING"` an addon binding
// receives a display index higher than every engine binding — it
// renders orphaned at the very bottom of the keybind list rather than
// under TARGETING.
//
// To land inside a specific section, the binding's display index has
// to fall between that section's `HEADER_*` entry and the next one.
// The cleanest way to arrange that is to splice the bindings into the
// FrameXML file the engine itself parses, so they take their indices
// from the same running counter every native binding does.
//
// What gets spliced is driven by `src/bindings/Bindings.xml`. The
// root `<Bindings header="X">` tag names the engine section to extend
// (e.g. `"TARGETING"`); the inner `<Binding>` children are spliced
// verbatim. The splicer locates `header="X"` in the engine file
// (which appears on the first binding of section X), then the next
// `header="..."` after that (start of the following section), and
// inserts our content just before that next-section marker — so our
// entries land at the tail of section X. If X is the last section in
// the file, the splice goes just before `</Bindings>`.
//
// The engine's binding parser is `FUN_004b6f70`. It calls
// `FUN_FILE_READ` (`0x00648620`) to load the file, then walks `<Binding>`
// elements assigning each the next display index from a running
// counter. By the time our injected bindings are processed, the
// engine has already emitted the section's `HEADER_*` and a sequence
// of native entries; ours append to that sequence and the next
// section's header follows.
#include "Inject.h"
#include "Offsets.h"
#include "embedded_bindings.h"
#include <cstdio>
#include <cstring>
namespace Bindings::Inject {
namespace {
constexpr const char *kFrameXMLBindingsPath = "Interface\\FrameXML\\Bindings.xml";
// Parsed view of `src/bindings/Bindings.xml`:
// - `header` is the value of `header="..."` on the root `<Bindings>`
// tag. Tells the splicer which engine section to extend.
// - `innerData / innerSize` point at the bytes between the
// `<Bindings ...>` opening and `</Bindings>` closing tags.
//
// All-zero fields mean "embedded buffer is malformed" — defensive,
// shouldn't happen since the source is build-time-validated. Splice
// is skipped (engine sees its original Bindings.xml unchanged).
struct EmbeddedConfig {
char header[64];
const char *innerData;
size_t innerSize;
};
// Advances `p` past any `<!-- ... -->` comment block starting at the
// current position. Returns the new position (past the closing `-->`)
// if a comment was skipped, or `p` unchanged if no comment was at
// the current position. Caps at `end` if the comment is unterminated.
const char *SkipCommentIfAny(const char *p, const char *end) {
if (p + 4 > end) return p;
if (std::memcmp(p, "<!--", 4) != 0) return p;
p += 4;
while (p + 3 <= end) {
if (std::memcmp(p, "-->", 3) == 0)
return p + 3;
++p;
}
return end;
}
// Linear scan for `marker` in `[start, end)`, skipping any `<!--...-->`
// comment blocks (where unrelated occurrences of XML tags might live).
const char *FindOutsideComment(const char *start, const char *end, const char *marker) {
const size_t markerLen = std::strlen(marker);
const char *p = start;
while (p + markerLen <= end) {
const char *afterComment = SkipCommentIfAny(p, end);
if (afterComment != p) {
p = afterComment;
continue;
}
if (std::memcmp(p, marker, markerLen) == 0)
return p;
++p;
}
return nullptr;
}
// Parses `header="X"` out of the byte range `[tagStart, tagEnd)` (the
// opening `<Bindings ...>` tag). Copies the value into `out` (size
// `outSize`) and returns true on success.
bool ExtractHeaderAttr(const char *tagStart, const char *tagEnd,
char *out, size_t outSize) {
static constexpr const char kAttr[] = "header=\"";
constexpr size_t kAttrLen = sizeof(kAttr) - 1;
for (const char *p = tagStart; p + kAttrLen < tagEnd; ++p) {
if (std::memcmp(p, kAttr, kAttrLen) != 0) continue;
const char *valStart = p + kAttrLen;
const char *valEnd = valStart;
while (valEnd < tagEnd && *valEnd != '"') ++valEnd;
const size_t len = static_cast<size_t>(valEnd - valStart);
if (len == 0 || len + 1 > outSize) return false;
std::memcpy(out, valStart, len);
out[len] = '\0';
return true;
}
return false;
}
EmbeddedConfig ResolveEmbeddedConfig() {
EmbeddedConfig out = {};
const char *content = reinterpret_cast<const char *>(kEmbeddedBindingsXml);
const char *end = content + kEmbeddedBindingsXmlSize;
// Locate `<Bindings` opening tag (skipping any comment blocks
// that mention the tag literally for documentation purposes).
const char *open = FindOutsideComment(content, end, "<Bindings");
if (open == nullptr) return out;
// Find the closing `>` of the opening tag.
const char *tagEnd = open;
while (tagEnd < end && *tagEnd != '>') ++tagEnd;
if (tagEnd == end) return out;
// Pull out the `header="..."` attribute. Required — this tells
// the splicer which engine section to extend.
if (!ExtractHeaderAttr(open, tagEnd, out.header, sizeof(out.header)))
return out;
const char *innerStart = tagEnd + 1;
const char *close = FindOutsideComment(innerStart, end, "</Bindings>");
if (close == nullptr || close < innerStart) {
out.header[0] = '\0';
return out;
}
out.innerData = innerStart;
out.innerSize = static_cast<size_t>(close - innerStart);
return out;
}
// Computes the byte offset in the engine's Bindings.xml buffer where
// our entries should be spliced — i.e., the tail of the section
// identified by `headerName`. Strategy:
//
// 1. Find `header="<HEADER>"` in the engine's buffer (skipping
// comments). This is the attribute the engine itself uses on
// the FIRST binding of each section, so this finds where that
// section *starts*.
// 2. Find the NEXT `header="` after that. That marks the start of
// the FOLLOWING section.
// 3. Back up from that next-header position to the `<` of the
// `<Binding` tag containing it — that's our splice point.
// 4. If there's no next header (last section in the file), splice
// just before `</Bindings>`.
//
// Returns `nullptr` if step 1 fails (header missing from engine file
// — e.g., a heavily-modified FrameXML). Caller falls back to passing
// the original buffer through unchanged.
const char *FindSplicePosition(const char *content, size_t size,
const char *headerName) {
const char *end = content + size;
char headerNeedle[80];
std::snprintf(headerNeedle, sizeof(headerNeedle),
"header=\"%s\"", headerName);
const char *sectionStart = FindOutsideComment(content, end, headerNeedle);
if (sectionStart == nullptr) return nullptr;
// Walk past the matched attribute, then look for the next
// `header="` anywhere later in the file.
const char *afterCurrent = sectionStart + std::strlen(headerNeedle);
const char *nextHeader = FindOutsideComment(afterCurrent, end, "header=\"");
if (nextHeader != nullptr) {
// Back up to the `<` of the `<Binding` tag that owns this
// next-header attribute — that `<` is where the next section
// visually starts, and we splice immediately before it.
const char *p = nextHeader;
while (p > content && *p != '<') --p;
return p;
}
// Last section in the file — splice just before `</Bindings>`.
return FindOutsideComment(content, end, "</Bindings>");
}
char NormalizeChar(char c) {
if (c == '/') return '\\';
if (c >= 'A' && c <= 'Z') return static_cast<char>(c + 32);
return c;
}
bool PathEqualsCI(const char *a, const char *b) {
while (*a != '\0' && *b != '\0') {
if (NormalizeChar(*a) != NormalizeChar(*b)) return false;
++a; ++b;
}
return *a == *b;
}
using SMemAlloc_t = void *(__stdcall *)(size_t size, const char *file, int line, int flags);
using SMemFree_t = void(__stdcall *)(void *buf, const char *file, int line, int flags);
} // namespace
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
size_t extraBytes, int flag1, int flag2, FileReadFn original) {
if (path == nullptr || !PathEqualsCI(path, kFrameXMLBindingsPath))
return false;
// Pull the real file through the original reader with the caller's
// requested `extraBytes` honored — the original allocates a Storm
// buffer of `fileSize + extraBytes` and zero-fills the tail.
void *origBuf = nullptr;
size_t origSize = 0;
const int readOk = original(unused, path, &origBuf, &origSize,
extraBytes, flag1, flag2);
if (readOk == 0 || origBuf == nullptr)
return false;
static const EmbeddedConfig config = ResolveEmbeddedConfig();
if (config.innerData == nullptr || config.innerSize == 0 ||
config.header[0] == '\0') {
// Embedded XML is missing the `<Bindings header="...">` root
// or the `</Bindings>` close tag. Pass the original through.
if (outBuf != nullptr) *outBuf = origBuf;
if (outSize != nullptr) *outSize = origSize;
return true;
}
const char *content = static_cast<const char *>(origBuf);
const char *splice = FindSplicePosition(content, origSize, config.header);
if (splice == nullptr) {
// Requested section isn't present in this engine file (heavily
// modified FrameXML or different build). Pass through unchanged
// rather than risking corruption.
if (outBuf != nullptr) *outBuf = origBuf;
if (outSize != nullptr) *outSize = origSize;
return true;
}
const size_t splicePos = static_cast<size_t>(splice - content);
const size_t newSize = origSize + config.innerSize;
auto SMemAlloc = reinterpret_cast<SMemAlloc_t>(Offsets::FUN_STORM_SMEM_ALLOC);
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
void *newBuf = SMemAlloc(newSize + extraBytes, __FILE__, __LINE__, 0);
if (newBuf == nullptr) {
// Allocation failed — fall back to the original buffer so the
// engine still loads its own bindings.
if (outBuf != nullptr) *outBuf = origBuf;
if (outSize != nullptr) *outSize = origSize;
return true;
}
auto *dest = static_cast<char *>(newBuf);
std::memcpy(dest, content, splicePos);
std::memcpy(dest + splicePos, config.innerData, config.innerSize);
std::memcpy(dest + splicePos + config.innerSize,
content + splicePos, origSize - splicePos);
if (extraBytes > 0)
std::memset(dest + newSize, 0, extraBytes);
SMemFree(origBuf, __FILE__, __LINE__, 0);
if (outBuf != nullptr) *outBuf = newBuf;
if (outSize != nullptr) *outSize = newSize;
return true;
}
} // namespace Bindings::Inject
+37
View File
@@ -0,0 +1,37 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstddef>
namespace Bindings::Inject {
// `FUN_FILE_READ` shape — Storm-style: __stdcall, returns 1 on success
// with the buffer allocated via SMemAlloc into `*outBuf` (size in
// `*outSize`, plus `extraBytes` of trailing zero padding).
using FileReadFn = int(__stdcall *)(int unused, const char *path,
void **outBuf, size_t *outSize,
size_t extraBytes,
int flag1, int flag2);
// Path-matches `Interface\FrameXML\Bindings.xml`. On match: fetches
// the real file via `original`, splices our ClassicAPI bindings into
// the TARGETING block, and returns `true` with `*outBuf` / `*outSize`
// set to a fresh Storm buffer the caller can SMemFree normally.
// Returns `false` for any other path — caller should fall through to
// its own logic.
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
size_t extraBytes, int flag1, int flag2, FileReadFn original);
} // namespace Bindings::Inject
+129 -38
View File
@@ -331,6 +331,19 @@ int __fastcall Script_ClearIgnoredSlotsForSave(void *L) {
// would race with). Reads the per-CGItem instance flag at
// `OFF_ITEM_CLIENT_LOCK` bit 0 — same bit `C_Item.IsLocked` checks
// and the engine's own pickup/unlock paths manipulate.
//
// Gates on `Locations::FindGUID` BEFORE consulting the lock flag:
// for items the player has deleted/mailed/traded away, the engine's
// CGItem cache often retains a stale entry that `ResolveByGUID`
// still hands back, but the inventory walk no longer finds the GUID
// anywhere. Reading the lock flag off the stale CGItem reflects
// whatever bits were last set on it (frequently the "locked"
// in-transaction state from when it was being deleted/swapped),
// which would falsely report the set as "contains locked items"
// and prevent `UseEquipmentSet` from running through addon
// wrappers that gate on this check. The inventory presence check
// matches modern semantics: a missing item is *missing*, not
// *locked*.
int __fastcall Script_EquipmentSetContainsLockedItems(void *L) {
const uint32_t setID = ArgSetID(L, 1);
const Set *s = Data::FindByID(setID);
@@ -341,6 +354,8 @@ int __fastcall Script_EquipmentSetContainsLockedItems(void *L) {
const uint64_t g = s->items[i];
if (g == GUID_EMPTY || g == GUID_IGNORED)
continue;
if (Locations::FindGUID(g) == 0)
continue; // not in inventory — treat as missing, not locked
const uint8_t *item = Locations::ResolveItemByGUID(g);
if (item == nullptr)
continue;
@@ -424,60 +439,136 @@ int __fastcall Script_UseEquipmentSet(void *L) {
}
int nextFreeSlot = 0;
// Pre-snapshot each set item's location against the ORIGINAL
// state, before any swap packets fly. This mirrors how 4.3.4's
// `Script_UseEquipmentSet` (`0x009A15D0`) builds its action list:
// resolve all 19 source locations up front, then execute. The
// engine's CGItem / InvMgr state lags behind our packet sends
// (waits for server ACK), so any `FindGUID` mid-execution would
// return stale answers and we'd re-resolve items into the wrong
// place.
int sourceLoc[SLOT_COUNT];
for (int i = 0; i < SLOT_COUNT; ++i) {
const uint64_t g = guids[i];
if (g == GUID_EMPTY || g == GUID_IGNORED) {
sourceLoc[i] = 0;
continue;
}
sourceLoc[i] = Locations::FindGUID(g);
}
// Virtual paperdoll occupant model — seeded from the engine's
// initial paperdoll GUID array, then updated in-place after each
// swap so phase 1 can chain rotations and phase 3 can spot
// already-empty slots.
uint64_t virtualPaperdoll[SLOT_COUNT];
Locations::SnapshotPaperdoll(virtualPaperdoll);
// Locates the current virtual slot of a GUID on the paperdoll,
// or 0 if it's not currently there. Used during phase 1 so
// chained paperdoll-to-paperdoll moves operate on the *current*
// virtual position, not the stale pre-snapshot.
auto currentPaperdollSlot = [&](uint64_t g) -> int {
if (g == 0)
return 0;
for (int slot = 1; slot <= SLOT_COUNT; ++slot) {
if (virtualPaperdoll[slot - 1] == g)
return slot;
}
return 0;
};
// ── Phase 1: paperdoll → paperdoll ─────────────────────────────
// Move items already on the paperdoll into their target slots.
// Source and destination are both paperdoll, so atomic swaps
// never touch bag state — phase 2's bag lookups stay accurate.
for (int i = 0; i < SLOT_COUNT; ++i) {
const int targetSlot = i + 1;
const uint64_t g = guids[i];
// GUID_IGNORED: leave whatever the player has in this slot
// as-is. Distinct from GUID_EMPTY (= 0), which means "this
// slot should be empty after the swap" — handled below.
if (g == GUID_IGNORED)
if (g == GUID_EMPTY || g == GUID_IGNORED)
continue;
if (g == GUID_EMPTY) {
// Set wants this slot empty. If it already is, no-op.
// Otherwise unequip whatever's there into the next bag
// slot we reserved during the pre-loop snapshot. If
// we've exhausted the snapshot, silently skip — matches
// the modern API which surfaces no per-slot reason.
const uint8_t *equipped =
Item::Location::ResolveEquipmentSlot(targetSlot);
if (equipped == nullptr)
continue;
if (nextFreeSlot >= freeSlotCount)
continue;
const FreeSlot &fs = freeSlots[nextFreeSlot++];
Item::Swap::ToBag(equipped, targetSlot, fs.bag, fs.slot);
continue;
}
const int loc = Locations::FindGUID(g);
const int loc = sourceLoc[i];
if (loc == 0)
continue; // missing — nothing to do
// Already in its target slot? Skip.
if ((loc & LOC_BAGS) == 0 && (loc & LOC_BANK) == 0 &&
(loc & LOC_SLOT_MASK) == targetSlot)
continue;
// In bank (main or bank-bag) — server rejects equip-from-bank.
if (loc & LOC_BANK)
continue;
continue; // server rejects equip-from-bank
if (loc & LOC_BAGS)
continue; // phase 2
const int srcSlot = currentPaperdollSlot(g);
if (srcSlot == 0 || srcSlot == targetSlot)
continue; // missing or already there
const uint8_t *item = Locations::ResolveItemByGUID(g);
if (item == nullptr)
continue;
if (loc & LOC_BAGS) {
const int srcBag = (loc >> LOC_BAG_SHIFT) & 0xFF;
const int srcSlot = loc & LOC_SLOT_MASK;
Item::Swap::FromBag(item, srcBag, srcSlot, targetSlot);
} else {
const int srcSlot = loc & LOC_SLOT_MASK;
Item::Swap::FromPaperdoll(item, srcSlot, targetSlot);
const uint64_t displaced = virtualPaperdoll[targetSlot - 1];
if (Item::Swap::FromPaperdoll(item, srcSlot, targetSlot)) {
virtualPaperdoll[targetSlot - 1] = g;
virtualPaperdoll[srcSlot - 1] = displaced;
}
}
// ── Phase 2: bag → paperdoll ───────────────────────────────────
// Move items from bag slots onto the paperdoll. Each atomic swap
// displaces the current paperdoll occupant into the bag slot the
// incoming item vacated. Anything we still wanted on the paperdoll
// was already placed by phase 1, so phase 2 displacements can't
// strand a future set item in a bag.
for (int i = 0; i < SLOT_COUNT; ++i) {
const int targetSlot = i + 1;
const uint64_t g = guids[i];
if (g == GUID_EMPTY || g == GUID_IGNORED)
continue;
if (virtualPaperdoll[targetSlot - 1] == g)
continue; // already at target (probably by phase 1)
const int loc = sourceLoc[i];
if (loc == 0)
continue;
if (loc & LOC_BANK)
continue;
if ((loc & LOC_BAGS) == 0)
continue; // not from a bag — phase 1 handled, or item missing
const uint8_t *item = Locations::ResolveItemByGUID(g);
if (item == nullptr)
continue;
const int srcBag = (loc >> LOC_BAG_SHIFT) & 0xFF;
const int srcSlot = loc & LOC_SLOT_MASK;
if (Item::Swap::FromBag(item, srcBag, srcSlot, targetSlot)) {
virtualPaperdoll[targetSlot - 1] = g;
// The displaced item lands in (srcBag, srcSlot). We
// don't track bag-side virtual state — by phase 3 we
// only care about the paperdoll occupant.
}
}
// ── Phase 3: unequip slots the set wants empty ─────────────────
// For each `GUID_EMPTY` slot, send the current paperdoll occupant
// (if any) to a pre-snapshotted free bag slot. Runs last so any
// displaced items from phases 1/2 that landed in the slot get
// unequipped here too.
for (int i = 0; i < SLOT_COUNT; ++i) {
const int targetSlot = i + 1;
if (guids[i] != GUID_EMPTY)
continue;
const uint64_t occupant = virtualPaperdoll[targetSlot - 1];
if (occupant == 0)
continue;
if (nextFreeSlot >= freeSlotCount)
continue;
const uint8_t *equipped = Locations::ResolveItemByGUID(occupant);
if (equipped == nullptr)
continue;
const FreeSlot &fs = freeSlots[nextFreeSlot++];
if (Item::Swap::ToBag(equipped, targetSlot, fs.bag, fs.slot))
virtualPaperdoll[targetSlot - 1] = 0;
}
const int evt = Event::Custom::Lookup(kSwapFinishedEvent);
if (evt >= 0)
Event::Custom::Fire(evt, "%d%d", 1, static_cast<int>(setID));
+11
View File
@@ -177,4 +177,15 @@ const uint8_t *ResolveItemByGUID(uint64_t guid) {
return Item::Location::ResolveByGUID(guid);
}
void SnapshotPaperdoll(uint64_t *out) {
for (int i = 0; i < SLOT_COUNT; ++i)
out[i] = 0;
auto *invMgr = ResolvePlayerInvMgr();
auto *guids = InvMgrGuidArray(invMgr);
if (guids == nullptr)
return;
for (int i = 0; i < SLOT_COUNT; ++i)
out[i] = guids[i];
}
} // namespace EquipmentSet::Locations
+12
View File
@@ -42,4 +42,16 @@ int FindGUID(uint64_t targetGuid);
// isn't known to the client. Side-effect free.
const uint8_t *ResolveItemByGUID(uint64_t guid);
// Copies the current paperdoll GUIDs into `out[0..SLOT_COUNT-1]`,
// where `out[i]` is the GUID at 1-based equipment slot `i+1`.
// Empty / unequipped slots write 0. Reads from the player invMgr's
// flat GUID array directly — no Lua stack, no engine state changes.
//
// Used by `UseEquipmentSet` to seed a "virtual paperdoll" model that
// stays current across in-flight swaps. The engine's CGItem state
// lags behind packet sends (server has to ACK each swap), so any
// subsequent `FindGUID` after the first packet would return stale
// pre-swap locations. Tracking moves locally bypasses that lag.
void SnapshotPaperdoll(uint64_t *out);
} // namespace EquipmentSet::Locations
+71 -1
View File
@@ -18,6 +18,12 @@
// per-frame `OnMouseDown` scripts — these fire even when the click
// misses any UI frame.
//
// `IsMouseButtonDown([button])` — query the current held state of a
// specific button by name or 1-based ID, or "any button held" when
// called with no args. State is maintained from the same hook stream
// as the events (single source of truth — events firing and the
// state being readable happen at the same point in the pipeline).
//
// Same `WH_GETMESSAGE` thread-message hook pattern as
// [Input::Modifier](Modifier.cpp): we intercept the WM_*BUTTON*
// messages on the engine's main thread *before* dispatch. The hook
@@ -28,6 +34,9 @@
#include "Game.h"
#include "event/Custom.h"
#include <cstdint>
#include <cstring>
#include <windows.h>
namespace Input::GlobalMouse {
@@ -37,6 +46,19 @@ namespace {
constexpr const char *kDownEvent = "GLOBAL_MOUSE_DOWN";
constexpr const char *kUpEvent = "GLOBAL_MOUSE_UP";
// Held-state bitmap — bit `i` set means the button with 1-based
// `IsMouseButtonDown` ID `i+1` is currently held. Modern API IDs:
// 1 = LeftButton, 2 = RightButton, 3 = MiddleButton,
// 4 = Button4 (XBUTTON1), 5 = Button5 (XBUTTON2).
constexpr int BIT_LEFT_BUTTON = 0;
constexpr int BIT_RIGHT_BUTTON = 1;
constexpr int BIT_MIDDLE_BUTTON = 2;
constexpr int BIT_BUTTON4 = 3;
constexpr int BIT_BUTTON5 = 4;
constexpr uint32_t MASK_ANY_BUTTON = 0x1F;
uint32_t g_mouseButtonMask = 0;
HHOOK g_msgHook = nullptr;
// Maps WM_*BUTTON{DOWN,UP} → ("LeftButton"|..., isDown). Returns
@@ -60,16 +82,60 @@ const char *DecodeButton(UINT msg, WPARAM wParam, bool *outDown) {
}
}
// Maps a "LeftButton"/.../"Button5" name to its bit index. -1 for
// any other string.
int NameToBitIdx(const char *name) {
if (name == nullptr)
return -1;
if (std::strcmp(name, "LeftButton") == 0) return BIT_LEFT_BUTTON;
if (std::strcmp(name, "RightButton") == 0) return BIT_RIGHT_BUTTON;
if (std::strcmp(name, "MiddleButton") == 0) return BIT_MIDDLE_BUTTON;
if (std::strcmp(name, "Button4") == 0) return BIT_BUTTON4;
if (std::strcmp(name, "Button5") == 0) return BIT_BUTTON5;
return -1;
}
void ProcessMouseMessage(UINT msg, WPARAM wParam) {
bool down = false;
const char *name = DecodeButton(msg, wParam, &down);
if (name == nullptr)
return;
const int bitIdx = NameToBitIdx(name);
if (bitIdx >= 0) {
const uint32_t bit = 1u << bitIdx;
if (down) g_mouseButtonMask |= bit;
else g_mouseButtonMask &= ~bit;
}
const int slot = Event::Custom::Lookup(down ? kDownEvent : kUpEvent);
if (slot >= 0)
Event::Custom::Fire(slot, "%s", name);
}
int __fastcall Script_IsMouseButtonDown(void *L) {
bool down;
if (Game::Lua::GetTop(L) == 0) {
// No-arg form: any button held.
down = (g_mouseButtonMask & MASK_ANY_BUTTON) != 0;
} else {
int bitIdx = -1;
if (Game::Lua::IsNumber(L, 1)) {
const int id = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (id >= 1 && id <= 5)
bitIdx = id - 1;
} else if (Game::Lua::IsString(L, 1)) {
bitIdx = NameToBitIdx(Game::Lua::ToString(L, 1));
}
// Unrecognized button (bad id, unknown name) → false, matching
// the modern API's "did this specific button happen to be held"
// semantic. Bad input doesn't promote to the any-button check.
down = (bitIdx >= 0) && (g_mouseButtonMask & (1u << bitIdx)) != 0;
}
Game::Lua::PushBool(L, down);
return 1;
}
LRESULT CALLBACK GetMsgHook(int code, WPARAM wParam, LPARAM lParam) {
// Only process when the message is being removed from the queue
// (`PM_REMOVE` / `GetMessage`); `PM_NOREMOVE` peeks would deliver
@@ -88,7 +154,11 @@ void InstallHook() {
GetCurrentThreadId());
}
void RegisterLuaFunctions() { InstallHook(); }
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("IsMouseButtonDown",
&Script_IsMouseButtonDown);
InstallHook();
}
const Event::Custom::AutoReserve _reserveDown{kDownEvent};
const Event::Custom::AutoReserve _reserveUp{kUpEvent};
+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);
+51 -4
View File
@@ -105,6 +105,47 @@ bool FitsSlot(int slot1Based, uint32_t invType) {
return (kSlotMaskByInvType[invType] & (1u << (slot1Based - 1))) != 0;
}
// ── Proficiency gate ──────────────────────────────────────────────
// `FitsSlot` only checks invType→slot shape compatibility; without
// also gating on the player's proficiencies this would return (e.g.)
// mail helms for a warlock since both are `invType=HEAD`. Modern
// WoW's equipment-set picker filters by what the player can actually
// equip; we mirror that.
//
// Source of truth: the engine's per-itemClass proficiency bitmap at
// `[VAR_PROFICIENCY_TABLE]` (17 u32 entries indexed by `m_class`).
// SMSG_SET_PROFICIENCY's handler at `0x005E7B70` writes the bitmask
// for each class verbatim from the wire. Bit N of `table[class]` =
// "player can equip subclass N of this class". Same data the engine
// itself reads for the tooltip "Mail/Leather/Plate" red-text and the
// cursor-drop-on-paperdoll-slot check — covers both armor and weapon
// proficiencies in one lookup.
//
// 4.3.4's `Script_GetInventoryItemsForSlot` does the equivalent check
// via `FUN_00553AD0(itemClass) & (1 << subclass)`, reading from
// `DAT_00DD4D38` (its own copy of the same packet-fed table).
bool PlayerHasProficiency(uint32_t itemClass, uint32_t subclass) {
if (itemClass > 16)
return true; // table only covers 0..16; be permissive past that
const uint32_t mask = *reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_PROFICIENCY_TABLE) + itemClass * 4);
if (mask == 0)
return true; // table not populated yet (pre-SMSG_SET_PROFICIENCY)
// — fail open so empty-mask doesn't blank the list
return (mask & (1u << subclass)) != 0;
}
bool PlayerCanEquip(const uint8_t *record) {
if (record == nullptr)
return true; // missing record — be permissive, don't hide the item
const uint32_t classCode = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CLASS);
const uint32_t subclass = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SUBCLASS);
return PlayerHasProficiency(classCode, subclass);
}
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
@@ -112,11 +153,14 @@ const uint8_t *PeekItemRecord(uint32_t itemID) {
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
uint32_t ReadInventoryType(const uint8_t *cgItem) {
const uint8_t *ItemRecordForCGItem(const uint8_t *cgItem) {
const int itemID = Item::ID::FromCGItem(cgItem);
if (itemID <= 0)
return 0;
auto *record = PeekItemRecord(static_cast<uint32_t>(itemID));
return nullptr;
return PeekItemRecord(static_cast<uint32_t>(itemID));
}
uint32_t ReadInventoryType(const uint8_t *record) {
if (record == nullptr)
return 0;
return *reinterpret_cast<const uint32_t *>(
@@ -132,7 +176,10 @@ void CollectIfFits(int slot, const uint8_t *cgItem, int location,
std::vector<Hit> &out) {
if (cgItem == nullptr)
return;
if (!FitsSlot(slot, ReadInventoryType(cgItem)))
const uint8_t *record = ItemRecordForCGItem(cgItem);
if (!FitsSlot(slot, ReadInventoryType(record)))
return;
if (!PlayerCanEquip(record))
return;
out.push_back({location, cgItem});
}
+41
View File
@@ -16,8 +16,11 @@
#include "../Game.h"
#include "../Offsets.h"
#include "../guid/Guid.h"
#include "Arg.h"
#include "ID.h"
#include <cstring>
namespace Item::Location {
namespace {
@@ -183,6 +186,44 @@ bool FindByItemID(void *L, int itemID, ByGUIDResult *out) {
return false;
}
bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out) {
if (arg.itemID <= 0 && arg.name == nullptr)
return false;
for (int bagID = 0; bagID <= 4; ++bagID) {
const int slotCount = GetBagSlotCount(bagID);
for (int slotIndex = 1; slotIndex <= slotCount; ++slotIndex) {
auto *item = ResolveBag(L, bagID, slotIndex);
if (item == nullptr)
continue;
const int id = Item::ID::FromCGItem(item);
if (id == 0)
continue;
if (arg.itemID > 0) {
if (id != arg.itemID)
continue;
} else {
auto *record = PeekItemRecord(static_cast<uint32_t>(id));
if (record == nullptr)
continue;
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (name == nullptr || _stricmp(name, arg.name) != 0)
continue;
}
out->equipmentSlotIndex = 0;
out->bagID = bagID;
out->slotIndex = slotIndex;
out->item = item;
return true;
}
}
return false;
}
bool FindByGUID(void *L, uint64_t guid, ByGUIDResult *out) {
if (guid == 0)
return false;
+20
View File
@@ -96,6 +96,26 @@ bool FindByGUID(void *L, uint64_t guid, ByGUIDResult *out);
// for link-decoration purposes. Stomps the Lua stack.
bool FindByItemID(void *L, int itemID, ByGUIDResult *out);
} // namespace Item::Location
// Forward-declare for `FindByArgInBags` below.
namespace Item::Arg { struct Resolved; }
namespace Item::Location {
// Walks the player's bags (0..4 only — no equipment) looking for the
// first item matching `arg`. `arg.itemID > 0` ⇒ direct ID compare;
// otherwise `arg.name` is matched case-insensitively against the
// cached `ItemStats_C.m_name[0]` of each candidate. On hit, populates
// `out->bagID` / `out->slotIndex` / `out->item` and sets
// `equipmentSlotIndex = 0`. Stomps the Lua stack.
//
// Used by the by-name action APIs (`C_Item.EquipItemByName`,
// `C_Item.UseItemByName`, `C_Item.UseAtCursor`) — none of them want
// equipment in the candidate pool (the engine doesn't equip-from-
// equipped or use-from-equipped; the item has to be in bags first).
bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out);
// Parses the `"0xHHHHHHHHLLLLLLLL"` GUID string format
// `C_Item.GetItemGUID` returns. Strict: requires exactly `0x` prefix
// + 16 case-insensitive hex digits. Returns false (and leaves `*out`
+62 -45
View File
@@ -11,18 +11,31 @@
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Item.GetItemUniqueness(itemLocation)` — uniqueness cap. Reads
// `ItemStats_C.m_maxCount` from the item's cached record; that's the
// only uniqueness signal vanilla's client carries. `1` = unique
// ("Held In Off-hand" trinkets, special quest items, etc.); `0` =
// unlimited; higher values cap the inventory count.
// `C_Item.GetItemUniqueness(itemLocation)` and the `*ByID` variant
// — uniqueness cap. Both read `ItemStats_C.m_maxCount` from the
// item's cached record; that's the only uniqueness signal vanilla
// carries. `1` = unique-equipped (one at a time); higher values
// cap the inventory count; `0` = unlimited.
//
// Modern WoW returns a 3-tuple `(maxEquipped, category, equippedCount)`
// keyed off the post-TBC category system (e.g. "Unique-Equipped:
// Brewfest Mug"). Vanilla has no category system, so we surface
// `m_maxCount` as the first return and leave the category nil and
// equipped count `0` — matching modern's "field present but inapplicable"
// convention.
// Modern WoW (Classic Era 1.15.x) gives the two functions **different
// signatures**, verified against `_classic_` BlizzardInterfaceCode's
// `ItemDocumentation.lua`:
//
// GetItemUniqueness(itemLocation) →
// limitCategory (number, non-nilable),
// limitMax (number, non-nilable)
//
// GetItemUniquenessByID(itemInfo) →
// isUnique (bool, non-nilable),
// limitCategoryName (string, nilable),
// limitCategoryCount (number, nilable),
// limitCategoryID (number, nilable)
//
// Vanilla has no `LimitCategory.dbc` system (TBC+ addition for
// "Unique-Equipped: Brewfest Mug"-style cross-item category caps),
// so the category fields are always nil/0. `isUnique` collapses to
// `m_maxCount > 0`; `limitMax` and `limitCategoryCount` both surface
// the raw `m_maxCount`.
#include "Game.h"
#include "Offsets.h"
@@ -50,54 +63,58 @@ const uint8_t *PeekItemRecord(uint32_t itemID) {
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
} // namespace
// Pushes the 3-tuple `(maxAllowed, nil, 0)` derived from the
// ItemStats_C record, or nil + early-return on cache miss.
int PushFromItemID(void *L, uint32_t itemID) {
// Returns the item's `m_maxCount` or -1 if the record isn't cached.
// `-1` is a "warm-and-bail" sentinel; caller pushes nothing on -1
// so the Lua side sees the modern "MayReturnNothing" behaviour.
int ReadMaxCount(uint32_t itemID) {
const uint8_t *record = PeekItemRecord(itemID);
if (record == nullptr) {
Item::Data::WarmCache(itemID);
Game::Lua::PushNil(L);
return 1;
return -1;
}
const int maxCount = *reinterpret_cast<const int *>(
return *reinterpret_cast<const int *>(
record + Offsets::OFF_ITEMSTATS_MAX_COUNT);
Game::Lua::PushNumber(L, static_cast<double>(maxCount));
Game::Lua::PushNil(L); // category (vanilla has none)
Game::Lua::PushNumber(L, 0.0); // equippedCount (not tracked)
return 3;
}
} // namespace
// `GetItemUniqueness(itemLocation)` shape: `(limitCategory, limitMax)`.
static int __fastcall Script_GetItemUniqueness(void *L) {
if (!Item::Location::IsLocationArg(L, 1)) {
Game::Lua::PushNil(L);
return 1;
}
if (!Item::Location::IsLocationArg(L, 1))
return 0;
auto *item = Item::Location::Resolve(L, 1);
if (item == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
if (item == nullptr)
return 0;
const int itemID = Item::ID::FromCGItem(item);
if (itemID <= 0) {
Game::Lua::PushNil(L);
return 1;
}
return PushFromItemID(L, static_cast<uint32_t>(itemID));
if (itemID <= 0)
return 0;
const int maxCount = ReadMaxCount(static_cast<uint32_t>(itemID));
if (maxCount < 0)
return 0; // cache miss — modern API documents "MayReturnNothing"
Game::Lua::PushNumber(L, 0.0); // limitCategory (vanilla has no LimitCategory.dbc)
Game::Lua::PushNumber(L, static_cast<double>(maxCount));
return 2;
}
// `GetItemUniquenessByID(itemID)` shape:
// `(isUnique, limitCategoryName, limitCategoryCount, limitCategoryID)`.
static int __fastcall Script_GetItemUniquenessByID(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::PushNil(L);
return 1;
}
if (!Game::Lua::IsNumber(L, 1))
return 0;
const int itemID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (itemID <= 0) {
Game::Lua::PushNil(L);
return 1;
}
return PushFromItemID(L, static_cast<uint32_t>(itemID));
if (itemID <= 0)
return 0;
const int maxCount = ReadMaxCount(static_cast<uint32_t>(itemID));
if (maxCount < 0)
return 0;
Game::Lua::PushBool(L, maxCount > 0);
Game::Lua::PushNil(L); // limitCategoryName (no categories in vanilla)
if (maxCount > 0)
Game::Lua::PushNumber(L, static_cast<double>(maxCount));
else
Game::Lua::PushNil(L); // limitCategoryCount — nil when not limited
Game::Lua::PushNil(L); // limitCategoryID
return 4;
}
static void RegisterLuaFunctions() {
+82 -54
View File
@@ -11,9 +11,12 @@
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Item.UseItemByName(itemInfo)` — finds the first item in the
// player's bags matching `itemInfo` (itemID, link, or name) and uses
// it via the engine's `CGItem::UseItem` primitive.
// `C_Item.UseItemByName(itemInfo [, unit])` — finds the first item in
// the player's bags matching `itemInfo` (itemID, link, or name) and
// uses it via the engine's `CGItem::UseItem` primitive. Optional
// `unit` is a unit token (`"player"`, `"target"`, `"focus"`, …)
// passed to spell-cast items (scrolls, traps, on-use targeted
// effects) as the cast target.
//
// Same structure 3.3.5's `Script_UseItemByName` uses: locate the item
// directly, then hand it to the engine's by-item-pointer use call.
@@ -25,91 +28,116 @@
// food / potion / hearthstone / scroll / quiver all route through
// the same entry.
//
// Target arg: we pass nullptr / a zero GUID. The engine substitutes
// the player itself for self-use items (hearthstone, food, potion);
// for target-required items (scrolls, traps) the engine reads
// cursor-target state at call time, which a Lua-side caller can
// pre-set with `TargetUnit("...")`.
// Target arg: `FUN_ITEM_USE`'s `param_1` is read as a `uint64_t *`
// pointing at the target's GUID. The engine writes it through to the
// "use item" event (`DAT_00c4c1d0`/`DAT_00c4c1d4` + event 0x122) for
// spell-cast items, and into the cast-targeting helper at
// `FUN_006e5a90`. For self-use items (hearthstone, food, potion) the
// engine overwrites with the item's own GUID before dispatch, so a
// stray target is harmless. We default to a zero GUID when the caller
// omits `unit` — matches existing call sites.
//
// Returns nothing. Silently no-ops on bad input, item not found in
// bags, item locked, on cooldown, etc.
// bags, item locked, on cooldown, etc. An unrecognized `unit` string
// is treated as "no target" rather than raising, matching arg-1's
// "silently no-ops on bad input" contract.
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include "item/ID.h"
#include "item/Location.h"
#include <cstdint>
#include <string.h>
#include <cstring>
namespace Item::Use {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
using UseItem_t = unsigned(__thiscall *)(const void *item, const uint64_t *targetGuid,
int flag);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
bool HasPrefix(const char *s, const char *prefix) {
return std::strncmp(s, prefix, std::strlen(prefix)) == 0;
}
// Returns the matching CGItem* in `*outItem` on hit; mirrors the
// `Item::Equipment::FindItemInBags` walk but skips bag/slot output
// since `FUN_ITEM_USE` operates directly on the CGItem pointer.
bool FindItemInBags(void *L, const Item::Arg::Resolved &arg, const uint8_t **outItem) {
for (int bag = 0; bag <= 4; ++bag) {
const int slotCount = Item::Location::GetBagSlotCount(bag);
for (int slot = 1; slot <= slotCount; ++slot) {
const uint8_t *item = Item::Location::ResolveBag(L, bag, slot);
if (item == nullptr) continue;
// Walk past any "target" suffixes (`targettarget`, `focustargettarget`,
// …); returns a pointer at the first non-"target" character.
const char *SkipTargetSuffixes(const char *s) {
while (HasPrefix(s, "target"))
s += 6;
return s;
}
const int id = Item::ID::FromCGItem(item);
if (id == 0) continue;
if (arg.itemID > 0) {
if (id == arg.itemID) {
*outItem = item;
return true;
}
continue;
}
auto *record = PeekItemRecord(static_cast<uint32_t>(id));
if (record == nullptr) continue;
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (name == nullptr) continue;
if (_stricmp(name, arg.name) == 0) {
*outItem = item;
return true;
}
}
// Predicate mirror of the engine's token grammar inside
// `FUN_TOKEN_TO_GUID` (`0x00515970`) plus the families our own
// `Unit::TokenExtensions` hook adds (`focus`, `nameplateN`). The
// engine raises a Lua error on unknown input, so we gate before
// dispatching to keep our "silently no-ops on bad input" contract.
//
// Every base token may be followed by zero or more `target` suffixes
// (`playertarget`, `party1targettarget`, …) which the engine's
// resolver walks via `LAB_005159d3..0x00515A2C`.
bool MatchBase(const char *s, const char *base, bool requireDigits) {
if (!HasPrefix(s, base))
return false;
const char *rest = s + std::strlen(base);
if (requireDigits) {
const char *p = rest;
while (*p >= '0' && *p <= '9')
++p;
if (p == rest)
return false;
rest = p;
}
return *SkipTargetSuffixes(rest) == '\0';
}
bool LooksLikeToken(const char *s) {
if (s == nullptr || *s == '\0')
return false;
// Order matters for shared roots: longer prefix first
// (`partypet`/`raidpet` before `party`/`raid`).
if (MatchBase(s, "partypet", true)) return true;
if (MatchBase(s, "raidpet", true)) return true;
if (MatchBase(s, "party", true)) return true;
if (MatchBase(s, "raid", true)) return true;
if (MatchBase(s, "nameplate", true)) return true;
if (MatchBase(s, "mouseover", false)) return true;
if (MatchBase(s, "player", false)) return true;
if (MatchBase(s, "target", false)) return true;
if (MatchBase(s, "focus", false)) return true;
if (MatchBase(s, "npc", false)) return true;
if (MatchBase(s, "pet", false)) return true;
return false;
}
uint64_t ResolveUnitGuid(void *L, int idx) {
if (!Game::Lua::IsString(L, idx))
return 0;
const char *token = Game::Lua::ToString(L, idx);
if (!LooksLikeToken(token))
return 0;
auto fn = reinterpret_cast<TokenToGUID_t>(Offsets::FUN_TOKEN_TO_GUID);
return fn(token);
}
int __fastcall Script_C_Item_UseItemByName(void *L) {
const auto arg = Item::Arg::Resolve(L, 1);
if (arg.itemID <= 0 && arg.name == nullptr) {
return 0;
}
const uint8_t *cgItem = nullptr;
if (!FindItemInBags(L, arg, &cgItem)) {
Item::Location::ByGUIDResult found;
if (!Item::Location::FindByArgInBags(L, arg, &found)) {
return 0;
}
const uint64_t zeroTarget = 0;
const uint64_t targetGuid = ResolveUnitGuid(L, 2);
auto useItem = reinterpret_cast<UseItem_t>(Offsets::FUN_ITEM_USE);
useItem(cgItem, &zeroTarget, 0);
useItem(found.item, &targetGuid, 0);
return 0;
}
+76
View File
@@ -0,0 +1,76 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Item.UseAtCursor(itemInfo)` — use the named item at the player's
// current cursor world position. For items whose on-use spell is
// ground-target (Iron Grenade, demolition charges, etc.), bypasses
// the manual click on the AoE reticle that the engine would
// otherwise require.
//
// Mirrors `C_Item.UseItemByName`'s bag-walk + `FUN_ITEM_USE` dispatch
// — the engine's item-use path eventually hands the on-use spell to
// the same cast dispatcher that ground-target spells go through, so
// placement-mode activation works identically. After firing the
// item, `Spell::AtCursor::Resolve()` commits the placement at cursor.
//
// Returns `true` when the cursor-placement leg landed; `false` for
// items that aren't ground-target (the item still fires, just with
// no implicit target), unparseable input, item-not-in-bags, etc.
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include "item/Location.h"
#include "spell/AtCursor.h"
#include <cstdint>
namespace Item::UseAtCursor {
namespace {
using UseItem_t = unsigned(__thiscall *)(const void *item, const uint64_t *targetGuid,
int flag);
int __fastcall Script_C_Item_UseAtCursor(void *L) {
const auto arg = Item::Arg::Resolve(L, 1);
if (arg.itemID <= 0 && arg.name == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
Item::Location::ByGUIDResult found;
if (!Item::Location::FindByArgInBags(L, arg, &found)) {
Game::Lua::PushBool(L, false);
return 1;
}
const uint64_t zeroTarget = 0;
auto useItem = reinterpret_cast<UseItem_t>(Offsets::FUN_ITEM_USE);
useItem(found.item, &zeroTarget, 0);
const bool placed = Spell::AtCursor::Resolve();
Game::Lua::PushBool(L, placed);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Item", "UseAtCursor",
&Script_C_Item_UseAtCursor);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Item::UseAtCursor
+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
+175
View File
@@ -0,0 +1,175 @@
// 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 <cstdint>
#include <cstdio>
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};
} // 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