Compare commits

..
22 Commits
Author SHA1 Message Date
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
36 changed files with 4407 additions and 86 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
+3 -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: 1.3
## Author: Brues
GlobalStrings.lua
@@ -27,3 +27,5 @@ Util\TimedCallback.lua
Util\EquipmentManager.lua
Util\PlayerUtil.lua
Util\SlashCommandsRegistry.lua
Util\LinkUtil.lua
Util\IconDataProvider.lua
+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;
@@ -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)
+32 -6
View File
@@ -23,22 +23,25 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [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` |
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [Input](docs/API.md#input) | `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `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` |
| [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` |
@@ -64,7 +67,8 @@ 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) |
@@ -94,6 +98,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 +124,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 +164,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)")
+831 -20
View File
@@ -6,6 +6,9 @@ build instructions.
## Contents
- [Account](#account)
- [`SaveAccount(name, password)` / `DeleteAccount(name)` / `GetSavedAccounts()` / `LoginWithSavedAccount(name)` — GlueXML only](#saveaccountname-password--deleteaccountname--getsavedaccounts--loginwithsavedaccountname--gluexml-only)
- [Action](#action)
- [`GetActionInfo(slot)`](#getactioninfoslot)
@@ -34,7 +37,9 @@ build instructions.
- [`GetItemCooldown(itemInfo)` / `C_Container.GetItemCooldown(itemID)`](#getitemcooldowniteminfo--c_containergetitemcooldownitemid)
- [`C_Container.GetContainerItemDurability(containerIndex, slotIndex)`](#c_containergetcontaineritemdurabilitycontainerindex-slotindex)
- [`C_Container.GetContainerItemRepairCost(containerIndex, slotIndex)`](#c_containergetcontaineritemrepaircostcontainerindex-slotindex)
- [`C_Container.GetContainerItemCharges(containerIndex, slotIndex)`](#c_containergetcontaineritemchargescontainerindex-slotindex)
- [`C_Container.GetContainerNumFreeSlots(bagID)`](#c_containergetcontainernumfreeslotsbagid)
- [`C_Container.IsContainerItemOpenable(containerIndex, slotIndex)`](#c_containeriscontaineritemopenablecontainerindex-slotindex)
- [`C_Container.PlayerHasHearthstone()`](#c_containerplayerhashearthstone)
- [`C_Container.UseHearthstone()`](#c_containerusehearthstone)
- [`C_Container.SwapItems(srcBag, srcSlot, dstBag, dstSlot)`](#c_containerswapitemssrcbag-srcslot-dstbag-dstslot)
@@ -44,6 +49,9 @@ build instructions.
- [CVar](#cvar)
- [`C_CVar.GetCVarBool(cvar)`](#c_cvargetcvarboolcvar)
- [Cursor](#cursor)
- [`GetCursorInfo()`](#getcursorinfo)
- [EquipmentSet](#equipmentset)
- [Overview & file format](#overview--file-format)
- [`C_EquipmentSet.CanUseEquipmentSets()`](#c_equipmentsetcanuseequipmentsets)
@@ -73,6 +81,8 @@ build instructions.
- [`EQUIPMENT_SWAP_FINISHED` event](#equipment_swap_finished-event)
- [`FACTION_STANDING_CHANGED` event](#faction_standing_changed-event)
- [`MODIFIER_STATE_CHANGED` event](#modifier_state_changed-event)
- [`NAME_PLATE_CREATED` / `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` events](#name_plate_created--name_plate_unit_added--name_plate_unit_removed-events)
- [`PLAYER_FOCUS_CHANGED` event](#player_focus_changed-event)
- [`QUEST_ACCEPTED` event](#quest_accepted-event)
- [`QUEST_TURNED_IN` event](#quest_turned_in-event)
- [`UPDATE_SHAPESHIFT_FORM` event](#update_shapeshift_form-event)
@@ -92,6 +102,11 @@ build instructions.
- [`C_Reputation.SetWatchedFactionByID(factionID)`](#c_reputationsetwatchedfactionbyidfactionid)
- [`C_Reputation.GetLastStandingChange()`](#c_reputationgetlaststandingchange)
- [Focus](#focus)
- [`FocusUnit(unit)`](#focusunitunit)
- [`ClearFocus()`](#clearfocus)
- [Unit token (`focus` / `focustarget`)](#unit-token-focus--focustarget)
- [FriendList](#friendlist)
- [`C_FriendList.SendWhoQueryByName(name)`](#c_friendlistsendwhoquerybynamename)
- [`C_FriendList.IsWhoQueryPending()`](#c_friendlistiswhoquerypending)
@@ -170,6 +185,7 @@ build instructions.
- [`C_Item.GetItemUniqueness(itemLocation)` / `C_Item.GetItemUniquenessByID(item)`](#c_itemgetitemuniquenessitemlocation--c_itemgetitemuniquenessbyiditem)
- [`C_Item.GetItemLink(itemLocation)`](#c_itemgetitemlinkitemlocation)
- [`C_Item.GetItemInventoryType(itemLocation)` / `C_Item.GetItemInventoryTypeByID(item)`](#c_itemgetiteminventorytypeitemlocation--c_itemgetiteminventorytypebyiditem)
- [`C_Item.IsItemOpenable(itemLocation)`](#c_itemisitemopenableitemlocation)
- [`C_Item.IsLocked(itemLocation)`](#c_itemislockeditemlocation)
- [`C_Item.LockItem(itemLocation)`](#c_itemlockitemitemlocation)
- [`C_Item.LockItemByGUID(itemGUID)`](#c_itemlockitembyguiditemguid)
@@ -183,6 +199,8 @@ build instructions.
- [Numeric spellIDs in `/cast` and `CastSpellByName`](#numeric-spellids-in-cast-and-castspellbyname)
- [`CastSpellNoToggle` as a macro cast line](#castspellnotoggle-as-a-macro-cast-line)
- [`GetMacroSpell(macroSlot)`](#getmacrospellmacroslot)
- [`GetMacroIcons` / `GetMacroItemIcons` / `GetLooseMacroIcons` / `GetLooseMacroItemIcons`](#getmacroicons--getmacroitemicons--getloosemacroicons--getloosemacroitemicons)
- [`IconDataProviderMixin`](#icondataprovidermixin)
- [Map](#map)
- [`C_Map.GetBestMapForUnit(unitToken)`](#c_mapgetbestmapforunitunittoken)
@@ -195,6 +213,13 @@ build instructions.
- [`C_MerchantFrame.IsMerchantItemRefundable(slot)`](#c_merchantframeismerchantitemrefundableslot)
- [`C_MerchantFrame.IsSellAllJunkEnabled()`](#c_merchantframeissellalljunkenabled)
- [NamePlate](#nameplate)
- [`C_NamePlate.GetNamePlates()`](#c_nameplategetnameplates)
- [`C_NamePlate.GetNamePlateGUIDs()`](#c_nameplategetnameplateguids)
- [`C_NamePlate.GetNamePlateForUnit(unitToken)`](#c_nameplategetnameplateforunitunittoken)
- [`C_NamePlate.GetNamePlateForGUID(guidString)`](#c_nameplategetnameplateforguidguidstring)
- [Unit tokens (`nameplateN`)](#unit-tokens-nameplaten)
- [NameCache](#namecache)
- [`GetPlayerInfoByGUID(guid)`](#getplayerinfobyguidguid)
- [`C_PlayerCache.GetPlayerInfoByName(name)`](#c_playercachegetplayerinfobynamename)
@@ -314,6 +339,120 @@ build instructions.
- [`C_UnitAuras.GetUnitAuras(unit [, filter])`](#c_unitaurasgetunitaurasunit--filter)
- [`C_UnitAuras.GetAuraDispelTypeColor(dispelName)`](#c_unitaurasgetauradispeltypecolordispelname)
## Account
### `SaveAccount(name, password)` / `DeleteAccount(name)` / `GetSavedAccounts()` / `LoginWithSavedAccount(name)` — GlueXML only
Persists account credentials in Windows Credential Manager (per-user,
DPAPI-encrypted) and dispatches login from C so the plaintext password
never crosses the C↔Lua boundary on the way out. Designed for GlueXML
rewrites of `AccountLogin.lua` that want a selection list of remembered
accounts without persisting plaintext passwords to SavedVariables.
> **Only callable from the login screen** (the "glue" Lua state).
> Calling them from an in-world addon errors with "attempt to call nil"
> — they aren't registered there.
- `SaveAccount(name, password)` — encrypt and save under the current
realmlist. Returns `true` on success, `false` if name or password is
empty, no realmlist is set, or the OS rejects the write.
- `DeleteAccount(name)` — remove the saved entry. Returns `true` if a
matching entry existed and was deleted.
- `GetSavedAccounts()` — returns a numeric-keyed table of entries for
the current realmlist. Other realmlists' entries are invisible. Empty
table if no realmlist is set. Each entry is a table:
| Field | Type | Notes |
|---|---|---|
| `name` | string | Account name as saved. |
| `lastUsed` | number | Unix epoch seconds of the last write to the vault entry. Refreshed automatically by `LoginWithSavedAccount`, so this functions as a "last used" timestamp. `0` if Windows didn't supply a timestamp (extremely rare, e.g. credentials manually injected). |
```lua
for i, entry in ipairs(GetSavedAccounts()) do
print(entry.name, date("%Y-%m-%d %H:%M", entry.lastUsed))
end
```
- `LoginWithSavedAccount(name)` — decrypt internally and feed the
credentials to the engine's login function. Returns `true` if
credentials were found and dispatched, `false` if no such entry
exists. **Plaintext is never returned to Lua.** Equivalent to what
`DefaultServerLogin(name, password)` does, but with the password
fetched from the vault rather than passed in. Also re-saves the
credential (same value) to refresh the `lastUsed` timestamp.
#### Scoping per realmlist
Every operation is scoped by the current `realmList` CVar (the 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
```
Switching realmlists changes the visible account list automatically;
no separate "select realm" step. If you need a refresh while still on
the login screen, call `Autologin_Load()` (or `GetSavedAccounts()`
directly) after the realmlist changes.
#### Storage
Entries live in the OS Credential Manager under the
`ClassicAPI/WoW/<realmlist>/` namespace. Users can inspect and wipe
them via Windows' `control /name Microsoft.CredentialManager` →
Generic Credentials, the same way they'd manage any other Windows-
stored credential.
#### Security caveats
Realistic about what this defends against:
- **Defeats**: casual file inspection of WTF, sharing the WTF folder,
cloud-syncing it, another Windows user on the same machine, malware
running as a different Windows user.
- **Does not defeat**: any process running as the current Windows user
can decrypt these via DPAPI — it's per-user, not per-process. No
"decrypt and return plaintext" path is exposed to Lua, so a hostile
addon can trigger a login with a saved account but can't extract the
password. Plaintext is also unavoidably in process memory while the
engine sends SRP.
#### Addon example
```lua
-- GlueXML, e.g. AccountLogin.lua: save credentials on first manual
-- login, one-click login on subsequent shows.
local function OnLoginClicked()
local name = AccountLoginAccountEdit:GetText()
local password = AccountLoginPasswordEdit:GetText()
if not name or name == "" then return end
if password and password ~= "" then
-- Fresh login or password change: persist + log in.
SaveAccount(name, password)
LoginWithSavedAccount(name)
else
-- Reuse stored credentials.
LoginWithSavedAccount(name)
end
end
-- Populate a selection list from the vault, sorted by recency.
local accounts = GetSavedAccounts()
table.sort(accounts, function(a, b) return a.lastUsed > b.lastUsed end)
for i = 1, table.getn(accounts) do
local e = accounts[i]
print(i, e.name, "last used:", date("%m/%d/%Y", e.lastUsed))
end
-- Forget an account.
DeleteAccount("OLDACCT")
```
## Action
### `GetActionInfo(slot)`
@@ -633,10 +772,6 @@ if UnitAffectingCombat("player") then
end
```
Equivalent to `UnitAffectingCombat("player")` but faster — reads the
`UNIT_FLAG_IN_COMBAT` bit directly off the player CGUnit's
m_objectFields, no token-resolution roundtrip.
## Container
### `C_Container.GetContainerItemID(bagIndex, slotIndex)`
@@ -756,6 +891,62 @@ Useful for "repair only items above N copper" smart-repair logic
without scanning tooltips. ClassicAPI addition; modern WoW has no
direct equivalent.
### `C_Container.GetContainerItemCharges(containerIndex, slotIndex)`
Per-slot equivalent of
[`C_Item.GetItemCount`](#c_itemgetitemcountitem-includebank-includecharges)'s
`includeCharges=true` mode — returns the total uses available in
*this single slot*, where `GetItemCount` totals the same value
across every matching slot.
```
uses = C_Container.GetContainerItemCharges(containerIndex, slotIndex)
```
`containerIndex` accepts the same values as
[`C_Container.GetContainerItemDurability`](#c_containergetcontaineritemdurabilitycontainerindex-slotindex)
(`0` = backpack, `1..4` = equipped bags, `-1`/`5..11` = bank, etc.).
`slotIndex` is 1-based.
Math is `stack * usesPerItem`, where `usesPerItem` is
`abs(SPELL_CHARGES[0])` for items with a negative charges field
(consume-on-use: wands, healthstones, mana gems, sapper charges)
and `1` for everything else. Worked examples:
| Slot contents | Stack | Raw charges | Returns |
|---|---:|---:|---:|
| Wand of Decay at 50 charges | 1 | -50 | **50** |
| Wand at 1 charge | 1 | -1 | **1** |
| Healthstone | 1 | -1 | **1** |
| Stack of 20 water | 20 | -1 | **20** |
| Stack of 5 mana potions | 5 | -1 | **5** |
| Hearthstone (cooldown-only) | 1 | 0 | **1** |
| Any other item in the slot | 1 | 0 | **1** |
Returns `nil` only when the slot is empty.
```lua
-- "how many charges does my wand have left?"
local charges = C_Container.GetContainerItemCharges(0, 1)
print("Wand charges remaining: " .. charges)
-- "how many drinks across the bags?" — same as GetItemCount(water, false, true)
local total = 0
for bag = 0, 4 do
for slot = 1, GetContainerNumSlots(bag) do
if C_Container.GetContainerItemID(bag, slot) == waterID then
total = total + C_Container.GetContainerItemCharges(bag, slot)
end
end
end
```
ClassicAPI addition; modern WoW has no direct equivalent (modern
addons read charges off tooltip text). Useful when you need a
per-slot number rather than `GetItemCount`'s rollup — e.g. to
display "X charges" on the slot UI for a wand without re-walking
every other matching item.
### `C_Container.GetContainerNumFreeSlots(bagID)`
Returns the number of empty slots in the given bag, plus the bag's
@@ -809,6 +1000,27 @@ internally and counts slots that resolve to a null `CGItem *` (i.e.,
empty). Cross-checked in-game against a manual
`C_Container.GetContainerItemID` walk; counts match.
### `C_Container.IsContainerItemOpenable(containerIndex, slotIndex)`
Positional-arg wrapper for [`C_Item.IsItemOpenable`](#c_itemisitemopenableitemlocation--c_itemisitemopenablebyiditem)
against a bag/slot pair. Same `(isOpenable, canOpen)` tuple — see
the linked section for full semantics. Both returns are `nil` for
empty slots or items whose data hasn't been cached yet.
```lua
-- Sweep main backpack for openables
for slot = 1, GetContainerNumSlots(0) do
if C_Container.IsContainerItemOpenable(0, slot) then
-- ...
end
end
```
Same cache-warming caveat as the underlying `C_Item.IsItemOpenable` —
a freshly-seen item may report `false` for one call while the
`SMSG_ITEM_QUERY_SINGLE` round-trips. Bag-resident items are almost
always already cached.
### `C_Container.PlayerHasHearthstone()`
Returns the itemID of the hearthstone if one is in the player's bags,
@@ -896,18 +1108,29 @@ C_Container.SwapItems(1, 3, 0, 7)
C_Container.SwapItems(0, 1, 0, 2)
```
Bag IDs match the rest of `C_Container.*`: `0` is the backpack, `1..4`
are the equipped bags. Slots are 1-based. The engine accepts both
directions:
Bag IDs match the rest of `C_Container.*`:
- Same container (e.g. backpack ↔ backpack, bag1 ↔ bag1) sends opcode
`0x10D` `CMSG_SWAP_INV_ITEM` with the two linear slot bytes.
- Cross-container (e.g. backpack ↔ bag1, bag2 ↔ bag3) sends opcode
`0x10C` `CMSG_SWAP_ITEM` with `(srcBag, srcSlot, dstBag, dstSlot)`.
- `0` — backpack
- `1..4` — equipped bags
- `-1` — main bank (24 slots; **requires the bank window to be open**)
- `5..10` — equipped bank bags (**requires the bank window to be open**)
Slots are 1-based. The engine accepts both directions:
- Same container (e.g. backpack ↔ backpack, bag1 ↔ bag1, bank ↔ bank)
sends opcode `0x10D` `CMSG_SWAP_INV_ITEM` with the two linear slot bytes.
- Cross-container (e.g. backpack ↔ bag1, bag2 ↔ bank, bank ↔ bank bag)
sends opcode `0x10C` `CMSG_SWAP_ITEM` with `(srcBag, srcSlot, dstBag, dstSlot)`.
Destination empty becomes a move; destination occupied becomes an
atomic swap.
> **Bank slots require the bank window to be open.** The engine
> doesn't sync bank-side `CGContainer`s or bank inventory entries
> before the first bank interaction, so any bank `EncodeBagSlot`
> path returns false until then. Same constraint as
> `C_Container.GetContainerItemInfo` on bank IDs.
> **ClassicAPI-only.** Modern Classic Era has no direct swap-two-slots
> call — addons there drive the cursor with two `PickupContainerItem`
> calls in sequence. This single-call form bypasses the cursor
@@ -949,6 +1172,9 @@ consolidate partial stacks — `SwapItems` swaps the whole slots, which
isn't what you want when both slots hold the same item but one has a
partial stack.
Bank IDs (`-1`, `5..10`) work here too with the same bank-window
constraint as `SwapItems`.
> **vs. the modern equivalent.** Modern Classic Era has no direct
> one-call move — addons there string `SplitContainerItem(bag, slot,
> count)` + `PickupContainerItem(dstBag, dstSlot)` together to drive
@@ -1035,6 +1261,83 @@ immediately visible on the other (a `SetCVar("foo", "1")` from a
GlueXML script will read back as `"1"` on the in-world side, and
vice versa).
## Cursor
### `GetCursorInfo()`
Returns a tuple describing whatever the player has picked up on the
cursor (item, money, spell, macro, merchant slot), or nothing when
the cursor is empty. Reads the same engine globals `CursorHasItem` /
`CursorHasSpell` / `GetCursorMoney` consult, so the answer always
matches what the rest of the engine sees.
Return shape per cursor state:
| Cursor holds | Returns |
|---|---|
| nothing | *nil* |
| a bag item (PickupContainerItem, mail/trade detach with GUID) | `"item"`, `itemID`, `itemLink` |
| a drag-source item (equipment slot, action bar, trade, mail) | `"item"`, `itemID` |
| money (gold-split UI, `PickupPlayerMoney`) | `"money"`, `copperAmount` |
| a spell from the spellbook | `"spell"`, `spellbookSlot`, `bookType`, `spellID` |
| a macro | `"macro"`, `macroIndex` |
| a merchant slot | `"merchant"`, `merchantIndex` |
`bookType` is `"spell"` for player spells, `"pet"` for pet spells.
`spellbookSlot` is the 1-based slot the spell lives at in the
spellbook arrays, found by walking the player/pet books. `0` if the
spellID isn't in either book (rare — passive talent-granted spells).
```lua
local kind, a, b, c = GetCursorInfo()
if kind == "item" then
print(("cursor item: %d"):format(a))
elseif kind == "money" then
print(("cursor money: %d copper"):format(a))
elseif kind == "spell" then
print(("cursor spell: id=%d slot=%d (%s book)"):format(c, a, b))
elseif kind == "macro" then
print(("cursor macro index: %d"):format(a))
end
```
#### `itemLink` availability
Returned only when the cursor holds a **bag item** (type 1) —
`PickupContainerItem` / `PickupInventoryItem` from a bag slot, mail
or trade detach. The cursor stores the item's GUID in this case, so
we resolve back to the live `CGItem *` and feed it to the engine's
link builder, which produces the full per-instance form including
enchant ID and random-suffix decoration:
```
|cff1eff00|Hitem:16791:255:0:0|h[Silkstream Cuffs]|h|r
|cff1eff00|Hitem:9877:0:767:0|h[Sorcerer Cloak of the Owl]|h|r
```
Not returned for drag-source items (equipment slot, action bar
slot, trade slot, mail attachment — types 5/6/7/9). In those cases
the cursor only stores the itemID (no GUID, no live `CGItem *`), so
the engine's link builder isn't reachable. Derive a basic link from
the itemID via `GetItemInfo` if you need one for display:
```lua
local kind, itemID, link = GetCursorInfo()
if kind == "item" and not link then
link = (select(2, GetItemInfo(itemID))) or ("item:" .. itemID)
end
```
#### Types we don't surface
The engine has cursor types beyond what `GetCursorInfo` advertises in
modern WoW — pet action drag, trade-slot drag, mail-attach drag,
ability-bar drag from `PickupAction`, etc. These don't map cleanly to
any modern `GetCursorInfo` string, so we return `nil` for them.
Consumers needing to detect those can use the engine-native
`CursorHasItem` / `CursorHasSpell` / `CursorHasMoney` (which already
exist in vanilla) and the source-side `PickupX` calls.
## EquipmentSet
Backports the modern `C_EquipmentSet.*` namespace on top of a
@@ -1567,6 +1870,93 @@ renderer-state changes that recreate WoW's main window (e.g. toggling
vertical sync), where an `SetWindowLongPtr`-style `WNDPROC` subclass
would be left dangling.
### `NAME_PLATE_CREATED` / `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` events
Fire when nameplate state actually changes. Payloads:
| Event | `arg1` | Notes |
|-------|--------|-------|
| `NAME_PLATE_CREATED` | nameplate **Frame** | Matches modern WoW. Fires once per unique `CGNamePlateFrame` pointer — same frame re-used via pool recycle does NOT refire. |
| `NAME_PLATE_UNIT_ADDED` | `"nameplateN"` **unit token** | Matches modern WoW. Pass straight to `UnitName` / `UnitGUID` / `UnitClass` / etc., or to [`GetNamePlateForUnit`](#c_nameplategetnameplateforunitunittoken) for the frame. The token is positional — see [Unit tokens](#unit-tokens-nameplaten) for ordering semantics. |
| `NAME_PLATE_UNIT_REMOVED` | `"nameplateN"` **unit token** | Same as above. Computed from the plate's slot *before* it shifts out of the ordered list, so the token still resolves to the leaving unit during the event handler. |
```lua
local f = CreateFrame("Frame")
f:RegisterEvent("NAME_PLATE_CREATED")
f:RegisterEvent("NAME_PLATE_UNIT_ADDED")
f:RegisterEvent("NAME_PLATE_UNIT_REMOVED")
f:SetScript("OnEvent", function()
if event == "NAME_PLATE_CREATED" then
-- arg1 = the nameplate Frame itself
arg1:SetAlpha(0.8)
elseif event == "NAME_PLATE_UNIT_ADDED" then
-- arg1 = "nameplate1" / "nameplate2" / ...
local name = UnitName(arg1)
local plate = C_NamePlate.GetNamePlateForUnit(arg1)
-- ... style based on the unit ...
end
end)
```
> **CREATED timing with nameplate addons (pfUI / TidyPlates / etc).**
> The event fires when the *engine* allocates the underlying
> `CGNamePlateFrame`. Nameplate-mod addons typically decorate the
> frame on their own per-frame update — so `arg1` at `CREATED` time
> is a bare frame with no addon-side decorations yet. For
> unit-specific work after the addon has decorated, use
> `NAME_PLATE_UNIT_ADDED` (fires next tick at the latest) or fetch
> the current frame on-demand via `GetNamePlateForUnit(arg1)`.
> **Token stability gotcha.** Like modern WoW, the `arg1` token is
> positional — `"nameplate3"` today may resolve to a different unit
> after the slot vacates and shifts. If you need a per-unit hash key
> for cross-event bookkeeping, call `UnitGUID(arg1)` and store the
> GUID instead. See [Unit tokens](#unit-tokens-nameplaten) for the
> ordering rules.
**Implementation notes**
Detected by per-frame polling, not engine hooks. Each world tick we
walk the object hash for nameplated units and diff against the
previous tick's snapshot. Modern WoW also synthesizes these via
diffing (the underlying engine has no event for "plate state
changed"). The cost is ~20-50µs/frame even in busy raids — well
below noise.
The diff approach absorbs the engine's transient hide/reshow cycle:
vanilla has ~7 code paths that briefly zero `unit + 0xE60` (z-order
rebuilds, anchor changes, flag-change re-eval) and the next frame's
show path re-allocates from the pool. Those transient zeroes never
become events because the unit appears in both the previous and
current tick's snapshot.
### `PLAYER_FOCUS_CHANGED` event
Fires whenever the player's focus changes — assignment via
[`FocusUnit`](#focusunitunit), clear via [`ClearFocus`](#clearfocus),
or a `FocusUnit(token)` call where the token resolves to no GUID
(implicit clear). No payload args; the new focus GUID is read off
[`UnitGUID("focus")`](#unitguidunit) in the handler.
```lua
local f = CreateFrame("Frame")
f:RegisterEvent("PLAYER_FOCUS_CHANGED")
f:SetScript("OnEvent", function()
local guid = UnitGUID("focus")
if guid then
print("focusing", UnitName("focus"))
else
print("focus cleared")
end
end)
```
**Identity-checked**: assigning the same GUID twice is a no-op
(no event refires), matching 3.3.5's `FUN_0051FF20` behavior.
Mirrors modern's documented semantics — "fired whenever the
player's focus target is changed, including when the focus target
is lost or cleared".
### `QUEST_ACCEPTED` event
Fires once per quest the player just accepted, with two payload args:
@@ -1983,6 +2373,82 @@ This is a ClassicAPI-only call; modern WoW has no equivalent (modern
addons get the factionID from `FACTION_STANDING_CHANGED` directly, so
they don't need a separate getter).
## Focus
Polyfills modern WoW's focus-target system: a single sticky GUID
that addons can pin to a unit and address via the `"focus"` unit
token across the entire `UnitX` API surface. Backed by a hook on
`FUN_TOKEN_TO_GUID` (shared with the [`nameplateN`](#unit-tokens-nameplaten)
tokens — see `unit/TokenExtensions.cpp`).
State is **session-only** — drops on `/reload`, `/logout`, and zone
loads that recreate Lua state. Modern Classic Era behaves the same;
addons that want persistence have to re-`FocusUnit` from `SavedVariables`
at `ADDON_LOADED`.
**Auto-clear on despawn.** When the focused unit leaves the client's
object table — out of rendering range, full despawn, dies and
decays — focus drops and [`PLAYER_FOCUS_CHANGED`](#player_focus_changed-event)
fires. The unit re-entering range does NOT auto-refocus. Matches
modern WoW's documented behavior. Implementation: per-tick
`ObjectByGUID(g_focusGUID)` probe; on null result, `Set(0)`.
### `FocusUnit(unit)`
Sets focus to the given unit. Argument is a unit token —
`"target"`, `"mouseover"`, `"party1"`, `"nameplate1"`, anything the
resolver accepts. Calling with no argument is shorthand for
`FocusUnit("target")` (matches modern's `/focus` slash command
default).
```lua
FocusUnit("target") -- pin the current target
FocusUnit("nameplate1") -- pin the unit behind nameplate1
FocusUnit() -- same as FocusUnit("target")
```
Fires [`PLAYER_FOCUS_CHANGED`](#player_focus_changed-event) if the
resolved GUID differs from the current focus. No-op (no event) if
the same unit is already focused — matches the
identity-check-first behavior of 3.3.5's `FUN_0051FF20`.
Passing a token that doesn't resolve to a unit (e.g. `"target"`
with nothing targeted, or `"party5"` solo) clears focus — same as
calling `ClearFocus()`.
### `ClearFocus()`
Drops the focus. Fires `PLAYER_FOCUS_CHANGED` if there was one;
no-op otherwise.
```lua
ClearFocus()
```
### Unit token (`focus` / `focustarget`)
`"focus"` resolves to whatever GUID `FocusUnit` last stashed.
Accepted by every `UnitX` function:
```lua
local name = UnitName("focus") -- nil if no focus
local hp = UnitHealth("focus")
local _, class = UnitClass("focus")
```
Chains compose through the engine's standard suffix walker:
`"focustarget"`, `"focustargettarget"`, etc. — same behavior as
`"targettarget"`, mirrored instruction-for-instruction in our hook
so addons get the engine semantics they expect.
Returns `nil` cleanly when no focus is set; doesn't raise the
"Unknown unit name" error.
[`UnitTokenFromGUID`](#unittokenfromguidguid) scans `"focus"` right
after `"target"` (matching retail order), so a focused unit's GUID
reverse-resolves to `"focus"` only if it isn't already addressable
as `"player"` / `"party*"` / `"raid*"` / `"nameplate*"` / `"target"`.
## FriendList
### `C_FriendList.SendWhoQueryByName(name)`
@@ -2470,14 +2936,28 @@ lines; re-saving a set repopulates itemIDs.
### `CLASSIC_API_VERSION`
Defined once FrameScript has booted. Addons can use this to detect that
the DLL is loaded and which version is in use. The value is
`X*10000 + Y*100 + Z` for a tag of `vX.Y.Z` passed to CMake at configure
time via `-DCLASSICAPI_TAG=vX.Y.Z`. Unset versions resolve to `0`.
Defined once FrameScript has booted. Addons can use this to detect
that the DLL is loaded and which version is in use. The value is
`X*10000 + Y*100 + Z` for a tag of `vX.Y.Z` passed to CMake at
configure time via `-DCLASSICAPI_TAG=vX.Y.Z`.
Untagged builds (local dev, CI without a release tag) get a
**sentinel value of `99999999`** — encoded as `v9999.99.99`, chosen
to be higher than every plausible real release so addon-side
feature gates like `CLASSIC_API_VERSION >= 10200` don't reject the
dev build.
```lua
if CLASSIC_API_VERSION and CLASSIC_API_VERSION >= 10200 then
-- ClassicAPI v1.2.0 or newer is loaded
-- ClassicAPI v1.2.0 or newer is loaded (or untagged dev build)
end
```
If you specifically want to detect a dev build:
```lua
if CLASSIC_API_VERSION == 99999999 then
-- running against a locally-built, untagged DLL
end
```
@@ -3808,6 +4288,55 @@ if t == 1 then -- head
end
```
### `C_Item.IsItemOpenable(itemLocation)`
Returns two values: `(isOpenable, canOpen)`.
- **`isOpenable`** — `true` if the item type is right-click-openable
(sack, clam, simple chest, quest box, lockbox, etc. — anything
whose tooltip *could* show `<Right Click to Open>`). Intrinsic to
the item type. From `ItemStats.Flags & 0x4`.
- **`canOpen`** — `true` if the player can right-click *this specific
instance* and trigger the open action right now. For unlocked-by-
default items (clams, sacks) this matches `isOpenable`. For items
with a lock (lockboxes), it's only true after the lock has been
removed (rogue Pick Lock, key item, etc.) — so a priest looking at
a fresh lockbox sees `(true, false)`, while a rogue who just
picked it sees `(true, true)`.
Both values are `nil` when the item data isn't cached yet (background
`SMSG_ITEM_QUERY_SINGLE` fired) or when the location is empty /
invalid. Lets callers distinguish "data unknown" from "definitely
not openable".
There's no `ByID` variant — the `canOpen` check depends on a specific
instance's `ITEM_FIELD_FLAGS`, which you can only have via an
itemLocation. If you only have an itemID, use
`C_Item.GetItemInfoInstant` to inspect the item type instead.
```lua
-- Loop a bag and gather every openable the player can act on now
for slot = 1, GetContainerNumSlots(0) do
local _, canOpen = C_Container.IsContainerItemOpenable(0, slot)
if canOpen then
-- pickup + use; this slot is good to open
end
end
-- Show a different hint depending on the lock state
local isOpenable, canOpen = C_Item.IsItemOpenable({bagID = 0, slotIndex = 1})
if isOpenable and not canOpen then
print("This item is locked.")
end
```
Implementation: `isOpenable` reads `ItemStats.Flags & 0x4` from the
client-side ItemSparse cache. `canOpen` adds a check that either
`ItemStats.LockID == 0` (no lock) or the CGItem instance's
`ITEM_FIELD_FLAGS & 0x4` (UNLOCKED) bit is set. Same conditions the
engine's tooltip builder uses at `0x0052E323` to gate the
`ITEM_OPENABLE` line.
### `C_Item.IsLocked(itemLocation)`
Returns `true` if the item is in the client-side "in-transaction"
@@ -4145,6 +4674,114 @@ section tags them with the same spellID a `/cast` line would, so
> opening them in the Macro UI and clicking Okay re-runs the parser
> and the new behavior takes effect.
### `GetMacroIcons` / `GetMacroItemIcons` / `GetLooseMacroIcons` / `GetLooseMacroItemIcons`
Modern Classic Era's 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).
```lua
local spellList, itemList = {}, {}
-- Modern's canonical call order (mirrors IconDataProvider.lua):
GetLooseMacroIcons(spellList)
GetLooseMacroItemIcons(itemList)
GetMacroIcons(spellList)
GetMacroItemIcons(itemList)
-- spellList now has `Ability_*` / `Spell_*` basenames;
-- itemList now has `INV_*` basenames.
```
The split is `loose` (icons the user dropped into `Interface\Icons\`
on disk) 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 scheme by hooking
vanilla's three scan callbacks
(`FUN_MACRO_ICON_CB_DISK` / `*_USER_MPQ` / `*_INSTALL_MPQ`) and tagging
each captured filename by source + prefix.
**Vanilla quirk worth noting**: the engine's main icon DB (what
`GetMacroIconInfo(i)` returns) is filtered down to `Ability_*` /
`Spell_*` only — `INV_*` filenames flow through the scan callbacks
(~2,500+ per session in the Octo client) but never land in the DB
because something downstream of the callbacks rejects them. We
capture before that rejection, so `GetMacroItemIcons` works even
though no engine-level `GetMacroItemIconInfo(i)` exists.
Each appended entry is the 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(...)`. Output is alphabetically
sorted within each function (sorted lazily on first read).
Capture-time dedup ensures each unique basename only appears once
per source/prefix combination even though most files flow through
multiple scan callbacks. There is **no cross-dedup** between loose
and mpq, or between spell and item — matches modern engine
behavior (a file that exists both as a loose drop-in and inside an
MPQ will appear in both the loose and mpq lists).
The vanilla legacy globals `GetNumMacroIcons` / `GetMacroIconInfo`
remain available unchanged.
### `IconDataProviderMixin`
Lua-side wrapper (ported from
`Blizzard_FrameXMLBase/Classic/IconDataProvider.lua`) around the four
mutators above. Bundles `Spell` and `Item` buckets into one stateful
provider with an optional "extras" prepend section, and exposes the
modern `GetIconByIndex(i)` / `GetNumIcons()` / `GetIndexOfIcon(icon)`
/ `Release()` / `GetIconForSaving(i)` API the modern Macro UI and
Equipment Manager icon-pickers consume.
```lua
-- Equipment Manager icon picker (matches modern Cata code as-written):
local provider = CreateAndInitFromMixin(IconDataProviderMixin,
IconDataProviderExtraType.Equipment)
provider:GetNumIcons() -- total count (1 + extras + base)
provider:GetIconByIndex(1) -- always the `?` sentinel
provider:GetIconByIndex(2) -- first equipped-item icon
-- ...
provider:Release() -- decref + collectgarbage when last ref drops
```
`IconDataProviderExtraType`:
- `Spellbook` — pre-walks the spellbook tabs
(`GetNumSpellTabs` / `GetSpellTabInfo` / `GetSpellTexture`) **and**
the three vanilla talent trees
(`GetNumTalentTabs` / `GetNumTalents` / `GetTalentInfo`), gathering
every talent's icon — including talents the player hasn't put points
into. Dedups via a set, prepends to the picker. Vanilla doesn't have
spec groups or PvP talents, so dual-spec / PvP-talent walks present
in modern's `IconDataProvider.lua` are omitted. Spells not surfaced
in the spellbook UI (racials kept off the bar, profession recipes
the engine doesn't list as spells) are still missed — that's a
vanilla data limitation, not a port omission.
- `Equipment` — pre-walks `INVSLOT_FIRST_EQUIPPED..INVSLOT_LAST_EQUIPPED`
via `GetInventoryItemTexture("player", i)`.
- `None` — no prepend; base DB only.
`IconDataProviderIconType`:
- `Spell` (`= 1`) — `Ability_*` / `Spell_*` set.
- `Item` (`= 2`) — `INV_*` set.
The mixin doesn't cross-dedup between extras and the base DB — the
player's currently-equipped weapon icon may appear twice in the
final picker (once at the head as an "extra", once again later in
its alphabetical spot inside the `INV_*` base list). That's
deliberate; the modern engine has the same behavior. Pickers
typically tolerate it because the extras section is short.
Memory model matches modern: `BaseIconFilenames` is module-level
state shared across all active providers. The first `Init` triggers
the four engine calls (which collectively walk ~30k icon paths); on
the last `Release`, `BaseIconFilenames` is nil'd and `collectgarbage()`
runs explicitly.
## Map
### `C_Map.GetBestMapForUnit(unitToken)`
@@ -4325,6 +4962,174 @@ disable the sell-all-junk button; vanilla has no such setting, so
the feature is always on. Function exists so retail addons that
gate `SellAllJunkItems` on this don't no-op silently.
## NamePlate
Modern `C_NamePlate.*` returns nameplate `Frame` objects keyed off
unit data. Vanilla 1.12 doesn't ship the API at all — but the
underlying data (per-unit nameplate pointer at `CGUnit + 0xE60`)
exists. We enumerate visible units via the local-player-anchored
object hash table, filter by `TYPEMASK_UNIT`, and return matches.
Modern's `"nameplateN"` unit-token family is also supported — see
[Unit tokens](#unit-tokens-nameplaten) below. `NAME_PLATE_UNIT_ADDED`
/ `_REMOVED` / `_CREATED` events fire via a per-tick visible-plate
diff (see [the events section](#name_plate_created--name_plate_unit_added--name_plate_unit_removed-events)).
### `C_NamePlate.GetNamePlates()`
Returns a 1-based table of nameplate `Frame` objects — one per
CGUnit that currently has an allocated **Lua-registered** nameplate.
The frames are real Lua tables with methods (`:GetName()`,
`:GetWidth()`, `:SetAlpha()`, etc.) and any addon-added decorations
on them.
```lua
local plates = C_NamePlate.GetNamePlates()
for i, plate in ipairs(plates) do
print(i, plate:GetName(), plate:GetWidth())
end
```
Two kinds of plates can show up:
- **Addon-created plates** (pfUI, TidyPlates, NamePlateMod, etc.):
registered with Lua via `CreateFrame`, so each has a real
registry ref. We push `registry[plate + 0x08]`. Identity is
stable across calls — caching is safe while the frame is alive.
- **Default vanilla plates**: created internally by the engine
without ever calling `CreateFrame`. Their `+0x08` field holds the
sentinel `LUA_NOREF` (`-2`), not a real registry key. We build a
fresh wrapper table per call (`{[0] = lightuserdata(plate)}` with
the global `__framescript_meta` metatable) so addons get the
same method surface. The wrapper isn't cached engine-side, so
identity isn't stable across calls — don't compare wrappers, and
don't store them across the unit going out of range (the
underlying frame may be freed). Call `GetNamePlates()` fresh
each time you need plates.
### Reading region content from a default nameplate
Vanilla plates have six child regions in stable positions. Walk them
with `:GetRegions()`:
```lua
local plates = C_NamePlate.GetNamePlates()
for _, plate in ipairs(plates) do
local regions = {plate:GetRegions()}
local name = regions[3]:GetText() -- e.g. "Joseph Dalton"
local level = tonumber(regions[4]:GetText()) -- e.g. 60
-- regions[1], [2], [5], [6] are textures (border, healthbar,
-- glow, raid-icon — order depends on the engine's draw order)
end
```
Lua 5.0 has no `select()`, so collect into a table via
`{plate:GetRegions()}` and index. Addon-created plates have
different region layouts — those frames inherit whatever shape the
addon built, not this one.
### `C_NamePlate.GetNamePlateForUnit(unitToken)`
Returns the nameplate Frame for a single unit (resolved via the
engine's token-to-GUID path, so out-of-range party/raid members
work too), or `nil` if the unit has no allocated nameplate.
```lua
local plate = C_NamePlate.GetNamePlateForUnit("target")
if plate then
local regions = {plate:GetRegions()}
print("targeting:", regions[3]:GetText()) -- e.g. "Santora"
end
```
Same registered-vs-fresh-wrapper behavior as `GetNamePlates()` —
addon-created plates return their cached wrapper, default vanilla
plates get a fresh per-call wrapper. Don't cache the result across
the unit going out of range.
### `C_NamePlate.GetNamePlateForGUID(guidString)`
Same as `GetNamePlateForUnit` but takes the `"0xHHHHHHHHHHHHHHHH"`
GUID-string form. Useful when you've stored a unit GUID across
events (e.g., converted the positional `"nameplateN"` token to a
GUID via `UnitGUID(arg1)` at `NAME_PLATE_UNIT_ADDED` time) and need
the frame later.
```lua
local platesByGuid = {}
local f = CreateFrame("Frame")
f:RegisterEvent("NAME_PLATE_UNIT_ADDED")
f:SetScript("OnEvent", function()
-- arg1 = "nameplateN" token; convert to stable GUID for storage
local guid = UnitGUID(arg1)
platesByGuid[guid] = C_NamePlate.GetNamePlateForGUID(guid)
end)
```
Returns `nil` if the GUID doesn't parse, doesn't resolve to a
visible CGUnit, or the unit has no allocated nameplate.
### `C_NamePlate.GetNamePlateGUIDs()`
Returns a 1-based table of GUID strings (modern
`"0xHHHHHHHHHHHHHHHH"` format) — one per CGUnit with an allocated
nameplate, **regardless** of whether the frame has been registered
with Lua. Catches default vanilla nameplates that
[`GetNamePlates`](#c_nameplategetnameplates) can't surface as
frames.
```lua
/dump C_NamePlate.GetNamePlateGUIDs()
-- { "0xF13000C36C26FD02", "0xF130000009276912", ... }
```
Walks the local-player-anchored object hash table for `TYPEMASK_UNIT`
entries, filters by `*(unit + 0xE60) != nullptr`. The per-unit
nameplate pointer is set by `FUN_006086E0`'s "show nameplate" path
regardless of which nameplate system rendered it. Order follows
hash-bucket iteration and isn't stable across calls.
### Unit tokens (`nameplateN`)
`"nameplate1"`, `"nameplate2"`, … work as unit tokens against every
`UnitX` function: `UnitName`, `UnitGUID`, `UnitClass`, `UnitHealth`,
`UnitHealthMax`, `UnitLevel`, `UnitFaction`, `UnitReaction`,
`UnitExists`, `UnitIsPlayer`, `UnitIsEnemy`, `UnitIsDead`, etc. —
~30 functions for free.
```lua
for i = 1, 40 do
if not UnitExists("nameplate" .. i) then break end
print(i, UnitName("nameplate" .. i), UnitClass("nameplate" .. i))
end
```
Ordering is **creation-order** — each new plate appends to the end of
the list and stays at its index until the unit goes out of range or
the plate is removed, at which point later indices shift down.
Stable for the lifetime of a single plate; no mid-frame reordering.
Same semantics as modern WoW.
Token chains work too — `"nameplate1target"`, `"nameplate1targettarget"`,
etc. — by mirroring the engine's own `targettarget`-style suffix
walker (read `UNIT_FIELD_TARGET` off `m_objectFields`, loop). Other
suffixes (`pet`, `master`) aren't supported by the vanilla engine's
own walker either, so they don't compose.
Out-of-range indices return `nil` cleanly without raising "Unknown
unit name" — `UnitExists("nameplate99")` just returns `false`.
**Implementation note.** We hook `FUN_TOKEN_TO_GUID` (the central
token→GUID resolver) so the entire `Script_Unit*` surface gains the
new token form transparently. The hook is gated by an `SStrCmpI`
prefix check against `"nameplate"`; non-nameplate tokens
(`"player"`, `"target"`, `"partyN"`, etc.) fall straight through to
the unmodified resolver. The ordered list is maintained alongside
the existing `NAME_PLATE_UNIT_ADDED` / `_REMOVED` diff in the
per-tick nameplate walker.
## NameCache
GUID-keyed cache of player names and classes. The engine itself
@@ -6519,13 +7324,19 @@ known unit tokens and return the first one currently mapped to that
GUID, or `nil` if none of them point at it.
The search order matches modern retail with post-1.12 tokens
omitted (`vehicle`, `nameplateN`, `arenaN`, `arenapetN`, `bossN`,
`focus`, `softenemy`, `softfriend`, `softinteract` all post-date
vanilla and the engine's resolver doesn't recognize them):
omitted (`vehicle`, `arenaN`, `arenapetN`, `bossN`, `softenemy`,
`softfriend`, `softinteract` all post-date vanilla and the engine's
resolver doesn't recognize them). `nameplateN` and `focus` are
included — we hook the resolver so the engine recognizes both token
forms. As with the other positional tokens, a returned
`"nameplate3"` result is only valid at that instant; the slot may
shift to a different unit between calls, so re-verify with
`UnitGUID(token)` before reusing.
```
player → pet → party1..4 → partypet1..4 → raid1..40
→ raidpet1..40 → target → npc → mouseover
→ raidpet1..40 → nameplate1..N → target → focus
→ npc → mouseover
```
```lua
+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
+284
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-
@@ -1692,6 +1783,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,
@@ -2089,6 +2215,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 +2395,44 @@ 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,
// `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`
@@ -3043,4 +3223,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,
};
+323
View File
@@ -0,0 +1,323 @@
// 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 <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) {
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);
void __fastcall AddonInit_h(const char *accountName) {
AddonInit_o(accountName);
auto TocParser = reinterpret_cast<TocParser_t>(Offsets::FUN_TOC_PARSER);
TocParser(kAddonName);
}
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
+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
+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
+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
+45 -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;
}
+4 -21
View File
@@ -17,6 +17,7 @@
#include "item/ID.h"
#include "item/Link.h"
#include "item/Location.h"
#include "item/QualityColor.h"
#include <cstdint>
#include <cstdio>
@@ -134,25 +135,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 +259,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);
+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
+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