Compare commits

...
45 Commits
Author SHA1 Message Date
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
Brues ec73652b67 2nd arg "canOpen" for IsItemOpenable
removed IsItemOpenableByID
2026-05-28 23:26:04 -05:00
Brues 4c798ba58a turtle wow injects a bad version of print 2026-05-28 16:01:17 -05:00
brues-code fbe81b7f01 Simplify API documentation for combat check
Removed redundant explanation for UnitAffectingCombat.
2026-05-28 15:30:44 -05:00
brues-code 1c0e20cca8 Update CharacterOrder to CharacterList in README 2026-05-28 15:21:40 -05:00
Brues 1242f1dd9f Openable: return nil for uncached items 2026-05-28 14:37:43 -05:00
Brues de0c41dd1d IsItemOpenable 2026-05-28 14:33:41 -05:00
Brues 3e22e2c087 useless comment 2026-05-28 14:33:23 -05:00
Brues beb9dec6e1 max out CLASSICAPI_VERSION_VALUE for dev build 2026-05-28 00:52:17 -05:00
Brues e760d5f07e Drop focus when out of render distance 2026-05-27 23:44:22 -05:00
Brues d0b6a7c0f3 C_Container.GetContainerItemCharges 2026-05-27 23:43:28 -05:00
brues-codeandBrues 96b0cf6075 FocusUnit / ClearFocus + focus unit token (#2)
* FocusUnit

* UnitTokenFromGUID: place focus after target, not after mouseover

Retail's documented scan order is
  player -> vehicle -> pet -> party -> partypet -> raid -> raidpet
        -> nameplate -> arena -> arenapet -> boss -> target -> focus
        -> npc -> mouseover -> softenemy -> softfriend -> softinteract

Earlier commit placed focus at the very end. Moves it into the
target-adjacent slot so a focused unit reverse-resolves to "focus"
the moment it isn't also "target", matching retail.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-05-27 19:34:12 -05:00
Brues c0c14c6947 C_Container.SwapItems / MoveItem: accept bank slot IDs
Extend EncodeBagSlot with two new branches matching vanilla's bagID
conventions:

- bagID = -1  -> main bank (24 slots at linear 39..62, container =
  player GUID; same shape as backpack with a +38 offset instead of
  +22).
- bagID = 5..10 -> equipped bank bags (containers at equipment slots
  64..69; same shape as equipped bags with the index range shifted).

Both paths require the bank window to be open in the current
session — the engine doesn't sync bank-side CGContainers or bank
inventory entries before then, so ResolveEquipmentSlot returns null
and EncodeBagSlot bails cleanly. Same constraint
GetContainerItemInfo has on bank IDs.
2026-05-26 19:11:55 -05:00
Brues a0df98750b UnitTokenFromGUID: scan nameplateN between raidpet and target
Now that the engine recognizes `nameplateN` tokens (via the resolver
hook in nameplate/TokenResolver.cpp), they belong in the reverse-
lookup walk too. Iterates the dense ordered list directly via
NamePlate::Events::GetGUIDByIndex — bails on the first zero return,
skipping the token-resolver + SStrCmpI cost per index.
2026-05-26 16:32:08 -05:00
brues-codeandBrues da8454c7c2 C_NamePlate API + nameplateN unit tokens (#1)
* C_NamePlate.GetNamePlateGUIDs

* fixed NameCache header

* C_NamePlate.GetNamePlates

* GetNamePlates will show blizzard nameplates as well

* C_NamePlate.GetNamePlateForUnit

* nameplate events

* C_NamePlate.GetNamePlateForGUID

* NAME_PLATE_CREATED now sends frame as argument

* document verification provenance for nameplate-adjacent offsets

- Offsets.h: annotate LUA_REF_REF / LUA_REF_UNREF with their Ghidra
  decompile shapes (FREELIST_REF chain + LUA_REFNIL/NOREF sentinels)
  so the next maintainer doesn't have to re-derive whether these are
  really luaL_ref/unref.
- Walk.h: warn that the 0x00B41414 player global overlays multiple
  sub-objects; reading +0x1C/+0x24 here is safe, feeding it to the
  inventory path crashes.

* harden nameplate frame push against stale registry refs

PushNamePlateFrame now type-checks the rawgeti'd value; on a stale
slot (post-/reload windows, freed slot, etc.) it pops and falls back
to a fresh wrapper instead of returning whatever the registry happens
to hold at that index in the new Lua state. Script_GetNamePlates now
routes through the same helper so it inherits the guard.

Also document the bound on g_seenPlates: pool reuse via DAT_00c4d920
caps it at the session's peak simultaneous-plate count.

* support nameplateN unit tokens

Hook FUN_TOKEN_TO_GUID so every Script_Unit* (UnitName, UnitGUID,
UnitHealth, UnitClass, UnitExists, ...) accepts `nameplateN` tokens
and their `target`-suffix chains (`nameplate1target`,
`nameplate1targettarget`) — modern WoW semantics on the vanilla
engine. Hook is gated by an SStrCmpI prefix check; non-nameplate
tokens fall straight through to the original resolver.

- Ordered GUID list is maintained alongside the existing
  NAME_PLATE_UNIT_ADDED / _REMOVED diff. Append on ADDED, erase on
  REMOVED — creation-order, stable for a plate's lifetime, modern
  semantics.
- Suffix walker mirrors the engine's own LAB_005159d3 walker
  instruction-for-instruction: SStrCmpI "target" + ObjectByGUID +
  read UNIT_FIELD_TARGET (m_objectFields + 0x28).
- SStrCmpI typedef is __stdcall, not __cdecl. The function ends with
  ret 0xc; declaring it cdecl makes MSVC emit a redundant add esp, 12
  on top of the callee cleanup, drifting ESP +12 per call and
  crashing somewhere deep in Lua once L gets read from a poisoned
  stack slot.
- m_objectFields is a *pointer* at obj+0x110, not an inline byte
  offset. Two dereferences (mov eax, [obj+0x110]; mov edi, [eax+0x28]),
  not one — earlier version mistook the inline-vs-pointer shape and
  read instruction bytes as GUIDs.

* document nameplateN unit-token support

- API.md NamePlate section: replace the "scope vs. modern (deferred)"
  callout with a pointer to the new Unit tokens subsection; update
  the events table and "modern divergence" callout to explain why
  the GUID payload remains (engine dispatcher can't carry Lua values
  through %s/%d/%u/%f format codes) even though the token form now
  works.
- New "Unit tokens (`nameplateN`)" subsection covering the resolved
  ~30 UnitX functions, creation-order semantics, suffix chains
  (`nameplate1target`, `targettarget`), and the SStrCmpI prefix-gate
  implementation detail.
- UnitTokenFromGUID's "post-1.12 omitted" list: nameplateN is no
  longer engine-unsupported (we hook the resolver) but it's still
  not used as a reverse-lookup key — indices can be reassigned
  during a session, so it'd be unstable.
- README: new Unit tokens table row pointing to the API.md anchor.

* return stable nameplate wrapper across calls

PushNamePlateFrame was building a fresh Lua table on every call for
unregistered (default vanilla) nameplates — so fields addons set on
the wrapper they received in NAME_PLATE_CREATED were lost on the
next GetNamePlateForUnit lookup. pfUI specifically writes
`plate.nameplate = styledButton` on the CREATED-delivered table to
hand its overlay back through our API, and that field was
disappearing.

Fix: pin the first wrapper for each nameplate pointer in the Lua
registry via luaL_ref, cache the refkey in g_wrapperRefkeys, and
rawgeti on every subsequent push. Same table identity for the life
of the engine plate. Bound by the CGNamePlateFrame pool size (~80).

Cleared on /reload via a new NamePlate::Info::PrepareForReload()
wired into FrameScript_Initialize_h next to the existing
Event::Custom and NameCache reload hooks — stale refkeys would
otherwise point into the freed registry.

* NAME_PLATE_UNIT events now fire token

* nameplate events: ship "nameplateN" tokens, delegate wrappers to engine

Two intertwined changes that together make nameplate-mod addons
(pfUI) survive pool recycle.

1. NAME_PLATE_UNIT_ADDED / _REMOVED arg1 is now the "nameplateN"
   unit token, matching modern WoW exactly. For REMOVED we compute
   the token before erasing from g_orderedGUIDs so the handler can
   still resolve the leaving unit. Addons that want a stable hash
   key call UnitGUID(arg1) to convert.

2. PushNamePlateFrame now delegates to the engine's own
   FrameScript_Object::ScriptRegister (0x00701BD0) instead of
   maintaining our own luaL_ref'd wrapper cache. Per-pointer the
   engine builds a wrapper table once, refs it into the registry,
   and stores the refkey at nameplate+0x08 - every code path
   (ours, addons' CreateFrame(parent) extraction, the engine's own
   internal pushers) then funnels through
   rawgeti(REGISTRY, nameplate+0x08) and gets the same Lua table.

The prior approach kept our own wrapper alongside the engine's
once pfUI's CreateFrame inside NAME_PLATE_CREATED caused the
engine to register the underlying frame itself - fields set on
our wrapper (plate.nameplate = decoratedButton) disappeared from
the API surface even though they still existed on the orphaned
table. Single-source-of-truth fixes it. The refcount-pinning
caveat CLAUDE.md warned about is benign for pool-managed
nameplates (never destroyed during a session).

Also clear seenPlates / lastTickPlates / orderedGUIDs on
FrameScript_Initialize so currently-visible plates refire CREATED
+ UNIT_ADDED after /reload - re-presents them to the freshly
reloaded UI, matching modern WoW.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-05-26 16:23:09 -05:00
Brues d5a45c73cb Addon: LinkUtil 2026-05-26 02:10:41 -05:00
56 changed files with 6771 additions and 959 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
+4 -1
View File
@@ -1,7 +1,7 @@
## 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
@@ -27,3 +27,6 @@ Util\TimedCallback.lua
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";
+1 -1
View File
@@ -110,7 +110,7 @@ PLAYER_FACTION_COLORS =
{
[0] = PLAYER_FACTION_COLOR_HORDE,
[1] = PLAYER_FACTION_COLOR_ALLIANCE,
};-- Quest
};
WOW_PROJECT_MAINLINE = 1;
WOW_PROJECT_CLASSIC = 2;
+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
+4 -1
View File
@@ -62,7 +62,10 @@ LinkTypes = {
LinkUtil = {};
function LinkUtil.FormatLink(linkType, linkDisplayText, ...)
local linkFormatTable = { string.format("|H%s", linkType), ... };
local linkFormatTable = { string.format("|H%s", linkType) };
for i = 1, arg.n do
linkFormatTable[i + 1] = arg[i];
end
local returnLink = table.concat(linkFormatTable, ":");
if linkDisplayText then
return returnLink .. string.format("|h%s|h", linkDisplayText);
-6
View File
@@ -17,12 +17,6 @@
-- Secure-Templates system, so the protective wrappers in 3.3.5
-- reduce to a plain pcall.
--
-- Skipping if print is already defined keeps this file safe to load
-- alongside any other DLL or addon that might inject its own print.
if type(print) == "function" then
return
end
----------------------------------------------------------------------------
-- tostringall: convert every vararg to a string, preserving count.
+33 -3
View File
@@ -25,9 +25,38 @@ 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)
set(CLASSICAPI_VERSION_VALUE 1)
add_library(${PROJECT_NAME} SHARED
${SOURCES} ${HEADERS} ${TINYCBOR_SOURCES}
${EMBEDDED_CLASSICAPI_HEADER})
# Dev-build sentinel — represents "newer than every plausible
# release". Encoded as v9999.99.99 = 9999 * 10000 + 99 * 100 + 99 =
# 99,999,999. Picked so addon-side feature gates of the form
# `CLASSIC_API_VERSION >= X` always pass during local development;
# CI release builds overwrite it via `-DCLASSICAPI_TAG=vX.Y.Z`.
set(CLASSICAPI_VERSION_VALUE 99999999)
set(CLASSICAPI_TAG
""
@@ -50,10 +79,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)
+38 -11
View File
@@ -18,32 +18,35 @@ 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` |
| [Container](docs/API.md#container) | `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetItemCooldown`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
| [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`) |
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid` |
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
| [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.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.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` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellName`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [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` |
@@ -64,9 +67,11 @@ the glue state because GlueXML had no way to reach them otherwise.
| Group | Calls |
|-------|-------|
| CharacterOrder | `GetSavedCharacterOrder`, `SetSavedCharacterOrder` (persist to `WTF\Account\...\ClassicAPI.txt`) |
| [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
@@ -94,6 +99,10 @@ functions, just behavior the stock 1.12 engine didn't have. See the
| `HEARTHSTONE_BOUND` | *(none)* |
| `ITEM_DATA_LOAD_RESULT` | `itemID, success` |
| `MODIFIER_STATE_CHANGED` | `keyName, down` |
| `NAME_PLATE_CREATED` | `nameplateFrame` |
| `NAME_PLATE_UNIT_ADDED` | `unitToken` ("nameplateN") |
| `NAME_PLATE_UNIT_REMOVED` | `unitToken` ("nameplateN") |
| `PLAYER_FOCUS_CHANGED` | *(none)* |
| `PLAYER_STARTED_LOOKING` | *(none)* |
| `PLAYER_STOPPED_LOOKING` | *(none)* |
| `PLAYER_STARTED_MOVING` | *(none)* |
@@ -116,6 +125,13 @@ functions, just behavior the stock 1.12 engine didn't have. See the
| Addon security | `Enum.AddOnSecurityStatus.{Secure,Insecure,Banned,NotAvailable}` |
| Power type | `Enum.PowerType.{HealthCost,None,Mana,Rage,Focus,Energy,Happiness}` |
### Unit tokens
| Token | Resolves to |
|-------|-------------|
| `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). |
## Installation
Use [VanillaFixes](https://github.com/hannesmann/vanillafixes) to load the
@@ -149,8 +165,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)")
+1719 -784
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -16,6 +16,7 @@
#include "MinHook.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "nameplate/Walk.h"
#include "player/NameCache.h"
static Game::FrameScript_Initialize_t FrameScript_Initialize_o = nullptr;
@@ -37,6 +38,15 @@ static bool __fastcall FrameScript_Initialize_h() {
// table is rebuilt at a fresh allocation; the old slots are stale.
Event::Custom::PrepareForReload();
// Clear the nameplate diff state so currently-visible plates
// refire CREATED and UNIT_ADDED on the next tick — re-presents
// them to the freshly reloaded UI, matching modern WoW. The
// wrappers themselves now live in the engine's own registry
// (via `FrameScript_Object::ScriptRegister`), which the engine
// tears down and rebuilds across the Lua reset — no per-module
// cache to clear here.
NamePlate::Events::PrepareForReload();
// Persist the name cache before the engine starts tearing down.
// This hook fires on both `/reload` and `/logout` (the engine
// re-initializes Lua state in both cases), giving us a clean
+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
+362 -4
View File
@@ -200,6 +200,30 @@ enum Offsets {
// Registers a single global Lua function. __fastcall(name, func).
FUN_FRAMESCRIPT_REGISTER_FUNCTION = 0x00704120,
// `FrameScript_Object::ScriptRegister(this, name)` — `__thiscall`,
// `this` = a `CFrameScriptObject *`. On first call (when `this+0x04`
// is zero) builds a Lua wrapper table `{[0] = lightuserdata(this)}`
// with `_G["__framescript_meta"]` as metatable, `luaL_ref`s it into
// the registry, stores the refkey at `this+0x08`. Always increments
// `this+0x04` (the Lua-side refcount). Optional `name` argument
// installs `_G[name] = wrapper` for engine-named frames.
//
// We call this in `PushNamePlateFrame` so the engine and our own
// C_NamePlate getters operate on the **same** wrapper table —
// every push through `lua_rawgeti(REGISTRY, this+0x08)` (the
// canonical engine path) lands on the same Lua object pfUI
// received in `NAME_PLATE_CREATED`, so addon-set fields
// (`plate.nameplate = decoratedButton`) survive engine-side
// re-fetches. Earlier note in `Info.cpp` warned about pinning the
// refcount; for pool-managed nameplates the engine never
// un-registers them anyway, so the pin is benign.
FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER = 0x00701BD0,
// `this+0x04` Lua refcount, incremented by `ScriptRegister`. We
// read it as a "has the engine ever exposed this CObject to Lua"
// probe — equivalent to checking `this+0x08 > 0` but more direct.
OFF_COBJECT_LUA_REFCOUNT = 0x04,
// Direct cvar lookup — `__fastcall(const char *name) → CVar* | NULL`.
// Hash-table by-name lookup over the CVar registry; same call
// `Script_GetCVar` makes internally before the engine wraps the
@@ -281,6 +305,28 @@ enum Offsets {
// input it's the right primitive.
FUN_TOKEN_TO_GUID = 0x00515970,
// `SStrCmpI(a, b, n)` — Storm's case-insensitive memcmp-style
// comparator. **`int __stdcall(const char *a, const char *b, int n)`**
// — the function ends with `ret 0xc`, so the callee pops the
// 3-arg stack frame. Declaring it as `__cdecl` and calling makes
// MSVC emit a redundant `add esp, 12` post-call, drifting ESP
// upward by 12 per call and corrupting the caller's stack frame
// — manifested as a deep-Lua crash whose `L` pointer landed in
// `.text`. Returns 0 when the first `n` characters match
// (ignoring case) or both strings end before `n`.
FUN_SSTR_CMP_I = 0x0064A4C0,
// UNIT_FIELD_TARGET within `m_objectFields` — 64-bit GUID at byte
// offsets +0x28 (lo) / +0x2C (hi). Verified by disassembling
// `FUN_TOKEN_TO_GUID`'s suffix walker at `0x00515A1C-A2C`:
// `mov eax, [obj + 0x110]; mov edi, [eax + 0x28]; mov ebx, [eax + 0x2c]`
// — reads the target GUID to chain `targettarget`-style tokens.
// Distinct from the higher field offsets in the
// `OFF_UNIT_FIELD_*` block; UNIT_FIELD_TARGET is one of the few
// 1.12 offsets that matches the CMaNGOS-documented vanilla
// layout.
OFF_UNIT_FIELD_TARGET = 0x28,
// Party / raid roster counts and the party GUID array referenced
// in the `FUN_TOKEN_TO_GUID` dispatch comment above. Used by
// `UnitTokenFromGUID` to cap its candidate iteration — solo
@@ -847,6 +893,14 @@ enum Offsets {
// descriptor bytes for a worn-and-bound item.
OFF_DESCRIPTOR_FLAGS = 0x3C,
ITEM_FLAG_SOULBOUND = 0x01,
// Bit 2 — instance is "unlocked", i.e. the lock referenced by
// `ItemStats.LockID` has been opened (rogue Pick Lock, key item,
// etc.). For unlockable items, the engine gates the
// `<Right Click to Open>` tooltip on this bit: a fresh lockbox
// has it clear (priest sees no openable affordance), a picked
// lockbox has it set. Items with `LockID == 0` (clams, simple
// sacks) never need the bit.
ITEM_FLAG_UNLOCKED = 0x04,
// Client-side "in-transaction" lock — set when the engine puts an
// item on the cursor / mail-attaches it / trade-attaches it, cleared
@@ -1014,7 +1068,44 @@ 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).
// The tooltip line `<Right Click to Open>` (`ITEM_OPENABLE`
// string) renders for items with this bit set, gated on the
// player's lock skill when the item also carries a non-zero
// LockID. Same bit modern WoW's `C_Item.IsItemOpenable`
// surfaces.
// Other documented bits (`0x1` = BIND_ON_PICKUP, `0x2` = CONJURED,
// `0x10` = LOOTABLE, `0x200` = WRAPPER, etc.) live in the same
// field but we don't read them here. Add named constants as
// consumers materialize.
// Reader at `0x0052E323` (the tooltip-builder's openable branch in
// `FUN_0052B650`) does `(ItemStats[+0x20] & 0x4)` against this
// field; verified by checking the field offset matches the
// 5-DWORD-per-row layout `puVar4[8]` resolves to in the decompile.
OFF_ITEMSTATS_FLAGS = 0x20,
ITEMSTATS_FLAG_OPENABLE = 0x4,
OFF_ITEMSTATS_SELL_PRICE = 0x28,
// `m_lockID` — references `Lock.dbc`. Zero means "no lock"; any
// nonzero value gates the openable interaction on the player
// successfully unlocking the item (instance flag bit 2 in
// `ITEM_FIELD_FLAGS`). Same field the tooltip builder reads as
// `ItemStats[+0x1AC]` (puVar4[0x6b]) when deciding whether the
// `<Right Click to Open>` line should require the lock to be
// picked first.
OFF_ITEMSTATS_LOCK_ID = 0x1AC,
OFF_ITEMSTATS_INVENTORY_TYPE = 0x2C,
OFF_ITEMSTATS_ITEM_LEVEL = 0x38, // u32 — base ilvl from ItemSparse
// `m_stackable` — max stack size for this item type (1 for non-
@@ -1397,6 +1488,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
@@ -1692,6 +1812,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,
@@ -2021,6 +2176,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
@@ -2089,6 +2245,22 @@ enum Offsets {
// to L->top → `add [ecx+8], 0x10` incr_top). `0x6F32B0` is
// `lua_replace` (single TValue copy + decr_top).
LUA_PUSH_VALUE = 0x6F3350,
// `luaL_ref(L, t)` — pops the top, stores it in the table at `t`
// at a freshly-allocated integer key, returns the key. Use with
// `LUA_REGISTRY_INDEX` to stash Lua values across C-side scopes;
// pair with `LUA_REF_UNREF` to release. Verified by decompiling
// `FUN_006f5310`: classic luaL_ref shape — nil-top early-out
// returning `LUA_REFNIL = -1`, FREELIST_REF chain pop (pushvalue +
// tonumber on table[0]), else objlen-based new slot, finally
// table[ref] = popped value.
LUA_REF_REF = 0x6F5310,
// `luaL_unref(L, t, ref)` — releases a ref previously returned
// by `luaL_ref`, freeing the slot for future allocations. Verified
// by decompiling `FUN_006f5400`: `ref >= 0` guard (skips
// `LUA_NOREF = -2` / `LUA_REFNIL = -1`), reads current FREELIST_REF
// head, writes it to `table[ref]`, then updates FREELIST_REF = ref
// — the canonical freelist-link operation.
LUA_REF_UNREF = 0x6F5400,
LUA_PUSH_CCLOSURE = 0x6F3920,
LUA_NEW_TABLE = 0x6F3C90,
LUA_GET_TABLE = 0x6F3A40, // (was 0x6F3EA0, which is lua_rawset)
@@ -2253,6 +2425,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`
@@ -2387,9 +2639,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`
@@ -2410,7 +2664,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,
@@ -3043,4 +3297,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,
+159
View File
@@ -0,0 +1,159 @@
// 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 the engine's TARGETING group.
//
// 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 TARGETING, the binding's display index has to fall
// between HEADER_TARGETING and HEADER_INTERFACE. The cleanest way to
// arrange that is to splice the bindings into the FrameXML file the
// engine itself parses, so they take their indices from the same
// running counter every native binding does.
//
// Splice site: just before `<Binding name="TOGGLECHARACTER0"` (the
// first INTERFACE-section entry). Our injected bindings inherit
// TARGETING from the preceding `header="TARGETING"` declaration on
// `TARGETNEARESTENEMY` (the engine never re-asserts a header on each
// binding — group membership is positional). They appear at the tail
// of TARGETING, just after PETATTACK / ATTACKTARGET.
//
// The engine's binding parser is `FUN_004b6f70`. It calls
// `FUN_FILE_READ` (`0x00648620`) to load the file, then walks `<Binding>`
// elements assigning each the next display index from a running
// counter. By the time our injected bindings are processed, the
// engine has already emitted HEADER_TARGETING and a sequence of
// TARGETING entries; ours append to that sequence and HEADER_INTERFACE
// follows.
#include "Inject.h"
#include "Offsets.h"
#include <cstring>
namespace Bindings::Inject {
namespace {
constexpr const char *kFrameXMLBindingsPath = "Interface\\FrameXML\\Bindings.xml";
// First INTERFACE-section binding. Splicing immediately before this
// puts our entries at the tail of the TARGETING block.
constexpr const char *kSpliceMarker = "<Binding name=\"TOGGLECHARACTER0\"";
// LF-only line endings — vanilla's XML parser is whitespace-tolerant
// and accepts either LF or CRLF freely. Indentation matches the
// surrounding file (one leading tab per `<Binding>`).
constexpr const char *kInjectedXml =
"<Binding name=\"FOCUSTARGET\">\n"
"\t\tFocusUnit(\"target\");\n"
"\t</Binding>\n"
"\t<Binding name=\"TARGETFOCUS\">\n"
"\t\tTargetUnit(\"focus\");\n"
"\t</Binding>\n"
"\t";
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);
const char *FindMarker(const char *content, size_t size, const char *marker) {
const size_t markerLen = std::strlen(marker);
if (size < markerLen) return nullptr;
for (size_t i = 0; i + markerLen <= size; ++i) {
if (std::memcmp(content + i, marker, markerLen) == 0)
return content + i;
}
return nullptr;
}
} // namespace
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
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;
const char *content = static_cast<const char *>(origBuf);
const char *splice = FindMarker(content, origSize, kSpliceMarker);
if (splice == nullptr) {
// Marker missing — likely a modified or non-vanilla FrameXML
// (Turtle patch level, custom build). Pass the original
// through unchanged rather than corrupting it.
if (outBuf != nullptr) *outBuf = origBuf;
if (outSize != nullptr) *outSize = origSize;
return true;
}
const size_t injectLen = std::strlen(kInjectedXml);
const size_t splicePos = static_cast<size_t>(splice - content);
const size_t newSize = origSize + injectLen;
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, kInjectedXml, injectLen);
std::memcpy(dest + splicePos + injectLen,
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
+55
View File
@@ -0,0 +1,55 @@
// 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_Container.GetContainerItemCharges(containerIndex, slotIndex)` —
// returns the `abs(SPELL_CHARGES[0])` value of the item in the
// specified bag slot, or `nil` for empty slots and items that don't
// carry a charge concept. Wands return their remaining cast count
// (e.g. 50 → 49 → …); single-use items (Healthstone, Mana Gem,
// Goblin Sapper Charge) return `1`.
//
// Modern equivalent: none — modern WoW exposes charges only through
// tooltip text scanning. `C_Item.GetItemCount(includeBank, includeCharges)`
// totals charges across every matching stack; this is the per-slot
// reader.
#include "Game.h"
#include "item/Charges.h"
#include "item/Location.h"
namespace Container::Charges {
namespace {
int __fastcall Script_C_Container_GetContainerItemCharges(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L,
"Usage: C_Container.GetContainerItemCharges(containerIndex, slotIndex)");
return 0;
}
const int bagID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const int slotIndex = static_cast<int>(Game::Lua::ToNumber(L, 2));
return Item::Charges::PushChargesForItem(
L, Item::Location::ResolveBag(L, bagID, slotIndex));
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "GetContainerItemCharges",
&Script_C_Container_GetContainerItemCharges);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Container::Charges
+49
View File
@@ -0,0 +1,49 @@
// 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_Container.IsContainerItemOpenable(bag, slot)` — positional-arg
// wrapper around `Item::Openable::PushIsItemOpenable`. Same chain
// `C_Container.GetContainerItemDurability` and friends use:
// `ResolveBag` → `GetItemBySlot`. Returns the
// `(isOpenable, canOpen)` tuple — see [[item/Openable.h]].
#include "Game.h"
#include "item/Location.h"
#include "item/Openable.h"
namespace Container::Openable {
namespace {
int __fastcall Script_C_Container_IsContainerItemOpenable(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L,
"Usage: C_Container.IsContainerItemOpenable(containerIndex, slotIndex)");
return 0;
}
const int bagID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const int slotIndex = static_cast<int>(Game::Lua::ToNumber(L, 2));
return Item::Openable::PushIsItemOpenable(
L, Item::Location::ResolveBag(L, bagID, slotIndex));
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "IsContainerItemOpenable",
&Script_C_Container_IsContainerItemOpenable);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Container::Openable
+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
+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};
+39
View File
@@ -0,0 +1,39 @@
// 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 "Charges.h"
#include "../Game.h"
#include "../Offsets.h"
#include <cstdlib>
namespace Item::Charges {
int PushChargesForItem(void *L, const uint8_t *item) {
if (item == nullptr)
return 0;
auto *descriptor = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_DESCRIPTOR);
if (descriptor == nullptr)
return 0;
const int32_t stack = static_cast<int32_t>(*reinterpret_cast<const uint32_t *>(
descriptor + Offsets::OFF_DESCRIPTOR_STACK_COUNT));
const int32_t rawCharges = *reinterpret_cast<const int32_t *>(
descriptor + Offsets::OFF_DESCRIPTOR_SPELL_CHARGES_0);
const int32_t usesPerItem = (rawCharges < 0) ? -rawCharges : 1;
Game::Lua::PushNumber(L, static_cast<double>(stack * usesPerItem));
return 1;
}
} // namespace Item::Charges
+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
#include <cstdint>
namespace Item::Charges {
// Pushes the total uses available in this single slot — the
// per-slot equivalent of `GetItemCount(_, _, includeCharges=true)`.
// Returns `1` (pushed one value) on success, `0` (pushes nothing,
// caller surfaces as `nil`) when the item is missing or the
// descriptor is null.
//
// Math is `stack * usesPerItem` where `usesPerItem` follows
// `Item::Count::GetUsesPerItem`:
// - `SPELL_CHARGES[0] >= 0` → `1` (no destroy-on-zero behavior;
// stack count alone reflects "how many uses").
// - `SPELL_CHARGES[0] < 0` → `abs(value)` (consume-on-use items;
// wand at 50 has `-50` so each item contributes 50).
//
// Worked examples:
// - Wand at 50 charges (stack=1, raw=-50): `1 * 50 = 50`
// - Hearthstone (stack=1, raw=0): `1 * 1 = 1` (cooldown-
// only, no charges concept; we still surface "1 use").
// - Water stack of 20 (stack=20, raw=-1): `20 * 1 = 20`
// - Equipped sword (stack=1, raw=0): `1 * 1 = 1`
//
// Caller can `return PushChargesForItem(L, item);` to forward the
// count directly.
int PushChargesForItem(void *L, const uint8_t *item);
} // namespace Item::Charges
+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});
}
+37
View File
@@ -146,6 +146,43 @@ 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 FindByGUID(void *L, uint64_t guid, ByGUIDResult *out) {
if (guid == 0)
return false;
+10
View File
@@ -86,6 +86,16 @@ 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);
// 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`
+113
View File
@@ -0,0 +1,113 @@
// 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.IsItemOpenable(itemLocation)` — backport of the modern WoW
// accessor that says "right-clicking this item triggers an open/loot
// interaction" (clams, sacks, simple chests, quest boxes, lockboxes).
// Returns two values: `(isOpenable, canOpen)` — the first is the
// item-type-intrinsic openable flag, the second is whether the
// player can right-click *this specific instance* right now (lock
// is gone or never existed).
//
// Vanilla 1.12's tooltip builder gates the `<Right Click to Open>`
// string (`ITEM_OPENABLE`) on `ItemStats.Flags & 0x4` AND
// (`LockID == 0` OR `ITEM_FIELD_FLAGS & 0x4`). We mirror both checks
// — see `Offsets::ITEMSTATS_FLAG_OPENABLE`, `ITEMSTATS_LOCK_ID`, and
// `ITEM_FLAG_UNLOCKED`.
#include "Game.h"
#include "Offsets.h"
#include "item/Data.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Openable.h"
#include <cstdint>
namespace Item::Openable {
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
namespace {
// Reads `ITEM_FIELD_FLAGS` from the instance descriptor at +0x114.
// Defaults to 0 if `cgItem` is null or the descriptor pointer hasn't
// been populated yet.
uint32_t InstanceFlags(const uint8_t *cgItem) {
if (cgItem == nullptr)
return 0;
auto *descriptor = *reinterpret_cast<const uint8_t *const *>(
cgItem + Offsets::OFF_ITEM_DESCRIPTOR);
if (descriptor == nullptr)
return 0;
return *reinterpret_cast<const uint32_t *>(
descriptor + Offsets::OFF_DESCRIPTOR_FLAGS);
}
} // namespace
int PushIsItemOpenable(void *L, const uint8_t *cgItem) {
const int itemID = Item::ID::FromCGItem(cgItem);
if (itemID <= 0)
return 0;
const uint8_t *record = PeekItemRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
return 0;
}
const uint32_t typeFlags = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_FLAGS);
const uint32_t lockID = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_LOCK_ID);
const bool isOpenable = (typeFlags & Offsets::ITEMSTATS_FLAG_OPENABLE) != 0;
const uint32_t instFlags = InstanceFlags(cgItem);
const bool canOpen = isOpenable && (lockID == 0 ||
(instFlags & Offsets::ITEM_FLAG_UNLOCKED) != 0);
Game::Lua::PushBool(L, isOpenable);
Game::Lua::PushBool(L, canOpen);
return 2;
}
namespace {
int __fastcall Script_C_Item_IsItemOpenable(void *L) {
if (!Item::Location::IsLocationArg(L, 1)) {
Game::Lua::Error(L, "Usage: C_Item.IsItemOpenable(itemLocation)");
return 0;
}
return PushIsItemOpenable(L, Item::Location::Resolve(L, 1));
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Item", "IsItemOpenable",
&Script_C_Item_IsItemOpenable);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Item::Openable
+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 <cstdint>
namespace Item::Openable {
// Pushes two values for the openable check on a player-owned item
// (`isOpenable, canOpen`), returning 2. The two semantics:
//
// `isOpenable` — bool. The item type carries the openable flag
// (`ItemStats.Flags & 0x4` — clams, sacks, lockboxes, etc.).
// `canOpen` — bool. The player can right-click this specific
// instance and trigger the open action right now. True for
// openable items with no lock (`LockID == 0`) or whose instance
// has been unlocked (`ITEM_FIELD_FLAGS & 0x4` — set after a
// rogue picks the lock, a key is used, etc.). False for fresh
// lockboxes a non-rogue is looking at.
//
// `cgItem` may be null (empty slot), in which case nothing is pushed
// and 0 is returned (Lua sees nil for both returns). Uncached itemID
// also returns 0 plus a background cache warm.
int PushIsItemOpenable(void *L, const uint8_t *cgItem);
} // namespace Item::Openable
+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
+77 -2
View File
@@ -64,12 +64,34 @@ constexpr int FIRST_BAG_CONTAINER_LINEAR = 19;
// player invMgr — i.e. 1-based bag-0 slot S maps to linear 22+S.
constexpr int BACKPACK_LINEAR_OFFSET = 22;
// Main bank contents (bagID -1) live at linear slots 39..62 in the
// player invMgr — i.e. 1-based bank slot S maps to linear 38+S.
// Same encoding shape as the backpack (container is the player
// itself), different base offset. Range valid only when the bank
// window has been opened in the current session.
constexpr int BANK_MAIN_LINEAR_OFFSET = 38;
constexpr int BANK_MAIN_NUM_SLOTS = 24;
// Bank bag containers occupy 1-based equipment slots 64..69 (linear
// 63..68). Addressed by bagID 5..10 — bagID 5 = first bank bag.
// Requires bank window open; otherwise the engine's CGContainer for
// the bag is unsynced and `ResolveEquipmentSlot` returns null.
constexpr int FIRST_BANK_BAG_EQ_SLOT = 64;
constexpr int FIRST_BANK_BAG_ID = 5;
constexpr int LAST_BANK_BAG_ID = 10;
// (bagID, 1-based slot) → (containerGUID, engine linear slot). Returns
// false for unequipped bags or out-of-range slots; does NOT validate
// that the slot is occupied (caller checks separately).
//
// bagID 0 → container = player, linear = BACKPACK_LINEAR_OFFSET + slot
// bagID 1..4 → container = equipped bag's CGContainer, linear = slot - 1
// bagID 0 → container = player, linear = 22 + slot (backpack)
// bagID 1..4 → container = equipped bag's CGContainer, linear = slot - 1
// bagID -1 → container = player, linear = 38 + slot (bank main)
// bagID 5..10 → container = bank bag's CGContainer, linear = slot - 1
//
// Bank-side encodings only resolve once the bank window has been
// opened in the current session (the engine doesn't sync the bank
// CGContainers or bank-slot inventory entries before then).
bool EncodeBagSlot(int bagID, int slotInBag,
uint32_t *containerLo, uint32_t *containerHi,
uint32_t *linearSlot) {
@@ -94,6 +116,27 @@ bool EncodeBagSlot(int bagID, int slotInBag,
*linearSlot = static_cast<uint32_t>(slotInBag - 1);
return true;
}
if (bagID == -1) {
if (slotInBag > BANK_MAIN_NUM_SLOTS)
return false;
void *player = ResolvePlayer();
if (player == nullptr)
return false;
if (!ReadGuid(player, containerLo, containerHi))
return false;
*linearSlot = static_cast<uint32_t>(BANK_MAIN_LINEAR_OFFSET + slotInBag);
return true;
}
if (bagID >= FIRST_BANK_BAG_ID && bagID <= LAST_BANK_BAG_ID) {
const uint8_t *bag = Item::Location::ResolveEquipmentSlot(
FIRST_BANK_BAG_EQ_SLOT + (bagID - FIRST_BANK_BAG_ID));
if (bag == nullptr)
return false;
if (!ReadGuid(bag, containerLo, containerHi))
return false;
*linearSlot = static_cast<uint32_t>(slotInBag - 1);
return true;
}
return false;
}
@@ -156,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() {
+87 -11
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,14 +28,19 @@
// 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"
@@ -41,6 +49,7 @@
#include "item/Location.h"
#include <cstdint>
#include <cstring>
#include <string.h>
namespace Item::Use {
@@ -54,6 +63,73 @@ using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t item
using UseItem_t = unsigned(__thiscall *)(const void *item, const uint64_t *targetGuid,
int flag);
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;
}
// 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;
}
// 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);
}
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);
@@ -107,9 +183,9 @@ int __fastcall Script_C_Item_UseItemByName(void *L) {
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(cgItem, &targetGuid, 0);
return 0;
}
+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
+253
View File
@@ -0,0 +1,253 @@
// 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/>.
// Nameplate lifecycle events: NAME_PLATE_CREATED, NAME_PLATE_UNIT_ADDED,
// NAME_PLATE_UNIT_REMOVED.
//
// Per-frame poll: walk visible CGUnits with allocated nameplates, diff
// against last frame's snapshot, fire events on real transitions only.
// The engine's internal hide/show cycle (z-order rebuilds, anchor
// changes, ~7 callers of `FUN_00608A10` that transiently zero `+0xE60`)
// is absorbed because we only compare with the previous *frame's*
// state, not every transient `unit + 0xE60` write.
//
// Payload shape (matches modern WoW exactly):
// - `NAME_PLATE_CREATED` — `arg1` is the nameplate **Frame**. The
// engine's printf-style dispatcher (`FUN_FIRE_EVENT`) only knows
// `%s`/`%d`/`%u`/`%f` format codes with no "push Lua value" option,
// so we route this event through a pre-set path: save current
// `_G.arg1`, set it to the frame, fire with empty format
// (dispatcher leaves `_G.arg<N>` alone when no codes are parsed),
// restore.
// - `NAME_PLATE_UNIT_ADDED` / `_REMOVED` — `arg1` is the
// `"nameplateN"` unit token (formatted from the plate's index in
// `g_orderedGUIDs` at fire time). The token resolves to the unit
// via the `nameplateN`-aware token resolver in `unit/TokenExtensions.cpp`
// — addons can pass it straight to `UnitName`, `UnitGUID`, etc.,
// or to `GetNamePlateForUnit` for the frame.
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "nameplate/Walk.h"
#include "tick/WorldTick.h"
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace NamePlate::Events {
namespace {
constexpr const char *kEventCreated = "NAME_PLATE_CREATED";
constexpr const char *kEventUnitAdded = "NAME_PLATE_UNIT_ADDED";
constexpr const char *kEventUnitRemoved = "NAME_PLATE_UNIT_REMOVED";
const Event::Custom::AutoReserve _r1{kEventCreated};
const Event::Custom::AutoReserve _r2{kEventUnitAdded};
const Event::Custom::AutoReserve _r3{kEventUnitRemoved};
// Previous tick's snapshot — GUID → nameplate-frame pointer for each
// nameplated unit. Compared against the next tick's walk to compute
// ADDED/REMOVED diffs.
std::unordered_map<uint64_t, const void *> g_lastTickPlates;
// Scratch map reused each tick, swapped into `g_lastTickPlates` at
// the end. File-static so we don't pay the constructor/destructor
// cycle every frame — `clear()` keeps the existing bucket capacity.
std::unordered_map<uint64_t, const void *> g_currentTickPlates;
// Frame pointers we've ever surfaced as nameplate plates. First
// sighting fires NAME_PLATE_CREATED; same pointer reappearing (pool
// reuse) doesn't refire.
//
// Bounded by the engine's CGNamePlateFrame freelist high-water mark:
// `FUN_006087F0` first checks the global freelist head at
// `DAT_00c4d920` and recycles any waiting frame before falling back
// to `SMemAlloc(0x518)`. So the set grows only up to the peak
// simultaneous-visible-plate count for the session — typically
// <80 even in AV-scale scenes (matching the `reserve(64)` below) —
// then tops out as pool reuse covers all subsequent shows.
std::unordered_set<const void *> g_seenPlates;
// Ordered list of currently-visible nameplate GUIDs, in
// creation-order. Append on UNIT_ADDED, erase on UNIT_REMOVED. Backs
// the `nameplateN` unit-token resolver in `unit/TokenExtensions.cpp`. Order
// matches modern WoW semantics: stable for the lifetime of each
// plate, gaps when middle plates vanish (until the next REMOVED
// shifts later entries down).
std::vector<uint64_t> g_orderedGUIDs;
// Fire `eventName` with a pre-formatted string as `arg1`. The engine
// dispatcher's `%s` format code pushes the C string into `_G.arg1`
// as a Lua string — no escaping concerns for our own input
// (`"nameplateN"`).
void FireWithString(const char *eventName, const char *value) {
if (value == nullptr)
return;
const int slot = Event::Custom::Lookup(eventName);
if (slot < 0)
return;
Event::Custom::Fire(slot, "%s", value);
}
// Format a 1-based nameplate index as `"nameplateN"` for ADDED /
// REMOVED event payloads. Buffer should be at least 24 bytes — 9
// for the prefix, up to 10 digits for the index, room for null.
// Returns `buf` for convenient inline use.
const char *FormatNamePlateToken(char *buf, size_t bufSize, int oneBasedIndex) {
std::snprintf(buf, bufSize, "nameplate%d", oneBasedIndex);
return buf;
}
// Fire `eventName` with the nameplate `Frame` set as `_G.arg1`.
// `FUN_FIRE_EVENT`'s format-string parser only handles primitive
// types, but it only mutates `_G.arg<N>` for codes it actually
// parses. With an empty format we pre-set `_G.arg1` and the
// dispatcher leaves it alone. Restore the previous value after
// fire so we don't leak our frame into unrelated global state.
//
// Lua-stack-clean: stack depth on entry == stack depth on exit.
using LuaRefRef_t = int(__fastcall *)(void *L, int t);
using LuaRefUnref_t = void(__fastcall *)(void *L, int t, int ref);
using LuaRawGetI_t = void(__fastcall *)(void *L, int t, int n);
void FireWithFrame(const char *eventName, void *frame) {
if (frame == nullptr)
return;
const int slot = Event::Custom::Lookup(eventName);
if (slot < 0)
return;
void *L = Game::Lua::State();
if (L == nullptr)
return;
auto refRef = reinterpret_cast<LuaRefRef_t>(
static_cast<uintptr_t>(Offsets::LUA_REF_REF));
auto refUnref = reinterpret_cast<LuaRefUnref_t>(
static_cast<uintptr_t>(Offsets::LUA_REF_UNREF));
auto rawgeti = reinterpret_cast<LuaRawGetI_t>(
Offsets::FUN_FRAMESCRIPT_PUSH_OBJECT);
// Save current `_G.arg1` to the registry.
Game::Lua::PushString(L, "arg1");
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
const int savedRef = refRef(L, Game::Lua::REGISTRY_INDEX);
// Set `_G.arg1 = frame`.
Game::Lua::PushString(L, "arg1");
NamePlate::Info::PushNamePlateFrame(L, frame);
Game::Lua::SetTable(L, Game::Lua::GLOBALS_INDEX);
// Fire — empty format, so the dispatcher doesn't touch `arg1`.
Event::Custom::Fire(slot, "");
// Restore previous `_G.arg1` from the saved registry ref.
Game::Lua::PushString(L, "arg1");
rawgeti(L, Game::Lua::REGISTRY_INDEX, savedRef);
Game::Lua::SetTable(L, Game::Lua::GLOBALS_INDEX);
refUnref(L, Game::Lua::REGISTRY_INDEX, savedRef);
}
void OnWorldTick() {
g_currentTickPlates.clear();
g_currentTickPlates.reserve(64); // typical visible-nameplate ceiling
NamePlate::Walk::ForEachNamePlatedUnit(
[](const uint8_t *, const uint8_t *nameplate,
const uint8_t *instance) {
const uint64_t guid = *reinterpret_cast<const uint64_t *>(instance);
if (guid == 0)
return;
g_currentTickPlates.emplace(guid, nameplate);
});
// Fire CREATED (with the Frame as arg1) for never-before-seen
// frame pointers; ADDED (with `"nameplateN"` token as arg1) for
// GUIDs not in last tick's snapshot. New entries are appended to
// the ordered list *before* firing so the token resolves to the
// newly-added plate during the event handler.
for (const auto &kv : g_currentTickPlates) {
if (g_seenPlates.insert(kv.second).second)
FireWithFrame(kEventCreated, const_cast<void *>(kv.second));
if (g_lastTickPlates.find(kv.first) == g_lastTickPlates.end()) {
g_orderedGUIDs.push_back(kv.first);
char tokenBuf[24];
FireWithString(kEventUnitAdded,
FormatNamePlateToken(tokenBuf, sizeof tokenBuf,
static_cast<int>(g_orderedGUIDs.size())));
}
}
// Fire REMOVED for GUIDs in last tick's snapshot but not current.
// We compute the token from the position *before* erasing so the
// event payload reflects the slot the unit just vacated; the
// handler can still resolve the token to the unit via
// `g_orderedGUIDs[slot]` during dispatch. Later plates shift down
// when we erase — matches modern semantics.
for (const auto &kv : g_lastTickPlates) {
if (g_currentTickPlates.find(kv.first) == g_currentTickPlates.end()) {
auto it = std::find(g_orderedGUIDs.begin(), g_orderedGUIDs.end(),
kv.first);
if (it == g_orderedGUIDs.end())
continue;
const int oneBased = static_cast<int>(it - g_orderedGUIDs.begin()) + 1;
char tokenBuf[24];
FireWithString(kEventUnitRemoved,
FormatNamePlateToken(tokenBuf, sizeof tokenBuf, oneBased));
g_orderedGUIDs.erase(it);
}
}
g_lastTickPlates.swap(g_currentTickPlates);
}
} // namespace
static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
// Called from `FrameScript_Initialize_h` ahead of the engine's Lua
// teardown. Clears the diff state alongside `NamePlate::Info`'s
// wrapper-cache reset so that on the first post-reload tick every
// currently-visible plate refires `NAME_PLATE_CREATED` and
// `NAME_PLATE_UNIT_ADDED`. Without this, addons that decorate via
// CREATED (pfUI: builds its overlay button per-pointer) never see
// the existing plates after a `/reload` — `g_seenPlates` would
// suppress every refire, and the freshly-built wrapper would lack
// the addon's `.nameplate` field.
//
// `g_orderedGUIDs` is also cleared so the post-reload token indices
// start at `nameplate1` again, matching the order plates re-fire in.
void PrepareForReload() {
g_seenPlates.clear();
g_lastTickPlates.clear();
g_orderedGUIDs.clear();
}
// Exposed via `nameplate/Walk.h` so the `nameplateN` token resolver
// in `unit/TokenExtensions.cpp` can map an index to a GUID without seeing
// the internal vector.
uint64_t GetGUIDByIndex(int oneBased) {
if (oneBased <= 0)
return 0;
const size_t idx = static_cast<size_t>(oneBased - 1);
if (idx >= g_orderedGUIDs.size())
return 0;
return g_orderedGUIDs[idx];
}
} // namespace NamePlate::Events
+265
View File
@@ -0,0 +1,265 @@
// 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_NamePlate.GetNamePlates()` — returns nameplate Frame objects,
// matching modern WoW's signature. Two paths because vanilla has two
// kinds of nameplates:
//
// 1. **Addon-created** (pfUI, TidyPlates, etc.) — already registered
// with Lua via `CreateFrame`. Their Lua-registry ref-key sits at
// `+0x08`; we push `registry[refKey]` to return the cached
// wrapper. Identity stable across calls.
//
// 2. **Default vanilla nameplates** — created internally without ever
// calling `CreateFrame`, so `+0x08` is 0. We build a fresh wrapper
// table per call: `{[0] = lightuserdata(frame)}` with the global
// `__framescript_meta` metatable. Methods work
// (`:GetWidth()` / `:GetAlpha()` / etc.) but the wrapper isn't
// cached engine-side — calling `GetNamePlates()` again returns a
// different table for the same frame. Don't compare wrappers by
// identity, and don't cache them across the unit going out of
// range (the underlying frame may be freed).
//
// We deliberately don't call the engine's frame-registration helper
// (`FUN_00701BD0`) for the unregistered case — it increments a Lua
// refcount on the frame that's never decremented, pinning the frame
// in memory.
//
// `C_NamePlate.GetNamePlateGUIDs()` — companion returning the GUID
// strings of the same set of units, regardless of registration
// state. Cheapest enumeration when an addon only needs the GUIDs.
//
// Vanilla 1.12 stores each unit's nameplate pointer at `CGUnit + 0xE60`
// (verified via `FUN_006086E0`'s "ensure nameplate exists" path). The
// nameplate also caches the unit's GUID at `+0x4E8` for back-lookup.
// There's no central "active nameplates" list — the engine maintains
// per-unit pointers updated by per-unit state-change handlers.
//
// To enumerate, we walk the local-player-anchored object hash table
// (`player + 0x1C` = bucket array, `player + 0x24` = mask). Each
// bucket header stores the link-field offset at byte 0 and the
// chain-head pointer at byte 8 — Storm's intrusive-hash pattern.
// Filter by `TYPEMASK_UNIT` (`flags & 0x08` at `*(entry+8) + 8`) and
// check `+0xE60` for a non-null nameplate pointer.
#include "Game.h"
#include "Offsets.h"
#include "guid/Guid.h"
#include "nameplate/Walk.h"
#include <cstdint>
namespace NamePlate::Info {
namespace {
using NamePlate::Walk::ForEachNamePlatedUnit;
using NamePlate::Walk::kOffUnitNamePlate;
using LuaRawGetI_t = void(__fastcall *)(void *L, int idx, int n);
using ScriptRegister_t = void(__fastcall *)(void *this_, void *edx_unused,
const char *name);
using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
using ResolveByGUID_t = void *(__fastcall *)(int type, const char *debugName,
uint32_t guidLo, uint32_t guidHi,
int priority);
} // namespace
static int __fastcall Script_GetNamePlates(void *L) {
Game::Lua::NewTable(L);
int nextIndex = 1;
ForEachNamePlatedUnit(
[L, &nextIndex](const uint8_t *, const uint8_t *nameplate,
const uint8_t *) {
Game::Lua::PushNumber(L, static_cast<double>(nextIndex++));
// Shared helper validates refkey freshness (defensive vs.
// stale-across-reload) and falls back to a fresh wrapper
// if the registry slot doesn't actually hold the frame
// table any more.
PushNamePlateFrame(L, const_cast<uint8_t *>(nameplate));
Game::Lua::SetTable(L, -3);
});
return 1;
}
static int __fastcall Script_GetNamePlateGUIDs(void *L) {
Game::Lua::NewTable(L);
int nextIndex = 1;
ForEachNamePlatedUnit(
[L, &nextIndex](const uint8_t *, const uint8_t *,
const uint8_t *instance) {
const uint64_t guid = *reinterpret_cast<const uint64_t *>(instance);
if (guid == 0)
return;
char buf[Guid::STRING_SIZE];
Game::Lua::PushNumber(L, static_cast<double>(nextIndex++));
Game::Lua::PushString(L,
Guid::FormatAsString(guid, buf, sizeof buf));
Game::Lua::SetTable(L, -3);
});
return 1;
}
// Exported via `nameplate/Walk.h` so `Events.cpp` can reuse the same
// frame-push path. Internal callers (Script_GetNamePlates etc.) call
// it through the unqualified name (they live in the same namespace).
//
// Delegate wrapper construction to the engine's own
// `FrameScript_Object::ScriptRegister`. First call for a never-seen
// nameplate builds a `{[0] = lightuserdata}` table with the
// framescript metatable, `luaL_ref`s it into the registry, and
// writes the refkey to `nameplate + 0x08` (`OFF_COBJECT_LUA_REGISTRY_REF`).
// Subsequent calls find a populated refkey and just rawgeti.
//
// Why delegate instead of building our own wrapper:
//
// - **Single source of truth.** Every code path that pushes this
// frame — ours via this function, addons' `CreateFrame("Type",
// "name", plate)` extracting the parent, the engine's own internal
// pushers — funnels through `rawgeti(REGISTRY, plate+0x08)` and
// gets the same Lua table. Earlier "build our own wrapper +
// `luaL_ref` it" approach kept a side cache, but pfUI's
// `CreateFrame("Button", "name", parent)` inside `NAME_PLATE_CREATED`
// ends up causing the engine to register the parent itself —
// the engine's wrapper diverges from ours, and later
// `GetNamePlateForUnit` calls return the engine's bare wrapper
// instead of our decorated one. Addon fields on the wrapper
// (`plate.nameplate = styledButton`) disappear from the API's
// perspective even though they still exist on the orphaned
// table.
//
// - **Refcount-pinning is benign here.** `ScriptRegister`
// unconditionally increments `nameplate + 0x04`, which normally
// keeps the engine from GC-ing the CObject. Nameplates are
// pool-managed and never destroyed during a session, so pinning
// doesn't actually leak anything. We call `ScriptRegister` at
// most once per nameplate pointer (gated by `refKey <= 0`), so
// the refcount tops out at one increment per pool slot.
//
// Defensive fallback: if the registry slot exists but isn't a table
// any more (some other code freed it), we re-register. This shouldn't
// happen in practice — engines only unref on frame destruction —
// but a stale type check keeps callers from ever seeing a non-table.
void PushNamePlateFrame(void *L, void *nameplate) {
auto rawgeti = reinterpret_cast<LuaRawGetI_t>(
Offsets::FUN_FRAMESCRIPT_PUSH_OBJECT);
auto scriptRegister = reinterpret_cast<ScriptRegister_t>(
static_cast<uintptr_t>(Offsets::FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER));
int refKey = *reinterpret_cast<const int *>(
static_cast<uint8_t *>(nameplate) + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
if (refKey <= 0) {
scriptRegister(nameplate, nullptr, nullptr);
refKey = *reinterpret_cast<const int *>(
static_cast<uint8_t *>(nameplate) + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
}
rawgeti(L, Game::Lua::REGISTRY_INDEX, refKey);
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE)
return;
Game::Lua::SetTop(L, -2);
// Slot got freed somewhere — re-register to rebuild a fresh
// wrapper and slot. Zero the refcount so `ScriptRegister`
// re-enters its build branch.
*reinterpret_cast<int *>(
static_cast<uint8_t *>(nameplate) + Offsets::OFF_COBJECT_LUA_REFCOUNT) = 0;
scriptRegister(nameplate, nullptr, nullptr);
refKey = *reinterpret_cast<const int *>(
static_cast<uint8_t *>(nameplate) + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
rawgeti(L, Game::Lua::REGISTRY_INDEX, refKey);
}
// GUID → nameplate Frame pushed on stack. Pushes nil if the GUID
// doesn't resolve to a visible CGUnit, or the unit has no allocated
// nameplate.
static void PushNamePlateForGUID(void *L, uint64_t guid) {
if (guid == 0) {
Game::Lua::PushNil(L);
return;
}
auto resolve = reinterpret_cast<ResolveByGUID_t>(
static_cast<uintptr_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID));
auto *unit = static_cast<uint8_t *>(
resolve(Offsets::OBJ_TYPE_UNIT, "NamePlate",
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32),
0x172));
if (unit == nullptr) {
Game::Lua::PushNil(L);
return;
}
auto *nameplate = *reinterpret_cast<uint8_t *const *>(
unit + kOffUnitNamePlate);
if (nameplate == nullptr) {
Game::Lua::PushNil(L);
return;
}
PushNamePlateFrame(L, nameplate);
}
// `C_NamePlate.GetNamePlateForUnit(unitToken)` — returns the
// nameplate Frame for the given unit, or `nil` if the unit has no
// nameplate (out of range, hidden, etc.). Resolves the token to a
// GUID via the engine's `FUN_TOKEN_TO_GUID` so distant party/raid
// members can be queried.
static int __fastcall Script_GetNamePlateForUnit(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::PushNil(L);
return 1;
}
const char *token = Game::Lua::ToString(L, 1);
if (token == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
auto tokenToGuid = reinterpret_cast<TokenToGUID_t>(
static_cast<uintptr_t>(Offsets::FUN_TOKEN_TO_GUID));
PushNamePlateForGUID(L, tokenToGuid(token));
return 1;
}
// `C_NamePlate.GetNamePlateForGUID(guidString)` — same as
// `GetNamePlateForUnit` but takes a `"0xHHHHHHHHHHHHHHHH"` GUID
// string. Useful for handling the `NAME_PLATE_UNIT_ADDED` /
// `_REMOVED` events whose payload is a GUID, not a unit token.
static int __fastcall Script_GetNamePlateForGUID(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::PushNil(L);
return 1;
}
const char *guidStr = Game::Lua::ToString(L, 1);
uint64_t guid = 0;
if (guidStr == nullptr || !Guid::Parse(guidStr, &guid)) {
Game::Lua::PushNil(L);
return 1;
}
PushNamePlateForGUID(L, guid);
return 1;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_NamePlate", "GetNamePlates",
&Script_GetNamePlates);
Game::Lua::RegisterTableFunction("C_NamePlate", "GetNamePlateGUIDs",
&Script_GetNamePlateGUIDs);
Game::Lua::RegisterTableFunction("C_NamePlate", "GetNamePlateForUnit",
&Script_GetNamePlateForUnit);
Game::Lua::RegisterTableFunction("C_NamePlate", "GetNamePlateForGUID",
&Script_GetNamePlateForGUID);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace NamePlate::Info
+121
View File
@@ -0,0 +1,121 @@
// 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 <cstdint>
// Shared nameplate enumeration — walks the local-player-anchored
// object hash table for visible `TYPEMASK_UNIT` entries and invokes
// `emit(unit, nameplate, instance)` for each unit that currently has
// an allocated nameplate (`unit + 0xE60` non-null).
//
// Header-only template — used by both the Lua-facing accessors in
// `nameplate/Info.cpp` and the per-tick differ in `nameplate/Events.cpp`.
namespace NamePlate::Info {
// Pushes the nameplate Frame at `nameplate` onto the Lua stack.
// Returns the engine's canonical wrapper table — the same one
// `rawgeti(REGISTRY, nameplate + 0x08)` resolves to anywhere else
// in the engine — so addon-set fields (pfUI: `plate.nameplate`)
// survive every roundtrip through our API. Defined in `Info.cpp`.
void PushNamePlateFrame(void *L, void *nameplate);
} // namespace NamePlate::Info
namespace NamePlate::Events {
// Returns the GUID currently bound to `nameplateN` (1-based, matching
// the modern Lua token form). `0` means no nameplate at that index.
// Backed by the per-tick UNIT_ADDED / UNIT_REMOVED diff in
// `Events.cpp` — order is creation-order (append on ADDED, erase on
// REMOVED), stable within a frame.
uint64_t GetGUIDByIndex(int oneBased);
// Clears the per-tick diff state (seen pointers, last tick's
// snapshot, ordered GUID list) so the post-`/reload` tick refires
// CREATED + UNIT_ADDED for every currently-visible plate. Called
// from `FrameScript_Initialize_h` alongside the wrapper-cache reset
// in `NamePlate::Info::PrepareForReload`.
void PrepareForReload();
} // namespace NamePlate::Events
namespace NamePlate::Walk {
// `0x00B41414` is a player-related global that overlays multiple
// sub-objects at different offsets. We read the object-hash-table
// fields at `+0x1C` (bucket array) and `+0x24` (bucket mask) — that
// sub-struct is safe to walk for any unit the engine knows about.
// **Do not feed this pointer to inventory routines** (`GetItemBySlot`
// etc.); the CGPlayer_C inventory manager lives at a different
// offset (`+0x1D38`) and is sourced via `ResolveUnitToken("player")`,
// not via this global. See CLAUDE.md "Resolving an item-location"
// for the inventory-crash story.
constexpr uintptr_t kLocalPlayerGlobal = 0x00B41414;
constexpr int kOffPlayerBucketArray = 0x1C;
constexpr int kOffPlayerBucketMask = 0x24;
constexpr int kBucketStride = 12;
constexpr int kBucketLinkOffsetField = 0;
constexpr int kBucketChainHeadField = 8;
constexpr int kOffEntryInstanceBlock = 0x08;
constexpr int kOffInstanceTypeMask = 0x08;
constexpr uint32_t kTypeMaskUnit = 0x08;
constexpr int kOffUnitNamePlate = 0xE60;
template <typename F>
int ForEachNamePlatedUnit(F &&emit) {
auto *player = *reinterpret_cast<uint8_t *const *>(kLocalPlayerGlobal);
if (player == nullptr)
return 0;
auto *buckets = *reinterpret_cast<uint8_t *const *>(
player + kOffPlayerBucketArray);
const uint32_t mask = *reinterpret_cast<const uint32_t *>(
player + kOffPlayerBucketMask);
if (buckets == nullptr || mask == 0xFFFFFFFFu)
return 0;
int count = 0;
for (uint32_t b = 0; b <= mask; ++b) {
const uint8_t *bucket = buckets + b * kBucketStride;
const uint32_t linkOffset = *reinterpret_cast<const uint32_t *>(
bucket + kBucketLinkOffsetField);
uintptr_t entry = *reinterpret_cast<const uintptr_t *>(
bucket + kBucketChainHeadField);
while (entry != 0 && (entry & 1) == 0) {
auto *obj = reinterpret_cast<const uint8_t *>(entry);
auto *instance = *reinterpret_cast<const uint8_t *const *>(
obj + kOffEntryInstanceBlock);
if (instance != nullptr) {
const uint32_t typeMask = *reinterpret_cast<const uint32_t *>(
instance + kOffInstanceTypeMask);
if ((typeMask & kTypeMaskUnit) != 0) {
const auto *nameplate = *reinterpret_cast<const uint8_t *const *>(
obj + kOffUnitNamePlate);
if (nameplate != nullptr) {
emit(obj, nameplate, instance);
++count;
}
}
}
entry = *reinterpret_cast<const uintptr_t *>(
obj + linkOffset + 4);
}
}
return count;
}
} // namespace NamePlate::Walk
+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
+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
+127
View File
@@ -0,0 +1,127 @@
// 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/>.
// `FocusUnit(unit)` / `ClearFocus()` / `focus` unit token. Polyfills
// the post-vanilla focus-target system on top of the engine's
// existing token resolver via the hook in
// `unit/TokenExtensions.cpp`.
//
// Storage: a single 64-bit GUID held in-process. The 3.3.5 engine
// keeps the same shape (two globals at `DAT_00BD07D0`/`D4`); we
// don't share those because vanilla never reads them. The resolver
// hook reads our `Unit::Focus::Get()` directly.
//
// Setter mirrors 3.3.5's `FUN_0051FF20` minus the post-vanilla
// `UnitFlag bit 0x2000` ("focus glow" rendering hint, introduced in
// TBC for the default focus frame). Vanilla addons (pfUI) render
// their own focus indicator and don't need the hint.
//
// Auto-clear on despawn: modern WoW fires `PLAYER_FOCUS_CHANGED`
// when the focused unit leaves the client's object table (out of
// rendering range, despawn) and does NOT auto-refocus when they
// come back. We mirror that by probing `ObjectByGUID(g_focusGUID)`
// every world tick — when it returns null, we `Set(0)` which fires
// the event. Cost is one hash-table lookup per tick while focus is
// set; zero when no focus is active.
#include "Focus.h"
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "tick/WorldTick.h"
#include <cstdint>
namespace Unit::Focus {
namespace {
constexpr const char *kEventName = "PLAYER_FOCUS_CHANGED";
const Event::Custom::AutoReserve _reserve{kEventName};
uint64_t g_focusGUID = 0;
using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
using ResolveByGUID_t = void *(__fastcall *)(int type, const char *debugName,
uint32_t guidLo, uint32_t guidHi,
int priority);
uint64_t ResolveTokenGUID(const char *token) {
if (token == nullptr || *token == '\0')
return 0;
auto fn = reinterpret_cast<TokenToGUID_t>(
static_cast<uintptr_t>(Offsets::FUN_TOKEN_TO_GUID));
return fn(token);
}
// Per-tick despawn watcher. Probes the engine's object table for
// `g_focusGUID`; when it disappears (out of range, fully despawned),
// clear focus and fire PLAYER_FOCUS_CHANGED — matches modern's
// "leaves render distance → focus drops" behavior. Won't refocus
// when the unit comes back, also matching modern.
void OnWorldTick() {
if (g_focusGUID == 0)
return;
auto resolve = reinterpret_cast<ResolveByGUID_t>(
static_cast<uintptr_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID));
if (resolve(Offsets::OBJ_TYPE_UNIT, "Focus",
static_cast<uint32_t>(g_focusGUID),
static_cast<uint32_t>(g_focusGUID >> 32),
0x172) == nullptr)
Set(0);
}
// `FocusUnit(unit)` — sets focus to whatever GUID `unit` currently
// resolves to. Modern signature: `FocusUnit(unit)` with the token
// argument. Modern also allows `FocusUnit()` (no arg) to mean
// `"target"`, matching the `/focus` slash command's default.
int __fastcall Script_FocusUnit(void *L) {
if (Game::Lua::IsString(L, 1)) {
const char *token = Game::Lua::ToString(L, 1);
Set(ResolveTokenGUID(token));
} else {
Set(ResolveTokenGUID("target"));
}
return 0;
}
// `ClearFocus()` — drops the focus, fires PLAYER_FOCUS_CHANGED.
int __fastcall Script_ClearFocus(void *L) {
(void)L;
Set(0);
return 0;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("FocusUnit", &Script_FocusUnit);
Game::Lua::RegisterGlobalFunction("ClearFocus", &Script_ClearFocus);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
} // namespace
uint64_t Get() { return g_focusGUID; }
void Set(uint64_t guid) {
if (guid == g_focusGUID)
return; // no-op: same target → no event fire (matches 3.3.5)
g_focusGUID = guid;
const int slot = Event::Custom::Lookup(kEventName);
if (slot >= 0)
Event::Custom::Fire(slot, "");
}
} // namespace Unit::Focus
+31
View File
@@ -0,0 +1,31 @@
// 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 <cstdint>
namespace Unit::Focus {
// Current focus GUID, or `0` when nothing is focused. Read by the
// `focus` / `focustarget*` branch of the token resolver in
// `unit/TokenExtensions.cpp`.
uint64_t Get();
// Sets focus to `guid` and fires `PLAYER_FOCUS_CHANGED` (no args).
// Identity-checks first — assigning the same GUID is a no-op,
// matching 3.3.5's `FUN_0051FF20` behavior so the event only fires
// on a real transition. Pass `0` to clear focus.
void Set(uint64_t guid);
} // namespace Unit::Focus
+27 -5
View File
@@ -14,6 +14,7 @@
#include "Game.h"
#include "Offsets.h"
#include "guid/Guid.h"
#include "nameplate/Walk.h"
#include <cstdint>
#include <cstdio>
@@ -105,13 +106,17 @@ static int __fastcall Script_UnitGUID(void *L) {
// nil if none of the known tokens point at it.
//
// The search walks the modern retail token order with post-vanilla
// tokens dropped (no `vehicle`, `nameplateN`, `arenaN`, `arenapetN`,
// `bossN`, `focus`, `softenemy`, `softfriend`, `softinteract` —
// those all post-date 1.12 and `FUN_TOKEN_TO_GUID` would raise
// "Unknown unit name" for them). Order:
// tokens dropped (no `vehicle`, `arenaN`, `arenapetN`, `bossN`,
// `softenemy`, `softfriend`, `softinteract` — those all post-date
// 1.12 and `FUN_TOKEN_TO_GUID` would raise "Unknown unit name" for
// them). `nameplateN` and `focus` are included — we hook the token
// resolver so the engine recognizes them (see
// `unit/TokenExtensions.cpp` and `unit/Focus.cpp`). Order matches
// retail with the dropped tokens skipped in place:
//
// player → pet → party1..4 → partypet1..4 → raid1..40
// → raidpet1..40 → target → npc → mouseover
// → raidpet1..40 → nameplate1..N → target → focus
// → npc → mouseover
//
// The result is inherently unstable — multiple tokens can map to the
// same GUID at once (your target IS your mouseover IS your party1), and
@@ -183,7 +188,24 @@ static int __fastcall Script_UnitTokenFromGUID(void *L) {
if (check(buf)) return 1;
}
// Visible nameplates. The ordered list (see
// `NamePlate::Events::g_orderedGUIDs`) is dense — `GetGUIDByIndex`
// returns 0 once we step past the populated count, terminating
// the loop. Skips the token-resolver hook + SStrCmpI cost per
// index by comparing GUIDs directly.
for (int i = 1; ; ++i) {
const uint64_t plateGuid = NamePlate::Events::GetGUIDByIndex(i);
if (plateGuid == 0)
break;
if (plateGuid == target) {
std::snprintf(buf, sizeof buf, "nameplate%d", i);
Game::Lua::PushString(L, buf);
return 1;
}
}
if (check("target")) return 1;
if (check("focus")) return 1;
if (check("npc")) return 1;
if (check("mouseover")) return 1;
+153
View File
@@ -0,0 +1,153 @@
// 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/>.
// Extend `FUN_TOKEN_TO_GUID` (`0x00515970`) with post-vanilla unit
// tokens. Hooking the central token→GUID resolver means every
// `Script_Unit*` (UnitName, UnitGUID, UnitHealth, UnitClass,
// UnitExists, …) gains support for the new token families for free,
// since they all funnel here.
//
// Currently extended families:
//
// - `nameplate1`..`nameplateN` — index into the ordered list of
// visible nameplates maintained in `nameplate/Events.cpp`. See
// `Unit tokens (nameplateN)` in docs/API.md.
//
// - `focus` — single-slot sticky target backed by `Unit::Focus`.
// See `unit/Focus.cpp` for the storage + `PLAYER_FOCUS_CHANGED`
// event firing.
//
// Both families compose with the engine's own `target`-suffix walker
// (`focustarget`, `nameplate1targettarget`) via the shared
// `WalkSuffix` helper, which mirrors the engine's
// `LAB_005159d3..0x00515A2C` walker instruction-for-instruction
// (SStrCmpI "target" + ObjectByGUID + read UNIT_FIELD_TARGET).
//
// Resolution order in the hook: focus check first (literal-equal,
// short token), then nameplate prefix (longer, requires digit).
// Both branches short-circuit; on no-match the original engine
// resolver runs unchanged.
#include "Game.h"
#include "Offsets.h"
#include "nameplate/Walk.h"
#include "unit/Focus.h"
#include <cstdint>
namespace Unit::TokenExtensions {
namespace {
using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
// `__stdcall`, not `__cdecl` — `FUN_00064A4C0` ends with `ret 0xc`,
// callee cleans 3-arg stack frame. Calling it through a cdecl typedef
// makes MSVC emit `add esp, 12` after the call on top of the callee's
// own cleanup, drifting ESP +12 per call and corrupting the caller's
// frame.
using SStrCmpI_t = int(__stdcall *)(const char *a, const char *b, int n);
using ResolveByGUID_t = void *(__fastcall *)(int type, const char *debugName,
uint32_t guidLo, uint32_t guidHi,
int priority);
constexpr const char kNamePlatePrefix[] = "nameplate";
constexpr int kNamePlatePrefixLen = static_cast<int>(sizeof(kNamePlatePrefix) - 1);
constexpr const char kFocusPrefix[] = "focus";
constexpr int kFocusPrefixLen = static_cast<int>(sizeof(kFocusPrefix) - 1);
constexpr const char kSuffixTarget[] = "target";
constexpr int kSuffixTargetLen = static_cast<int>(sizeof(kSuffixTarget) - 1);
// Engine's own priority value for the suffix walker's ObjectByGUID
// calls (verified at `0x005159FE: PUSH 0x6E`). Different from the
// `0x172` we use elsewhere — matching the engine here so the
// resolver's lookup path is identical to the native `targettarget`
// walker.
constexpr int kResolvePriority = 0x6e;
TokenToGUID_t Original_o = nullptr;
uint64_t WalkSuffix(uint64_t guid, const char *suffix) {
auto resolve = reinterpret_cast<ResolveByGUID_t>(
Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
auto sstrcmpi = reinterpret_cast<SStrCmpI_t>(Offsets::FUN_SSTR_CMP_I);
while (guid != 0 && *suffix != '\0') {
if (sstrcmpi(suffix, kSuffixTarget, kSuffixTargetLen) != 0)
return 0; // unknown suffix component — modern returns nil
suffix += kSuffixTargetLen;
auto *obj = static_cast<uint8_t *>(
resolve(Offsets::OBJ_TYPE_UNIT, "ClassicAPI",
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32),
kResolvePriority));
if (obj == nullptr)
return 0;
// `m_objectFields` is a *pointer* stored at `obj + 0x110`,
// not an inline array. Engine's walker does
// `mov eax, [obj+0x110]; mov edi, [eax+0x28]` — two
// dereferences. Earlier version of this code missed the
// first one and read instruction bytes interpreted as GUIDs.
auto *fields = *reinterpret_cast<const uint8_t *const *>(
obj + Offsets::OFF_CGUNIT_OBJECT_FIELDS);
if (fields == nullptr)
return 0;
guid = *reinterpret_cast<const uint64_t *>(
fields + Offsets::OFF_UNIT_FIELD_TARGET);
}
return guid;
}
uint64_t __fastcall Hook_h(const char *token) {
if (token == nullptr || *token == '\0')
return Original_o(token);
auto sstrcmpi = reinterpret_cast<SStrCmpI_t>(Offsets::FUN_SSTR_CMP_I);
// `focus` / `focustarget*`. The 5-char prefix is unique among
// unit tokens (no `focuscast` / `focused*` etc. live in vanilla
// or modern resolvers), so any match continues as our token.
if (sstrcmpi(token, kFocusPrefix, kFocusPrefixLen) == 0) {
const uint64_t guid = Unit::Focus::Get();
if (guid == 0)
return 0;
return WalkSuffix(guid, token + kFocusPrefixLen);
}
// `nameplateN` / `nameplate1target*`. Requires a digit after the
// prefix; anything else (`nameplate`, `nameplateX`) falls through
// to the engine for its standard "Unknown unit name" error.
if (sstrcmpi(token, kNamePlatePrefix, kNamePlatePrefixLen) == 0) {
const char *p = token + kNamePlatePrefixLen;
if (*p >= '0' && *p <= '9') {
int index = 0;
while (*p >= '0' && *p <= '9') {
index = index * 10 + (*p - '0');
++p;
}
const uint64_t guid = NamePlate::Events::GetGUIDByIndex(index);
if (guid == 0)
return 0;
return WalkSuffix(guid, p);
}
}
return Original_o(token);
}
} // namespace
static const Game::HookAutoRegister _hook{
Offsets::FUN_TOKEN_TO_GUID,
reinterpret_cast<void *>(&Hook_h),
reinterpret_cast<void **>(&Original_o)};
} // namespace Unit::TokenExtensions