Compare commits

..
39 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
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
Brues 94b521881b remove max equipment sets 2026-05-30 21:38:49 -05:00
Brues 634a440def set addon toc to dev 2026-05-30 21:38:33 -05:00
brues-code 4dce58a457 Fix State section formatting in README.md 2026-05-30 19:54:31 -05:00
Brues 99e9621590 IsLoggedIn 2026-05-30 18:54:30 -05:00
Brues e8fc583f19 Expand GetQuestDetails: srcItemID, completionText, poi, objective text
Trace SMSG_QUEST_QUERY_RESPONSE serializer at 0x006DBCEC to map the
remaining cache-block field offsets, exposing four new fields plus
per-requirement override text. Empirical dumps across three quests
confirmed the +0x14..+0x24 slots (would-be SuggestedPlayers /
LimitTime / RequiredRaces / RequiredSkill / RequiredSkillValue) are
never populated by the vanilla server — those constraints are
enforced server-side and never broadcast, so addons needing them
must use an external DB.

Also corrects OFF_SORT_PTR -> OFF_ZONE_OR_SORT (int32, positive =
zone areaID, negative = -QuestSort.dbc row).
2026-05-30 17:15:18 -05:00
Brues 03f63d391a GameTooltip_Hide 2026-05-30 16:50:10 -05:00
Brues 6df403444c C_QuestLog.GetQuestDetails 2026-05-30 16:21:12 -05:00
Brues cb7953b503 GameTooltip:SetItemByGUID
Modern method that renders the tooltip for a specific item *instance*
identified by GUID — same string `C_Item.GetItemGUID` returns. Distinct
from `SetItemByID` (which uses base `ItemSparse` data with no
per-instance state): this path goes through the live CGItem, so the
tooltip includes enchant lines, random-suffix-decorated name +
bonuses, and locked/broken state.

Implementation: parse GUID via `Item::Location::ParseGUIDString`,
resolve through the engine's `FUN_OBJECT_RESOLVE_BY_GUID`, then build
the per-instance hyperlink via `Item::Link::FromCGItem`. Strip the
`|cff…|H…|h[…]|h|r` decoration to the bare `item:N:enchant:gem:gem:
gem:gem:suffix:unique` payload (SetHyperlink rejects the decorated
form with "Unknown link type") and dispatch to the engine's existing
SetHyperlink. Closes the loop with `GameTooltip:GetItem()`, which
returns the same dressed link back.
2026-05-30 15:40:06 -05:00
Brues 774df8d0d4 UseEquipmentSet unequips slots the set wants empty
Previously only `GUID_IGNORED` and `GUID_EMPTY` were both treated as
"skip this slot," so equipment sets with cleared (but not ignored)
slots couldn't be used to unequip — running a "naked" set returned
`true` but left the player's gear on.

Now `GUID_EMPTY` triggers a paperdoll-to-bag move. Implementation:

- `Item::Swap::ToBag` — new atomic primitive (paperdoll → bag) using
  the same `FUN_INVENTORY_SWAP` engine helper as the equip variants,
  just with player+slot as the source side and a bag-encoded
  destination.
- `Script_UseEquipmentSet` snapshots all empty bag slots at entry
  and consumes them sequentially. Snapshotting up front avoids the
  client-side stale-state collision where iterating `ResolveBag`
  per unequip would keep picking the same "first empty" slot — the
  client's bag state doesn't update between packet sends, so the
  second packet would target a slot the first had already filled,
  and the server would try to swap the previously-unequipped item
  back onto the wrong paperdoll slot ("This item cannot be
  equipped").
2026-05-30 15:17:01 -05:00
Brues 36e1aa87e4 C_AddOns.IsAddOnLoaded 2026-05-30 14:53:45 -05:00
Brues 40532aca8e Ensure !!!ClassicAPI is loaded early 2026-05-30 14:31:06 -05:00
Brues 8592254b2d Fixed items equipped string in SetEquipmentSet tooltip 2026-05-30 13:59:28 -05:00
brues-code 289f82a7e7 Remove IconDataProviderMixin documentation
Removed the `IconDataProviderMixin` section and its details from the API documentation.
2026-05-30 13:53:22 -05:00
Brues 5e77e5e2a1 FillOutExtraIconsMapWithAllEquipment 2026-05-30 04:18:05 -05:00
Brues 49d09cdf1e Macro Icon functions 2026-05-30 03:54:37 -05:00
Brues 64d373a34e If disk version is older than dll, dll wins 2026-05-30 02:54:50 -05:00
Brues 4b1a3d40f5 Update github workflow 2026-05-30 02:30:47 -05:00
Brues b1fa0172fd !!!ClassicAPI addon is now embedded in the dll 2026-05-30 02:27:40 -05:00
Brues 0a901d545d Add GetCursorInfo() backport
Backports the modern WoW global that surfaces whatever's picked up
on the cursor. Reads the engine's cursor-state globals (type tag at
0x00B4D900 + per-type payload slots) and returns one of:

  nil                           empty cursor
  "item", itemID, itemLink      bag item (full per-instance link)
  "item", itemID, itemLink      drag-source item (action bar /
                                equipment / mail) — itemLink
                                recovered by walking equipment+bags
                                for the live CGItem, same trick the
                                action-bar tooltip uses to display
                                enchant info on dragged gear
  "money", copper               gold-split / PickupPlayerMoney
  "spell", slot, type, spellID  spellbook spell
  "macro", index                macro
  "merchant", merchantSlot      merchant-frame item

Also splits the credstore/login offsets back into the same enum
(resolves the lingering merge in Offsets.h) and adds a shared
Item::QualityColor helper that reads the engine's `"|cffRRGGBB"`
color-prefix table at 0x00854124 directly instead of duplicating
the hex palette in cursor/Info.cpp and item/Tooltip.cpp.

New Item::Location::FindByItemID walks equipment then bags looking
for the first CGItem matching an itemID — used by the drag-source
link recovery.
2026-05-29 03:54:17 -05:00
brues-codeandBrues ffd208a578 Windows Credentialed Autologin (GlueXML) (#3)
* Windows Credentialed Autologin

* documentation

* share last modified info with client

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-05-29 02:40:29 -05:00
62 changed files with 6142 additions and 1210 deletions
+14 -20
View File
@@ -16,33 +16,27 @@ jobs:
with:
submodules: recursive
# Stamp the addon's TOC version from the tag (drops the leading `v`)
# BEFORE CMake configure, so the embed step picks up the stamped TOC.
# The DLL bundles AddOns/!!!ClassicAPI/ at build time — see the
# `embed_addon` custom command in CMakeLists.txt and the file-read
# hook in src/addons/Embedded.cpp.
- name: Stamp embedded TOC version
shell: pwsh
run: |
$version = '${{ github.ref_name }}' -replace '^v',''
$toc = 'AddOns/!!!ClassicAPI/!!!ClassicAPI.toc'
(Get-Content $toc) -replace '^## Version:.*', "## Version: $version" |
Set-Content $toc
- name: Configure
run: cmake -B build -A Win32 "-DCLASSICAPI_TAG=${{ github.ref_name }}"
- name: Build
run: cmake --build build --config Release
- name: Stage release bundle
shell: pwsh
run: |
$tag = '${{ github.ref_name }}'
$version = $tag -replace '^v',''
$stage = "ClassicAPI-$tag"
New-Item -ItemType Directory -Force -Path $stage | Out-Null
Copy-Item build/Release/ClassicAPI.dll $stage
Copy-Item -Recurse 'AddOns/!!!ClassicAPI' $stage
# Stamp the addon's TOC version line from the tag (drops the leading `v`)
$toc = Join-Path $stage '!!!ClassicAPI/!!!ClassicAPI.toc'
(Get-Content $toc) -replace '^## Version:.*', "## Version: $version" |
Set-Content $toc
Compress-Archive -Path "$stage/*" -DestinationPath "ClassicAPI-Bundle-$tag.zip"
Compress-Archive -Path "$stage/!!!ClassicAPI" -DestinationPath "ClassicAPI-AddOnOnly-$tag.zip"
- name: Release
uses: softprops/action-gh-release@v2
with:
files: |
ClassicAPI-Bundle-${{ github.ref_name }}.zip
ClassicAPI-AddOnOnly-${{ github.ref_name }}.zip
ClassicAPI-${{ github.ref_name }}/ClassicAPI.dll
files: build/Release/ClassicAPI.dll
generate_release_notes: true
+5 -2
View File
@@ -1,10 +1,11 @@
## Interface: 11200
## Title: !!!ClassicAPI
## Notes: Library to emulate aspects of the modern WoW API on 1.12.1 / Lua 5.0.
## Version: 1.0
## Version: DEV
## Author: Brues
GlobalStrings.lua
locales\locales.xml
Compat.lua
Util\Print.lua
Util\Constants.lua
@@ -28,3 +29,5 @@ Util\EquipmentManager.lua
Util\PlayerUtil.lua
Util\SlashCommandsRegistry.lua
Util\LinkUtil.lua
Util\IconDataProvider.lua
Util\GameTooltip.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";
+5 -6
View File
@@ -83,12 +83,11 @@ function EventUtil.TriggerOnVariablesLoaded()
end
function EventUtil.ContinueOnAddOnLoaded(addOnName, callback)
-- 1.12's IsAddOnLoaded returns a single bool. 3.3.5 split that into
-- (isLoadedOrLoading, isLoaded); treat truthy as "ready to use".
if IsAddOnLoaded(addOnName) then
callback()
return
end
local isLoadedOrLoading, isLoaded = C_AddOns.IsAddOnLoaded(addOnName)
if isLoaded then
callback();
return;
end
EventUtil.RegisterOnceFrameEventAndCallback("ADDON_LOADED", callback, addOnName)
end
@@ -0,0 +1,3 @@
function GameTooltip_Hide()
GameTooltip:Hide()
end
@@ -0,0 +1,242 @@
-- Backport of modern Classic Era's IconDataProvider.lua (lives in
-- Blizzard_FrameXMLBase/Classic/) to vanilla 1.12 / Lua 5.0.
--
-- Wraps the four engine icon-enumeration mutators
-- (`GetLooseMacroIcons` / `GetLooseMacroItemIcons` / `GetMacroIcons`
-- / `GetMacroItemIcons` — all surfaced by ClassicAPI.dll) into a
-- stateful `Spell`/`Item` bucket pair, with an optional
-- "extras" array of icons prepended ahead of the base DB. The two
-- "extra" types are:
--
-- * `Spellbook` — icons of every spell currently in the player's
-- spellbook UI. Used by the modern Macro UI's icon picker so
-- "icons relevant to my class" surface first. In vanilla this
-- misses spells that aren't on the spellbook UI (racials kept
-- off the bar, profession recipes the engine doesn't list as
-- spells, etc.) but covers the common case.
-- * `Equipment` — icons of currently-equipped items (slots 1..19
-- via `GetInventoryItemTexture("player", i)`). Used by the
-- modern Equipment-Manager popup so the picker leads with what
-- the player is wearing.
--
-- Implementation differences from the modern source:
--
-- * `EnumUtil.MakeEnum` is inlined as a plain table.
-- * Modern's `C_SpecializationInfo.GetTalentInfo(query)` (Cata+
-- spec-aware talent API) and `GetPvpTalentInfoByID` (MoP+ PvP
-- talents) are replaced with vanilla's `GetTalentInfo(tab, idx)`
-- across the 3 talent trees. Vanilla has no specs, no dual-spec
-- groups, and no PvP talent system, so those layers are dropped.
-- * `GetSpellBookItemTexture(slot, "player")` is replaced with
-- vanilla's `GetSpellTexture(slot, "spell")`. Flyouts don't
-- exist in vanilla so that branch is dropped entirely.
-- * `extraIconsMap` is a Lua-set keyed by texture path or
-- fileID — `GetKeysArray` flattens it to a numbered array.
-- Same dedup semantics as modern: each texture appears once
-- within the extras section, but no cross-dedup with the
-- base DB walk (the player's equipped weapon icon may still
-- appear twice in the final picker, once in extras and once
-- in `GetMacroItemIcons`).
local QuestionMarkIconPath = "INTERFACE\\ICONS\\INV_MISC_QUESTIONMARK"
local NumActiveIconDataProviders = 0
local BaseIconFilenames = nil
-- Builds the table BaseIconFilenames with engine icon DB.
-- Lazy: shared across all active IconDataProvider instances, so
-- the four engine calls (each of which scans thousands of files)
-- only happens once per picker session.
local function IconDataProvider_RefreshIconTextures()
if BaseIconFilenames ~= nil then
return
end
BaseIconFilenames = {}
BaseIconFilenames[IconDataProviderIconType.Spell] = {}
BaseIconFilenames[IconDataProviderIconType.Item] = {}
GetLooseMacroIcons(BaseIconFilenames[IconDataProviderIconType.Spell])
GetLooseMacroItemIcons(BaseIconFilenames[IconDataProviderIconType.Item])
GetMacroIcons(BaseIconFilenames[IconDataProviderIconType.Spell])
GetMacroItemIcons(BaseIconFilenames[IconDataProviderIconType.Item])
end
local function IconDataProvider_ClearIconTextures()
BaseIconFilenames = nil
collectgarbage()
end
local function IconDataProvider_GetBaseIconTexture(iconType, index)
local texture = BaseIconFilenames[iconType][index]
local fileDataID = tonumber(texture)
if fileDataID ~= nil then
return fileDataID
else
return "INTERFACE\\ICONS\\" .. texture
end
end
function IconDataProvider_GetAllIconTypes()
local iconTypeValues = GetValuesArray(IconDataProviderIconType)
table.sort(iconTypeValues)
return iconTypeValues
end
IconDataProviderMixin = {}
-- `EnumUtil.MakeEnum("Spell", "Item")` produces `{ Spell = 1, Item = 2 }`.
IconDataProviderIconType = { Spell = 1, Item = 2 }
IconDataProviderExtraType = {
Spellbook = 1,
Equipment = 2,
None = 3,
}
local function FillOutExtraIconsMapWithSpells(extraIconsMap)
for tab = 1, GetNumSpellTabs() do
local _, _, offset, numSpells = GetSpellTabInfo(tab)
offset = offset + 1
local tabEnd = offset + numSpells
for slot = offset, tabEnd - 1 do
local tex = GetSpellTexture(slot, "spell")
if tex ~= nil and tex ~= "" then
extraIconsMap[tex] = true
end
end
end
end
-- Walks all three vanilla talent trees, gathering every talent's
-- icon — including talents the player hasn't put points into. The
-- whole tree is "relevant to my class" for picker purposes, not
-- just maxed talents. `GetTalentInfo(tab, idx)` returns
-- `(name, icon, tier, column, rank, maxRank, ...)` — we read the
-- icon (second return) directly.
local function FillOutExtraIconsMapWithTalents(extraIconsMap)
for tab = 1, GetNumTalentTabs() do
local numTalents = GetNumTalents(tab)
for talent = 1, numTalents do
local _, icon = GetTalentInfo(tab, talent)
if icon ~= nil and icon ~= "" then
extraIconsMap[icon] = true
end
end
end
end
local function FillOutExtraIconsMapWithEquipment(extraIconsMap)
for slot = INVSLOT_FIRST_EQUIPPED, INVSLOT_LAST_EQUIPPED do
for _, itemLink in pairs(GetInventoryItemsForSlot(slot, {})) do
local _, _, _, _, itemTexture = C_Item.GetItemInfoInstant(itemLink)
if itemTexture then
extraIconsMap[itemTexture] = true
end
end
end
end
function IconDataProviderMixin:Init(extraType, extraIconsOnly, requestedIconTypes)
self.extraIcons = {}
self.extraIconType = extraType
self.requestedIconTypes = requestedIconTypes or IconDataProvider_GetAllIconTypes()
if extraType == IconDataProviderExtraType.Spellbook then
local extraIconsMap = {}
FillOutExtraIconsMapWithSpells(extraIconsMap)
FillOutExtraIconsMapWithTalents(extraIconsMap)
self.extraIcons = GetKeysArray(extraIconsMap)
elseif extraType == IconDataProviderExtraType.Equipment then
local extraIconsMap = {}
FillOutExtraIconsMapWithEquipment(extraIconsMap)
self.extraIcons = GetKeysArray(extraIconsMap)
end
if not extraIconsOnly then
NumActiveIconDataProviders = NumActiveIconDataProviders + 1
IconDataProvider_RefreshIconTextures()
end
end
function IconDataProviderMixin:SetIconTypes(iconTypes)
self.requestedIconTypes = iconTypes or IconDataProvider_GetAllIconTypes()
end
function IconDataProviderMixin:GetNumIcons()
-- 1 to account for the leading `?` icon.
local numIcons = 1
if self:ShouldShowExtraIcons() then
numIcons = numIcons + table.getn(self.extraIcons)
end
if BaseIconFilenames then
for _, v in pairs(self.requestedIconTypes) do
numIcons = numIcons + table.getn(BaseIconFilenames[v])
end
end
return numIcons
end
function IconDataProviderMixin:GetIconByIndex(index)
if index == 1 then
return QuestionMarkIconPath
end
index = index - 1
local numExtraIcons = (self:ShouldShowExtraIcons() and
table.getn(self.extraIcons)) or 0
if index <= numExtraIcons then
return self.extraIcons[index]
end
local baseIndex = index - numExtraIcons
local lookupIconType = nil
-- Each icon type's table is indexed from 1, so loop through the
-- tables to find which icon type we index to.
for _, v in pairs(self.requestedIconTypes) do
local numIconsForType = table.getn(BaseIconFilenames[v])
if baseIndex <= numIconsForType then
lookupIconType = v
break
end
baseIndex = baseIndex - numIconsForType
end
if lookupIconType then
return IconDataProvider_GetBaseIconTexture(lookupIconType, baseIndex)
else
return nil
end
end
function IconDataProviderMixin:GetIconForSaving(index)
local icon = self:GetIconByIndex(index)
if type(icon) == "string" then
icon = string.gsub(icon, "INTERFACE\\ICONS\\", "")
end
return icon
end
function IconDataProviderMixin:GetIndexOfIcon(icon)
local numIcons = self:GetNumIcons()
for i = 1, numIcons do
if self:GetIconByIndex(i) == icon then
return i
end
end
return nil
end
function IconDataProviderMixin:ShouldShowExtraIcons()
return (self.extraIconType == IconDataProviderExtraType.Spellbook and
tContains(self.requestedIconTypes, IconDataProviderIconType.Spell))
or (self.extraIconType == IconDataProviderExtraType.Equipment and
tContains(self.requestedIconTypes, IconDataProviderIconType.Item))
end
function IconDataProviderMixin:Release()
NumActiveIconDataProviders = NumActiveIconDataProviders - 1
if NumActiveIconDataProviders <= 0 then
IconDataProvider_ClearIconTextures()
end
end
+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 = "選定鎖定單位";
+47 -2
View File
@@ -25,7 +25,51 @@ set(TINYCBOR_SOURCES
external/tinycbor/src/cborparser_float.c
external/tinycbor/src/cborerrorstrings.c)
add_library(${PROJECT_NAME} SHARED ${SOURCES} ${HEADERS} ${TINYCBOR_SOURCES})
# Bundle `AddOns/!!!ClassicAPI/` into a generated header so the DLL can
# surface the addon to the engine even when the user doesn't have it
# installed on disk. The file-read hook in `src/addons/Embedded.cpp`
# returns these buffers when the engine asks for paths under
# `Interface\AddOns\!!!ClassicAPI\`.
set(EMBEDDED_CLASSICAPI_SRC "${CMAKE_CURRENT_SOURCE_DIR}/AddOns/!!!ClassicAPI")
set(EMBEDDED_CLASSICAPI_HEADER "${CMAKE_BINARY_DIR}/generated/embedded_classicapi.h")
file(GLOB_RECURSE EMBEDDED_CLASSICAPI_FILES CONFIGURE_DEPENDS
"${EMBEDDED_CLASSICAPI_SRC}/*")
add_custom_command(
OUTPUT "${EMBEDDED_CLASSICAPI_HEADER}"
COMMAND ${CMAKE_COMMAND}
-DSRC_DIR=${EMBEDDED_CLASSICAPI_SRC}
-DOUT_HEADER=${EMBEDDED_CLASSICAPI_HEADER}
-DADDON_NAME=ClassicAPI
-P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_addon.cmake"
DEPENDS
${EMBEDDED_CLASSICAPI_FILES}
"${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_addon.cmake"
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_BINDINGS_HEADER})
# Dev-build sentinel — represents "newer than every plausible
# release". Encoded as v9999.99.99 = 9999 * 10000 + 99 * 100 + 99 =
@@ -55,10 +99,11 @@ target_compile_definitions(
${PROJECT_NAME}
PRIVATE CLASSICAPI_VERSION_VALUE=${CLASSICAPI_VERSION_VALUE})
target_link_libraries(${PROJECT_NAME} PRIVATE minhook)
target_link_libraries(${PROJECT_NAME} PRIVATE minhook advapi32)
target_include_directories(
${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_BINARY_DIR}/generated
external/minhook/include
external
external/tinycbor/src)
+37 -10
View File
@@ -18,11 +18,12 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| Namespace | Calls |
|-----------|-------|
| [Action](docs/API.md#action) | `GetActionInfo` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
| [Combat](docs/API.md#combat) | `InCombatLockdown` |
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
| [Container](docs/API.md#container) | `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetItemCooldown`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
| EncodingUtil | `C_EncodingUtil.CompressString`, `C_EncodingUtil.DecompressString`, `C_EncodingUtil.EncodeBase64`, `C_EncodingUtil.DecodeBase64`, `C_EncodingUtil.EncodeHex`, `C_EncodingUtil.DecodeHex`, `C_EncodingUtil.SerializeJSON`, `C_EncodingUtil.DeserializeJSON`, `C_EncodingUtil.SerializeCBOR`, `C_EncodingUtil.DeserializeCBOR` |
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` (sets persist to `WTF\Account\...\ClassicAPI_EquipmentSets.txt`) |
@@ -32,20 +33,20 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
| [FriendList](docs/API.md#friendlist) | `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName` |
| [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:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura` |
| [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` |
| [Macros](docs/API.md#macros) | `GetMacroSpell` |
| [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.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` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellName`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsMounted`, `IsStealthed`, `IsSwimming` |
| [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` |
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime` |
@@ -66,9 +67,11 @@ the glue state because GlueXML had no way to reach them otherwise.
| Group | Calls |
|-------|-------|
| [Account](docs/API.md#account) | `SaveAccount`, `DeleteAccount`, `GetSavedAccounts`, `LoginWithSavedAccount` (passwords encrypted in Windows Credential Manager, scoped per realmlist; plaintext never returned to Lua) |
| [CharacterList](docs/API.md#characterlist) | `GetSavedCharacterOrder`, `SetSavedCharacterOrder` (persist to `WTF\Account\...\ClassicAPI.txt`) |
| CVar | `GetCVar`, `SetCVar`, `RegisterCVar`, `GetCVarDefault`, `C_CVar.GetCVarBool` (storage is process-global — writes from glue are visible in-world and vice versa) |
| Script | `RunScript` (compile and run a Lua chunk in the glue state's globals — useful for slash-command-style helpers in GlueXML) |
| State | `IsLoggedIn` |
### Macros
@@ -129,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
@@ -162,8 +178,19 @@ The triple-`!` prefix is **load-order-significant** — WoW loads
addons alphabetically, so `!!!ClassicAPI` is guaranteed to run before
any consumer that depends on its globals. Don't rename the folder.
To install: copy [`AddOns/!!!ClassicAPI/`](AddOns/!!!ClassicAPI/) into
your `Interface/AddOns/` directory.
**You don't have to install the addon manually** — the DLL embeds
the contents of [`AddOns/!!!ClassicAPI/`](AddOns/!!!ClassicAPI/) and
registers them with the engine as a synthetic addon on startup, so
the library is always available when the DLL is loaded. The engine
sees it as a normal addon: it appears in `GetAddOnInfo`, fires
`ADDON_LOADED`, supports `SavedVariables`, the works.
If you do drop the folder into your `Interface/AddOns/` directory,
**that copy wins** — the DLL's embedded version is only registered
when the engine's normal scan doesn't already have an entry under
that name. Useful for editing the Lua locally without rebuilding the
DLL. The dispatch is transparent: addons consuming `Mixin`,
`ColorMixin`, `TableUtil`, etc. behave identically in both cases.
## Bundled addon: DebugTools
+115
View File
@@ -0,0 +1,115 @@
# embed_addon.cmake — bundle an addon directory into a generated C++ header.
#
# Invoked from CMakeLists.txt via `cmake -P` as a custom command. Reads every
# file under SRC_DIR recursively and emits OUT_HEADER containing:
#
# namespace Addons::Embedded {
# struct File {
# const char *path;
# const unsigned char *data;
# size_t size;
# };
# extern const File kFiles[];
# extern const size_t kFileCount;
# }
#
# Paths are normalized to lowercase backslash-form relative to
# `Interface\AddOns\` so they match what the engine's TOC parser
# constructs when it asks for the addon's files (see
# `s_Interface_AddOns__s__s_toc_00853840` in the binary). The
# manifest is hash-table-friendly for fast lookup but the lookup
# walks the array linearly — fine for 25 files.
#
# Usage:
# cmake -DSRC_DIR=... -DOUT_HEADER=... -DADDON_NAME=... -P embed_addon.cmake
if(NOT SRC_DIR)
message(FATAL_ERROR "embed_addon.cmake: SRC_DIR required")
endif()
if(NOT OUT_HEADER)
message(FATAL_ERROR "embed_addon.cmake: OUT_HEADER required")
endif()
if(NOT ADDON_NAME)
message(FATAL_ERROR "embed_addon.cmake: ADDON_NAME required")
endif()
file(GLOB_RECURSE INPUT_FILES RELATIVE "${SRC_DIR}" "${SRC_DIR}/*")
list(SORT INPUT_FILES) # deterministic codegen
set(BODY "")
set(MANIFEST "")
set(INDEX 0)
foreach(REL_PATH ${INPUT_FILES})
set(ABS_PATH "${SRC_DIR}/${REL_PATH}")
file(READ "${ABS_PATH}" 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()
# Normalize path to engine-style: `<AddonName>\Sub\Path.lua` with
# backslashes. Engine constructs full path as
# `Interface\AddOns\<AddonName>\<file>` — we strip the
# `Interface\AddOns\<AddonName>\` prefix at lookup time so storing
# just the relative form keeps the manifest small.
string(REPLACE "/" "\\\\" REL_PATH_BS "${REL_PATH}")
string(APPEND BODY "static const unsigned char kData_${INDEX}[] = {\n${BYTES}};\n\n")
string(APPEND MANIFEST " { \"${REL_PATH_BS}\", kData_${INDEX}, ${FILE_SIZE} },\n")
math(EXPR INDEX "${INDEX} + 1")
endforeach()
set(HEADER_CONTENT "// Auto-generated by embed_addon.cmake — do not edit.
// Source: ${SRC_DIR}
//
// Addon \"${ADDON_NAME}\" embedded as a fallback for users who don't have
// it installed on disk. See src/addons/Embedded.cpp for the file-read
// hook that surfaces these buffers to the engine.
#pragma once
#include <cstddef>
namespace Addons::Embedded {
namespace ${ADDON_NAME}Files {
${BODY}static const struct File {
const char *path;
const unsigned char *data;
size_t size;
} kFiles[] = {
${MANIFEST}};
static constexpr size_t kFileCount = sizeof(kFiles) / sizeof(kFiles[0]);
} // namespace ${ADDON_NAME}Files
} // namespace Addons::Embedded
")
file(WRITE "${OUT_HEADER}" "${HEADER_CONTENT}")
message(STATUS "embed_addon: wrote ${OUT_HEADER} (${INDEX} files)")
+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)")
+1286 -758
View File
File diff suppressed because it is too large Load Diff
+33 -5
View File
@@ -186,17 +186,45 @@ void PushLocalizedString(void *L, const char *globalName, const char *fallback)
}
}
// In-place `string.gsub(stack[-1], pattern, replacement)` — replaces
// the string at the top of the stack with the substitution result.
// Discards gsub's second return (the substitution count) by
// requesting only one return from `Call`.
static void GsubInPlace(void *L, const char *pattern, const char *replacement) {
PushString(L, "string");
GetTable(L, GLOBALS_INDEX);
PushString(L, "gsub");
GetTable(L, -2); // [..., str, string, gsub]
Remove(L, -2); // [..., str, gsub]
Insert(L, -2); // [..., gsub, str]
PushString(L, pattern);
PushString(L, replacement);
Call(L, 3, 1); // [..., result]
}
void PushLocalizedFormatInt(void *L, const char *globalName,
const char *fallback, int n) {
// Resolve `string.format` from _G. The engine ships it as both a
// global and as `string.format`; the global is faster (one
// gettable instead of two) and equally available across builds.
// Resolve `string.format` from _G.
PushString(L, "format");
GetTable(L, GLOBALS_INDEX);
PushLocalizedString(L, globalName, fallback);
// Expand WoW's `|4singular:plural;` (and 3-form
// `|4nom:gen_sing:plural;`) pluralization template by piggybacking
// on Lua's `string.gsub`. The 2- vs 3-form distinction is locale
// grammar (Slavic counting has a separate genitive-singular form
// for 2..4); we collapse to nominative/plural since vanilla has
// no proper locale-aware counting rules. 3-form pattern runs
// first because it's more specific. Vanilla's `string.format`
// doesn't process `|4` natively, so without this the template
// would render literally.
const char *picker3 = (n == 1) ? "%1" : "%3";
const char *picker2 = (n == 1) ? "%1" : "%2";
GsubInPlace(L, "|4([^:;]+):([^:;]+):([^;]+);", picker3);
GsubInPlace(L, "|4([^:;]+):([^;]+);", picker2);
PushNumber(L, static_cast<double>(n));
Call(L, 2, 1); // format(fmt, n) → string
Call(L, 2, 1); // format(expandedFmt, n) → string
}
} // namespace Lua
+369 -4
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,
@@ -1068,6 +1100,17 @@ enum Offsets {
OFF_ITEMSTATS_NAME = 0x08,
OFF_ITEMSTATS_DISPLAY_INFO_ID = 0x18,
OFF_ITEMSTATS_QUALITY = 0x1C, // u32 — 0=Poor … 5=Legendary
// Item-quality color-prefix table. Array of 7 `const char *`
// pointers, indexed by quality 0..6 (POOR..ARTIFACT); each
// points to a `"|cffRRGGBB"` markup string used by the engine's
// own link builder. Discovered via the inner color helper at
// `0x0052AD90` which does `(&table)[quality]` with a fallback
// to white (`"|cffffffff"`, the index-1 entry) for quality > 6.
// Quality 7 (Heirloom) doesn't exist in vanilla; consumers
// should mirror the engine's fallback for safety.
VAR_QUALITY_COLOR_TABLE = 0x00854124,
QUALITY_COLOR_TABLE_SIZE = 7,
// `m_flags` — item-class flags bitfield. Bits we read:
// `ITEMSTATS_FLAG_OPENABLE` (`0x4`, bit 2): freely right-click-
// openable container (sack, clam, simple chest, quest box).
@@ -1477,6 +1520,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
@@ -1772,6 +1844,41 @@ enum Offsets {
// without re-parsing the body. See `Macro::Spell::Script_GetMacroSpell`.
FUN_MACRO_SLOT_TO_ENTRY = 0x004F0E40,
// Macro-icon database. Populated lazily by `FUN_LOAD_MACRO_ICONS`
// on the first `GetNumMacroIcons` call — enumerates `Interface\Icons\`
// for `*.blp` files plus a wildcard match, sorts, and de-dupes. Each
// entry is the basename (e.g. `"Ability_Kick"`) without the
// `Interface\Icons\` prefix; vanilla's `Script_GetMacroIconInfo`
// joins the prefix via sprintf before pushing. Verified by reading
// `Script_GetNumMacroIcons` (`0x004F19F0`) and
// `Script_GetMacroIconInfo` (`0x004F1A30`).
//
// Vanilla's loader has 3 enumeration passes, each with a per-file
// callback. The first two (`FUN_MACRO_ICON_CB_DISK`,
// `FUN_MACRO_ICON_CB_USER_MPQ`) prefix-filter on `"Ability_"` and
// `"Spell_"` — anything else (including `INV_*` item icons) is
// rejected. The third (`FUN_MACRO_ICON_CB_INSTALL_MPQ`) reads as
// extension-only filter in the disassembly (any `.blp`/`.tga` is
// accepted), but the engine's main icon DB ends up with zero
// `INV_*` entries regardless (`GetNumMacroIcons() == 746`, all
// `Ability_*`/`Spell_*`). Best guess: a check inside the
// `SStrDup`/array-append helpers downstream of all three callbacks
// filters them out — but the per-file callbacks themselves DO see
// `INV_*` filenames (verified by hook capture: 5,226 unique
// `INV_*` basenames flow through the callbacks per session).
//
// For `C_Macro::GetMacroItemIcons` we hook each callback at its
// entry, capture any `INV_*` filename into a DLL-owned side array,
// then forward to the original — dodges whichever downstream
// filter the engine applies and matches the parallel item-icon
// array 4.3.4 exposes (via `Script_GetMacroItemIcons`).
VAR_MACRO_ICON_COUNT = 0x00BDCC1C, // uint32 count of loaded icons
VAR_MACRO_ICON_ARRAY = 0x00BDCC20, // char ** — pointer to flat array of icon-name C strings (4-byte stride)
FUN_LOAD_MACRO_ICONS = 0x004F0090, // `__cdecl()` lazy populate; no-op if already loaded
FUN_MACRO_ICON_CB_DISK = 0x004F0220, // disk enumerator callback — `__fastcall(const char *fullPath)` — prefix-filtered
FUN_MACRO_ICON_CB_USER_MPQ = 0x004F0350, // user-MPQ enumerator callback — `__fastcall(MpqRecord *r)` — prefix-filtered
FUN_MACRO_ICON_CB_INSTALL_MPQ = 0x004F04F0, // install-MPQ enumerator callback — `__fastcall(MpqRecord *r)` — extension-only filter
// Quest log: 16-byte-stride entry array and active count.
// Field +0 of each entry is the questID for real quests (a category index
// for headers); field +8 is the header indicator: non-NULL = header,
@@ -1966,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)
@@ -1975,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>`,
@@ -2101,6 +2279,7 @@ enum Offsets {
// `Script_IsInInstance`).
VAR_CURRENT_MAP_ID = 0x00B4E378,
// Local player's current AreaTable.dbc area ID (u32 storage; only
// the low 16 bits are used — the value is broadcast over the wire
// as a u16 in `SMSG_PARTY_MEMBER_STATS`). Written by the zone-change
@@ -2349,6 +2528,86 @@ enum Offsets {
FUN_STORM_SMEM_ALLOC = 0x006462E0,
FUN_STORM_SMEM_FREE = 0x00646430,
// Engine's main file-content reader. Used by the TOC parser at
// `FUN_TOC_PARSER` to slurp addon `.toc` files and by the addon
// load pass for `.lua` source. Allocates a Storm buffer via
// `FUN_STORM_SMEM_ALLOC`, writes the file content + `extraBytes`
// trailing zeros, hands the buffer to the caller. Caller frees
// via `FUN_STORM_SMEM_FREE`.
//
// __cdecl int FileRead(
// int unused, // always 0 from engine call sites
// const char *path, // "Interface\AddOns\X\Y.lua"
// void **outBuf, // Storm-allocated content
// size_t *outSize, // optional, may be NULL
// size_t extraBytes, // trailing zero-fill (1 for nul-term)
// int flag1, // always 1 from TOC parser
// int flag2); // 0
// Returns 1 on success, 0 on failure.
//
// We hook this to redirect reads of `Interface\AddOns\!!!ClassicAPI\*`
// to embedded buffers (see src/addons/Embedded.cpp). Allocating via
// the same Storm allocator the engine uses means the caller's
// normal `SMemFree` works without us tracking lifetime.
FUN_FILE_READ = 0x00648620,
// TOC parser — `__fastcall(this=addonName)`. Already documented in
// CLAUDE.md under "AddOn registry & hot-reload". Dedup-safe: if
// the addon is already in the hash table, returns immediately
// without re-parsing. Reads the TOC file via `FUN_FILE_READ`. We
// call this from the post-`FUN_ADDON_INIT` hook to register the
// embedded `!!!ClassicAPI` addon — the dedup behavior means our
// call is a no-op when the user has the addon installed on disk.
FUN_TOC_PARSER = 0x0051C9B0,
// AddOn registry init — `__fastcall(accountName)`. Documented in
// CLAUDE.md ("AddOn registry & hot-reload"). Hooked post-call so
// we can splice the embedded addon entry into the freshly-populated
// registry before the load pass.
FUN_ADDON_INIT = 0x0051C740,
// Generic intrusive-list insert/move helper used by `FUN_TOC_PARSER`
// to thread new addon entries into the registry's doubly-linked
// list. Signature (`__thiscall`):
// `(ListCtrl *this, void *entry, int position, int anchor)`
// Where:
// - `this` — list-control struct (for the addon registry,
// this is `&VAR_ADDON_LIST_CTRL`).
// - `entry` — entry to insert. If already linked, the
// function removes it from its current
// position first.
// - `position` — `1` = insert at head, anything else (engine
// passes `2`) = insert at tail.
// - `anchor` — `0` = anchor against the list sentinel
// (default), nonzero = anchor relative to
// another entry's link field at that offset.
//
// Used by `Addons::Embedded` to move our injected `!!!ClassicAPI`
// entry from the tail (where `FUN_TOC_PARSER` puts it) to the head
// — vanilla's addon load pass at `FUN_0051F600` walks the linked
// list in insertion order, so without this fix our addon would
// load *last*, breaking dependent addons that expect our globals
// to be ready in their main chunk.
FUN_INTRUSIVE_LIST_INSERT = 0x00521AD0,
// Addon-registry intrusive-list control. Layout
// (`__thiscall`-friendly, generic across all intrusive lists in the
// binary):
// +0x00 (= `0x00BE1B64`) — `int` next-pointer offset within each
// entry (= `0xC` at runtime; entries
// store their next-link at
// `entry+0xC..0x10`).
// +0x04 (= `0x00BE1B68`) — sentinel `next` field
// (points back into &sentinel+1 when
// the list is empty).
// +0x08 (= `0x00BE1B6C`) — list head (alias for sentinel `prev`
// when treated as circular).
//
// `VAR_ADDON_LIST_CTRL` is just the address of the control struct
// (i.e., `0x00BE1B64`). Pass `&VAR_ADDON_LIST_CTRL` as the `this`
// arg to `FUN_INTRUSIVE_LIST_INSERT`.
VAR_ADDON_LIST_CTRL = 0x00BE1B64,
// `SStrDup(const char *src, const char *file, int line)`. `__stdcall`.
// Storm's string-copy wrapper around `SMemAlloc` — used by the engine
// itself to populate event entry names. We use it from `Event::Custom`
@@ -2483,9 +2742,11 @@ enum Offsets {
// We dispatch to these directly so the wrappers don't need to
// bounce through `Script_GetAddOnInfo` and unwind 7 returns.
//
// IS_LOAD_ON_DEMAND `__fastcall(entry) → byte`
// Hash-table lookup in the addon registry. Non-zero ⇒
// `## LoadOnDemand: 1` in the .toc.
// IS_LOADED `__fastcall(entry) → byte`
// Hash-table lookup in the addon registry. Returns the byte
// at `entry+0x18`, which the addon loader (`FUN_0051F240`)
// writes `1` to when the addon has finished loading. Non-zero
// ⇒ the addon is loaded.
//
// CAN_LOAD `__fastcall(entry, char fullCheck=1, int *outStatus,
// int *outDepHandle, const char *accountName) → byte`
@@ -2506,7 +2767,7 @@ enum Offsets {
// Hash-table lookup that returns the security category index
// (0=SECURE, 1=INSECURE, 2=BANNED). Defaults to `1` (INSECURE)
// when the entry isn't in the override table.
FUN_ADDON_IS_LOAD_ON_DEMAND = 0x0051E6F0,
FUN_ADDON_IS_LOADED = 0x0051E6F0,
FUN_ADDON_CAN_LOAD = 0x0051E780,
FUN_ADDON_GET_SECURITY_INDEX = 0x0051E990,
@@ -3139,4 +3400,108 @@ enum Offsets {
SMSG_OPCODE_MIRROR_TIMER_PAUSE = 0x1DA,
SMSG_OPCODE_MIRROR_TIMER_STOP = 0x1DB,
MIRROR_TIMER_SLOT_COUNT = 3,
// `realmList` CVar reader — returns the configured realmlist
// address (e.g. "logon.turtle-wow.org") as a `const char *`, or
// NULL when unset. `__fastcall(int forceRefresh)`; pass 0 to read
// the cached value (callers in the engine pass non-zero only
// around CMSG_AUTH_SESSION reconnects). Lazily registers the CVar
// on first call.
//
// This is the realmlist *address*, not the display name (the
// friendly server name visible in the realm-select dialog lives
// in the `realmName` CVar at `FUN_005AB7D0`, read by
// `Script_GetServerName`). Credentials are scoped on this value
// because two different private servers may share the same
// friendly name but never the same realmlist address.
FUN_CVAR_REALM_LIST = 0x005AB680,
// Engine login entry — the C function `Script_DefaultServerLogin`
// (the glue Lua binding at `0x0046D160`) tail-calls into here with
// the account/password as raw C strings. We invoke it directly from
// C++ when dispatching a saved-credential login, bypassing the
// round-trip through Lua so the plaintext never crosses the
// Lua boundary on the way out.
//
// __fastcall void(ecx = const char *account, edx = char *password)
//
// The function:
// 1. Gates on three globals — VAR_GLUE_LOGIN_READY1,
// VAR_GLUE_LOGIN_READY2 (both must be nonzero) and
// VAR_GLUE_LOGIN_INPROGRESS (must be zero). All set up by the
// normal glue boot flow; a call made while the AccountLogin
// screen is showing is safe, an off-screen call silently no-ops.
// 2. Copies the account into a 0x40-byte buffer at `0x00B41DB0`
// and uppercases it in place.
// 3. Fires the "connecting" status event.
// 4. Hands the (uppercased account, password) pair to the SRP
// initiator at `FUN_005AB4B0`.
// 5. **Zeros the caller's password buffer in place** via an
// unrolled `REP STOSD` loop counting strlen bytes — so the
// decrypted-on-stack buffer we feed in gets scrubbed for free
// after the SRP step consumes it.
// 6. Calls into the AddOn-registry init (`FUN_0051C740`) with the
// uppercased account name. Same call documented in CLAUDE.md
// under "AddOn registry & hot-reload".
FUN_GLUE_LOGIN_ATTEMPT = 0x0046AFB0,
// Glue-state readiness flags read by `FUN_GLUE_LOGIN_ATTEMPT`'s
// prologue. Names reflect the gate-condition logic, not anything
// intrinsic to the engine — the engine just checks `R1 && R2 && !IP`.
VAR_GLUE_LOGIN_READY1 = 0x00B41DFC,
VAR_GLUE_LOGIN_READY2 = 0x00B41E04,
VAR_GLUE_LOGIN_INPROGRESS = 0x00B41DA0,
// Cursor state — single global type-tag at VAR_CURSOR_TYPE
// dispatches reads through one of several payload slots depending
// on what's picked up. The engine's `CursorHasItem` /
// `CursorHasSpell` / `GetCursorMoney` and the various `Pickup*`
// functions all read/write through these. Discovered by tracing
// `Script_CursorHasItem` (`0x004895D0`) and the writers in
// `FUN_00494B60` (item), `FUN_00494CC0` (money), `FUN_00494D20`
// (spell), `FUN_00494E20` (pet action), `FUN_00494F80` (macro),
// `FUN_004950F0` (action/inv/etc.), `FUN_00495010` (merchant).
//
// Type values:
// 0 empty (nothing on cursor)
// 1 bag item — GUID at `VAR_CURSOR_ITEM_GUID_LO/_HI`,
// itemID resolved by feeding the GUID to
// `FUN_OBJECT_FROM_GUID` (filter 0x2).
// 2 money — copper at `VAR_CURSOR_MONEY_COPPER`
// 3 player spell — spellID at `VAR_CURSOR_SPELL_ID`
// 4 pet action — packed at `VAR_CURSOR_PETACTION_PACKED`
// 5 merchant item — itemID at `VAR_CURSOR_GENERIC_SLOT`,
// 0-based merchant slot at `VAR_CURSOR_GENERIC_SOURCE`.
// Written by `Script_PickupMerchantItem` (`0x004FB760`) →
// `FUN_004950F0(itemID, ?, slot0, 5)`.
// 6/7 other drag-source items — itemID at
// `VAR_CURSOR_GENERIC_SLOT`. Type 7 is action-bar pickup
// (`FUN_004E6130`); type 6 is another drag path (mail/etc.).
// 8 macro — 1-based index at `VAR_CURSOR_MACRO_INDEX`
// 9 inventory item (equipped slot) — itemID stored at
// `VAR_CURSOR_GENERIC_SLOT`. Bare itemID rather than a GUID
// because the item is out of the descriptor stream while
// on the cursor.
// 10 stable pet — 1-based index at `VAR_CURSOR_STABLEPET_INDEX`.
// Written by `Script_ClickStablePet` (`FUN_004CB420`) →
// `FUN_00495010(slot)`.
//
// `CursorHasItem` returns true for types 1 *and* 9 only.
VAR_CURSOR_TYPE = 0x00B4D900,
VAR_CURSOR_ITEM_GUID_LO = 0x00B4E248,
VAR_CURSOR_ITEM_GUID_HI = 0x00B4E24C,
VAR_CURSOR_MONEY_COPPER = 0x00B4E2F0,
VAR_CURSOR_SPELL_ID = 0x00B4E2F4,
VAR_CURSOR_PETACTION_PACKED = 0x00B4E2F8,
VAR_CURSOR_MACRO_INDEX = 0x00B4E2FC,
VAR_CURSOR_STABLEPET_INDEX = 0x00B4E300,
VAR_CURSOR_GENERIC_SLOT = 0x00B4B41C,
VAR_CURSOR_GENERIC_DISPLAY = 0x00B4D8EC,
VAR_CURSOR_GENERIC_SOURCE = 0x00B4B420,
// GUID → CGObject resolver. `__fastcall(type_filter, guid_lo,
// guid_hi) → CGObject *` at `0x00468460`. Type filter 0x2 = item,
// matching what `FUN_00494B60` and the trade-attached paths pass
// when re-resolving the cursor GUID to a `CGItem *`.
FUN_OBJECT_FROM_GUID = 0x00468460,
};
+379
View File
@@ -0,0 +1,379 @@
// 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/>.
// Embedded `!!!ClassicAPI` addon fallback. When the user doesn't have
// the addon installed on disk, this module makes the engine think it
// does — without writing anything to the filesystem.
//
// How:
//
// 1. The CMake build embeds every file under `AddOns/!!!ClassicAPI/`
// into a generated header (`embedded_classicapi.h`) as a byte
// array per file plus a `{path, data, size}` manifest.
//
// 2. We hook the engine's file-read function at `FUN_FILE_READ`.
// When the engine asks for a path under
// `Interface\AddOns\!!!ClassicAPI\`, we allocate a Storm buffer
// via the same allocator the engine itself uses, copy the
// embedded content in, and hand it back. The caller's normal
// `SMemFree` reclaims it on completion — no special lifetime.
//
// 3. We post-hook the engine's `FUN_ADDON_INIT`. After the engine's
// own `Interface\AddOns\` scan finishes, we call the TOC parser
// directly with `"!!!ClassicAPI"`. The parser opens its TOC via
// our hooked read function and registers the addon as a normal
// entry. The TOC parser is dedup-safe: if the user already has
// the addon on disk, the engine's scan picked it up, the
// hash-table lookup hits, and our call is a no-op. So the
// conditional "only if not on disk" behavior is free.
//
// Same pattern WeirdUtils.dll uses (we reverse-engineered it from
// `0x10015A74` while figuring this out). The addon load pass then
// reads each `.lua` file through the same hooked read path —
// `GlobalStrings.lua`, `Util\Print.lua`, etc. all surface from
// embedded buffers.
#include "embedded_classicapi.h"
#include "Game.h"
#include "Offsets.h"
#include "bindings/Inject.h"
#include <cstdint>
#include <cstdio>
#include <cstring>
namespace Addons::Embedded {
namespace {
constexpr const char *kAddonName = "!!!ClassicAPI";
constexpr const char *kAddonTocFile = "!!!ClassicAPI.toc";
// `Interface\AddOns\!!!ClassicAPI\` — the prefix the engine
// constructs for any of our addon's files. Comparing case-
// insensitively because the TOC parser uses `Interface\AddOns\%s\…`
// where %s comes from the on-disk directory name, and Windows file
// paths are case-insensitive.
constexpr const char *kAddonPathPrefix = "Interface\\AddOns\\!!!ClassicAPI\\";
constexpr size_t kAddonPathPrefixLen = 30; // length of the literal above
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 && *b) {
if (NormalizeChar(*a) != NormalizeChar(*b)) return false;
++a; ++b;
}
return *a == *b;
}
// Strip the `Interface\AddOns\!!!ClassicAPI\` prefix and return the
// suffix (e.g. `Util\Print.lua`) on a match, NULL otherwise.
const char *StripAddonPrefix(const char *path) {
if (path == nullptr) return nullptr;
const char *p = path;
const char *q = kAddonPathPrefix;
while (*q) {
if (*p == '\0') return nullptr;
if (NormalizeChar(*p) != NormalizeChar(*q)) return nullptr;
++p; ++q;
}
return p;
}
const ClassicAPIFiles::File *LookupEmbedded(const char *suffix) {
for (size_t i = 0; i < ClassicAPIFiles::kFileCount; ++i) {
if (PathEqualsCI(suffix, ClassicAPIFiles::kFiles[i].path))
return &ClassicAPIFiles::kFiles[i];
}
return nullptr;
}
// Storm allocator — same one `FUN_FILE_READ` uses internally. Buffer
// allocated here is freed cleanly by the caller's standard `SMemFree`
// (`FUN_STORM_SMEM_FREE`). `__stdcall` per the function's `RET 0x10`
// (4 args × 4 bytes = 16) epilogue.
// __stdcall void *SMemAlloc(size_t size, const char *file, int line, int flags)
using SMemAlloc_t = void *(__stdcall *)(size_t size, const char *file, int line, int flags);
// `FUN_FILE_READ` — same shape derived from the Octo decompile.
// Calling convention is `__stdcall` (callee cleans 28 bytes via
// `RET 0x1C`), not `__cdecl` — confirmed via the function epilogue.
// Getting this wrong silently corrupts the caller's stack frame.
// __stdcall int FileRead(int unused, const char *path, void **outBuf,
// size_t *outSize, size_t extraBytes,
// int flag1, int flag2)
using FileRead_t = int(__stdcall *)(int unused, const char *path, void **outBuf,
size_t *outSize, size_t extraBytes,
int flag1, int flag2);
FileRead_t FileRead_o = nullptr;
// Storm-allocator free. Used to release the disk-TOC buffer after we
// scrape its version line — same path the engine itself uses when
// `FileRead_o` succeeded and the caller's done with the result.
// __stdcall void SMemFree(void *buf, const char *file, int line, int flags)
using SMemFree_t = void(__stdcall *)(void *buf, const char *file, int line, int flags);
// Extracts the value of the `## Version: X` line from a TOC byte
// buffer. Writes into `out` (size `outSize`) and returns true on
// success. The TOC format is `## Key: Value`, one per line, case-
// sensitive on the key. Returns false if no Version line is found.
bool ExtractTocVersion(const char *content, size_t size,
char *out, size_t outSize) {
static const char kKey[] = "## Version:";
constexpr size_t kKeyLen = sizeof(kKey) - 1;
for (size_t i = 0; i + kKeyLen <= size; ++i) {
const bool atLineStart = (i == 0) || content[i - 1] == '\n';
if (!atLineStart) continue;
if (std::memcmp(content + i, kKey, kKeyLen) != 0) continue;
const char *p = content + i + kKeyLen;
const char *end = content + size;
while (p < end && (*p == ' ' || *p == '\t')) ++p;
size_t j = 0;
while (p < end && *p != '\r' && *p != '\n' && j + 1 < outSize) {
out[j++] = *p++;
}
while (j > 0 && (out[j - 1] == ' ' || out[j - 1] == '\t')) --j;
out[j] = '\0';
return j > 0;
}
if (outSize > 0) out[0] = '\0';
return false;
}
// Returns -1/0/+1 for `a < b` / `a == b` / `a > b`. "DEV" is a
// sentinel meaning "newer than any real release" — local dev builds
// always win against installed-on-disk versions. Otherwise the two
// strings are walked as dot-separated numeric semver components
// (`1.2` < `1.10`).
int CompareVersions(const char *a, const char *b) {
const bool aDev = std::strcmp(a, "DEV") == 0;
const bool bDev = std::strcmp(b, "DEV") == 0;
if (aDev && bDev) return 0;
if (aDev) return 1;
if (bDev) return -1;
while (*a || *b) {
int va = 0, vb = 0;
while (*a >= '0' && *a <= '9') { va = va * 10 + (*a - '0'); ++a; }
while (*b >= '0' && *b <= '9') { vb = vb * 10 + (*b - '0'); ++b; }
if (va != vb) return va < vb ? -1 : 1;
if (*a == '.') ++a;
if (*b == '.') ++b;
if (!*a && !*b) break;
if (!*a) return -1;
if (!*b) return 1;
}
return 0;
}
// Pre-extracted embedded TOC version, populated lazily on first
// FileRead_h call under our prefix. Empty until populated.
char g_embeddedVersion[64] = "";
// Which source wins the embed-vs-disk comparison, decided once on
// first relevant file read.
enum class Source { Undecided, Disk, Embedded };
Source g_source = Source::Undecided;
void EnsureEmbeddedVersionExtracted() {
if (g_embeddedVersion[0] != '\0') return;
for (size_t i = 0; i < ClassicAPIFiles::kFileCount; ++i) {
if (PathEqualsCI(kAddonTocFile, ClassicAPIFiles::kFiles[i].path)) {
ExtractTocVersion(
reinterpret_cast<const char *>(ClassicAPIFiles::kFiles[i].data),
ClassicAPIFiles::kFiles[i].size,
g_embeddedVersion, sizeof(g_embeddedVersion));
return;
}
}
}
// Decide whether disk or embedded should serve all reads for this
// addon. Called on the first FileRead_h call with a matching prefix.
// Reads the on-disk TOC via the original `FUN_FILE_READ` (bypassing
// our hook), parses its version, compares to the embedded version,
// and caches the winner. If disk has no TOC at all, embedded wins by
// default.
void DecideSource() {
if (g_source != Source::Undecided) return;
EnsureEmbeddedVersionExtracted();
char fullPath[256];
std::snprintf(fullPath, sizeof(fullPath), "%s%s",
kAddonPathPrefix, kAddonTocFile);
void *diskBuf = nullptr;
size_t diskSize = 0;
const int ok = FileRead_o(0, fullPath, &diskBuf, &diskSize, 1, 1, 0);
if (ok == 0 || diskBuf == nullptr) {
g_source = Source::Embedded;
return;
}
char diskVersion[64] = "";
ExtractTocVersion(static_cast<const char *>(diskBuf), diskSize,
diskVersion, sizeof(diskVersion));
// Free the disk buffer we just borrowed — we only needed it for
// the version-line scrape. The actual content for the engine's
// TOC read comes through the regular hook path below.
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
SMemFree(diskBuf, __FILE__, __LINE__, 0);
// Missing or unparseable disk version → assume it's older than
// anything we ship, embedded wins.
if (diskVersion[0] == '\0') {
g_source = Source::Embedded;
return;
}
const int cmp = CompareVersions(g_embeddedVersion, diskVersion);
g_source = (cmp > 0) ? Source::Embedded : Source::Disk;
}
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
// pass-through, we don't care.
return FileRead_o(unused, path, outBuf, outSize, extraBytes,
flag1, flag2);
}
DecideSource();
if (g_source == Source::Disk) {
// Disk version is at least as new — serve disk. If disk
// doesn't have this specific file (e.g. embedded has files
// disk doesn't), fall through and try embedded.
const int diskResult = FileRead_o(unused, path, outBuf, outSize,
extraBytes, flag1, flag2);
if (diskResult != 0) return diskResult;
}
// Source::Embedded path, OR Source::Disk-but-file-missing fallback.
// Look up the embedded version and synthesize a Storm buffer the
// caller can free normally.
const auto *entry = LookupEmbedded(suffix);
if (entry == nullptr) {
// Not in our embedded set either — let the engine try disk
// one more time (returns 0 if that also fails). Covers
// pathological cases like an empty disk install + embedded
// missing some file.
return FileRead_o(unused, path, outBuf, outSize, extraBytes,
flag1, flag2);
}
auto SMemAlloc = reinterpret_cast<SMemAlloc_t>(
Offsets::FUN_STORM_SMEM_ALLOC);
const size_t totalSize = entry->size + extraBytes;
void *buf = SMemAlloc(totalSize, __FILE__, __LINE__, 0);
if (buf == nullptr) return 0;
std::memcpy(buf, entry->data, entry->size);
if (extraBytes > 0) {
std::memset(static_cast<uint8_t *>(buf) + entry->size, 0, extraBytes);
}
if (outBuf != nullptr) *outBuf = buf;
if (outSize != nullptr) *outSize = entry->size;
return 1;
}
// `FUN_ADDON_INIT` — `__fastcall(accountName)`. We post-hook it: let
// the engine's normal `Interface\AddOns\` scan run first, then call
// the TOC parser with `"!!!ClassicAPI"`. Dedup-safe — see file header.
using AddonInit_t = void(__fastcall *)(const char *accountName);
AddonInit_t AddonInit_o = nullptr;
using TocParser_t = void(__fastcall *)(const char *name);
using ListInsert_t = void(__thiscall *)(void *listCtrl, void *entry,
int position, int anchor);
// `FUN_TOC_PARSER` appends new entries to the *tail* of the addon
// registry's linked list, and the engine's addon-load pass at
// `FUN_0051F600` walks that list in *insertion order* (NOT
// alphabetical order — the alphabetical sort only applies to the
// flat array `GetAddOnInfo(i)` reads from). So our post-hook
// injection lands at the very end of the load order, after every
// disk-scanned addon. Addons that consume our globals from their
// main chunk (idTip's `FrameWatcher`, etc.) would see `nil` and
// hard-error during their boot.
//
// Fix: walk the linked list, find our entry by name, and re-link
// it to the head via `FUN_INTRUSIVE_LIST_INSERT(position=1)`. The
// engine's own insert helper handles remove-then-insert atomically,
// so it's safe to call on an entry that's already in the list.
//
// Entries are pointed to by `[VAR_ADDON_LIST_HEAD]` (`0x00BE1B6C`),
// with the next-pointer field at `entry + 0x10` (computed as
// `*(value-at-VAR_ADDON_LIST_CTRL) + entry + 4` = `0xC + entry + 4`
// = `entry + 0x10`). Low-bit-1 means "sentinel" (end of list).
// Name pointer lives at `entry + 0x14`.
constexpr uintptr_t VAR_ADDON_LIST_HEAD = 0x00BE1B6C;
constexpr int OFF_ADDON_ENTRY_NAME_PTR = 0x14;
void MoveEmbeddedToListHead() {
uintptr_t entry = *reinterpret_cast<uintptr_t *>(VAR_ADDON_LIST_HEAD);
const int linkOffset = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL));
while ((entry & 1) == 0 && entry != 0) {
const char *name = *reinterpret_cast<const char *const *>(
entry + OFF_ADDON_ENTRY_NAME_PTR);
if (name != nullptr && std::strcmp(name, kAddonName) == 0) {
auto fn = reinterpret_cast<ListInsert_t>(
Offsets::FUN_INTRUSIVE_LIST_INSERT);
fn(reinterpret_cast<void *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL)),
reinterpret_cast<void *>(entry),
/*position=*/ 1, /*anchor=*/ 0);
return;
}
entry = *reinterpret_cast<const uintptr_t *>(
entry + linkOffset + 4);
}
}
void __fastcall AddonInit_h(const char *accountName) {
AddonInit_o(accountName);
auto TocParser = reinterpret_cast<TocParser_t>(Offsets::FUN_TOC_PARSER);
TocParser(kAddonName);
MoveEmbeddedToListHead();
}
const Game::HookAutoRegister _hookFileRead{
Offsets::FUN_FILE_READ,
reinterpret_cast<void *>(&FileRead_h),
reinterpret_cast<void **>(&FileRead_o)};
const Game::HookAutoRegister _hookAddonInit{
Offsets::FUN_ADDON_INIT,
reinterpret_cast<void *>(&AddonInit_h),
reinterpret_cast<void **>(&AddonInit_o)};
} // namespace
} // namespace Addons::Embedded
+51 -4
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;
}
@@ -163,9 +174,13 @@ int __fastcall Script_IsAddOnLoadable(void *L) {
return 2;
}
auto isLoD = reinterpret_cast<EntryByteFn_t>(
Offsets::FUN_ADDON_IS_LOAD_ON_DEMAND);
if (isLoD(entry) != 0) {
// Already-loaded addons are trivially loadable — skip the
// recursive dep walk and return (true, nil) directly. `IS_LOADED`
// reads the byte at `entry+0x18` (the loader sets that to 1 on
// successful load).
auto isLoaded = reinterpret_cast<EntryByteFn_t>(
Offsets::FUN_ADDON_IS_LOADED);
if (isLoaded(entry) != 0) {
Game::Lua::PushBoolean(L, 1);
Game::Lua::PushNil(L);
return 2;
@@ -229,6 +244,36 @@ int __fastcall Script_GetAddOnSecurity(void *L) {
return 1;
}
// `C_AddOns.IsAddOnLoaded(indexOrName)` → `(loadedOrLoading, loaded)`.
//
// Modern WoW distinguishes "load-in-progress" from "fully loaded" —
// matters for LoD addons whose load is split across multiple
// `LoadAddOn` callbacks. Vanilla 1.12's `FUN_0051F240` is fully
// synchronous: the loaded byte at `entry+0x18` flips from 0 → 1
// inside a single call, so the "loading" state is never observable
// from Lua. We return the same boolean twice — `loadedOrLoading`
// and `loaded` are identical here.
//
// Resolves both numeric indices (via `FUN_ADDON_GET_BY_INDEX`) and
// string names. `IS_LOADED` accepts an entry pointer as its name arg
// because the entry's first 12 bytes are the inline NUL-terminated
// directory name (same as the other per-field accessors), so we can
// feed it either an entry or a Lua-side string transparently.
int __fastcall Script_IsAddOnLoaded(void *L) {
const uint8_t *entry = ResolveAddOnName(L);
if (entry == nullptr) {
Game::Lua::PushBool(L, false);
Game::Lua::PushBool(L, false);
return 2;
}
auto isLoaded = reinterpret_cast<EntryByteFn_t>(
Offsets::FUN_ADDON_IS_LOADED);
const bool loaded = isLoaded(entry) != 0;
Game::Lua::PushBool(L, loaded);
Game::Lua::PushBool(L, loaded);
return 2;
}
// `C_AddOns.DoesAddOnExist(indexOrName)` — true iff the engine's
// addon registry has a matching entry. For numeric input we trust
// `GetByIndex`'s OOR-NULL; for string input we walk the flat array,
@@ -276,6 +321,8 @@ static void RegisterLuaFunctions() {
&Script_GetAddOnSecurity);
Game::Lua::RegisterTableFunction("C_AddOns", "DoesAddOnExist",
&Script_DoesAddOnExist);
Game::Lua::RegisterTableFunction("C_AddOns", "IsAddOnLoaded",
&Script_IsAddOnLoaded);
Game::Lua::RegisterIntegerEnum(
"Enum", "AddOnSecurityStatus",
kAddOnSecurityStatusEntries,
+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
+276
View File
@@ -0,0 +1,276 @@
// 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/>.
// `GetCursorInfo()` — backport of the modern WoW global. Surfaces
// whatever the player has picked up (item, money, spell, macro,
// merchant slot) as a tuple. Read from the same cursor-state globals
// the engine's `CursorHasItem` / `CursorHasSpell` / `GetCursorMoney`
// consult — see the cursor type table in [[src/Offsets.h]].
//
// Return shape per type (matches modern Lua API where it makes sense
// for 1.12's actual storage):
//
// nothing on cursor — nil
// "item", itemID, link — bag item (type 1; link is the full
// per-instance form via the engine's link
// builder, including enchantID and
// random-suffix decoration)
// "item", itemID — drag-source item (types 5/6/7 trade /
// mail / action bar; type 9 equipment
// slot) — only the itemID is in cursor
// storage, no live CGItem to feed the
// link builder
// "money", amount — type 2; amount in copper
// "spell", slot, bookType, spellID
// — type 3; bookType is "spell" or "pet"
// (1.12 only writes player-spell IDs to
// this slot, so bookType is always
// "spell" here; the cursor's "pet
// action" path is a distinct type)
// "macro", index — type 8; 1-based macro index
// "merchant", index — type 10; 1-based merchant-roster index
//
// Type 4 (pet action) is not surfaced — doesn't map cleanly to a
// modern `GetCursorInfo` string. Consumers can fall back to
// `CursorHasItem` / engine `PickupX` calls when they need to detect
// that.
#include "Game.h"
#include "Offsets.h"
#include "item/ID.h"
#include "item/Link.h"
#include "item/Location.h"
#include "item/QualityColor.h"
#include "spell/Lookup.h"
#include <cstdint>
#include <cstdio>
namespace Cursor::Info {
namespace {
// Engine signature is `__thiscall(this = typeFilter, guidLo, guidHi,
// unused)`. The "this" param is just a small integer filter — Ghidra
// shows it cast as `(void *)0x2` at every call site for items. The
// function reads only the first two stack args (`[ebp+8]` and
// `[ebp+0xC]`) but cleans 12 bytes via `ret 0xc`, so the caller must
// push a third dword for stack balance — engine callers push a
// scratch dword (`0x90`, `0x1C13`, etc.) for this. Declaring a 4th
// arg here makes MSVC emit the matching push; without it, stack
// imbalance of -4 bytes silently corrupts the caller's frame and
// (downstream) wedges things like chat input.
using ObjectFromGUID_t = uint8_t *(__thiscall *)(uintptr_t typeFilter,
uint32_t guidLo, uint32_t guidHi,
uint32_t unused);
constexpr uint32_t TYPE_EMPTY = 0;
constexpr uint32_t TYPE_BAG_ITEM = 1;
constexpr uint32_t TYPE_MONEY = 2;
constexpr uint32_t TYPE_SPELL = 3;
// Type 5 = merchant pickup. Written by `Script_PickupMerchantItem`
// (`0x004FB760`) via `FUN_004950F0(itemID, ?, slot0Based, 5)`. The
// itemID lands in `VAR_CURSOR_GENERIC_SLOT` and the 0-based merchant
// slot in `VAR_CURSOR_GENERIC_SOURCE` — we push slot+1 to Lua to
// match modern's 1-based merchant convention.
constexpr uint32_t TYPE_MERCHANT = 5;
// Types 6/7/9 — drag-source items from various pickup paths. All
// share the same storage trio. Type 7 is action-bar item pickup
// (`FUN_004E6130`), type 9 is equipment-slot pickup (`FUN_004C7300`),
// and type 6 is another drag-source path (mail or similar). In all
// cases the itemID is at `VAR_CURSOR_GENERIC_SLOT`. For modern API
// compat we surface them as `"item"`.
constexpr uint32_t TYPE_GENERIC_ITEM_LO = 6;
constexpr uint32_t TYPE_GENERIC_ITEM_HI = 7;
constexpr uint32_t TYPE_MACRO = 8;
constexpr uint32_t TYPE_INVENTORY_ITEM = 9;
// Type 10 is stable-pet pickup (`FUN_00495010`, the source of the
// earlier misidentification as "merchant"). Modern WoW has no
// equivalent `GetCursorInfo` return for stable pets, so we leave it
// as nil.
constexpr uint32_t GUID_TYPE_FILTER_ITEM = 0x2;
uint32_t ReadVar(uintptr_t va) {
return *reinterpret_cast<const uint32_t *>(va);
}
int PushItem(void *L, const uint8_t *cgItem) {
if (cgItem == nullptr)
return 0;
const int itemID = Item::ID::FromCGItem(cgItem);
if (itemID <= 0)
return 0;
Game::Lua::PushString(L, "item");
Game::Lua::PushNumber(L, static_cast<double>(itemID));
// Engine link builder works fine on cursored items — the cursor
// leaves the instance block at `+0x08` and the m_objectFields
// descriptor at `+0x114` intact, which is all the builder reads
// (plus a virtual call into the sub-object at `+0x110`, whose
// vftable is also untouched while held on the cursor). Earlier
// crash repros were the stack-imbalance bug in the GUID resolver
// — see Offsets::FUN_OBJECT_FROM_GUID.
const char *link = Item::Link::FromCGItem(cgItem);
if (link != nullptr && *link != '\0') {
Game::Lua::PushString(L, link);
return 3;
}
return 2;
}
int PushBagItemCursor(void *L) {
const uint32_t lo = ReadVar(Offsets::VAR_CURSOR_ITEM_GUID_LO);
const uint32_t hi = ReadVar(Offsets::VAR_CURSOR_ITEM_GUID_HI);
if (lo == 0 && hi == 0)
return 0;
auto resolver = reinterpret_cast<ObjectFromGUID_t>(Offsets::FUN_OBJECT_FROM_GUID);
const uint8_t *cgItem = resolver(GUID_TYPE_FILTER_ITEM, lo, hi, 0);
return PushItem(L, cgItem);
}
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);
}
// Builds a basic `"|cffRRGGBB|Hitem:N:0:0:0|h[Name]|h|r"` link from
// the cached ItemStats record for `itemID`. No enchant / random-
// suffix decoration — the cursor only stores the bare itemID for
// drag-source items (types 6/7/9) with no live `CGItem *` to read
// instance state from. Returns true and writes into `out` (size
// `outSize`) on success; returns false if the item isn't cached.
bool BuildBasicItemLink(uint32_t itemID, char *out, size_t outSize) {
const uint8_t *record = PeekItemRecord(itemID);
if (record == nullptr) return false;
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (name == nullptr || *name == '\0') return false;
const uint32_t quality = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_QUALITY);
const int n = std::snprintf(out, outSize,
"%s|Hitem:%u:0:0:0|h[%s]|h|r",
Item::QualityColor::Prefix(static_cast<int>(quality)),
itemID, name);
return n > 0 && static_cast<size_t>(n) < outSize;
}
// Shared path for types 6/7 (action bar / mail drag) and type 9
// (equipment slot). All three write the itemID at
// `VAR_CURSOR_GENERIC_SLOT` — see the type-table notes in
// [[src/Offsets.h]]. The cursor doesn't preserve a GUID for these,
// so the live CGItem isn't directly addressable here. To recover
// enchant / random-suffix decoration we walk the player's
// equipment + bags looking for the first CGItem with a matching
// itemID — same trick the action-bar tooltip uses to display
// enchanted bracers correctly when they're on an action bar. Falls
// back to a basic ID-only link when no instance is found (item was
// deleted, or it's a vendor-roster reference with no local copy).
int PushGenericItemCursor(void *L) {
const uint32_t itemID = ReadVar(Offsets::VAR_CURSOR_GENERIC_SLOT);
if (itemID == 0)
return 0;
Game::Lua::PushString(L, "item");
Game::Lua::PushNumber(L, static_cast<double>(itemID));
Item::Location::ByGUIDResult found;
if (Item::Location::FindByItemID(L, static_cast<int>(itemID), &found)) {
const char *link = Item::Link::FromCGItem(found.item);
if (link != nullptr && *link != '\0') {
Game::Lua::PushString(L, link);
return 3;
}
}
char linkBuf[256];
if (BuildBasicItemLink(itemID, linkBuf, sizeof(linkBuf))) {
Game::Lua::PushString(L, linkBuf);
return 3;
}
return 2;
}
int PushMoneyCursor(void *L) {
const uint32_t copper = ReadVar(Offsets::VAR_CURSOR_MONEY_COPPER);
Game::Lua::PushString(L, "money");
Game::Lua::PushNumber(L, static_cast<double>(copper));
return 2;
}
int PushSpellCursor(void *L) {
const uint32_t spellID = ReadVar(Offsets::VAR_CURSOR_SPELL_ID);
if (spellID == 0)
return 0;
int bookType = 0;
const int slot = Spell::Lookup::FindSpellbookSlot(
static_cast<int>(spellID), &bookType);
Game::Lua::PushString(L, "spell");
// Slot can be 0 (rare — talent-passive auras the player has but
// that have no spellbook entry). Push it anyway; the modern API's
// contract is "slot may be 0 when there's no book row".
Game::Lua::PushNumber(L, static_cast<double>(slot));
Game::Lua::PushString(L, bookType == 1 ? "pet" : "spell");
Game::Lua::PushNumber(L, static_cast<double>(spellID));
return 4;
}
int PushMacroCursor(void *L) {
const uint32_t index = ReadVar(Offsets::VAR_CURSOR_MACRO_INDEX);
if (index == 0)
return 0;
Game::Lua::PushString(L, "macro");
Game::Lua::PushNumber(L, static_cast<double>(index));
return 2;
}
int PushMerchantCursor(void *L) {
// Engine stores the 0-based merchant slot at
// `VAR_CURSOR_GENERIC_SOURCE`. Convert to 1-based for the modern
// Lua-API contract (`MerchantItemButton`s and friends are
// 1-indexed).
const uint32_t slot0 = ReadVar(Offsets::VAR_CURSOR_GENERIC_SOURCE);
Game::Lua::PushString(L, "merchant");
Game::Lua::PushNumber(L, static_cast<double>(slot0 + 1));
return 2;
}
int __fastcall Script_GetCursorInfo(void *L) {
const uint32_t type = ReadVar(Offsets::VAR_CURSOR_TYPE);
if (type >= TYPE_GENERIC_ITEM_LO && type <= TYPE_GENERIC_ITEM_HI)
return PushGenericItemCursor(L);
switch (type) {
case TYPE_BAG_ITEM: return PushBagItemCursor(L);
case TYPE_MERCHANT: return PushMerchantCursor(L);
case TYPE_INVENTORY_ITEM: return PushGenericItemCursor(L);
case TYPE_MONEY: return PushMoneyCursor(L);
case TYPE_SPELL: return PushSpellCursor(L);
case TYPE_MACRO: return PushMacroCursor(L);
case TYPE_EMPTY:
default: return 0;
}
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetCursorInfo", &Script_GetCursorInfo);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Cursor::Info
+151 -16
View File
@@ -35,6 +35,7 @@
#include "Offsets.h"
#include "Set.h"
#include "event/Custom.h"
#include "item/Location.h"
#include "item/Swap.h"
#include <cstdint>
@@ -330,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);
@@ -340,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;
@@ -401,39 +417,158 @@ int __fastcall Script_UseEquipmentSet(void *L) {
uint64_t guids[SLOT_COUNT];
std::memcpy(guids, s->items, sizeof(guids));
// Snapshot empty bag slots up front. The CGItem state doesn't
// update between packet sends (server has to acknowledge each
// swap), so iterating the bag walk fresh per unequip would
// repeatedly pick the same "first empty" slot — second packet
// collides with what packet 1 just put there, and the server
// tries to swap that item back onto the wrong paperdoll slot
// → "This item cannot be equipped".
struct FreeSlot {
int bag;
int slot;
};
FreeSlot freeSlots[64];
int freeSlotCount = 0;
for (int bag = 0; bag <= 4 && freeSlotCount < 64; ++bag) {
const int slots = Item::Location::GetBagSlotCount(bag);
for (int slot = 1; slot <= slots && freeSlotCount < 64; ++slot) {
if (Item::Location::ResolveBag(L, bag, slot) == nullptr)
freeSlots[freeSlotCount++] = {bag, slot};
}
}
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];
if (g == GUID_EMPTY || g == GUID_IGNORED)
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));
-2
View File
@@ -140,8 +140,6 @@ uint32_t Create(const char *name, const char *icon) {
EnsureLoaded();
if (g_path.empty() || name == nullptr || name[0] == '\0')
return 0;
if (g_sets.size() >= MAX_SETS)
return 0;
if (FindByName(name) != nullptr)
return 0;
Set s;
+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
-5
View File
@@ -56,11 +56,6 @@ inline const char *DefaultIcon() { return "INV_Misc_QuestionMark"; }
// (1.15.x is also 32). 4.3.4 used the same buffer size.
constexpr int MAX_NAME_LEN = 31;
// Hard cap matching 4.3.4 behavior. Modern Classic Era (1.15) raised
// this to 30; we follow the 4.3.4 number so legacy addons that show
// "X / 10" don't surprise users.
constexpr int MAX_SETS = 10;
// `GetItemLocations` encoding — matches Blizzard FrameXML's
// `EquipmentManager_UnpackLocation` (constants are in
// `Constants.lua`: `ITEM_INVENTORY_LOCATION_*` and
+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
+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};
+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));
+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});
}
+78
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 {
@@ -146,6 +149,81 @@ const uint8_t *ResolveByGUID(uint64_t guid) {
0x172));
}
bool FindByItemID(void *L, int itemID, ByGUIDResult *out) {
if (itemID <= 0)
return false;
for (int slot = Offsets::EQUIPMENT_SLOT_FIRST;
slot <= Offsets::EQUIPMENT_SLOT_LAST; ++slot) {
auto *item = ResolveEquipmentSlot(slot);
if (item == nullptr)
continue;
if (Item::ID::FromCGItem(item) == itemID) {
out->equipmentSlotIndex = slot;
out->bagID = 0;
out->slotIndex = 0;
out->item = item;
return true;
}
}
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;
if (Item::ID::FromCGItem(item) == itemID) {
out->equipmentSlotIndex = 0;
out->bagID = bagID;
out->slotIndex = slotIndex;
out->item = item;
return true;
}
}
}
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;
+30
View File
@@ -86,6 +86,36 @@ struct ByGUIDResult {
// Lua stack — only safe from inside a Lua callback.
bool FindByGUID(void *L, uint64_t guid, ByGUIDResult *out);
// Same equipment-first, bag-second walk as `FindByGUID`, but matches
// on itemID instead. Returns the FIRST instance found — equipment
// slots are checked before bags, so an equipped enchanted copy is
// preferred over an unenchanted duplicate in the backpack. Used
// when we only have the itemID for a slot (e.g. cursor-held items
// picked up from the action bar, which the engine stores as a
// bare itemID with no GUID) and want to recover the live CGItem
// 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`
+42
View File
@@ -0,0 +1,42 @@
// 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 "Offsets.h"
#include <cstdint>
namespace Item::QualityColor {
// Returns the engine's `"|cffRRGGBB"` color-prefix string for the
// given item quality. Reads directly from the engine table at
// `Offsets::VAR_QUALITY_COLOR_TABLE` so we don't have to duplicate
// the hex palette across modules. Mirrors the engine's own fallback
// at `0x0052AD90`: quality > 6 returns white (index 1).
inline const char *Prefix(int quality) {
auto *table = reinterpret_cast<const char *const *>(
Offsets::VAR_QUALITY_COLOR_TABLE);
if (quality < 0 || quality >= Offsets::QUALITY_COLOR_TABLE_SIZE)
return table[1]; // white fallback, matches engine
return table[quality];
}
// Returns just the 6-char hex (e.g. "9d9d9d"), pointing into the
// engine string past the `|cff` prefix. Useful for callers that
// build their own markup format and need the bare hex.
inline const char *Hex(int quality) {
return Prefix(quality) + 4;
}
} // namespace Item::QualityColor
+32
View File
@@ -199,6 +199,38 @@ bool FromPaperdoll(const void *cgItem, int srcPaperdollSlot, int dstPaperdollSlo
dstPaperdollSlot);
}
bool ToBag(const void *cgItem, int srcPaperdollSlot, int dstBagID, int dstSlotInBag) {
if (srcPaperdollSlot < 1 || srcPaperdollSlot > 19)
return false;
if (cgItem == nullptr)
return false;
void *player = ResolvePlayer();
if (player == nullptr)
return false;
uint32_t playerLo = 0, playerHi = 0;
if (!ReadGuid(player, &playerLo, &playerHi))
return false;
uint32_t dstContainerLo = 0, dstContainerHi = 0, dstLinear = 0;
if (!EncodeBagSlot(dstBagID, dstSlotInBag,
&dstContainerLo, &dstContainerHi, &dstLinear))
return false;
uint32_t itemLo = 0, itemHi = 0;
if (!ReadGuid(cgItem, &itemLo, &itemHi))
return false;
auto fn = reinterpret_cast<SwapFn_t>(Offsets::FUN_INVENTORY_SWAP);
fn(player,
itemLo, itemHi,
playerLo, playerHi,
static_cast<uint32_t>(srcPaperdollSlot - 1),
dstContainerLo, dstContainerHi, dstLinear,
0);
return true;
}
bool MoveCount(void *L, int srcBag, int srcSlot, int dstBag, int dstSlot, int count) {
// Vanilla protocol writes count as a single byte. Clamp at 255;
// anything larger would either truncate silently (255 sent) or
+8
View File
@@ -32,6 +32,12 @@ namespace Item::Swap {
// paperdoll slot `srcSlot` (1-based). Used for paperdoll-to-
// paperdoll swaps like rings 11/12, trinkets 13/14.
//
// - `ToBag(item, srcPaperdollSlot, dstBag, dstSlot)` — item currently
// in paperdoll slot, moving to a bag slot. The "unequip" direction
// `UseEquipmentSet` needs for slots the set wants empty. Uses the
// same atomic swap packet — server-side accept rules are identical
// (bagID 0..4, dst slot must be valid for that bag).
//
// All slot args are 1-based Lua-facing; helpers do the
// linear-slot conversion. Returns `false` on bad args (slot out of
// range, no player object, missing instance block on the item or
@@ -44,6 +50,8 @@ bool FromBag(const void *cgItem, int bagID, int slotInBag, int dstPaperdollSlot)
bool FromPaperdoll(const void *cgItem, int srcPaperdollSlot, int dstPaperdollSlot);
bool ToBag(const void *cgItem, int srcPaperdollSlot, int dstBagID, int dstSlotInBag);
// General bag-to-bag swap — neither side is constrained to the
// paperdoll. Same engine helper (`FUN_INVENTORY_SWAP`), which routes
// to opcode 0x10D (same-container slot swap, e.g. backpack ↔ backpack)
+79 -21
View File
@@ -17,9 +17,11 @@
#include "item/ID.h"
#include "item/Link.h"
#include "item/Location.h"
#include "item/QualityColor.h"
#include <cstdint>
#include <cstdio>
#include <cstring>
namespace Item::Tooltip {
@@ -134,25 +136,6 @@ static int __fastcall Script_GameTooltipSetInventoryItemByID(void *L) {
return fn(L);
}
// Modern `Enum.ItemQuality` color codes for the SetItemByID fallback
// path (no CGItem → no engine link helper). For the CGItem-resolved
// path we delegate to the engine's own builder, which uses the same
// palette internally.
static const char *QualityHex(int quality) {
static const char *const kHex[] = {
"9d9d9d", // POOR
"ffffff", // COMMON
"1eff00", // UNCOMMON
"0070dd", // RARE
"a335ee", // EPIC
"ff8000", // LEGENDARY
"e6cc80", // ARTIFACT (TBC+)
"00ccff", // HEIRLOOM (WotLK+)
};
if (quality < 0 || quality > 7)
return kHex[1];
return kHex[quality];
}
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
@@ -277,8 +260,9 @@ static int __fastcall Script_GameTooltipGetItem(void *L) {
char link[256];
std::snprintf(link, sizeof(link),
"|cff%s|Hitem:%d:0:0:0:0:0:0:0|h[%s]|h|r",
QualityHex(static_cast<int>(quality)), itemID, name);
"%s|Hitem:%d:0:0:0:0:0:0:0|h[%s]|h|r",
Item::QualityColor::Prefix(static_cast<int>(quality)),
itemID, name);
Game::Lua::PushString(L, name);
Game::Lua::PushString(L, link);
@@ -307,8 +291,82 @@ static int __fastcall Script_GameTooltipHasItem(void *L) {
return 1;
}
// `GameTooltip:SetItemByGUID(guidString)` — modern method that renders
// the tooltip for the specific item instance identified by GUID
// (`"0xHHHHHHHHLLLLLLLL"` per `C_Item.GetItemGUID`). Distinct from
// `SetItemByID`, which shows the base `ItemSparse` data with no
// per-instance state: this path uses the live CGItem so the tooltip
// includes enchant lines, random-suffix-decorated name + bonuses,
// locked/broken state, etc.
//
// Implementation: parse the GUID string, resolve via the engine's
// `FUN_OBJECT_RESOLVE_BY_GUID` (same path `C_Item.GetItemLocation`
// and `GameTooltip:GetItem` use), build the fully-decorated
// hyperlink via `Item::Link::FromCGItem` (engine helper at
// `FUN_GAMETOOLTIP_BUILD_ITEM_LINK`), then dispatch to the engine's
// existing `Script_GameTooltip_SetHyperlink`. SetHyperlink parses
// the link's `item:N:enchant:gem:gem:gem:gem:suffix:unique` payload
// back into instance-specific tooltip data — closing the loop
// without us needing a separate engine entry point for "tooltip
// from CGItem*".
//
// Silent no-op when the GUID is malformed, not loaded in the
// client, or doesn't resolve to an item (creature/object GUIDs go
// to a different resolver).
static int __fastcall Script_GameTooltipSetItemByGUID(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, "Usage: GameTooltip:SetItemByGUID(itemGUID)");
return 0;
}
if (Game::Lua::Type(L, 2) != Game::Lua::TYPE_STRING) {
Game::Lua::Error(L, "Usage: GameTooltip:SetItemByGUID(itemGUID)");
return 0;
}
const char *guidStr = Game::Lua::ToString(L, 2);
uint64_t guid = 0;
if (!Item::Location::ParseGUIDString(guidStr, &guid))
return 0;
const uint8_t *cgItem = Item::Location::ResolveByGUID(guid);
if (cgItem == nullptr)
return 0;
const char *link = Item::Link::FromCGItem(cgItem);
if (link == nullptr || *link == '\0')
return 0;
// `Item::Link::FromCGItem` returns the full
// `|cffXXXXXXXX|Hitem:N:E:S:S:S:S:R:U|h[Name]|h|r` decorated link
// (the public-link form addons paste into chat). `SetHyperlink`
// wants just the bare `item:N:E:S:S:S:S:R:U` payload — passing the
// decorated form trips its "Unknown link type" Lua error because
// the parser sees `|c` first and gives up. Extract the payload by
// finding `|Hitem:` and copying through to the closing `|h`.
const char *open = std::strstr(link, "|Hitem:");
if (open == nullptr)
return 0;
open += 2; // skip past `|H` so we start at `item:`
const char *close = std::strstr(open, "|h");
if (close == nullptr)
return 0;
const size_t payloadLen = static_cast<size_t>(close - open);
char payload[128];
if (payloadLen + 1 > sizeof(payload))
return 0;
std::memcpy(payload, open, payloadLen);
payload[payloadLen] = '\0';
// Replace stack[2] (the GUID) with the bare item link and
// dispatch to SetHyperlink with (self, "item:...").
Game::Lua::SetTop(L, 1);
Game::Lua::PushString(L, payload);
using Script_t = int(__fastcall *)(void *L);
auto fn = reinterpret_cast<Script_t>(Offsets::FUN_SCRIPT_GAMETOOLTIP_SET_HYPERLINK);
return fn(L);
}
static const Game::Lua::FrameMethodEntry g_methods[] = {
{"SetItemByID", &Script_GameTooltipSetItemByID},
{"SetItemByGUID", &Script_GameTooltipSetItemByGUID},
{"SetInventoryItemByID", &Script_GameTooltipSetInventoryItemByID},
{"GetItem", &Script_GameTooltipGetItem},
{"HasItem", &Script_GameTooltipHasItem},
+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
+311
View File
@@ -0,0 +1,311 @@
// 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/>.
// `GetMacroIcons(list)` / `GetMacroItemIcons(list)` /
// `GetLooseMacroIcons(list)` / `GetLooseMacroItemIcons(list)` —
// the modern 4-function append-to-table icon enumeration surface.
// Each takes a Lua table as its only argument and appends icon
// basenames; returns nothing (mutation is the contract).
//
// Modern WoW's `IconDataProviderMixin` (see
// `AddOns/!!!ClassicAPI/Util/IconDataProvider.lua`) calls all four
// in this order to assemble its `BaseIconFilenames` cache:
//
// GetLooseMacroIcons(spellList)
// GetLooseMacroItemIcons(itemList)
// GetMacroIcons(spellList)
// GetMacroItemIcons(itemList)
//
// The split is `loose` (icons dropped onto disk under
// `Interface\Icons\` by the user) vs MPQ (icons baked into the game's
// MPQ archives), crossed with `Spell` (basenames starting with
// `Ability_` / `Spell_`) vs `Item` (basenames starting with `INV_`).
// Modern engines maintain four parallel arrays in their loader; we
// replicate the same scheme by hooking vanilla's three scan
// callbacks and tagging each captured filename by its callback
// source and basename prefix.
//
// **Data source (vanilla 1.12 / Octo):**
//
// - `FUN_MACRO_ICON_CB_DISK` (`LAB_004f0220`) — engine's disk-scan
// per-file callback. Files the user has dropped loose into
// `Interface\Icons\`. Filename arg is the full
// `"Interface\Icons\<name>.blp"` path. Tagged as `loose`.
// - `FUN_MACRO_ICON_CB_USER_MPQ` (`FUN_004f0350`) — user-mounted
// patch-MPQ callback. Already prefix-filtered to `Ability_*` /
// `Spell_*` by the engine. Tagged as `mpq`.
// - `FUN_MACRO_ICON_CB_INSTALL_MPQ` (`LAB_004f04f0`) — install-MPQ
// callback. Accepts any `.blp`/`.tga` (no prefix filter in the
// engine), and is the only source that surfaces `INV_*` filenames
// in vanilla. Tagged as `mpq`.
//
// Each callback gets a hook that examines the filename, classifies
// it by prefix (`INV_` → item, `Ability_`/`Spell_` → spell, else
// ignore), and appends to the matching side array. Capture-time
// dedup via per-array `unordered_set` ensures each unique basename
// only appears once per source even though most files flow through
// multiple callbacks. The arrays are sorted lazily on first Lua
// read so the output order matches modern engines.
//
// Entry shape (all four functions): uppercase basename stripped of
// the `Interface\Icons\` prefix and any `.blp`/`.tga` extension —
// e.g. `"INV_SWORD_25"`, `"ABILITY_KICK"`. Callers concat the
// prefix themselves before `texture:SetTexture(...)`.
#include "Game.h"
#include "Offsets.h"
#include <algorithm>
#include <cstdint>
#include <cstring>
#include <string>
#include <unordered_set>
#include <vector>
namespace Macro::Icons {
namespace {
using LoadIcons_t = void(__cdecl *)();
void EnsureLoaded() {
if (*reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_MACRO_ICON_COUNT)) != 0)
return;
reinterpret_cast<LoadIcons_t>(Offsets::FUN_LOAD_MACRO_ICONS)();
}
constexpr int ICON_BUF_LEN = 128;
// Last path-component of `s` (after the final `\` or `/`), or `s`
// itself if no separator. NULL-safe.
const char *Basename(const char *s) {
if (s == nullptr)
return nullptr;
const char *bn = s;
for (const char *p = s; *p; ++p) {
if (*p == '\\' || *p == '/')
bn = p + 1;
}
return bn;
}
// Case-insensitive prefix match. `prefix` must be uppercase ASCII.
bool StartsWithCI(const char *s, const char *prefix) {
for (int i = 0; prefix[i] != '\0'; ++i) {
char c = s[i];
if (c >= 'a' && c <= 'z')
c = static_cast<char>(c - ('a' - 'A'));
if (c != prefix[i])
return false;
}
return true;
}
// Normalize an icon path for dedup: strip `Interface\Icons\` (or any
// directory) prefix, strip extension at first `.`, uppercase ASCII.
// `dst` must be `ICON_BUF_LEN` bytes. Result is null-terminated.
void NormalizeIconKey(char *dst, const char *src) {
const char *bn = Basename(src);
if (bn == nullptr) {
dst[0] = '\0';
return;
}
int i = 0;
while (i + 1 < ICON_BUF_LEN && bn[i] != '\0' && bn[i] != '.') {
char c = bn[i];
if (c >= 'a' && c <= 'z')
c = static_cast<char>(c - ('a' - 'A'));
dst[i] = c;
++i;
}
dst[i] = '\0';
}
// =============================================================
// Four side arrays — (loose|mpq) × (spell|item). Each is a
// dedup-on-insert set plus a parallel vector preserving insertion
// order. We sort each vector once before the first Lua read so
// `GetMacroIcons` etc. return alphabetical results matching modern
// engines.
// =============================================================
enum Source { SRC_LOOSE = 0, SRC_MPQ = 1 };
enum Category { CAT_SPELL = 0, CAT_ITEM = 1 };
struct Bucket {
std::vector<std::string> vec;
std::unordered_set<std::string> set;
};
Bucket &BucketFor(Source src, Category cat) {
static Bucket buckets[2][2];
return buckets[src][cat];
}
void Capture(const char *raw, Source src) {
const char *bn = Basename(raw);
if (bn == nullptr || *bn == '\0')
return;
Category cat;
if (StartsWithCI(bn, "INV_"))
cat = CAT_ITEM;
else if (StartsWithCI(bn, "ABILITY_") || StartsWithCI(bn, "SPELL_"))
cat = CAT_SPELL;
else
return;
char key[ICON_BUF_LEN];
NormalizeIconKey(key, bn);
if (key[0] == '\0')
return;
Bucket &b = BucketFor(src, cat);
auto [it, inserted] = b.set.emplace(key);
if (inserted)
b.vec.emplace_back(*it);
}
// Sort all four buckets alphabetically. Called once before the first
// Lua read so output is stable and matches modern engines' sorted
// output. Idempotent — re-running re-sorts (cheap for already-sorted
// vectors) but `EnsureSorted` short-circuits via the flag.
bool g_sorted = false;
void EnsureSorted() {
if (g_sorted)
return;
for (int s = 0; s < 2; ++s) {
for (int c = 0; c < 2; ++c) {
auto &v = BucketFor(static_cast<Source>(s),
static_cast<Category>(c)).vec;
std::sort(v.begin(), v.end());
}
}
g_sorted = true;
}
// =============================================================
// Engine scan-callback hooks.
// =============================================================
// Disk-scan callback (`__fastcall(const char *fullPath)`). Path is
// `"Interface\Icons\<NAME>"`-shaped — Basename() strips the prefix
// before classification.
using DiskCb_t = int (__fastcall *)(const char *path);
DiskCb_t IconCbDisk_o = nullptr;
int __fastcall IconCbDisk_h(const char *path) {
Capture(path, SRC_LOOSE);
return IconCbDisk_o(path);
}
// MPQ-scan callbacks (`__fastcall(MpqRecord *)`). Record layout:
// `+0x04` = file flags (bit `0x10` = directory), `+0x08` = inline
// null-terminated filename (no prefix, no extension stripped).
using MpqCb_t = int (__fastcall *)(uint8_t *record);
MpqCb_t IconCbUserMpq_o = nullptr;
int __fastcall IconCbUserMpq_h(uint8_t *record) {
if (record != nullptr && (record[4] & 0x10) == 0)
Capture(reinterpret_cast<const char *>(record + 8), SRC_MPQ);
return IconCbUserMpq_o(record);
}
MpqCb_t IconCbInstallMpq_o = nullptr;
int __fastcall IconCbInstallMpq_h(uint8_t *record) {
if (record != nullptr && (record[4] & 0x10) == 0)
Capture(reinterpret_cast<const char *>(record + 8), SRC_MPQ);
return IconCbInstallMpq_o(record);
}
const Game::HookAutoRegister _hookCbDisk{
Offsets::FUN_MACRO_ICON_CB_DISK,
reinterpret_cast<void *>(&IconCbDisk_h),
reinterpret_cast<void **>(&IconCbDisk_o)};
const Game::HookAutoRegister _hookCbUserMpq{
Offsets::FUN_MACRO_ICON_CB_USER_MPQ,
reinterpret_cast<void *>(&IconCbUserMpq_h),
reinterpret_cast<void **>(&IconCbUserMpq_o)};
const Game::HookAutoRegister _hookCbInstallMpq{
Offsets::FUN_MACRO_ICON_CB_INSTALL_MPQ,
reinterpret_cast<void *>(&IconCbInstallMpq_h),
reinterpret_cast<void **>(&IconCbInstallMpq_o)};
// =============================================================
// Lua surface — four global mutators.
// =============================================================
// Walks `list[1..]` to find the first nil slot — that's where we
// start appending. Leaves the stack unchanged on return.
int FindAppendIndex(void *L) {
int idx = 1;
while (true) {
Game::Lua::PushNumber(L, static_cast<double>(idx));
Game::Lua::RawGet(L, 1);
const int t = Game::Lua::Type(L, -1);
Game::Lua::SetTop(L, -2);
if (t == Game::Lua::TYPE_NIL)
return idx;
++idx;
}
}
void AppendBucket(void *L, Source src, Category cat, const char *usage) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, usage);
return;
}
EnsureLoaded();
EnsureSorted();
const auto &v = BucketFor(src, cat).vec;
if (v.empty())
return;
int idx = FindAppendIndex(L);
for (const auto &name : v) {
Game::Lua::PushNumber(L, static_cast<double>(idx++));
Game::Lua::PushString(L, name.c_str());
Game::Lua::RawSet(L, 1);
}
}
int __fastcall Script_GetMacroIcons(void *L) {
AppendBucket(L, SRC_MPQ, CAT_SPELL, "Usage: GetMacroIcons(table)");
return 0;
}
int __fastcall Script_GetMacroItemIcons(void *L) {
AppendBucket(L, SRC_MPQ, CAT_ITEM, "Usage: GetMacroItemIcons(table)");
return 0;
}
int __fastcall Script_GetLooseMacroIcons(void *L) {
AppendBucket(L, SRC_LOOSE, CAT_SPELL, "Usage: GetLooseMacroIcons(table)");
return 0;
}
int __fastcall Script_GetLooseMacroItemIcons(void *L) {
AppendBucket(L, SRC_LOOSE, CAT_ITEM,
"Usage: GetLooseMacroItemIcons(table)");
return 0;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetMacroIcons", &Script_GetMacroIcons);
Game::Lua::RegisterGlobalFunction("GetMacroItemIcons",
&Script_GetMacroItemIcons);
Game::Lua::RegisterGlobalFunction("GetLooseMacroIcons",
&Script_GetLooseMacroIcons);
Game::Lua::RegisterGlobalFunction("GetLooseMacroItemIcons",
&Script_GetLooseMacroItemIcons);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Macro::Icons
+74
View File
@@ -0,0 +1,74 @@
// 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/>.
// `IsLoggedIn()` — exposed on both Lua states (glue and in-game).
// Returns `true` once a character has been loaded *and identified*
// (GUID populated), `false` on glue, pre-load, and the brief window
// between struct allocation and character-data unpack.
//
// Check is two-step:
// 1. `[VAR_LOCAL_PLAYER_PTR]` non-NULL — the player aggregator
// struct has been allocated
// 2. `*(uint64*)(player + OFF_LOCAL_PLAYER_GUID)` non-zero — the
// character's GUID has been populated into the struct
//
// The pointer alone is too lax: the engine allocates the struct
// shortly before the character data lands, so there's a window where
// the pointer is non-NULL but the GUID is still zero. Most addons
// that check `IsLoggedIn` go straight to `UnitGUID("player")` next,
// and we don't want them to see GUID==nil right after IsLoggedIn
// reported true.
//
// 3.3.5 uses a single byte flag at `[0x00BD0793]` cleared on world
// entry — we couldn't pin down the analogous 1.12 flag (the engine
// path that signals event 253 wires through CVar-init machinery and
// doesn't map cleanly across the binary). The pointer+GUID check is
// the same effective signal: both flip together at the moment the
// player object is ready, and stay stable across zone transitions.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Player::LoginStatus {
namespace {
int __fastcall Script_IsLoggedIn(void *L) {
auto *player = *reinterpret_cast<const uint8_t *const *>(
Offsets::VAR_LOCAL_PLAYER_PTR);
if (player == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
const uint64_t guid = *reinterpret_cast<const uint64_t *>(
player + Offsets::OFF_LOCAL_PLAYER_GUID);
Game::Lua::PushBool(L, guid != 0);
return 1;
}
void RegisterInGame() {
Game::Lua::RegisterGlobalFunction("IsLoggedIn", &Script_IsLoggedIn);
}
void RegisterGlue() {
Game::Lua::RegisterGlueFunction("IsLoggedIn", &Script_IsLoggedIn);
}
} // namespace
static const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
static const Game::GlueModuleAutoRegister _gluereg{&RegisterGlue};
} // namespace Player::LoginStatus
+155 -1
View File
@@ -40,15 +40,156 @@ inline const uint8_t *Peek(uint32_t questID) {
}
// Field offsets within the data block returned by `Lookup`.
constexpr int OFF_TITLE = 0x9C;
//
// Derived from the engine's own `Script_GetQuestLog*` accessors at
// `0x004DF930..0x004E12B0`. Each accessor calls `_GetRecord` then
// reads from one of these offsets; the existing comment block on each
// constant identifies the source function. See CLAUDE.md's
// "Quest static-info cache" section for the full trace.
// Numeric metadata fields, in wire order from `SMSG_QUEST_QUERY_RESPONSE`.
//
// The cache layout mirrors the packet's field order — the deserializer
// at the SMSG handler unpacks 15 uint32 fields at +0x00..+0x38 before
// the reward/choice item arrays. Names and semantics cross-referenced
// from the in-binary serializer (`FUN_006DBCEC`) plus the canonical
// vanilla 1.12 packet structure documented by vmangos/cmangos emulator
// projects. Fields without a direct binary-side accessor (so just
// known from wire-format alignment) are marked **(hypothesis)** —
// they should be verified empirically before being relied on for
// gameplay logic.
//
// QuestId — uint32, echo of the questID this entry caches.
constexpr int OFF_QUEST_ID = 0x00;
// QuestMethod — uint32. Server-side classification (typically `2` for
// active-and-available, `0` for unavailable). Vanilla doesn't use
// this for gameplay; it's stored but no engine accessor reads it.
// **(hypothesis)**
constexpr int OFF_QUEST_METHOD = 0x04;
// Quest level — int32. Source: `FUN_004df340` (called by
// `Script_GetQuestLogTitle`) does `mov eax, [block + 0x08]` and
// pushes it as `level`.
constexpr int OFF_LEVEL = 0x08;
// ZoneOrSort — int32. The packet's `ZoneOrSort` field, used by the
// quest log UI to group entries under headers:
// - Positive = `AreaTable.dbc` zone areaID (e.g. 132 = Coldridge
// Valley, 12 = Elwynn Forest)
// - Negative = `-QuestSort.dbc` row (e.g. -263 = "Druid" sort)
// `FUN_004dee60` reads this directly and matches it against
// `VAR_QUEST_LOG_CATEGORY_TABLE` to find the collapsible header index.
constexpr int OFF_ZONE_OR_SORT = 0x0C;
// QuestInfo.dbc row — uint32 row index into the small DBC at
// `0x00C0D9CC` (records) / `0x00C0D9D0` (count). Each record is
// `{id@+0, char *name[locale]@+4}`; the localized name is the
// quest-type tag string ("Dungeon", "Raid", "Group", "PvP", "Elite"
// — modern WoW's `frequency`/`questTag`). Source: `FUN_004df2a0`.
constexpr int OFF_QUEST_INFO_ROW = 0x10;
// +0x14, +0x18, +0x1C, +0x20, +0x24 — five uint32 cache slots that
// SMSG_QUEST_QUERY_RESPONSE in vanilla 1.12 doesn't populate. The
// struct has them (the serializer at `0x006DBCEC` writes them as part
// of the WDB save / network shape), but the response handler leaves
// them zero-filled. Verified empirically across three diverse quests
// (race-restricted starter, dungeon, authored-timer delivery) — all
// five slots zero in every case.
//
// Wire-format docs (vmangos / cmangos emulator quest_template) name
// them SuggestedPlayers, LimitTime, RequiredRaces, RequiredSkill,
// RequiredSkillValue respectively, but the vanilla server *enforces*
// these constraints server-side and filters quests before broadcasting
// them, so the client never needs the data. Addons that want race /
// class / skill / timer info must source from an external scraped DB
// (pfQuest-style). Constants intentionally omitted — no use case.
//
// Signed money delta — int32. Positive = reward copper, negative =
// `-required` copper. The engine has two accessors that share this
// field with complementary sign:
// - `Script_GetQuestLogRewardMoney` returns max(0, +money)
// - `Script_GetQuestLogRequiredMoney` returns max(0, -money)
constexpr int OFF_MONEY_DELTA = 0x28;
// Level-cap reward money bonus — int32. Added to the reward when
// the player's level is ≥ 60. Source: `Script_GetQuestLogRewardMoney`
// does `cmp [player.unit + 0x70], 0x3C` (level >= 60) then
// `add edx, [block + 0x2C]`.
constexpr int OFF_LEVEL_CAP_BONUS_MONEY = 0x2C;
// Reward spellID — int32. 0 = no spell reward. Source:
// `Script_GetQuestLogRewardSpell` does `mov eax, [block + 0x30]` then
// resolves the SpellIcon path and locale-applied name via `Spell.dbc`.
// This is a spell *learned by the player* on completion (recipes,
// taught spells); not to be confused with `OFF_SRC_ITEM_ID` which
// is the item the questgiver hands over at accept-time.
constexpr int OFF_REWARD_SPELL_ID = 0x30;
// SrcItemId — itemID given to the player when the quest is accepted.
// 0 = no source item. The questgiver hands this item over so the
// player can complete the quest objective (e.g. "Read the Verdant
// Sigil" gives item 9580; player uses it, then turns the same item
// back in).
//
// Empirically derived (the offset was originally interpreted as
// `RewardSpellCast` from one emulator wire-order variant, but a
// real-world dump of quest 3120 stored `9580` here while the quest's
// own item objective also references item 9580 — consistent with
// SrcItemId semantics, not with a spell-cast-at-turn-in).
constexpr int OFF_SRC_ITEM_ID = 0x34;
// Quest flags — uint32. Bit definitions match modern WoW's
// `QUEST_FLAGS_*` enum, of which only bit 3 (`0x08` SHARABLE) is
// confirmed-tested by the engine (`Script_GetQuestLogPushable`'s
// `test byte ptr [block + 0x38], 0x08`). Other bits are presumed
// stored but unverified.
constexpr int OFF_QUEST_FLAGS = 0x38;
// Reward and choice item arrays — up to 4 reward and 6 choice
// itemIDs stored inline (4 bytes each). 0 in any slot means
// "no item in this slot". Used by `GetQuestLogItemLink` /
// `GetQuestLogItemID`.
//
// Each itemID array is paired with a parallel uint32 count array
// (the "how many of this item" reward). Layout in cache memory:
// +0x3C RewardItems[4]
// +0x4C RewardItemsCount[4]
// +0x5C ChoiceItems[6]
// +0x74 ChoiceItemsCount[6]
// Verified from `Script_GetQuestLogRewardInfo` (`fild [block + slot*4 + 0x4C]`
// for numItems) and `Script_GetQuestLogChoiceInfo` (`fild [block + slot*4 + 0x74]`).
constexpr int OFF_REWARD_ITEMS = 0x3C;
constexpr int OFF_REWARD_ITEMS_COUNT = 0x4C;
constexpr int OFF_CHOICE_ITEMS = 0x5C;
constexpr int OFF_CHOICE_ITEMS_COUNT = 0x74;
constexpr int REWARD_COUNT = 4;
constexpr int CHOICE_COUNT = 6;
// Point-of-interest marker — server-supplied map coordinate hint
// used by the questgiver objective panel to display "go here" arrows
// in modern WoW. Set on quests that have a designated location;
// 0/0/0 = no POI. Read sequentially by the serializer at
// `0x006DBE5B..0x006DBE8E` using mixed integer + float wire methods.
// **(hypothesis)**
constexpr int OFF_POI_MAP_ID = 0x8C; // uint32
constexpr int OFF_POI_X = 0x90; // float
constexpr int OFF_POI_Y = 0x94; // float
constexpr int OFF_POI_OPT = 0x98; // uint32 — display option, or doubles as title length
constexpr int OFF_TITLE = 0x9C;
// Inline narrative text — both locale-applied and printf-style
// format-expanded by the engine's variable substituter at
// `0x00506F70` (handles tokens like `$N` for player name, `$C` for
// class, etc.). Source: `Script_GetQuestLogQuestText` reads both,
// pushes `description` first then `objectives`.
//
// We deliberately expose the *raw* inline bytes, not the format-
// expanded version, because the substituter wants a 1024-byte
// scratch buffer and the substitutions (player name, class) belong
// in the addon layer anyway. Addons can run `string.gsub` if they
// need the runtime values, otherwise raw text is more useful for
// guides / databases.
constexpr int OFF_OBJECTIVES = 0x29C;
constexpr int OFF_DESCRIPTION = 0xA9C;
// Completion text — the "talk to me now" body shown on the
// quest-reward UI panel after objectives are met. 0x200 (512) bytes
// inline. Modern API exposes this as `GetQuestLogCompletionText()`.
// Confirmed by the cache copy at `0x00562E1F..0x00562E36` (a 0x200-byte
// memcpy from src+0x129C to dst+0x129C) and by the serializer at
// `0x006DBEBE` (`lea eax, [edi+0x129C]` followed by the string-writer
// call). Vanilla LeaderBoard reads from this offset for some override
// cases but the engine never directly exposes the text to Lua —
// `Script_GetQuestLogQuestText` returns description + objectives only.
constexpr int OFF_COMPLETION_TEXT = 0x129C;
// Objective arrays — four uint32[4] parallel slabs carrying
// SMSG_QUEST_QUERY_RESPONSE's RequiredNPCOrGo / Count / Item / Count
// fields verbatim. NPC-or-GO is **signed**: positive = creature
@@ -66,5 +207,18 @@ constexpr int OFF_REQUIRED_NPC_OR_GO_COUNT = 0x14AC; // uint32[4]
constexpr int OFF_REQUIRED_ITEM = 0x14BC; // uint32[4]
constexpr int OFF_REQUIRED_ITEM_COUNT = 0x14CC; // uint32[4]
constexpr int OBJECTIVE_COUNT = 4;
// Per-objective override text — array of 4 inline char buffers,
// 256 (0x100) bytes per entry, indexed in objective declaration
// order (NPC/GO slots fill first, then item slots — same order the
// LeaderBoard accessor enumerates). Empty string = no override
// (engine auto-formats as `"<NPC name>: X/Y"`); otherwise the
// engine displays the buffer verbatim. Confirmed by the
// `shl ecx, 8; mov dl, [block + ecx + 0x14DC]` access pattern in
// `Script_GetQuestLogLeaderBoard` and by the four sequential reads
// in the packet serializer at `0x006DBF17..0x006DBF41` (0x14DC,
// 0x15DC, 0x16DC, 0x17DC).
constexpr int OFF_OBJECTIVE_TEXT = 0x14DC;
constexpr int OBJECTIVE_TEXT_STRIDE = 0x100;
constexpr int OBJECTIVE_TEXT_COUNT = 4;
} // namespace Quest::Cache
+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);
}
}
+254
View File
@@ -0,0 +1,254 @@
// 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_QuestLog.GetQuestDetails(questID)` — single accessor that returns
// everything we know how to read from the quest static-info cache as a
// Lua table. Powers quest-helper-style addons that want one round-trip
// instead of N separate `C_QuestLog.GetTitleForQuestID` / etc. calls.
//
// Returns `nil` if the quest isn't cached. Caller should pair with
// `C_QuestLog.RequestLoadQuestByID` + listen for `QUEST_DATA_LOAD_RESULT`.
// All offsets read here come from the engine's own `Script_GetQuestLog*`
// accessors — see `Cache.h` for the per-field derivation.
#include "Game.h"
#include "Offsets.h"
#include "quest/Cache.h"
#include <cstdint>
namespace Quest::Details {
namespace {
// QuestInfo.dbc — 12-row table holding the localized quest-type tags
// ("Dungeon" / "Raid" / "Group" / "PvP" / "Elite"). Address pair from
// `docs/DBCs.md`.
constexpr uintptr_t VAR_QUESTINFO_DBC_RECORDS = 0x00C0D9CC;
constexpr uintptr_t VAR_QUESTINFO_DBC_COUNT = 0x00C0D9D0;
const char *LookupQuestType(uint32_t row) {
if (row == 0)
return nullptr;
const int count = *reinterpret_cast<const int *>(VAR_QUESTINFO_DBC_COUNT);
if (count <= 0 || row > static_cast<uint32_t>(count))
return nullptr;
const uint8_t *const *records =
*reinterpret_cast<const uint8_t *const *const *>(
VAR_QUESTINFO_DBC_RECORDS);
if (!records)
return nullptr;
const uint8_t *record = records[row];
if (!record)
return nullptr;
const int localeIdx =
*reinterpret_cast<const int *>(Offsets::VAR_LOCALE_INDEX);
if (localeIdx < 0 || localeIdx > 8)
return nullptr;
auto *names = reinterpret_cast<const char *const *>(record + 4);
return names[localeIdx];
}
// Pushes an array of `{id=itemID, count=N}` tables for each non-zero
// reward slot. `ids[count]` and `counts[count]` are parallel arrays
// in the cache block. Leaves exactly one table on top of the stack.
void PushItemArray(void *L, const uint32_t *ids, const uint32_t *counts,
int count) {
Game::Lua::NewTable(L);
int next = 1;
for (int i = 0; i < count; ++i) {
if (ids[i] == 0)
continue;
Game::Lua::PushNumber(L, static_cast<double>(next++));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "id", static_cast<double>(ids[i]));
Game::Lua::SetFieldNumber(L, "count", static_cast<double>(counts[i]));
Game::Lua::SetTable(L, -3);
}
}
// Per-objective override text — the 4-slot array at `+0x14DC` with
// 256-byte stride. Index in objective-declaration order (NPC/GO first,
// then item, same enumeration the LeaderBoard accessor walks).
// Returns empty string for slots past the cache's 4-entry capacity
// or for slots with an empty buffer (engine auto-formats those).
const char *OverrideTextFor(const uint8_t *data, int objectiveSlot) {
if (objectiveSlot < 0 || objectiveSlot >= Quest::Cache::OBJECTIVE_TEXT_COUNT)
return "";
return reinterpret_cast<const char *>(
data + Quest::Cache::OFF_OBJECTIVE_TEXT +
objectiveSlot * Quest::Cache::OBJECTIVE_TEXT_STRIDE);
}
void PushRequirements(void *L, const uint8_t *data) {
Game::Lua::NewTable(L);
int next = 1;
int overrideSlot = 0;
auto *npcOrGo = reinterpret_cast<const int32_t *>(
data + Quest::Cache::OFF_REQUIRED_NPC_OR_GO);
auto *npcOrGoCount = reinterpret_cast<const uint32_t *>(
data + Quest::Cache::OFF_REQUIRED_NPC_OR_GO_COUNT);
for (int i = 0; i < Quest::Cache::OBJECTIVE_COUNT; ++i) {
const int32_t v = npcOrGo[i];
if (v == 0)
continue;
Game::Lua::PushNumber(L, static_cast<double>(next++));
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "kind", v > 0 ? "monster" : "object");
Game::Lua::SetFieldNumber(L, "id",
static_cast<double>(v > 0 ? v : -v));
Game::Lua::SetFieldNumber(L, "count",
static_cast<double>(npcOrGoCount[i]));
Game::Lua::SetFieldString(L, "text",
OverrideTextFor(data, overrideSlot++));
Game::Lua::SetTable(L, -3);
}
auto *items = reinterpret_cast<const uint32_t *>(
data + Quest::Cache::OFF_REQUIRED_ITEM);
auto *itemCounts = reinterpret_cast<const uint32_t *>(
data + Quest::Cache::OFF_REQUIRED_ITEM_COUNT);
for (int i = 0; i < Quest::Cache::OBJECTIVE_COUNT; ++i) {
if (items[i] == 0)
continue;
Game::Lua::PushNumber(L, static_cast<double>(next++));
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "kind", "item");
Game::Lua::SetFieldNumber(L, "id", static_cast<double>(items[i]));
Game::Lua::SetFieldNumber(L, "count",
static_cast<double>(itemCounts[i]));
Game::Lua::SetFieldString(L, "text",
OverrideTextFor(data, overrideSlot++));
Game::Lua::SetTable(L, -3);
}
}
int __fastcall Script_GetQuestDetails(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: C_QuestLog.GetQuestDetails(questID)");
return 0;
}
const int questID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (questID <= 0)
return 0;
const uint8_t *data = Quest::Cache::Peek(static_cast<uint32_t>(questID));
if (data == nullptr)
return 0;
const auto u32 = [data](int off) {
return *reinterpret_cast<const uint32_t *>(data + off);
};
const auto i32 = [data](int off) {
return *reinterpret_cast<const int32_t *>(data + off);
};
const auto str = [data](int off) {
return reinterpret_cast<const char *>(data + off);
};
const auto f32 = [data](int off) {
return *reinterpret_cast<const float *>(data + off);
};
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "questID", static_cast<double>(questID));
Game::Lua::SetFieldString(L, "title", str(Quest::Cache::OFF_TITLE));
Game::Lua::SetFieldNumber(L, "level",
static_cast<double>(i32(Quest::Cache::OFF_LEVEL)));
if (const char *tag =
LookupQuestType(u32(Quest::Cache::OFF_QUEST_INFO_ROW))) {
if (*tag != '\0')
Game::Lua::SetFieldString(L, "questType", tag);
}
// Race / class / skill / timer / suggested-players gates aren't
// here intentionally — vanilla 1.12's quest-query response leaves
// those cache slots (+0x14..+0x24) zero-filled; the server enforces
// those constraints and only ships quests to clients who could
// accept them. Addons that need that data must source it from an
// external scraped DB (pfQuest-style).
const int32_t money = i32(Quest::Cache::OFF_MONEY_DELTA);
Game::Lua::SetFieldNumber(L, "rewardMoney",
money > 0 ? static_cast<double>(money) : 0.0);
Game::Lua::SetFieldNumber(L, "requiredMoney",
money < 0 ? static_cast<double>(-money) : 0.0);
Game::Lua::SetFieldNumber(
L, "rewardMoneyAtMaxLevel",
static_cast<double>(i32(Quest::Cache::OFF_LEVEL_CAP_BONUS_MONEY)));
Game::Lua::SetFieldNumber(
L, "rewardSpellID",
static_cast<double>(i32(Quest::Cache::OFF_REWARD_SPELL_ID)));
Game::Lua::SetFieldNumber(
L, "srcItemID",
static_cast<double>(u32(Quest::Cache::OFF_SRC_ITEM_ID)));
const uint32_t flags = u32(Quest::Cache::OFF_QUEST_FLAGS);
Game::Lua::SetFieldNumber(L, "questFlags", static_cast<double>(flags));
Game::Lua::SetFieldBool(L, "isSharable", (flags & 0x08) != 0);
Game::Lua::SetFieldString(L, "description",
str(Quest::Cache::OFF_DESCRIPTION));
Game::Lua::SetFieldString(L, "objectives",
str(Quest::Cache::OFF_OBJECTIVES));
Game::Lua::SetFieldString(L, "completionText",
str(Quest::Cache::OFF_COMPLETION_TEXT));
if (const uint32_t mapID = u32(Quest::Cache::OFF_POI_MAP_ID)) {
Game::Lua::PushString(L, "poi");
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "mapID", static_cast<double>(mapID));
Game::Lua::SetFieldNumber(L, "x", static_cast<double>(f32(Quest::Cache::OFF_POI_X)));
Game::Lua::SetFieldNumber(L, "y", static_cast<double>(f32(Quest::Cache::OFF_POI_Y)));
Game::Lua::SetFieldNumber(L, "opt",
static_cast<double>(u32(Quest::Cache::OFF_POI_OPT)));
Game::Lua::SetTable(L, -3);
}
Game::Lua::PushString(L, "rewardItems");
PushItemArray(L,
reinterpret_cast<const uint32_t *>(
data + Quest::Cache::OFF_REWARD_ITEMS),
reinterpret_cast<const uint32_t *>(
data + Quest::Cache::OFF_REWARD_ITEMS_COUNT),
Quest::Cache::REWARD_COUNT);
Game::Lua::SetTable(L, -3);
Game::Lua::PushString(L, "choiceItems");
PushItemArray(L,
reinterpret_cast<const uint32_t *>(
data + Quest::Cache::OFF_CHOICE_ITEMS),
reinterpret_cast<const uint32_t *>(
data + Quest::Cache::OFF_CHOICE_ITEMS_COUNT),
Quest::Cache::CHOICE_COUNT);
Game::Lua::SetTable(L, -3);
Game::Lua::PushString(L, "requirements");
PushRequirements(L, data);
Game::Lua::SetTable(L, -3);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_QuestLog", "GetQuestDetails",
&Script_GetQuestDetails);
}
} // namespace
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Quest::Details
+164
View File
@@ -0,0 +1,164 @@
// 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/>.
// Glue-side bindings for the saved-credentials API:
//
// SaveAccount(name, password) -> bool
// DeleteAccount(name) -> bool
// GetSavedAccounts() -> { "ACCT1", "ACCT2", ... }
// LoginWithSavedAccount(name) -> bool -- dispatched, plaintext
// never crosses C↔Lua
// boundary on the way out
//
// The first three are thin wrappers over `Security::CredStore`. The
// fourth is the load-bearing one: it pulls the encrypted blob, calls
// the engine's own `FUN_GLUE_LOGIN_ATTEMPT` (the function that
// `Script_DefaultServerLogin` tail-calls into), then scrubs the
// stack buffer. The engine itself zero-fills the password buffer
// after consuming it (see Offsets::FUN_GLUE_LOGIN_ATTEMPT docs), so
// the scrub here is belt-and-suspenders for any prefix bytes the
// engine's strlen-based wipe might miss.
//
// Registered on the glue Lua state only — there's no use for these
// outside the AccountLogin screen, and keeping them off the in-game
// state means no addon can call `LoginWithSavedAccount` to trigger
// an unexpected logout-and-login flow.
#include "CredStore.h"
#include "Game.h"
#include "Offsets.h"
#include <windows.h>
#include <cstring>
#include <vector>
namespace Security::Account {
namespace {
const char *ArgString(void *L, int idx) {
if (!Game::Lua::IsString(L, idx))
return nullptr;
return Game::Lua::ToString(L, idx);
}
// Engine login entry. `__fastcall(ecx = account, edx = mutable
// password buffer)`. The engine zero-fills the password buffer after
// SRP consumes it.
using LoginAttempt_t = void(__fastcall *)(const char *account, char *passwordMutable);
// `reinterpret_cast` from integer to function-pointer isn't constant
// in C++17, so the cast lives in the call site instead of a constexpr
// const.
inline void LoginAttempt(const char *account, char *passwordMutable) {
reinterpret_cast<LoginAttempt_t>(Offsets::FUN_GLUE_LOGIN_ATTEMPT)(
account, passwordMutable);
}
int __fastcall Script_SaveAccount(void *L) {
const char *name = ArgString(L, 1);
const char *password = ArgString(L, 2);
const bool ok =
name && *name && password && *password &&
Security::CredStore::Save(name, password);
Game::Lua::PushBool(L, ok);
return 1;
}
int __fastcall Script_DeleteAccount(void *L) {
const char *name = ArgString(L, 1);
const bool ok = name && *name && Security::CredStore::Delete(name);
Game::Lua::PushBool(L, ok);
return 1;
}
int __fastcall Script_GetSavedAccounts(void *L) {
auto accounts = Security::CredStore::List();
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
int key = 1;
for (const auto &acct : accounts) {
// Push outer index → inner table { name=..., lastUsed=... }.
Game::Lua::PushNumber(L, static_cast<double>(key++));
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "name", acct.name.c_str());
Game::Lua::SetFieldNumber(L, "lastUsed",
static_cast<double>(acct.lastUsedUnix));
Game::Lua::SetTable(L, -3);
}
return 1;
}
int __fastcall Script_LoginWithSavedAccount(void *L) {
const char *name = ArgString(L, 1);
if (!name || !*name) {
Game::Lua::PushBool(L, false);
return 1;
}
std::vector<char> password;
if (!Security::CredStore::Load(name, password) || password.empty()) {
Game::Lua::PushBool(L, false);
return 1;
}
// Keep a copy of the plaintext on the stack — the engine's login
// path zeros the buffer it consumes (see Offsets::FUN_GLUE_LOGIN_ATTEMPT
// notes), but we need the plaintext for one more thing: re-saving
// the credential so Windows updates LastWritten. Without that,
// `lastUsed` in `GetSavedAccounts()` would only reflect the
// original `SaveAccount` time, not actual logins.
std::vector<char> copyForTouch(password.begin(), password.end());
copyForTouch.push_back('\0');
// CredStore::Load gives us a vector sized to the password length
// with a trailing nul one past the end (resize to len after writing
// len+1 bytes). The engine login function reads via strlen, so we
// need the nul terminator accessible — `data()[size()]` is valid
// on std::vector (the spec guarantees a writable element there
// since C++11). Engine will zero out the bytes up through strlen
// after SRP consumes them.
LoginAttempt(name, password.data());
// Refresh LastWritten by re-saving the credential. Done after
// engine consumes so a no-op call (e.g. glue guards not satisfied)
// doesn't update the timestamp on a login that didn't happen.
// ... actually, the engine no-ops silently, so we can't gate on
// success. We update unconditionally; in practice this only fires
// from the AccountLogin screen, which has the guards satisfied.
Security::CredStore::Save(name, copyForTouch.data());
// Belt-and-suspenders scrub. Engine already zeroed `password.size()`
// bytes, but we re-scrub the full vector capacity in case the
// engine's wipe assumed an exact strlen match. Also scrub the
// touch copy that lived through the engine call.
SecureZeroMemory(password.data(), password.size());
SecureZeroMemory(copyForTouch.data(), copyForTouch.size());
Game::Lua::PushBool(L, true);
return 1;
}
void RegisterGlueFunctions() {
Game::Lua::RegisterGlueFunction("SaveAccount", &Script_SaveAccount);
Game::Lua::RegisterGlueFunction("DeleteAccount", &Script_DeleteAccount);
Game::Lua::RegisterGlueFunction("GetSavedAccounts", &Script_GetSavedAccounts);
Game::Lua::RegisterGlueFunction("LoginWithSavedAccount",
&Script_LoginWithSavedAccount);
}
const Game::GlueModuleAutoRegister _autoreg{&RegisterGlueFunctions};
} // namespace
} // namespace Security::Account
+238
View File
@@ -0,0 +1,238 @@
// 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/>.
#include "CredStore.h"
#include "Offsets.h"
#include <windows.h>
#include <wincred.h>
namespace Security::CredStore {
namespace {
constexpr const wchar_t kTargetPrefix[] = L"ClassicAPI/WoW/";
constexpr size_t kTargetPrefixLen =
sizeof(kTargetPrefix) / sizeof(wchar_t) - 1;
// Cred Manager target-name max is 32767 wchars; vanilla WoW account
// names cap at 0x40 bytes (see VAR_GLUE_LOGIN_ATTEMPT prologue). We
// allow generous bounds for the realm + account, leaving the rest of
// the target-name budget unused.
constexpr size_t kMaxRealmWChars = 256;
constexpr size_t kMaxAccountWChars = 256;
using GetRealmList_t = const char *(__fastcall *)(int forceRefresh);
// Returns the current engine realmList CVar's value, or empty string
// if unset. This is the realmlist address (e.g.
// "logon.turtle-wow.org"), not the friendly display name — scoping
// credentials on the address means two private servers with the same
// display name still get distinct vault entries.
std::string CurrentRealm() {
const char *r = reinterpret_cast<GetRealmList_t>(
Offsets::FUN_CVAR_REALM_LIST)(0);
if (!r || !*r) return {};
return r;
}
// Append a UTF-8 string `s` to `out` as wide chars. No-op (and false
// return) on conversion error or empty input. `max` caps the wchar
// count we'll append.
bool AppendWide(std::wstring &out, const char *s, size_t max) {
if (!s || !*s) return false;
int needed = MultiByteToWideChar(CP_UTF8, 0, s, -1, nullptr, 0);
if (needed <= 1) return false;
if (static_cast<size_t>(needed) > max) return false;
size_t base = out.size();
out.resize(base + needed - 1);
MultiByteToWideChar(CP_UTF8, 0, s, -1, &out[base], needed);
return true;
}
// Builds the per-account target name:
// ClassicAPI/WoW/<realmName>/<accountName>
//
// Returns the bare prefix when the realm or account is missing —
// callers use `result.size() > kTargetPrefixLen` to detect that and
// abort.
std::wstring MakeTargetName(const char *accountName) {
std::wstring out(kTargetPrefix, kTargetPrefixLen);
std::string realm = CurrentRealm();
if (realm.empty()) return out;
if (!AppendWide(out, realm.c_str(), kMaxRealmWChars)) {
out.resize(kTargetPrefixLen);
return out;
}
out.push_back(L'/');
if (!AppendWide(out, accountName, kMaxAccountWChars)) {
out.resize(kTargetPrefixLen);
return out;
}
return out;
}
// Builds the enumeration filter for the current realm:
// ClassicAPI/WoW/<realmName>/*
//
// Returns empty wstring if no realm is set (callers treat that as
// "no entries to list").
std::wstring MakeEnumFilter() {
std::wstring out(kTargetPrefix, kTargetPrefixLen);
std::string realm = CurrentRealm();
if (realm.empty()) return {};
if (!AppendWide(out, realm.c_str(), kMaxRealmWChars)) return {};
out.push_back(L'/');
out.push_back(L'*');
return out;
}
// Extracts the account-name suffix from a target-name during enum.
// Returns empty string if the structural shape doesn't match — the
// CredEnumerate filter should already restrict to our namespace, but
// hostile or stale entries from a previous version of this code could
// in theory land here.
std::string ExtractAccount(const wchar_t *targetName) {
if (!targetName) return {};
if (wcsncmp(targetName, kTargetPrefix, kTargetPrefixLen) != 0) {
return {};
}
// After the prefix we expect `<realm>/<account>`. Skip past the
// realm by finding the next '/'.
const wchar_t *afterPrefix = targetName + kTargetPrefixLen;
const wchar_t *sep = wcschr(afterPrefix, L'/');
if (!sep || !sep[1]) return {};
const wchar_t *account = sep + 1;
int needed = WideCharToMultiByte(CP_UTF8, 0, account, -1,
nullptr, 0, nullptr, nullptr);
if (needed <= 1) return {};
std::string out(static_cast<size_t>(needed - 1), '\0');
WideCharToMultiByte(CP_UTF8, 0, account, -1, &out[0], needed,
nullptr, nullptr);
return out;
}
} // namespace
bool Save(const char *accountName, const char *password) {
if (!accountName || !*accountName) return false;
if (!password || !*password) return false;
std::wstring target = MakeTargetName(accountName);
if (target.size() == kTargetPrefixLen) return false; // empty account
size_t pwLen = strlen(password);
if (pwLen == 0 || pwLen > CRED_MAX_CREDENTIAL_BLOB_SIZE) return false;
CREDENTIALW cred{};
cred.Type = CRED_TYPE_GENERIC;
cred.TargetName = const_cast<LPWSTR>(target.c_str());
cred.CredentialBlobSize = static_cast<DWORD>(pwLen);
cred.CredentialBlob =
reinterpret_cast<LPBYTE>(const_cast<char *>(password));
cred.Persist = CRED_PERSIST_LOCAL_MACHINE;
// UserName is required by some advapi32 builds to be non-NULL even
// for generic credentials; reuse the target so the entry is
// visually labeled correctly in `control /name
// Microsoft.CredentialManager` even though we don't read it back.
cred.UserName = const_cast<LPWSTR>(target.c_str());
return CredWriteW(&cred, 0) != FALSE;
}
bool Delete(const char *accountName) {
if (!accountName || !*accountName) return false;
std::wstring target = MakeTargetName(accountName);
if (target.size() == kTargetPrefixLen) return false;
return CredDeleteW(target.c_str(), CRED_TYPE_GENERIC, 0) != FALSE;
}
namespace {
// FILETIME (100-ns intervals since 1601-01-01) → Unix epoch seconds.
// Returns 0 for zero/invalid input.
uint64_t FileTimeToUnix(FILETIME ft) {
const uint64_t v = (static_cast<uint64_t>(ft.dwHighDateTime) << 32) |
ft.dwLowDateTime;
if (v == 0) return 0;
// 11644473600 seconds between 1601-01-01 and 1970-01-01, expressed
// in 100-ns ticks.
constexpr uint64_t kEpochDiff100ns = 116444736000000000ULL;
if (v < kEpochDiff100ns) return 0;
return (v - kEpochDiff100ns) / 10000000ULL;
}
} // namespace
std::vector<Entry> List() {
std::vector<Entry> result;
// Enumeration filter is scoped to the current realm, so entries
// for other private servers are invisible. The wildcard pattern
// `ClassicAPI/WoW/<realm>/*` matches every account under it.
std::wstring filter = MakeEnumFilter();
if (filter.empty()) return result;
DWORD count = 0;
PCREDENTIALW *entries = nullptr;
if (!CredEnumerateW(filter.c_str(), 0, &count, &entries)) {
// ERROR_NOT_FOUND is the expected "no entries yet" outcome.
return result;
}
result.reserve(count);
for (DWORD i = 0; i < count; ++i) {
if (!entries[i] || !entries[i]->TargetName) continue;
std::string account = ExtractAccount(entries[i]->TargetName);
if (account.empty()) continue;
result.push_back({std::move(account),
FileTimeToUnix(entries[i]->LastWritten)});
}
CredFree(entries);
return result;
}
bool Load(const char *accountName, std::vector<char> &outPassword) {
outPassword.clear();
if (!accountName || !*accountName) return false;
std::wstring target = MakeTargetName(accountName);
if (target.size() == kTargetPrefixLen) return false;
PCREDENTIALW cred = nullptr;
if (!CredReadW(target.c_str(), CRED_TYPE_GENERIC, 0, &cred)) {
return false;
}
if (!cred || !cred->CredentialBlob || cred->CredentialBlobSize == 0) {
if (cred) CredFree(cred);
return false;
}
const DWORD len = cred->CredentialBlobSize;
outPassword.resize(static_cast<size_t>(len) + 1, '\0');
memcpy(outPassword.data(), cred->CredentialBlob, len);
outPassword[len] = '\0';
// size() should reflect the password length, not the trailing nul.
outPassword.resize(len);
// Zero the OS-returned buffer before freeing — CredFree itself
// doesn't scrub. Safe even though the blob is a const-ish field,
// because we own it until CredFree returns and the API never
// reuses the allocation.
SecureZeroMemory(cred->CredentialBlob, len);
CredFree(cred);
return true;
}
} // namespace Security::CredStore
+74
View File
@@ -0,0 +1,74 @@
// 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>
#include <cstdint>
#include <string>
#include <vector>
// Thin C++ wrapper over the Win32 Credential Manager API (advapi32.dll:
// CredWriteW / CredReadW / CredEnumerateW / CredDeleteW). Entries are
// stored under per-user, DPAPI-encrypted blobs that other Windows users
// on the same machine cannot read; any process running as the current
// user can decrypt them (DPAPI is per-user, not per-process), so this
// is "secure at rest", not "secure against same-user attackers".
//
// Every operation is scoped by the current engine `realmList` CVar
// (the realmlist address from `realmlist.wtf` — e.g.
// "logon.turtle-wow.org", not the friendly display name). The same
// account name on two different private servers therefore gets two
// distinct vault entries:
// ClassicAPI/WoW/logon.turtle-wow.org/MYACCT
// ClassicAPI/WoW/logon.other-server.com/MYACCT
// Users can inspect/wipe entries via
// `control /name Microsoft.CredentialManager` → Generic Credentials.
//
// When no realmList is set, every operation returns false / empty —
// there's no meaningful key to scope under.
namespace Security::CredStore {
// Save (or overwrite) the password for `accountName` under the current
// engine realm. Returns false if no realm is set, the account name is
// empty, the password is empty, or the OS rejects the write.
bool Save(const char *accountName, const char *password);
// Delete the saved entry for `accountName` under the current engine
// realm. Returns true if a matching entry existed and was deleted.
bool Delete(const char *accountName);
// One entry returned by `List()` — the account name plus the
// Windows-tracked `LastWritten` timestamp converted to Unix epoch
// seconds. The engine refreshes `LastWritten` on every `CredWriteW`,
// so `LoginWithSavedAccount` re-saves the credential after use to
// keep the timestamp current ("last written" == "last used").
struct Entry {
std::string name;
uint64_t lastUsedUnix;
};
// Enumerate every saved account for the current engine realm. Empty
// list if no realm is set or no entries exist.
std::vector<Entry> List();
// Load the password for `accountName` under the current engine realm
// into `outPassword`. The output is nul-terminated and the vector's
// `size()` reflects the password length (not including the trailing
// nul). Returns false if no realm is set or no such entry exists. The
// caller should `SecureZeroMemory(outPassword.data(), outPassword.size())`
// after use.
bool Load(const char *accountName, std::vector<char> &outPassword);
} // namespace Security::CredStore
+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