Compare commits

...
32 Commits
Author SHA1 Message Date
Brues 1d370ada65 GetInventoryItemsForSlot: filter by player proficiency
Mail helms were showing up for cloth classes — the function only
checked invType→slot shape, not whether the player could actually
equip the item.

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

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

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

Eliminates the "trinket A in 14, B in bags" failure mode where an
earlier bag-to-paperdoll swap displaced an item later needed by the
set, then stale FindGUID returned the pre-swap paperdoll location.
2026-05-30 22:23:32 -05:00
Brues 94b521881b remove max equipment sets 2026-05-30 21:38:49 -05:00
Brues 634a440def set addon toc to dev 2026-05-30 21:38:33 -05:00
brues-code 4dce58a457 Fix State section formatting in README.md 2026-05-30 19:54:31 -05:00
Brues 99e9621590 IsLoggedIn 2026-05-30 18:54:30 -05:00
Brues e8fc583f19 Expand GetQuestDetails: srcItemID, completionText, poi, objective text
Trace SMSG_QUEST_QUERY_RESPONSE serializer at 0x006DBCEC to map the
remaining cache-block field offsets, exposing four new fields plus
per-requirement override text. Empirical dumps across three quests
confirmed the +0x14..+0x24 slots (would-be SuggestedPlayers /
LimitTime / RequiredRaces / RequiredSkill / RequiredSkillValue) are
never populated by the vanilla server — those constraints are
enforced server-side and never broadcast, so addons needing them
must use an external DB.

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

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

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

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

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

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

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

* documentation

* share last modified info with client

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-05-29 02:40:29 -05:00
Brues ec73652b67 2nd arg "canOpen" for IsItemOpenable
removed IsItemOpenableByID
2026-05-28 23:26:04 -05:00
Brues 4c798ba58a turtle wow injects a bad version of print 2026-05-28 16:01:17 -05:00
brues-code fbe81b7f01 Simplify API documentation for combat check
Removed redundant explanation for UnitAffectingCombat.
2026-05-28 15:30:44 -05:00
brues-code 1c0e20cca8 Update CharacterOrder to CharacterList in README 2026-05-28 15:21:40 -05:00
Brues 1242f1dd9f Openable: return nil for uncached items 2026-05-28 14:37:43 -05:00
Brues de0c41dd1d IsItemOpenable 2026-05-28 14:33:41 -05:00
Brues 3e22e2c087 useless comment 2026-05-28 14:33:23 -05:00
Brues beb9dec6e1 max out CLASSICAPI_VERSION_VALUE for dev build 2026-05-28 00:52:17 -05:00
39 changed files with 3940 additions and 124 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: DEV
## Author: Brues
GlobalStrings.lua
@@ -28,3 +28,5 @@ Util\EquipmentManager.lua
Util\PlayerUtil.lua
Util\SlashCommandsRegistry.lua
Util\LinkUtil.lua
Util\IconDataProvider.lua
Util\GameTooltip.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;
+5 -6
View File
@@ -83,12 +83,11 @@ function EventUtil.TriggerOnVariablesLoaded()
end
function EventUtil.ContinueOnAddOnLoaded(addOnName, callback)
-- 1.12's IsAddOnLoaded returns a single bool. 3.3.5 split that into
-- (isLoadedOrLoading, isLoaded); treat truthy as "ready to use".
if IsAddOnLoaded(addOnName) then
callback()
return
end
local isLoadedOrLoading, isLoaded = C_AddOns.IsAddOnLoaded(addOnName)
if isLoaded then
callback();
return;
end
EventUtil.RegisterOnceFrameEventAndCallback("ADDON_LOADED", callback, addOnName)
end
@@ -0,0 +1,3 @@
function GameTooltip_Hide()
GameTooltip:Hide()
end
@@ -0,0 +1,242 @@
-- Backport of modern Classic Era's IconDataProvider.lua (lives in
-- Blizzard_FrameXMLBase/Classic/) to vanilla 1.12 / Lua 5.0.
--
-- Wraps the four engine icon-enumeration mutators
-- (`GetLooseMacroIcons` / `GetLooseMacroItemIcons` / `GetMacroIcons`
-- / `GetMacroItemIcons` — all surfaced by ClassicAPI.dll) into a
-- stateful `Spell`/`Item` bucket pair, with an optional
-- "extras" array of icons prepended ahead of the base DB. The two
-- "extra" types are:
--
-- * `Spellbook` — icons of every spell currently in the player's
-- spellbook UI. Used by the modern Macro UI's icon picker so
-- "icons relevant to my class" surface first. In vanilla this
-- misses spells that aren't on the spellbook UI (racials kept
-- off the bar, profession recipes the engine doesn't list as
-- spells, etc.) but covers the common case.
-- * `Equipment` — icons of currently-equipped items (slots 1..19
-- via `GetInventoryItemTexture("player", i)`). Used by the
-- modern Equipment-Manager popup so the picker leads with what
-- the player is wearing.
--
-- Implementation differences from the modern source:
--
-- * `EnumUtil.MakeEnum` is inlined as a plain table.
-- * Modern's `C_SpecializationInfo.GetTalentInfo(query)` (Cata+
-- spec-aware talent API) and `GetPvpTalentInfoByID` (MoP+ PvP
-- talents) are replaced with vanilla's `GetTalentInfo(tab, idx)`
-- across the 3 talent trees. Vanilla has no specs, no dual-spec
-- groups, and no PvP talent system, so those layers are dropped.
-- * `GetSpellBookItemTexture(slot, "player")` is replaced with
-- vanilla's `GetSpellTexture(slot, "spell")`. Flyouts don't
-- exist in vanilla so that branch is dropped entirely.
-- * `extraIconsMap` is a Lua-set keyed by texture path or
-- fileID — `GetKeysArray` flattens it to a numbered array.
-- Same dedup semantics as modern: each texture appears once
-- within the extras section, but no cross-dedup with the
-- base DB walk (the player's equipped weapon icon may still
-- appear twice in the final picker, once in extras and once
-- in `GetMacroItemIcons`).
local QuestionMarkIconPath = "INTERFACE\\ICONS\\INV_MISC_QUESTIONMARK"
local NumActiveIconDataProviders = 0
local BaseIconFilenames = nil
-- Builds the table BaseIconFilenames with engine icon DB.
-- Lazy: shared across all active IconDataProvider instances, so
-- the four engine calls (each of which scans thousands of files)
-- only happens once per picker session.
local function IconDataProvider_RefreshIconTextures()
if BaseIconFilenames ~= nil then
return
end
BaseIconFilenames = {}
BaseIconFilenames[IconDataProviderIconType.Spell] = {}
BaseIconFilenames[IconDataProviderIconType.Item] = {}
GetLooseMacroIcons(BaseIconFilenames[IconDataProviderIconType.Spell])
GetLooseMacroItemIcons(BaseIconFilenames[IconDataProviderIconType.Item])
GetMacroIcons(BaseIconFilenames[IconDataProviderIconType.Spell])
GetMacroItemIcons(BaseIconFilenames[IconDataProviderIconType.Item])
end
local function IconDataProvider_ClearIconTextures()
BaseIconFilenames = nil
collectgarbage()
end
local function IconDataProvider_GetBaseIconTexture(iconType, index)
local texture = BaseIconFilenames[iconType][index]
local fileDataID = tonumber(texture)
if fileDataID ~= nil then
return fileDataID
else
return "INTERFACE\\ICONS\\" .. texture
end
end
function IconDataProvider_GetAllIconTypes()
local iconTypeValues = GetValuesArray(IconDataProviderIconType)
table.sort(iconTypeValues)
return iconTypeValues
end
IconDataProviderMixin = {}
-- `EnumUtil.MakeEnum("Spell", "Item")` produces `{ Spell = 1, Item = 2 }`.
IconDataProviderIconType = { Spell = 1, Item = 2 }
IconDataProviderExtraType = {
Spellbook = 1,
Equipment = 2,
None = 3,
}
local function FillOutExtraIconsMapWithSpells(extraIconsMap)
for tab = 1, GetNumSpellTabs() do
local _, _, offset, numSpells = GetSpellTabInfo(tab)
offset = offset + 1
local tabEnd = offset + numSpells
for slot = offset, tabEnd - 1 do
local tex = GetSpellTexture(slot, "spell")
if tex ~= nil and tex ~= "" then
extraIconsMap[tex] = true
end
end
end
end
-- Walks all three vanilla talent trees, gathering every talent's
-- icon — including talents the player hasn't put points into. The
-- whole tree is "relevant to my class" for picker purposes, not
-- just maxed talents. `GetTalentInfo(tab, idx)` returns
-- `(name, icon, tier, column, rank, maxRank, ...)` — we read the
-- icon (second return) directly.
local function FillOutExtraIconsMapWithTalents(extraIconsMap)
for tab = 1, GetNumTalentTabs() do
local numTalents = GetNumTalents(tab)
for talent = 1, numTalents do
local _, icon = GetTalentInfo(tab, talent)
if icon ~= nil and icon ~= "" then
extraIconsMap[icon] = true
end
end
end
end
local function FillOutExtraIconsMapWithEquipment(extraIconsMap)
for slot = INVSLOT_FIRST_EQUIPPED, INVSLOT_LAST_EQUIPPED do
for _, itemLink in pairs(GetInventoryItemsForSlot(slot, {})) do
local _, _, _, _, itemTexture = C_Item.GetItemInfoInstant(itemLink)
if itemTexture then
extraIconsMap[itemTexture] = true
end
end
end
end
function IconDataProviderMixin:Init(extraType, extraIconsOnly, requestedIconTypes)
self.extraIcons = {}
self.extraIconType = extraType
self.requestedIconTypes = requestedIconTypes or IconDataProvider_GetAllIconTypes()
if extraType == IconDataProviderExtraType.Spellbook then
local extraIconsMap = {}
FillOutExtraIconsMapWithSpells(extraIconsMap)
FillOutExtraIconsMapWithTalents(extraIconsMap)
self.extraIcons = GetKeysArray(extraIconsMap)
elseif extraType == IconDataProviderExtraType.Equipment then
local extraIconsMap = {}
FillOutExtraIconsMapWithEquipment(extraIconsMap)
self.extraIcons = GetKeysArray(extraIconsMap)
end
if not extraIconsOnly then
NumActiveIconDataProviders = NumActiveIconDataProviders + 1
IconDataProvider_RefreshIconTextures()
end
end
function IconDataProviderMixin:SetIconTypes(iconTypes)
self.requestedIconTypes = iconTypes or IconDataProvider_GetAllIconTypes()
end
function IconDataProviderMixin:GetNumIcons()
-- 1 to account for the leading `?` icon.
local numIcons = 1
if self:ShouldShowExtraIcons() then
numIcons = numIcons + table.getn(self.extraIcons)
end
if BaseIconFilenames then
for _, v in pairs(self.requestedIconTypes) do
numIcons = numIcons + table.getn(BaseIconFilenames[v])
end
end
return numIcons
end
function IconDataProviderMixin:GetIconByIndex(index)
if index == 1 then
return QuestionMarkIconPath
end
index = index - 1
local numExtraIcons = (self:ShouldShowExtraIcons() and
table.getn(self.extraIcons)) or 0
if index <= numExtraIcons then
return self.extraIcons[index]
end
local baseIndex = index - numExtraIcons
local lookupIconType = nil
-- Each icon type's table is indexed from 1, so loop through the
-- tables to find which icon type we index to.
for _, v in pairs(self.requestedIconTypes) do
local numIconsForType = table.getn(BaseIconFilenames[v])
if baseIndex <= numIconsForType then
lookupIconType = v
break
end
baseIndex = baseIndex - numIconsForType
end
if lookupIconType then
return IconDataProvider_GetBaseIconTexture(lookupIconType, baseIndex)
else
return nil
end
end
function IconDataProviderMixin:GetIconForSaving(index)
local icon = self:GetIconByIndex(index)
if type(icon) == "string" then
icon = string.gsub(icon, "INTERFACE\\ICONS\\", "")
end
return icon
end
function IconDataProviderMixin:GetIndexOfIcon(icon)
local numIcons = self:GetNumIcons()
for i = 1, numIcons do
if self:GetIconByIndex(i) == icon then
return i
end
end
return nil
end
function IconDataProviderMixin:ShouldShowExtraIcons()
return (self.extraIconType == IconDataProviderExtraType.Spellbook and
tContains(self.requestedIconTypes, IconDataProviderIconType.Spell))
or (self.extraIconType == IconDataProviderExtraType.Equipment and
tContains(self.requestedIconTypes, IconDataProviderIconType.Item))
end
function IconDataProviderMixin:Release()
NumActiveIconDataProviders = NumActiveIconDataProviders - 1
if NumActiveIconDataProviders <= 0 then
IconDataProvider_ClearIconTextures()
end
end
-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)
+25 -11
View File
@@ -18,12 +18,13 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| Namespace | Calls |
|-----------|-------|
| [Action](docs/API.md#action) | `GetActionInfo` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
| [Combat](docs/API.md#combat) | `InCombatLockdown` |
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
| [Container](docs/API.md#container) | `C_Container.GetContainerItemCharges`, `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` |
@@ -32,20 +33,20 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
| [FriendList](docs/API.md#friendlist) | `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName` |
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex` |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura` |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura` |
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [Input](docs/API.md#input) | `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Input](docs/API.md#input) | `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [Macros](docs/API.md#macros) | `GetMacroSpell` |
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [Macros](docs/API.md#macros) | `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
| [Map](docs/API.md#map) | `C_Map.GetBestMapForUnit` |
| [MerchantFrame](docs/API.md#merchantframe) | `C_MerchantFrame.GetBuybackItemID`, `C_MerchantFrame.GetItemInfo`, `C_MerchantFrame.GetNumJunkItems`, `C_MerchantFrame.IsMerchantItemRefundable`, `C_MerchantFrame.IsSellAllJunkEnabled`, `C_MerchantFrame.SellAllJunkItems` |
| [NamePlate](docs/API.md#nameplate) | `C_NamePlate.GetNamePlateForGUID`, `C_NamePlate.GetNamePlateForUnit`, `C_NamePlate.GetNamePlateGUIDs`, `C_NamePlate.GetNamePlates` |
| [NameCache](docs/API.md#namecache) | `C_CreatureInfo.GetCreatureID`, `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `GetPlayerInfoByGUID` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellName`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsMounted`, `IsStealthed`, `IsSwimming` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsStealthed`, `IsSwimming` |
| [Table](docs/API.md#table) | `table.wipe` |
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime` |
@@ -66,9 +67,11 @@ the glue state because GlueXML had no way to reach them otherwise.
| Group | Calls |
|-------|-------|
| CharacterOrder | `GetSavedCharacterOrder`, `SetSavedCharacterOrder` (persist to `WTF\Account\...\ClassicAPI.txt`) |
| [Account](docs/API.md#account) | `SaveAccount`, `DeleteAccount`, `GetSavedAccounts`, `LoginWithSavedAccount` (passwords encrypted in Windows Credential Manager, scoped per realmlist; plaintext never returned to Lua) |
| [CharacterList](docs/API.md#characterlist) | `GetSavedCharacterOrder`, `SetSavedCharacterOrder` (persist to `WTF\Account\...\ClassicAPI.txt`) |
| CVar | `GetCVar`, `SetCVar`, `RegisterCVar`, `GetCVarDefault`, `C_CVar.GetCVarBool` (storage is process-global — writes from glue are visible in-world and vice versa) |
| Script | `RunScript` (compile and run a Lua chunk in the glue state's globals — useful for slash-command-style helpers in GlueXML) |
| State | `IsLoggedIn` |
### Macros
@@ -162,8 +165,19 @@ The triple-`!` prefix is **load-order-significant** — WoW loads
addons alphabetically, so `!!!ClassicAPI` is guaranteed to run before
any consumer that depends on its globals. Don't rename the folder.
To install: copy [`AddOns/!!!ClassicAPI/`](AddOns/!!!ClassicAPI/) into
your `Interface/AddOns/` directory.
**You don't have to install the addon manually** — the DLL embeds
the contents of [`AddOns/!!!ClassicAPI/`](AddOns/!!!ClassicAPI/) and
registers them with the engine as a synthetic addon on startup, so
the library is always available when the DLL is loaded. The engine
sees it as a normal addon: it appears in `GetAddOnInfo`, fires
`ADDON_LOADED`, supports `SavedVariables`, the works.
If you do drop the folder into your `Interface/AddOns/` directory,
**that copy wins** — the DLL's embedded version is only registered
when the engine's normal scan doesn't already have an entry under
that name. Useful for editing the Lua locally without rebuilding the
DLL. The dispatch is transparent: addons consuming `Mixin`,
`ColorMixin`, `TableUtil`, etc. behave identically in both cases.
## Bundled addon: DebugTools
+115
View File
@@ -0,0 +1,115 @@
# embed_addon.cmake — bundle an addon directory into a generated C++ header.
#
# Invoked from CMakeLists.txt via `cmake -P` as a custom command. Reads every
# file under SRC_DIR recursively and emits OUT_HEADER containing:
#
# namespace Addons::Embedded {
# struct File {
# const char *path;
# const unsigned char *data;
# size_t size;
# };
# extern const File kFiles[];
# extern const size_t kFileCount;
# }
#
# Paths are normalized to lowercase backslash-form relative to
# `Interface\AddOns\` so they match what the engine's TOC parser
# constructs when it asks for the addon's files (see
# `s_Interface_AddOns__s__s_toc_00853840` in the binary). The
# manifest is hash-table-friendly for fast lookup but the lookup
# walks the array linearly — fine for 25 files.
#
# Usage:
# cmake -DSRC_DIR=... -DOUT_HEADER=... -DADDON_NAME=... -P embed_addon.cmake
if(NOT SRC_DIR)
message(FATAL_ERROR "embed_addon.cmake: SRC_DIR required")
endif()
if(NOT OUT_HEADER)
message(FATAL_ERROR "embed_addon.cmake: OUT_HEADER required")
endif()
if(NOT ADDON_NAME)
message(FATAL_ERROR "embed_addon.cmake: ADDON_NAME required")
endif()
file(GLOB_RECURSE INPUT_FILES RELATIVE "${SRC_DIR}" "${SRC_DIR}/*")
list(SORT INPUT_FILES) # deterministic codegen
set(BODY "")
set(MANIFEST "")
set(INDEX 0)
foreach(REL_PATH ${INPUT_FILES})
set(ABS_PATH "${SRC_DIR}/${REL_PATH}")
file(READ "${ABS_PATH}" CONTENT_HEX HEX)
string(LENGTH "${CONTENT_HEX}" HEX_LEN)
math(EXPR FILE_SIZE "${HEX_LEN} / 2")
# Convert hex string to comma-separated byte literals. 16 bytes per line
# keeps the generated header readable.
set(BYTES "")
set(COL 0)
set(POS 0)
while(POS LESS HEX_LEN)
string(SUBSTRING "${CONTENT_HEX}" ${POS} 2 BYTE)
if(COL EQUAL 0)
string(APPEND BYTES " ")
endif()
string(APPEND BYTES "0x${BYTE},")
math(EXPR POS "${POS} + 2")
math(EXPR COL "${COL} + 1")
if(COL EQUAL 16)
string(APPEND BYTES "\n")
set(COL 0)
else()
string(APPEND BYTES " ")
endif()
endwhile()
if(NOT COL EQUAL 0)
string(APPEND BYTES "\n")
endif()
# Normalize path to engine-style: `<AddonName>\Sub\Path.lua` with
# backslashes. Engine constructs full path as
# `Interface\AddOns\<AddonName>\<file>` — we strip the
# `Interface\AddOns\<AddonName>\` prefix at lookup time so storing
# just the relative form keeps the manifest small.
string(REPLACE "/" "\\\\" REL_PATH_BS "${REL_PATH}")
string(APPEND BODY "static const unsigned char kData_${INDEX}[] = {\n${BYTES}};\n\n")
string(APPEND MANIFEST " { \"${REL_PATH_BS}\", kData_${INDEX}, ${FILE_SIZE} },\n")
math(EXPR INDEX "${INDEX} + 1")
endforeach()
set(HEADER_CONTENT "// Auto-generated by embed_addon.cmake — do not edit.
// Source: ${SRC_DIR}
//
// Addon \"${ADDON_NAME}\" embedded as a fallback for users who don't have
// it installed on disk. See src/addons/Embedded.cpp for the file-read
// hook that surfaces these buffers to the engine.
#pragma once
#include <cstddef>
namespace Addons::Embedded {
namespace ${ADDON_NAME}Files {
${BODY}static const struct File {
const char *path;
const unsigned char *data;
size_t size;
} kFiles[] = {
${MANIFEST}};
static constexpr size_t kFileCount = sizeof(kFiles) / sizeof(kFiles[0]);
} // namespace ${ADDON_NAME}Files
} // namespace Addons::Embedded
")
file(WRITE "${OUT_HEADER}" "${HEADER_CONTENT}")
message(STATUS "embed_addon: wrote ${OUT_HEADER} (${INDEX} files)")
+511 -13
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)
@@ -14,6 +17,7 @@ build instructions.
- [`C_AddOns.GetAddOnTitle(indexOrName)`](#c_addonsgetaddontitleindexorname)
- [`C_AddOns.GetAddOnNotes(indexOrName)`](#c_addonsgetaddonnotesindexorname)
- [`C_AddOns.IsAddOnLoadable(indexOrName)`](#c_addonsisaddonloadableindexorname)
- [`C_AddOns.IsAddOnLoaded(indexOrName)`](#c_addonsisaddonloadedindexorname)
- [`C_AddOns.GetAddOnSecurity(indexOrName)`](#c_addonsgetaddonsecurityindexorname)
- [`C_AddOns.DoesAddOnExist(indexOrName)`](#c_addonsdoesaddonexistindexorname)
@@ -36,6 +40,7 @@ build instructions.
- [`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)
@@ -45,6 +50,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)
@@ -108,6 +116,7 @@ build instructions.
- [`GameTooltip:SetSpellByID(spellID)`](#gametooltipsetspellbyidspellid)
- [`GameTooltip:SetTalentByID(talentID)`](#gametooltipsettalentbyidtalentid)
- [`GameTooltip:SetInventoryItemByID(itemID)`](#gametooltipsetinventoryitembyiditemid)
- [`GameTooltip:SetItemByGUID(itemGUID)`](#gametooltipsetitembyguiditemguid)
- [`GameTooltip:SetEquipmentSet(name)`](#gametooltipsetequipmentsetname)
- [`GameTooltip:GetItem()`](#gametooltipgetitem)
- [`GameTooltip:GetSpell()`](#gametooltipgetspell)
@@ -143,6 +152,7 @@ build instructions.
- [`IsLeftControlKeyDown()` / `IsRightControlKeyDown()`](#isleftcontrolkeydown--isrightcontrolkeydown)
- [`IsLeftAltKeyDown()` / `IsRightAltKeyDown()`](#isleftaltkeydown--isrightaltkeydown)
- [`IsModifierKeyDown()`](#ismodifierkeydown)
- [`IsMouseButtonDown([button])`](#ismousebuttondownbutton)
- [Instance](#instance)
- [`GetInstanceInfo()`](#getinstanceinfo)
@@ -178,6 +188,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)
@@ -191,6 +202,7 @@ 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)
- [Map](#map)
- [`C_Map.GetBestMapForUnit(unitToken)`](#c_mapgetbestmapforunitunittoken)
@@ -227,6 +239,7 @@ build instructions.
- [`C_QuestLog.IsOnQuest(questID)`](#c_questlogisonquestquestid)
- [`C_QuestLog.IsUnitOnQuest(unit, questID)`](#c_questlogisunitonquestunit-questid)
- [`C_QuestLog.GetTitleForQuestID(questID)`](#c_questloggettitleforquestidquestid)
- [`C_QuestLog.GetQuestDetails(questID)`](#c_questloggetquestdetailsquestid)
- [`C_QuestLog.IsQuestDataCachedByID(questID)`](#c_questlogisquestdatacachedbyidquestid)
- [`GetQuestLogLeaderBoardID(objectiveIndex [, questIndex])`](#getquestlogleaderboardidobjectiveindex--questindex)
@@ -329,6 +342,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)`
@@ -442,6 +569,29 @@ C_AddOns.IsAddOnLoadable("HardcoreTooltips") -- false, "DISABLED"
C_AddOns.IsAddOnLoadable("garbage") -- false, nil
```
### `C_AddOns.IsAddOnLoaded(indexOrName)`
Returns `loadedOrLoading, loaded` — two booleans.
```lua
C_AddOns.IsAddOnLoaded("DebugTools") -- true, true
C_AddOns.IsAddOnLoaded("garbage") -- false, false
C_AddOns.IsAddOnLoaded(1) -- true, true (first addon by index)
```
Modern WoW splits the two returns to distinguish "load-in-progress"
from "fully loaded" — the difference matters for `LoadOnDemand`
addons whose load is split across multiple `LoadAddOn` callbacks.
Vanilla 1.12's addon loader (`FUN_0051F240`) is fully synchronous:
the `loaded` byte flips inside a single call, so the in-flight
state is never observable from Lua. Both returns are always the
same boolean here. We surface the two-return shape so consumer
code written against modern API doesn't need to special-case
vanilla.
Unknown addons (numeric index past `GetNumAddOns()`, or
string name not in the registry) return `false, false`.
### `C_AddOns.GetAddOnSecurity(indexOrName)`
Returns an `Enum.AddOnSecurityStatus` integer (not a string — modern
@@ -648,10 +798,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)`
@@ -880,6 +1026,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,
@@ -1120,6 +1287,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
@@ -1172,7 +1416,7 @@ Modern's signatures take a numeric `iconFileID`; we accept icon path
strings (e.g. `"INV_Shield_06"`) since vanilla has no fileDataID
system. Same string-or-default fallback semantic as 4.3.4 native.
Cap is **10 sets per character**, matching 4.3.4. The full list re-
No cap on the number of sets per character. The full list re-
serializes on every mutation; a corrupted file is harmless (parse
errors leave the in-memory list empty and the next save rewrites the
file from scratch).
@@ -1277,9 +1521,8 @@ returns its setID. Honors `IgnoreSlotForSave` — slots flagged ignored
at call time get the ignored marker instead of the equipped item's
GUID.
Returns the new setID on success. Returns nothing if:
- the name is empty or already in use
- the cap of 10 sets is reached
Returns the new setID on success. Returns nothing if the name is
empty or already in use.
`icon` defaults to `"INV_Misc_QuestionMark"` if omitted.
@@ -2356,6 +2599,37 @@ existing `Script_GameTooltip_SetHyperlink` (registry slot 12).
> caveat, just with `C_Item.RequestLoadItemData(itemLocation)` /
> `Item:OnItemLoad`-style continuation.
### `GameTooltip:SetItemByGUID(itemGUID)`
Renders the tooltip for a specific item *instance* identified by
GUID — same string `C_Item.GetItemGUID` returns
(`"0xHHHHHHHHLLLLLLLL"`). Distinct from `SetItemByID`: this path
goes through the live CGItem, so the tooltip includes enchant
lines, random-suffix-decorated name + bonuses, and locked/broken
state — the same depth `SetBagItem` / `SetInventoryItem` would
produce for that specific copy.
```lua
local guid = C_Item.GetItemGUID({ equipmentSlotIndex = 10 }) -- legs
GameTooltip:SetOwner(UIParent, "ANCHOR_CURSOR")
GameTooltip:SetItemByGUID(guid)
GameTooltip:Show()
```
Resolves through the engine's own `FUN_OBJECT_RESOLVE_BY_GUID`
(same path `C_Item.GetItemLocation` uses), builds the full
`item:N:enchant:gem:gem:gem:gem:suffix:unique`-style hyperlink via
the engine's link builder, then dispatches to
`Script_GameTooltip_SetHyperlink`. The link round-trip is what
gives `GameTooltip:GetItem()` the dressed link back when called
after — same shape `GetInventoryItemLink` / `GetContainerItemLink`
emit for the same slot.
Silent no-op when the GUID is malformed, the item isn't currently
loaded in the client (e.g. items only the server knows about), or
the GUID resolves to a non-item object (creature/gameobject GUIDs
go to a different resolver and won't match).
### `GameTooltip:SetUnitAura(unit, index, [filter])`
Modern unified-aura method. 1.12 splits this into `SetUnitBuff` and
@@ -2718,14 +2992,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
```
@@ -3050,6 +3338,41 @@ otherwise. Equivalent to
`IsShiftKeyDown() or IsControlKeyDown() or IsAltKeyDown()` but in one
call.
### `IsMouseButtonDown([button])`
Returns `true` if the given mouse button is currently held, `false`
otherwise. With no argument (or `nil`), returns `true` if **any**
mouse button is held.
`button` is either a 1-based ID or a name string:
| ID | Name |
|-----|------|
| `1` | `"LeftButton"` |
| `2` | `"RightButton"` |
| `3` | `"MiddleButton"` |
| `4` | `"Button4"` (XBUTTON1 / first side button) |
| `5` | `"Button5"` (XBUTTON2 / second side button) |
Unrecognized IDs / names return `false` (matches modern semantics:
the named button just isn't held — bad input doesn't fall through
to the any-button check).
State is maintained from the same `WH_GETMESSAGE` hook that drives
`GLOBAL_MOUSE_DOWN` / `GLOBAL_MOUSE_UP`, so press/release transitions
register at exactly the moment the corresponding event fires. Hook
scopes to WoW's message queue — clicks made while the game is
alt-tabbed out don't deliver, which means the state can stay
"down" if a button was held while focus left the window. Most uses
of `IsMouseButtonDown` are gated behind a frame that just received
mouse focus, so this rarely matters in practice.
```lua
if IsMouseButtonDown("RightButton") then
-- right-button-held bindings
end
```
## Instance
### `GetInstanceInfo()`
@@ -4056,6 +4379,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"
@@ -4393,6 +4765,58 @@ 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.
## Map
### `C_Map.GetBestMapForUnit(unitToken)`
@@ -5233,6 +5657,80 @@ end)
C_QuestLog.RequestLoadQuestByID(215)
```
### `C_QuestLog.GetQuestDetails(questID)`
Returns a table with every field we know how to read from the engine's
quest static-info cache for `questID`, or `nil` if the data isn't
loaded. One round-trip alternative to calling
`GetTitleForQuestID` / `GetQuestLogRewardMoney` / etc. across each of
its individual accessors. Quest-helper-style addons that want
structured data per quest are the primary consumers.
Like `GetTitleForQuestID`, this is a pure cache probe — pair it with
`C_QuestLog.RequestLoadQuestByID` and listen for
`QUEST_DATA_LOAD_RESULT` if the quest isn't already cached.
#### Returned table
| Field | Type | Notes |
|-------|------|-------|
| `questID` | number | Echo of the input. |
| `title` | string | Locale-applied. |
| `level` | number | Quest level from the static record. |
| `questType` | string \| absent | Localized tag string — `"Elite"`, `"Group"`, `"PvP"`, `"Dungeon"`, `"Raid"` — sourced from `QuestInfo.dbc`. Field is omitted for plain quests with no tag. |
| `rewardMoney` | number | Reward copper. `0` if the quest *requires* money instead. |
| `requiredMoney` | number | Copper the player must hand in to complete (e.g. quartermaster contributions). `0` for quests with no money requirement. |
| `rewardMoneyAtMaxLevel` | number | Vanilla's level-60 reward bonus. Added to `rewardMoney` when the player is level 60; populated even on low-level quests. |
| `rewardSpellID` | number | `spellID` of a spell *taught* on completion (e.g. profession recipes), or `0` for no learned spell reward. |
| `srcItemID` | number | `itemID` the questgiver hands the player on accept (e.g. the sigil in *"Verdant Sigil"* — given on accept, read by the player, then turned back in). `0` = no source item. Same as the quest's `requirements[].id` for "give-then-return" quests. |
| `questFlags` | number | Raw `QUEST_FLAGS_*` bitfield — only bit `0x08` (sharable) is positively confirmed-tested by the vanilla engine; other bits are presumed-stored but unverified. |
| `isSharable` | boolean | Convenience extraction of bit `0x08` from `questFlags`. |
| `description` | string | The questgiver's narrative text. Raw — printf-style `$N` (player name), `$C` (class), `$R` (race) tokens are **not** substituted; use `string.gsub` if you need runtime values. |
| `objectives` | string | The "what you must do" summary text, same raw / un-substituted format as `description`. |
| `completionText` | string | The "now turn it in" text shown on the reward UI panel after objectives are met. Often empty for simple quests; populated for narrative-heavy ones. Modern API name: `GetQuestLogCompletionText`. |
| `poi` | table \| absent | Point-of-interest marker (`{mapID, x, y, opt}`). Set on quests with a server-supplied "go here" location; omitted when `mapID == 0`. |
| `rewardItems` | array of `{id, count}` | Items the quest gives unconditionally on turn-in. Empty when there are no fixed rewards. |
| `choiceItems` | array of `{id, count}` | "Pick one" reward items. Empty when none. |
| `requirements` | array of `{kind, id, count, text}` | Objectives. `kind` is `"monster"` (creature), `"object"` (gameobject), or `"item"` (collect / interact). `text` is the questgiver's per-objective override (e.g. `"Investigate the cave"` instead of the auto-generated `"Mor'shan Bear: 0/8"`); empty string means use the auto-format. Order: NPC/GO objectives first, then item objectives, both 1-indexed. Only non-empty slots included. |
> **Not included** — race, class, skill, time limit, and suggested-player count. Vanilla 1.12's `SMSG_QUEST_QUERY_RESPONSE` doesn't ship those fields; the server enforces them and filters quests *before* broadcasting, so the client only ever sees quests it could accept and never receives the static restriction values. Verified empirically: a race-restricted starter (`3120` Verdant Sigil — Night Elf Druid only), a dungeon quest (`914`), and a timed delivery (`3364` Scalding Mornbrew — 5-min authored timer) all have those cache slots zero-filled in memory. **Addons that need that data must source it from an external scraped database** like pfQuest's.
>
> **Field reliability:** everything currently returned is either confirmed-correct via the engine's own `Script_GetQuestLog*` accessors or empirically verified against in-game quest semantics. The `poi` field is the only remaining hypothesis — its offsets are confirmed but no test quest has yet exercised it.
XP rewards aren't exposed — vanilla 1.12 has no `GetQuestLogRewardXP`,
and per emulator-decoded packet structure, the server doesn't include
an XP field in `SMSG_QUEST_QUERY_RESPONSE`. XP is computed
server-side at turn-in from level-scaling tables.
#### Example
```lua
local f = CreateFrame("Frame")
f:RegisterEvent("QUEST_DATA_LOAD_RESULT")
f:SetScript("OnEvent", function()
if event == "QUEST_DATA_LOAD_RESULT" and arg2 == 1 then
local d = C_QuestLog.GetQuestDetails(arg1)
if d then
print(d.title, "level", d.level, d.questType or "(no tag)")
for _, r in ipairs(d.requirements) do
print(" -", r.kind, r.id, "x", r.count)
end
end
end
end)
C_QuestLog.RequestLoadQuestByID(1467)
```
Sample output for quest 1467 (`"Reagents for Reclaimers Inc."`):
```
Reagents for Reclaimers Inc. level 40 (no tag)
- item 6253 x 1
```
with `d.choiceItems = {{id=6793, count=1}, {id=6794, count=1}}`,
`d.rewardMoney = 3500`, `d.rewardMoneyAtMaxLevel = 1920`, etc.
### `C_QuestLog.IsQuestDataCachedByID(questID)`
Returns `true` if the quest's static data is currently in the
+33 -5
View File
@@ -186,17 +186,45 @@ void PushLocalizedString(void *L, const char *globalName, const char *fallback)
}
}
// In-place `string.gsub(stack[-1], pattern, replacement)` — replaces
// the string at the top of the stack with the substitution result.
// Discards gsub's second return (the substitution count) by
// requesting only one return from `Call`.
static void GsubInPlace(void *L, const char *pattern, const char *replacement) {
PushString(L, "string");
GetTable(L, GLOBALS_INDEX);
PushString(L, "gsub");
GetTable(L, -2); // [..., str, string, gsub]
Remove(L, -2); // [..., str, gsub]
Insert(L, -2); // [..., gsub, str]
PushString(L, pattern);
PushString(L, replacement);
Call(L, 3, 1); // [..., result]
}
void PushLocalizedFormatInt(void *L, const char *globalName,
const char *fallback, int n) {
// Resolve `string.format` from _G. The engine ships it as both a
// global and as `string.format`; the global is faster (one
// gettable instead of two) and equally available across builds.
// Resolve `string.format` from _G.
PushString(L, "format");
GetTable(L, GLOBALS_INDEX);
PushLocalizedString(L, globalName, fallback);
// Expand WoW's `|4singular:plural;` (and 3-form
// `|4nom:gen_sing:plural;`) pluralization template by piggybacking
// on Lua's `string.gsub`. The 2- vs 3-form distinction is locale
// grammar (Slavic counting has a separate genitive-singular form
// for 2..4); we collapse to nominative/plural since vanilla has
// no proper locale-aware counting rules. 3-form pattern runs
// first because it's more specific. Vanilla's `string.format`
// doesn't process `|4` natively, so without this the template
// would render literally.
const char *picker3 = (n == 1) ? "%1" : "%3";
const char *picker2 = (n == 1) ? "%1" : "%2";
GsubInPlace(L, "|4([^:;]+):([^:;]+):([^;]+);", picker3);
GsubInPlace(L, "|4([^:;]+):([^;]+);", picker2);
PushNumber(L, static_cast<double>(n));
Call(L, 2, 1); // format(fmt, n) → string
Call(L, 2, 1); // format(expandedFmt, n) → string
}
} // namespace Lua
+300 -4
View File
@@ -893,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
@@ -1060,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-
@@ -1443,6 +1488,35 @@ enum Offsets {
VAR_SKILL_LINE_COUNT = 0x00C0D930,
OFF_SKILL_LINE_NAME = 0x0C,
// Player's known-skills table — separate from the DBC. Populated
// by the engine on login from server data; entries are pointers
// to per-skill records. Each record's `+0x00` is the
// `SkillLine.dbc` row (or `0` for header / category rows), and
// `+0x10` is the current skill rank (other rank/modifier fields
// follow). Source: `Script_GetSkillLineInfo` at `0x004D3610`,
// which walks this array indexed by the Lua arg.
VAR_PLAYER_SKILL_LIST = 0x00B7318C,
VAR_PLAYER_SKILL_COUNT = 0x00B731B8,
OFF_PLAYER_SKILL_LINE_ID = 0x00,
OFF_PLAYER_SKILL_RANK = 0x10,
// Per-itemClass proficiency bitmap maintained by the engine.
// 17 u32 entries (indexed by Item.dbc `m_class`, 0..16). Each
// entry is a bitmask where bit N is set if the player has
// proficiency in subclass N of that item class — i.e.
// `table[4] & (1 << 3)` = "can wear Mail" (armor=class 4,
// mail=subclass 3).
//
// Populated by SMSG_SET_PROFICIENCY's handler at
// `FUN_005E7B70`, which writes `table[itemClass] = mask`
// verbatim from the packet. Same source the engine itself uses
// for equip eligibility (the tooltip "Mail" red-text logic
// and the cursor-drop-on-paperdoll check both read this). Note:
// covers **weapons too** (class 2), making it strictly more
// general than walking the per-skill-line table for individual
// armor proficiencies.
VAR_PROFICIENCY_TABLE = 0x00C4D4A0,
// Spell.dbc `BaseLevel` — the level a spell becomes available
// (trainer offers it, quest reward grants it, etc.). Distinct
// from `SpellLevel` at +0x74 which is the effective level used
@@ -1738,6 +1812,41 @@ enum Offsets {
// without re-parsing the body. See `Macro::Spell::Script_GetMacroSpell`.
FUN_MACRO_SLOT_TO_ENTRY = 0x004F0E40,
// Macro-icon database. Populated lazily by `FUN_LOAD_MACRO_ICONS`
// on the first `GetNumMacroIcons` call — enumerates `Interface\Icons\`
// for `*.blp` files plus a wildcard match, sorts, and de-dupes. Each
// entry is the basename (e.g. `"Ability_Kick"`) without the
// `Interface\Icons\` prefix; vanilla's `Script_GetMacroIconInfo`
// joins the prefix via sprintf before pushing. Verified by reading
// `Script_GetNumMacroIcons` (`0x004F19F0`) and
// `Script_GetMacroIconInfo` (`0x004F1A30`).
//
// Vanilla's loader has 3 enumeration passes, each with a per-file
// callback. The first two (`FUN_MACRO_ICON_CB_DISK`,
// `FUN_MACRO_ICON_CB_USER_MPQ`) prefix-filter on `"Ability_"` and
// `"Spell_"` — anything else (including `INV_*` item icons) is
// rejected. The third (`FUN_MACRO_ICON_CB_INSTALL_MPQ`) reads as
// extension-only filter in the disassembly (any `.blp`/`.tga` is
// accepted), but the engine's main icon DB ends up with zero
// `INV_*` entries regardless (`GetNumMacroIcons() == 746`, all
// `Ability_*`/`Spell_*`). Best guess: a check inside the
// `SStrDup`/array-append helpers downstream of all three callbacks
// filters them out — but the per-file callbacks themselves DO see
// `INV_*` filenames (verified by hook capture: 5,226 unique
// `INV_*` basenames flow through the callbacks per session).
//
// For `C_Macro::GetMacroItemIcons` we hook each callback at its
// entry, capture any `INV_*` filename into a DLL-owned side array,
// then forward to the original — dodges whichever downstream
// filter the engine applies and matches the parallel item-icon
// array 4.3.4 exposes (via `Script_GetMacroItemIcons`).
VAR_MACRO_ICON_COUNT = 0x00BDCC1C, // uint32 count of loaded icons
VAR_MACRO_ICON_ARRAY = 0x00BDCC20, // char ** — pointer to flat array of icon-name C strings (4-byte stride)
FUN_LOAD_MACRO_ICONS = 0x004F0090, // `__cdecl()` lazy populate; no-op if already loaded
FUN_MACRO_ICON_CB_DISK = 0x004F0220, // disk enumerator callback — `__fastcall(const char *fullPath)` — prefix-filtered
FUN_MACRO_ICON_CB_USER_MPQ = 0x004F0350, // user-MPQ enumerator callback — `__fastcall(MpqRecord *r)` — prefix-filtered
FUN_MACRO_ICON_CB_INSTALL_MPQ = 0x004F04F0, // install-MPQ enumerator callback — `__fastcall(MpqRecord *r)` — extension-only filter
// Quest log: 16-byte-stride entry array and active count.
// Field +0 of each entry is the questID for real quests (a category index
// for headers); field +8 is the header indicator: non-NULL = header,
@@ -2067,6 +2176,7 @@ enum Offsets {
// `Script_IsInInstance`).
VAR_CURRENT_MAP_ID = 0x00B4E378,
// Local player's current AreaTable.dbc area ID (u32 storage; only
// the low 16 bits are used — the value is broadcast over the wire
// as a u16 in `SMSG_PARTY_MEMBER_STATS`). Written by the zone-change
@@ -2315,6 +2425,86 @@ enum Offsets {
FUN_STORM_SMEM_ALLOC = 0x006462E0,
FUN_STORM_SMEM_FREE = 0x00646430,
// Engine's main file-content reader. Used by the TOC parser at
// `FUN_TOC_PARSER` to slurp addon `.toc` files and by the addon
// load pass for `.lua` source. Allocates a Storm buffer via
// `FUN_STORM_SMEM_ALLOC`, writes the file content + `extraBytes`
// trailing zeros, hands the buffer to the caller. Caller frees
// via `FUN_STORM_SMEM_FREE`.
//
// __cdecl int FileRead(
// int unused, // always 0 from engine call sites
// const char *path, // "Interface\AddOns\X\Y.lua"
// void **outBuf, // Storm-allocated content
// size_t *outSize, // optional, may be NULL
// size_t extraBytes, // trailing zero-fill (1 for nul-term)
// int flag1, // always 1 from TOC parser
// int flag2); // 0
// Returns 1 on success, 0 on failure.
//
// We hook this to redirect reads of `Interface\AddOns\!!!ClassicAPI\*`
// to embedded buffers (see src/addons/Embedded.cpp). Allocating via
// the same Storm allocator the engine uses means the caller's
// normal `SMemFree` works without us tracking lifetime.
FUN_FILE_READ = 0x00648620,
// TOC parser — `__fastcall(this=addonName)`. Already documented in
// CLAUDE.md under "AddOn registry & hot-reload". Dedup-safe: if
// the addon is already in the hash table, returns immediately
// without re-parsing. Reads the TOC file via `FUN_FILE_READ`. We
// call this from the post-`FUN_ADDON_INIT` hook to register the
// embedded `!!!ClassicAPI` addon — the dedup behavior means our
// call is a no-op when the user has the addon installed on disk.
FUN_TOC_PARSER = 0x0051C9B0,
// AddOn registry init — `__fastcall(accountName)`. Documented in
// CLAUDE.md ("AddOn registry & hot-reload"). Hooked post-call so
// we can splice the embedded addon entry into the freshly-populated
// registry before the load pass.
FUN_ADDON_INIT = 0x0051C740,
// Generic intrusive-list insert/move helper used by `FUN_TOC_PARSER`
// to thread new addon entries into the registry's doubly-linked
// list. Signature (`__thiscall`):
// `(ListCtrl *this, void *entry, int position, int anchor)`
// Where:
// - `this` — list-control struct (for the addon registry,
// this is `&VAR_ADDON_LIST_CTRL`).
// - `entry` — entry to insert. If already linked, the
// function removes it from its current
// position first.
// - `position` — `1` = insert at head, anything else (engine
// passes `2`) = insert at tail.
// - `anchor` — `0` = anchor against the list sentinel
// (default), nonzero = anchor relative to
// another entry's link field at that offset.
//
// Used by `Addons::Embedded` to move our injected `!!!ClassicAPI`
// entry from the tail (where `FUN_TOC_PARSER` puts it) to the head
// — vanilla's addon load pass at `FUN_0051F600` walks the linked
// list in insertion order, so without this fix our addon would
// load *last*, breaking dependent addons that expect our globals
// to be ready in their main chunk.
FUN_INTRUSIVE_LIST_INSERT = 0x00521AD0,
// Addon-registry intrusive-list control. Layout
// (`__thiscall`-friendly, generic across all intrusive lists in the
// binary):
// +0x00 (= `0x00BE1B64`) — `int` next-pointer offset within each
// entry (= `0xC` at runtime; entries
// store their next-link at
// `entry+0xC..0x10`).
// +0x04 (= `0x00BE1B68`) — sentinel `next` field
// (points back into &sentinel+1 when
// the list is empty).
// +0x08 (= `0x00BE1B6C`) — list head (alias for sentinel `prev`
// when treated as circular).
//
// `VAR_ADDON_LIST_CTRL` is just the address of the control struct
// (i.e., `0x00BE1B64`). Pass `&VAR_ADDON_LIST_CTRL` as the `this`
// arg to `FUN_INTRUSIVE_LIST_INSERT`.
VAR_ADDON_LIST_CTRL = 0x00BE1B64,
// `SStrDup(const char *src, const char *file, int line)`. `__stdcall`.
// Storm's string-copy wrapper around `SMemAlloc` — used by the engine
// itself to populate event entry names. We use it from `Event::Custom`
@@ -2449,9 +2639,11 @@ enum Offsets {
// We dispatch to these directly so the wrappers don't need to
// bounce through `Script_GetAddOnInfo` and unwind 7 returns.
//
// IS_LOAD_ON_DEMAND `__fastcall(entry) → byte`
// Hash-table lookup in the addon registry. Non-zero ⇒
// `## LoadOnDemand: 1` in the .toc.
// IS_LOADED `__fastcall(entry) → byte`
// Hash-table lookup in the addon registry. Returns the byte
// at `entry+0x18`, which the addon loader (`FUN_0051F240`)
// writes `1` to when the addon has finished loading. Non-zero
// ⇒ the addon is loaded.
//
// CAN_LOAD `__fastcall(entry, char fullCheck=1, int *outStatus,
// int *outDepHandle, const char *accountName) → byte`
@@ -2472,7 +2664,7 @@ enum Offsets {
// Hash-table lookup that returns the security category index
// (0=SECURE, 1=INSECURE, 2=BANNED). Defaults to `1` (INSECURE)
// when the entry isn't in the override table.
FUN_ADDON_IS_LOAD_ON_DEMAND = 0x0051E6F0,
FUN_ADDON_IS_LOADED = 0x0051E6F0,
FUN_ADDON_CAN_LOAD = 0x0051E780,
FUN_ADDON_GET_SECURITY_INDEX = 0x0051E990,
@@ -3105,4 +3297,108 @@ enum Offsets {
SMSG_OPCODE_MIRROR_TIMER_PAUSE = 0x1DA,
SMSG_OPCODE_MIRROR_TIMER_STOP = 0x1DB,
MIRROR_TIMER_SLOT_COUNT = 3,
// `realmList` CVar reader — returns the configured realmlist
// address (e.g. "logon.turtle-wow.org") as a `const char *`, or
// NULL when unset. `__fastcall(int forceRefresh)`; pass 0 to read
// the cached value (callers in the engine pass non-zero only
// around CMSG_AUTH_SESSION reconnects). Lazily registers the CVar
// on first call.
//
// This is the realmlist *address*, not the display name (the
// friendly server name visible in the realm-select dialog lives
// in the `realmName` CVar at `FUN_005AB7D0`, read by
// `Script_GetServerName`). Credentials are scoped on this value
// because two different private servers may share the same
// friendly name but never the same realmlist address.
FUN_CVAR_REALM_LIST = 0x005AB680,
// Engine login entry — the C function `Script_DefaultServerLogin`
// (the glue Lua binding at `0x0046D160`) tail-calls into here with
// the account/password as raw C strings. We invoke it directly from
// C++ when dispatching a saved-credential login, bypassing the
// round-trip through Lua so the plaintext never crosses the
// Lua boundary on the way out.
//
// __fastcall void(ecx = const char *account, edx = char *password)
//
// The function:
// 1. Gates on three globals — VAR_GLUE_LOGIN_READY1,
// VAR_GLUE_LOGIN_READY2 (both must be nonzero) and
// VAR_GLUE_LOGIN_INPROGRESS (must be zero). All set up by the
// normal glue boot flow; a call made while the AccountLogin
// screen is showing is safe, an off-screen call silently no-ops.
// 2. Copies the account into a 0x40-byte buffer at `0x00B41DB0`
// and uppercases it in place.
// 3. Fires the "connecting" status event.
// 4. Hands the (uppercased account, password) pair to the SRP
// initiator at `FUN_005AB4B0`.
// 5. **Zeros the caller's password buffer in place** via an
// unrolled `REP STOSD` loop counting strlen bytes — so the
// decrypted-on-stack buffer we feed in gets scrubbed for free
// after the SRP step consumes it.
// 6. Calls into the AddOn-registry init (`FUN_0051C740`) with the
// uppercased account name. Same call documented in CLAUDE.md
// under "AddOn registry & hot-reload".
FUN_GLUE_LOGIN_ATTEMPT = 0x0046AFB0,
// Glue-state readiness flags read by `FUN_GLUE_LOGIN_ATTEMPT`'s
// prologue. Names reflect the gate-condition logic, not anything
// intrinsic to the engine — the engine just checks `R1 && R2 && !IP`.
VAR_GLUE_LOGIN_READY1 = 0x00B41DFC,
VAR_GLUE_LOGIN_READY2 = 0x00B41E04,
VAR_GLUE_LOGIN_INPROGRESS = 0x00B41DA0,
// Cursor state — single global type-tag at VAR_CURSOR_TYPE
// dispatches reads through one of several payload slots depending
// on what's picked up. The engine's `CursorHasItem` /
// `CursorHasSpell` / `GetCursorMoney` and the various `Pickup*`
// functions all read/write through these. Discovered by tracing
// `Script_CursorHasItem` (`0x004895D0`) and the writers in
// `FUN_00494B60` (item), `FUN_00494CC0` (money), `FUN_00494D20`
// (spell), `FUN_00494E20` (pet action), `FUN_00494F80` (macro),
// `FUN_004950F0` (action/inv/etc.), `FUN_00495010` (merchant).
//
// Type values:
// 0 empty (nothing on cursor)
// 1 bag item — GUID at `VAR_CURSOR_ITEM_GUID_LO/_HI`,
// itemID resolved by feeding the GUID to
// `FUN_OBJECT_FROM_GUID` (filter 0x2).
// 2 money — copper at `VAR_CURSOR_MONEY_COPPER`
// 3 player spell — spellID at `VAR_CURSOR_SPELL_ID`
// 4 pet action — packed at `VAR_CURSOR_PETACTION_PACKED`
// 5 merchant item — itemID at `VAR_CURSOR_GENERIC_SLOT`,
// 0-based merchant slot at `VAR_CURSOR_GENERIC_SOURCE`.
// Written by `Script_PickupMerchantItem` (`0x004FB760`) →
// `FUN_004950F0(itemID, ?, slot0, 5)`.
// 6/7 other drag-source items — itemID at
// `VAR_CURSOR_GENERIC_SLOT`. Type 7 is action-bar pickup
// (`FUN_004E6130`); type 6 is another drag path (mail/etc.).
// 8 macro — 1-based index at `VAR_CURSOR_MACRO_INDEX`
// 9 inventory item (equipped slot) — itemID stored at
// `VAR_CURSOR_GENERIC_SLOT`. Bare itemID rather than a GUID
// because the item is out of the descriptor stream while
// on the cursor.
// 10 stable pet — 1-based index at `VAR_CURSOR_STABLEPET_INDEX`.
// Written by `Script_ClickStablePet` (`FUN_004CB420`) →
// `FUN_00495010(slot)`.
//
// `CursorHasItem` returns true for types 1 *and* 9 only.
VAR_CURSOR_TYPE = 0x00B4D900,
VAR_CURSOR_ITEM_GUID_LO = 0x00B4E248,
VAR_CURSOR_ITEM_GUID_HI = 0x00B4E24C,
VAR_CURSOR_MONEY_COPPER = 0x00B4E2F0,
VAR_CURSOR_SPELL_ID = 0x00B4E2F4,
VAR_CURSOR_PETACTION_PACKED = 0x00B4E2F8,
VAR_CURSOR_MACRO_INDEX = 0x00B4E2FC,
VAR_CURSOR_STABLEPET_INDEX = 0x00B4E300,
VAR_CURSOR_GENERIC_SLOT = 0x00B4B41C,
VAR_CURSOR_GENERIC_DISPLAY = 0x00B4D8EC,
VAR_CURSOR_GENERIC_SOURCE = 0x00B4B420,
// GUID → CGObject resolver. `__fastcall(type_filter, guid_lo,
// guid_hi) → CGObject *` at `0x00468460`. Type filter 0x2 = item,
// matching what `FUN_00494B60` and the trade-attached paths pass
// when re-resolving the cursor GUID to a `CGItem *`.
FUN_OBJECT_FROM_GUID = 0x00468460,
};
+370
View File
@@ -0,0 +1,370 @@
// 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);
using ListInsert_t = void(__thiscall *)(void *listCtrl, void *entry,
int position, int anchor);
// `FUN_TOC_PARSER` appends new entries to the *tail* of the addon
// registry's linked list, and the engine's addon-load pass at
// `FUN_0051F600` walks that list in *insertion order* (NOT
// alphabetical order — the alphabetical sort only applies to the
// flat array `GetAddOnInfo(i)` reads from). So our post-hook
// injection lands at the very end of the load order, after every
// disk-scanned addon. Addons that consume our globals from their
// main chunk (idTip's `FrameWatcher`, etc.) would see `nil` and
// hard-error during their boot.
//
// Fix: walk the linked list, find our entry by name, and re-link
// it to the head via `FUN_INTRUSIVE_LIST_INSERT(position=1)`. The
// engine's own insert helper handles remove-then-insert atomically,
// so it's safe to call on an entry that's already in the list.
//
// Entries are pointed to by `[VAR_ADDON_LIST_HEAD]` (`0x00BE1B6C`),
// with the next-pointer field at `entry + 0x10` (computed as
// `*(value-at-VAR_ADDON_LIST_CTRL) + entry + 4` = `0xC + entry + 4`
// = `entry + 0x10`). Low-bit-1 means "sentinel" (end of list).
// Name pointer lives at `entry + 0x14`.
constexpr uintptr_t VAR_ADDON_LIST_HEAD = 0x00BE1B6C;
constexpr int OFF_ADDON_ENTRY_NAME_PTR = 0x14;
void MoveEmbeddedToListHead() {
uintptr_t entry = *reinterpret_cast<uintptr_t *>(VAR_ADDON_LIST_HEAD);
const int linkOffset = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL));
while ((entry & 1) == 0 && entry != 0) {
const char *name = *reinterpret_cast<const char *const *>(
entry + OFF_ADDON_ENTRY_NAME_PTR);
if (name != nullptr && std::strcmp(name, kAddonName) == 0) {
auto fn = reinterpret_cast<ListInsert_t>(
Offsets::FUN_INTRUSIVE_LIST_INSERT);
fn(reinterpret_cast<void *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL)),
reinterpret_cast<void *>(entry),
/*position=*/ 1, /*anchor=*/ 0);
return;
}
entry = *reinterpret_cast<const uintptr_t *>(
entry + linkOffset + 4);
}
}
void __fastcall AddonInit_h(const char *accountName) {
AddonInit_o(accountName);
auto TocParser = reinterpret_cast<TocParser_t>(Offsets::FUN_TOC_PARSER);
TocParser(kAddonName);
MoveEmbeddedToListHead();
}
const Game::HookAutoRegister _hookFileRead{
Offsets::FUN_FILE_READ,
reinterpret_cast<void *>(&FileRead_h),
reinterpret_cast<void **>(&FileRead_o)};
const Game::HookAutoRegister _hookAddonInit{
Offsets::FUN_ADDON_INIT,
reinterpret_cast<void *>(&AddonInit_h),
reinterpret_cast<void **>(&AddonInit_o)};
} // namespace
} // namespace Addons::Embedded
+51 -4
View File
@@ -115,13 +115,24 @@ int PushFieldOrNil(void *L, FieldByName_t accessor) {
// `C_AddOns.GetAddOnName(indexOrName)` — the addon's directory name.
// For numeric input, returns the engine's canonical casing. For
// string input, returns the input verbatim once existence is
// confirmed.
// confirmed; nil for an unregistered name.
//
// String-input existence isn't free: `ResolveAddOnName` echoes the
// string for the field accessors below (`GetAddOnTitle`, etc.) which
// short-circuit cleanly on unknown names via the engine's own NULL-
// return paths. `GetAddOnName` doesn't call any engine accessor, so
// it'd otherwise echo any string. Probe the registry explicitly.
int __fastcall Script_GetAddOnName(void *L) {
const uint8_t *entry = ResolveAddOnName(L);
if (entry == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_STRING &&
!AddOnExistsByName(reinterpret_cast<const char *>(entry))) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushString(L, reinterpret_cast<const char *>(entry));
return 1;
}
@@ -163,9 +174,13 @@ int __fastcall Script_IsAddOnLoadable(void *L) {
return 2;
}
auto isLoD = reinterpret_cast<EntryByteFn_t>(
Offsets::FUN_ADDON_IS_LOAD_ON_DEMAND);
if (isLoD(entry) != 0) {
// Already-loaded addons are trivially loadable — skip the
// recursive dep walk and return (true, nil) directly. `IS_LOADED`
// reads the byte at `entry+0x18` (the loader sets that to 1 on
// successful load).
auto isLoaded = reinterpret_cast<EntryByteFn_t>(
Offsets::FUN_ADDON_IS_LOADED);
if (isLoaded(entry) != 0) {
Game::Lua::PushBoolean(L, 1);
Game::Lua::PushNil(L);
return 2;
@@ -229,6 +244,36 @@ int __fastcall Script_GetAddOnSecurity(void *L) {
return 1;
}
// `C_AddOns.IsAddOnLoaded(indexOrName)` → `(loadedOrLoading, loaded)`.
//
// Modern WoW distinguishes "load-in-progress" from "fully loaded" —
// matters for LoD addons whose load is split across multiple
// `LoadAddOn` callbacks. Vanilla 1.12's `FUN_0051F240` is fully
// synchronous: the loaded byte at `entry+0x18` flips from 0 → 1
// inside a single call, so the "loading" state is never observable
// from Lua. We return the same boolean twice — `loadedOrLoading`
// and `loaded` are identical here.
//
// Resolves both numeric indices (via `FUN_ADDON_GET_BY_INDEX`) and
// string names. `IS_LOADED` accepts an entry pointer as its name arg
// because the entry's first 12 bytes are the inline NUL-terminated
// directory name (same as the other per-field accessors), so we can
// feed it either an entry or a Lua-side string transparently.
int __fastcall Script_IsAddOnLoaded(void *L) {
const uint8_t *entry = ResolveAddOnName(L);
if (entry == nullptr) {
Game::Lua::PushBool(L, false);
Game::Lua::PushBool(L, false);
return 2;
}
auto isLoaded = reinterpret_cast<EntryByteFn_t>(
Offsets::FUN_ADDON_IS_LOADED);
const bool loaded = isLoaded(entry) != 0;
Game::Lua::PushBool(L, loaded);
Game::Lua::PushBool(L, loaded);
return 2;
}
// `C_AddOns.DoesAddOnExist(indexOrName)` — true iff the engine's
// addon registry has a matching entry. For numeric input we trust
// `GetByIndex`'s OOR-NULL; for string input we walk the flat array,
@@ -276,6 +321,8 @@ static void RegisterLuaFunctions() {
&Script_GetAddOnSecurity);
Game::Lua::RegisterTableFunction("C_AddOns", "DoesAddOnExist",
&Script_DoesAddOnExist);
Game::Lua::RegisterTableFunction("C_AddOns", "IsAddOnLoaded",
&Script_IsAddOnLoaded);
Game::Lua::RegisterIntegerEnum(
"Enum", "AddOnSecurityStatus",
kAddOnSecurityStatusEntries,
+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
+136 -16
View File
@@ -35,6 +35,7 @@
#include "Offsets.h"
#include "Set.h"
#include "event/Custom.h"
#include "item/Location.h"
#include "item/Swap.h"
#include <cstdint>
@@ -401,39 +402,158 @@ int __fastcall Script_UseEquipmentSet(void *L) {
uint64_t guids[SLOT_COUNT];
std::memcpy(guids, s->items, sizeof(guids));
// Snapshot empty bag slots up front. The CGItem state doesn't
// update between packet sends (server has to acknowledge each
// swap), so iterating the bag walk fresh per unequip would
// repeatedly pick the same "first empty" slot — second packet
// collides with what packet 1 just put there, and the server
// tries to swap that item back onto the wrong paperdoll slot
// → "This item cannot be equipped".
struct FreeSlot {
int bag;
int slot;
};
FreeSlot freeSlots[64];
int freeSlotCount = 0;
for (int bag = 0; bag <= 4 && freeSlotCount < 64; ++bag) {
const int slots = Item::Location::GetBagSlotCount(bag);
for (int slot = 1; slot <= slots && freeSlotCount < 64; ++slot) {
if (Item::Location::ResolveBag(L, bag, slot) == nullptr)
freeSlots[freeSlotCount++] = {bag, slot};
}
}
int nextFreeSlot = 0;
// Pre-snapshot each set item's location against the ORIGINAL
// state, before any swap packets fly. This mirrors how 4.3.4's
// `Script_UseEquipmentSet` (`0x009A15D0`) builds its action list:
// resolve all 19 source locations up front, then execute. The
// engine's CGItem / InvMgr state lags behind our packet sends
// (waits for server ACK), so any `FindGUID` mid-execution would
// return stale answers and we'd re-resolve items into the wrong
// place.
int sourceLoc[SLOT_COUNT];
for (int i = 0; i < SLOT_COUNT; ++i) {
const uint64_t g = guids[i];
if (g == GUID_EMPTY || g == GUID_IGNORED) {
sourceLoc[i] = 0;
continue;
}
sourceLoc[i] = Locations::FindGUID(g);
}
// Virtual paperdoll occupant model — seeded from the engine's
// initial paperdoll GUID array, then updated in-place after each
// swap so phase 1 can chain rotations and phase 3 can spot
// already-empty slots.
uint64_t virtualPaperdoll[SLOT_COUNT];
Locations::SnapshotPaperdoll(virtualPaperdoll);
// Locates the current virtual slot of a GUID on the paperdoll,
// or 0 if it's not currently there. Used during phase 1 so
// chained paperdoll-to-paperdoll moves operate on the *current*
// virtual position, not the stale pre-snapshot.
auto currentPaperdollSlot = [&](uint64_t g) -> int {
if (g == 0)
return 0;
for (int slot = 1; slot <= SLOT_COUNT; ++slot) {
if (virtualPaperdoll[slot - 1] == g)
return slot;
}
return 0;
};
// ── Phase 1: paperdoll → paperdoll ─────────────────────────────
// Move items already on the paperdoll into their target slots.
// Source and destination are both paperdoll, so atomic swaps
// never touch bag state — phase 2's bag lookups stay accurate.
for (int i = 0; i < SLOT_COUNT; ++i) {
const int targetSlot = i + 1;
const uint64_t g = guids[i];
if (g == GUID_EMPTY || g == GUID_IGNORED)
continue;
const int loc = Locations::FindGUID(g);
const int loc = sourceLoc[i];
if (loc == 0)
continue; // missing — nothing to do
// Already in its target slot? Skip.
if ((loc & LOC_BAGS) == 0 && (loc & LOC_BANK) == 0 &&
(loc & LOC_SLOT_MASK) == targetSlot)
continue;
// In bank (main or bank-bag) — server rejects equip-from-bank.
if (loc & LOC_BANK)
continue;
continue; // server rejects equip-from-bank
if (loc & LOC_BAGS)
continue; // phase 2
const int srcSlot = currentPaperdollSlot(g);
if (srcSlot == 0 || srcSlot == targetSlot)
continue; // missing or already there
const uint8_t *item = Locations::ResolveItemByGUID(g);
if (item == nullptr)
continue;
if (loc & LOC_BAGS) {
const int srcBag = (loc >> LOC_BAG_SHIFT) & 0xFF;
const int srcSlot = loc & LOC_SLOT_MASK;
Item::Swap::FromBag(item, srcBag, srcSlot, targetSlot);
} else {
const int srcSlot = loc & LOC_SLOT_MASK;
Item::Swap::FromPaperdoll(item, srcSlot, targetSlot);
const uint64_t displaced = virtualPaperdoll[targetSlot - 1];
if (Item::Swap::FromPaperdoll(item, srcSlot, targetSlot)) {
virtualPaperdoll[targetSlot - 1] = g;
virtualPaperdoll[srcSlot - 1] = displaced;
}
}
// ── Phase 2: bag → paperdoll ───────────────────────────────────
// Move items from bag slots onto the paperdoll. Each atomic swap
// displaces the current paperdoll occupant into the bag slot the
// incoming item vacated. Anything we still wanted on the paperdoll
// was already placed by phase 1, so phase 2 displacements can't
// strand a future set item in a bag.
for (int i = 0; i < SLOT_COUNT; ++i) {
const int targetSlot = i + 1;
const uint64_t g = guids[i];
if (g == GUID_EMPTY || g == GUID_IGNORED)
continue;
if (virtualPaperdoll[targetSlot - 1] == g)
continue; // already at target (probably by phase 1)
const int loc = sourceLoc[i];
if (loc == 0)
continue;
if (loc & LOC_BANK)
continue;
if ((loc & LOC_BAGS) == 0)
continue; // not from a bag — phase 1 handled, or item missing
const uint8_t *item = Locations::ResolveItemByGUID(g);
if (item == nullptr)
continue;
const int srcBag = (loc >> LOC_BAG_SHIFT) & 0xFF;
const int srcSlot = loc & LOC_SLOT_MASK;
if (Item::Swap::FromBag(item, srcBag, srcSlot, targetSlot)) {
virtualPaperdoll[targetSlot - 1] = g;
// The displaced item lands in (srcBag, srcSlot). We
// don't track bag-side virtual state — by phase 3 we
// only care about the paperdoll occupant.
}
}
// ── Phase 3: unequip slots the set wants empty ─────────────────
// For each `GUID_EMPTY` slot, send the current paperdoll occupant
// (if any) to a pre-snapshotted free bag slot. Runs last so any
// displaced items from phases 1/2 that landed in the slot get
// unequipped here too.
for (int i = 0; i < SLOT_COUNT; ++i) {
const int targetSlot = i + 1;
if (guids[i] != GUID_EMPTY)
continue;
const uint64_t occupant = virtualPaperdoll[targetSlot - 1];
if (occupant == 0)
continue;
if (nextFreeSlot >= freeSlotCount)
continue;
const uint8_t *equipped = Locations::ResolveItemByGUID(occupant);
if (equipped == nullptr)
continue;
const FreeSlot &fs = freeSlots[nextFreeSlot++];
if (Item::Swap::ToBag(equipped, targetSlot, fs.bag, fs.slot))
virtualPaperdoll[targetSlot - 1] = 0;
}
const int evt = Event::Custom::Lookup(kSwapFinishedEvent);
if (evt >= 0)
Event::Custom::Fire(evt, "%d%d", 1, static_cast<int>(setID));
-2
View File
@@ -140,8 +140,6 @@ uint32_t Create(const char *name, const char *icon) {
EnsureLoaded();
if (g_path.empty() || name == nullptr || name[0] == '\0')
return 0;
if (g_sets.size() >= MAX_SETS)
return 0;
if (FindByName(name) != nullptr)
return 0;
Set s;
+11
View File
@@ -177,4 +177,15 @@ const uint8_t *ResolveItemByGUID(uint64_t guid) {
return Item::Location::ResolveByGUID(guid);
}
void SnapshotPaperdoll(uint64_t *out) {
for (int i = 0; i < SLOT_COUNT; ++i)
out[i] = 0;
auto *invMgr = ResolvePlayerInvMgr();
auto *guids = InvMgrGuidArray(invMgr);
if (guids == nullptr)
return;
for (int i = 0; i < SLOT_COUNT; ++i)
out[i] = guids[i];
}
} // namespace EquipmentSet::Locations
+12
View File
@@ -42,4 +42,16 @@ int FindGUID(uint64_t targetGuid);
// isn't known to the client. Side-effect free.
const uint8_t *ResolveItemByGUID(uint64_t guid);
// Copies the current paperdoll GUIDs into `out[0..SLOT_COUNT-1]`,
// where `out[i]` is the GUID at 1-based equipment slot `i+1`.
// Empty / unequipped slots write 0. Reads from the player invMgr's
// flat GUID array directly — no Lua stack, no engine state changes.
//
// Used by `UseEquipmentSet` to seed a "virtual paperdoll" model that
// stays current across in-flight swaps. The engine's CGItem state
// lags behind packet sends (server has to ACK each swap), so any
// subsequent `FindGUID` after the first packet would return stale
// pre-swap locations. Tracking moves locally bypasses that lag.
void SnapshotPaperdoll(uint64_t *out);
} // namespace EquipmentSet::Locations
-5
View File
@@ -56,11 +56,6 @@ inline const char *DefaultIcon() { return "INV_Misc_QuestionMark"; }
// (1.15.x is also 32). 4.3.4 used the same buffer size.
constexpr int MAX_NAME_LEN = 31;
// Hard cap matching 4.3.4 behavior. Modern Classic Era (1.15) raised
// this to 30; we follow the 4.3.4 number so legacy addons that show
// "X / 10" don't surprise users.
constexpr int MAX_SETS = 10;
// `GetItemLocations` encoding — matches Blizzard FrameXML's
// `EquipmentManager_UnpackLocation` (constants are in
// `Constants.lua`: `ITEM_INVENTORY_LOCATION_*` and
+71 -1
View File
@@ -18,6 +18,12 @@
// per-frame `OnMouseDown` scripts — these fire even when the click
// misses any UI frame.
//
// `IsMouseButtonDown([button])` — query the current held state of a
// specific button by name or 1-based ID, or "any button held" when
// called with no args. State is maintained from the same hook stream
// as the events (single source of truth — events firing and the
// state being readable happen at the same point in the pipeline).
//
// Same `WH_GETMESSAGE` thread-message hook pattern as
// [Input::Modifier](Modifier.cpp): we intercept the WM_*BUTTON*
// messages on the engine's main thread *before* dispatch. The hook
@@ -28,6 +34,9 @@
#include "Game.h"
#include "event/Custom.h"
#include <cstdint>
#include <cstring>
#include <windows.h>
namespace Input::GlobalMouse {
@@ -37,6 +46,19 @@ namespace {
constexpr const char *kDownEvent = "GLOBAL_MOUSE_DOWN";
constexpr const char *kUpEvent = "GLOBAL_MOUSE_UP";
// Held-state bitmap — bit `i` set means the button with 1-based
// `IsMouseButtonDown` ID `i+1` is currently held. Modern API IDs:
// 1 = LeftButton, 2 = RightButton, 3 = MiddleButton,
// 4 = Button4 (XBUTTON1), 5 = Button5 (XBUTTON2).
constexpr int BIT_LEFT_BUTTON = 0;
constexpr int BIT_RIGHT_BUTTON = 1;
constexpr int BIT_MIDDLE_BUTTON = 2;
constexpr int BIT_BUTTON4 = 3;
constexpr int BIT_BUTTON5 = 4;
constexpr uint32_t MASK_ANY_BUTTON = 0x1F;
uint32_t g_mouseButtonMask = 0;
HHOOK g_msgHook = nullptr;
// Maps WM_*BUTTON{DOWN,UP} → ("LeftButton"|..., isDown). Returns
@@ -60,16 +82,60 @@ const char *DecodeButton(UINT msg, WPARAM wParam, bool *outDown) {
}
}
// Maps a "LeftButton"/.../"Button5" name to its bit index. -1 for
// any other string.
int NameToBitIdx(const char *name) {
if (name == nullptr)
return -1;
if (std::strcmp(name, "LeftButton") == 0) return BIT_LEFT_BUTTON;
if (std::strcmp(name, "RightButton") == 0) return BIT_RIGHT_BUTTON;
if (std::strcmp(name, "MiddleButton") == 0) return BIT_MIDDLE_BUTTON;
if (std::strcmp(name, "Button4") == 0) return BIT_BUTTON4;
if (std::strcmp(name, "Button5") == 0) return BIT_BUTTON5;
return -1;
}
void ProcessMouseMessage(UINT msg, WPARAM wParam) {
bool down = false;
const char *name = DecodeButton(msg, wParam, &down);
if (name == nullptr)
return;
const int bitIdx = NameToBitIdx(name);
if (bitIdx >= 0) {
const uint32_t bit = 1u << bitIdx;
if (down) g_mouseButtonMask |= bit;
else g_mouseButtonMask &= ~bit;
}
const int slot = Event::Custom::Lookup(down ? kDownEvent : kUpEvent);
if (slot >= 0)
Event::Custom::Fire(slot, "%s", name);
}
int __fastcall Script_IsMouseButtonDown(void *L) {
bool down;
if (Game::Lua::GetTop(L) == 0) {
// No-arg form: any button held.
down = (g_mouseButtonMask & MASK_ANY_BUTTON) != 0;
} else {
int bitIdx = -1;
if (Game::Lua::IsNumber(L, 1)) {
const int id = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (id >= 1 && id <= 5)
bitIdx = id - 1;
} else if (Game::Lua::IsString(L, 1)) {
bitIdx = NameToBitIdx(Game::Lua::ToString(L, 1));
}
// Unrecognized button (bad id, unknown name) → false, matching
// the modern API's "did this specific button happen to be held"
// semantic. Bad input doesn't promote to the any-button check.
down = (bitIdx >= 0) && (g_mouseButtonMask & (1u << bitIdx)) != 0;
}
Game::Lua::PushBool(L, down);
return 1;
}
LRESULT CALLBACK GetMsgHook(int code, WPARAM wParam, LPARAM lParam) {
// Only process when the message is being removed from the queue
// (`PM_REMOVE` / `GetMessage`); `PM_NOREMOVE` peeks would deliver
@@ -88,7 +154,11 @@ void InstallHook() {
GetCurrentThreadId());
}
void RegisterLuaFunctions() { InstallHook(); }
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("IsMouseButtonDown",
&Script_IsMouseButtonDown);
InstallHook();
}
const Event::Custom::AutoReserve _reserveDown{kDownEvent};
const Event::Custom::AutoReserve _reserveUp{kUpEvent};
+51 -4
View File
@@ -105,6 +105,47 @@ bool FitsSlot(int slot1Based, uint32_t invType) {
return (kSlotMaskByInvType[invType] & (1u << (slot1Based - 1))) != 0;
}
// ── Proficiency gate ──────────────────────────────────────────────
// `FitsSlot` only checks invType→slot shape compatibility; without
// also gating on the player's proficiencies this would return (e.g.)
// mail helms for a warlock since both are `invType=HEAD`. Modern
// WoW's equipment-set picker filters by what the player can actually
// equip; we mirror that.
//
// Source of truth: the engine's per-itemClass proficiency bitmap at
// `[VAR_PROFICIENCY_TABLE]` (17 u32 entries indexed by `m_class`).
// SMSG_SET_PROFICIENCY's handler at `0x005E7B70` writes the bitmask
// for each class verbatim from the wire. Bit N of `table[class]` =
// "player can equip subclass N of this class". Same data the engine
// itself reads for the tooltip "Mail/Leather/Plate" red-text and the
// cursor-drop-on-paperdoll-slot check — covers both armor and weapon
// proficiencies in one lookup.
//
// 4.3.4's `Script_GetInventoryItemsForSlot` does the equivalent check
// via `FUN_00553AD0(itemClass) & (1 << subclass)`, reading from
// `DAT_00DD4D38` (its own copy of the same packet-fed table).
bool PlayerHasProficiency(uint32_t itemClass, uint32_t subclass) {
if (itemClass > 16)
return true; // table only covers 0..16; be permissive past that
const uint32_t mask = *reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_PROFICIENCY_TABLE) + itemClass * 4);
if (mask == 0)
return true; // table not populated yet (pre-SMSG_SET_PROFICIENCY)
// — fail open so empty-mask doesn't blank the list
return (mask & (1u << subclass)) != 0;
}
bool PlayerCanEquip(const uint8_t *record) {
if (record == nullptr)
return true; // missing record — be permissive, don't hide the item
const uint32_t classCode = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CLASS);
const uint32_t subclass = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SUBCLASS);
return PlayerHasProficiency(classCode, subclass);
}
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
@@ -112,11 +153,14 @@ const uint8_t *PeekItemRecord(uint32_t itemID) {
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
uint32_t ReadInventoryType(const uint8_t *cgItem) {
const uint8_t *ItemRecordForCGItem(const uint8_t *cgItem) {
const int itemID = Item::ID::FromCGItem(cgItem);
if (itemID <= 0)
return 0;
auto *record = PeekItemRecord(static_cast<uint32_t>(itemID));
return nullptr;
return PeekItemRecord(static_cast<uint32_t>(itemID));
}
uint32_t ReadInventoryType(const uint8_t *record) {
if (record == nullptr)
return 0;
return *reinterpret_cast<const uint32_t *>(
@@ -132,7 +176,10 @@ void CollectIfFits(int slot, const uint8_t *cgItem, int location,
std::vector<Hit> &out) {
if (cgItem == nullptr)
return;
if (!FitsSlot(slot, ReadInventoryType(cgItem)))
const uint8_t *record = ItemRecordForCGItem(cgItem);
if (!FitsSlot(slot, ReadInventoryType(record)))
return;
if (!PlayerCanEquip(record))
return;
out.push_back({location, cgItem});
}
+37
View File
@@ -146,6 +146,43 @@ const uint8_t *ResolveByGUID(uint64_t guid) {
0x172));
}
bool FindByItemID(void *L, int itemID, ByGUIDResult *out) {
if (itemID <= 0)
return false;
for (int slot = Offsets::EQUIPMENT_SLOT_FIRST;
slot <= Offsets::EQUIPMENT_SLOT_LAST; ++slot) {
auto *item = ResolveEquipmentSlot(slot);
if (item == nullptr)
continue;
if (Item::ID::FromCGItem(item) == itemID) {
out->equipmentSlotIndex = slot;
out->bagID = 0;
out->slotIndex = 0;
out->item = item;
return true;
}
}
for (int bagID = 0; bagID <= 4; ++bagID) {
const int slotCount = GetBagSlotCount(bagID);
for (int slotIndex = 1; slotIndex <= slotCount; ++slotIndex) {
auto *item = ResolveBag(L, bagID, slotIndex);
if (item == nullptr)
continue;
if (Item::ID::FromCGItem(item) == itemID) {
out->equipmentSlotIndex = 0;
out->bagID = bagID;
out->slotIndex = slotIndex;
out->item = item;
return true;
}
}
}
return false;
}
bool FindByGUID(void *L, uint64_t guid, ByGUIDResult *out) {
if (guid == 0)
return false;
+10
View File
@@ -86,6 +86,16 @@ struct ByGUIDResult {
// Lua stack — only safe from inside a Lua callback.
bool FindByGUID(void *L, uint64_t guid, ByGUIDResult *out);
// Same equipment-first, bag-second walk as `FindByGUID`, but matches
// on itemID instead. Returns the FIRST instance found — equipment
// slots are checked before bags, so an equipped enchanted copy is
// preferred over an unenchanted duplicate in the backpack. Used
// when we only have the itemID for a slot (e.g. cursor-held items
// picked up from the action bar, which the engine stores as a
// bare itemID with no GUID) and want to recover the live CGItem
// for link-decoration purposes. Stomps the Lua stack.
bool FindByItemID(void *L, int itemID, ByGUIDResult *out);
// Parses the `"0xHHHHHHHHLLLLLLLL"` GUID string format
// `C_Item.GetItemGUID` returns. Strict: requires exactly `0x` prefix
// + 16 case-insensitive hex digits. Returns false (and leaves `*out`
+113
View File
@@ -0,0 +1,113 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Item.IsItemOpenable(itemLocation)` — backport of the modern WoW
// accessor that says "right-clicking this item triggers an open/loot
// interaction" (clams, sacks, simple chests, quest boxes, lockboxes).
// Returns two values: `(isOpenable, canOpen)` — the first is the
// item-type-intrinsic openable flag, the second is whether the
// player can right-click *this specific instance* right now (lock
// is gone or never existed).
//
// Vanilla 1.12's tooltip builder gates the `<Right Click to Open>`
// string (`ITEM_OPENABLE`) on `ItemStats.Flags & 0x4` AND
// (`LockID == 0` OR `ITEM_FIELD_FLAGS & 0x4`). We mirror both checks
// — see `Offsets::ITEMSTATS_FLAG_OPENABLE`, `ITEMSTATS_LOCK_ID`, and
// `ITEM_FLAG_UNLOCKED`.
#include "Game.h"
#include "Offsets.h"
#include "item/Data.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Openable.h"
#include <cstdint>
namespace Item::Openable {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
} // namespace
namespace {
// Reads `ITEM_FIELD_FLAGS` from the instance descriptor at +0x114.
// Defaults to 0 if `cgItem` is null or the descriptor pointer hasn't
// been populated yet.
uint32_t InstanceFlags(const uint8_t *cgItem) {
if (cgItem == nullptr)
return 0;
auto *descriptor = *reinterpret_cast<const uint8_t *const *>(
cgItem + Offsets::OFF_ITEM_DESCRIPTOR);
if (descriptor == nullptr)
return 0;
return *reinterpret_cast<const uint32_t *>(
descriptor + Offsets::OFF_DESCRIPTOR_FLAGS);
}
} // namespace
int PushIsItemOpenable(void *L, const uint8_t *cgItem) {
const int itemID = Item::ID::FromCGItem(cgItem);
if (itemID <= 0)
return 0;
const uint8_t *record = PeekItemRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
return 0;
}
const uint32_t typeFlags = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_FLAGS);
const uint32_t lockID = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_LOCK_ID);
const bool isOpenable = (typeFlags & Offsets::ITEMSTATS_FLAG_OPENABLE) != 0;
const uint32_t instFlags = InstanceFlags(cgItem);
const bool canOpen = isOpenable && (lockID == 0 ||
(instFlags & Offsets::ITEM_FLAG_UNLOCKED) != 0);
Game::Lua::PushBool(L, isOpenable);
Game::Lua::PushBool(L, canOpen);
return 2;
}
namespace {
int __fastcall Script_C_Item_IsItemOpenable(void *L) {
if (!Item::Location::IsLocationArg(L, 1)) {
Game::Lua::Error(L, "Usage: C_Item.IsItemOpenable(itemLocation)");
return 0;
}
return PushIsItemOpenable(L, Item::Location::Resolve(L, 1));
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Item", "IsItemOpenable",
&Script_C_Item_IsItemOpenable);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Item::Openable
+37
View File
@@ -0,0 +1,37 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
namespace Item::Openable {
// Pushes two values for the openable check on a player-owned item
// (`isOpenable, canOpen`), returning 2. The two semantics:
//
// `isOpenable` — bool. The item type carries the openable flag
// (`ItemStats.Flags & 0x4` — clams, sacks, lockboxes, etc.).
// `canOpen` — bool. The player can right-click this specific
// instance and trigger the open action right now. True for
// openable items with no lock (`LockID == 0`) or whose instance
// has been unlocked (`ITEM_FIELD_FLAGS & 0x4` — set after a
// rogue picks the lock, a key is used, etc.). False for fresh
// lockboxes a non-rogue is looking at.
//
// `cgItem` may be null (empty slot), in which case nothing is pushed
// and 0 is returned (Lua sees nil for both returns). Uncached itemID
// also returns 0 plus a background cache warm.
int PushIsItemOpenable(void *L, const uint8_t *cgItem);
} // namespace Item::Openable
+42
View File
@@ -0,0 +1,42 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include "Offsets.h"
#include <cstdint>
namespace Item::QualityColor {
// Returns the engine's `"|cffRRGGBB"` color-prefix string for the
// given item quality. Reads directly from the engine table at
// `Offsets::VAR_QUALITY_COLOR_TABLE` so we don't have to duplicate
// the hex palette across modules. Mirrors the engine's own fallback
// at `0x0052AD90`: quality > 6 returns white (index 1).
inline const char *Prefix(int quality) {
auto *table = reinterpret_cast<const char *const *>(
Offsets::VAR_QUALITY_COLOR_TABLE);
if (quality < 0 || quality >= Offsets::QUALITY_COLOR_TABLE_SIZE)
return table[1]; // white fallback, matches engine
return table[quality];
}
// Returns just the 6-char hex (e.g. "9d9d9d"), pointing into the
// engine string past the `|cff` prefix. Useful for callers that
// build their own markup format and need the bare hex.
inline const char *Hex(int quality) {
return Prefix(quality) + 4;
}
} // namespace Item::QualityColor
+32
View File
@@ -199,6 +199,38 @@ bool FromPaperdoll(const void *cgItem, int srcPaperdollSlot, int dstPaperdollSlo
dstPaperdollSlot);
}
bool ToBag(const void *cgItem, int srcPaperdollSlot, int dstBagID, int dstSlotInBag) {
if (srcPaperdollSlot < 1 || srcPaperdollSlot > 19)
return false;
if (cgItem == nullptr)
return false;
void *player = ResolvePlayer();
if (player == nullptr)
return false;
uint32_t playerLo = 0, playerHi = 0;
if (!ReadGuid(player, &playerLo, &playerHi))
return false;
uint32_t dstContainerLo = 0, dstContainerHi = 0, dstLinear = 0;
if (!EncodeBagSlot(dstBagID, dstSlotInBag,
&dstContainerLo, &dstContainerHi, &dstLinear))
return false;
uint32_t itemLo = 0, itemHi = 0;
if (!ReadGuid(cgItem, &itemLo, &itemHi))
return false;
auto fn = reinterpret_cast<SwapFn_t>(Offsets::FUN_INVENTORY_SWAP);
fn(player,
itemLo, itemHi,
playerLo, playerHi,
static_cast<uint32_t>(srcPaperdollSlot - 1),
dstContainerLo, dstContainerHi, dstLinear,
0);
return true;
}
bool MoveCount(void *L, int srcBag, int srcSlot, int dstBag, int dstSlot, int count) {
// Vanilla protocol writes count as a single byte. Clamp at 255;
// anything larger would either truncate silently (255 sent) or
+8
View File
@@ -32,6 +32,12 @@ namespace Item::Swap {
// paperdoll slot `srcSlot` (1-based). Used for paperdoll-to-
// paperdoll swaps like rings 11/12, trinkets 13/14.
//
// - `ToBag(item, srcPaperdollSlot, dstBag, dstSlot)` — item currently
// in paperdoll slot, moving to a bag slot. The "unequip" direction
// `UseEquipmentSet` needs for slots the set wants empty. Uses the
// same atomic swap packet — server-side accept rules are identical
// (bagID 0..4, dst slot must be valid for that bag).
//
// All slot args are 1-based Lua-facing; helpers do the
// linear-slot conversion. Returns `false` on bad args (slot out of
// range, no player object, missing instance block on the item or
@@ -44,6 +50,8 @@ bool FromBag(const void *cgItem, int bagID, int slotInBag, int dstPaperdollSlot)
bool FromPaperdoll(const void *cgItem, int srcPaperdollSlot, int dstPaperdollSlot);
bool ToBag(const void *cgItem, int srcPaperdollSlot, int dstBagID, int dstSlotInBag);
// General bag-to-bag swap — neither side is constrained to the
// paperdoll. Same engine helper (`FUN_INVENTORY_SWAP`), which routes
// to opcode 0x10D (same-container slot swap, e.g. backpack ↔ backpack)
+79 -21
View File
@@ -17,9 +17,11 @@
#include "item/ID.h"
#include "item/Link.h"
#include "item/Location.h"
#include "item/QualityColor.h"
#include <cstdint>
#include <cstdio>
#include <cstring>
namespace Item::Tooltip {
@@ -134,25 +136,6 @@ static int __fastcall Script_GameTooltipSetInventoryItemByID(void *L) {
return fn(L);
}
// Modern `Enum.ItemQuality` color codes for the SetItemByID fallback
// path (no CGItem → no engine link helper). For the CGItem-resolved
// path we delegate to the engine's own builder, which uses the same
// palette internally.
static const char *QualityHex(int quality) {
static const char *const kHex[] = {
"9d9d9d", // POOR
"ffffff", // COMMON
"1eff00", // UNCOMMON
"0070dd", // RARE
"a335ee", // EPIC
"ff8000", // LEGENDARY
"e6cc80", // ARTIFACT (TBC+)
"00ccff", // HEIRLOOM (WotLK+)
};
if (quality < 0 || quality > 7)
return kHex[1];
return kHex[quality];
}
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
@@ -277,8 +260,9 @@ static int __fastcall Script_GameTooltipGetItem(void *L) {
char link[256];
std::snprintf(link, sizeof(link),
"|cff%s|Hitem:%d:0:0:0:0:0:0:0|h[%s]|h|r",
QualityHex(static_cast<int>(quality)), itemID, name);
"%s|Hitem:%d:0:0:0:0:0:0:0|h[%s]|h|r",
Item::QualityColor::Prefix(static_cast<int>(quality)),
itemID, name);
Game::Lua::PushString(L, name);
Game::Lua::PushString(L, link);
@@ -307,8 +291,82 @@ static int __fastcall Script_GameTooltipHasItem(void *L) {
return 1;
}
// `GameTooltip:SetItemByGUID(guidString)` — modern method that renders
// the tooltip for the specific item instance identified by GUID
// (`"0xHHHHHHHHLLLLLLLL"` per `C_Item.GetItemGUID`). Distinct from
// `SetItemByID`, which shows the base `ItemSparse` data with no
// per-instance state: this path uses the live CGItem so the tooltip
// includes enchant lines, random-suffix-decorated name + bonuses,
// locked/broken state, etc.
//
// Implementation: parse the GUID string, resolve via the engine's
// `FUN_OBJECT_RESOLVE_BY_GUID` (same path `C_Item.GetItemLocation`
// and `GameTooltip:GetItem` use), build the fully-decorated
// hyperlink via `Item::Link::FromCGItem` (engine helper at
// `FUN_GAMETOOLTIP_BUILD_ITEM_LINK`), then dispatch to the engine's
// existing `Script_GameTooltip_SetHyperlink`. SetHyperlink parses
// the link's `item:N:enchant:gem:gem:gem:gem:suffix:unique` payload
// back into instance-specific tooltip data — closing the loop
// without us needing a separate engine entry point for "tooltip
// from CGItem*".
//
// Silent no-op when the GUID is malformed, not loaded in the
// client, or doesn't resolve to an item (creature/object GUIDs go
// to a different resolver).
static int __fastcall Script_GameTooltipSetItemByGUID(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, "Usage: GameTooltip:SetItemByGUID(itemGUID)");
return 0;
}
if (Game::Lua::Type(L, 2) != Game::Lua::TYPE_STRING) {
Game::Lua::Error(L, "Usage: GameTooltip:SetItemByGUID(itemGUID)");
return 0;
}
const char *guidStr = Game::Lua::ToString(L, 2);
uint64_t guid = 0;
if (!Item::Location::ParseGUIDString(guidStr, &guid))
return 0;
const uint8_t *cgItem = Item::Location::ResolveByGUID(guid);
if (cgItem == nullptr)
return 0;
const char *link = Item::Link::FromCGItem(cgItem);
if (link == nullptr || *link == '\0')
return 0;
// `Item::Link::FromCGItem` returns the full
// `|cffXXXXXXXX|Hitem:N:E:S:S:S:S:R:U|h[Name]|h|r` decorated link
// (the public-link form addons paste into chat). `SetHyperlink`
// wants just the bare `item:N:E:S:S:S:S:R:U` payload — passing the
// decorated form trips its "Unknown link type" Lua error because
// the parser sees `|c` first and gives up. Extract the payload by
// finding `|Hitem:` and copying through to the closing `|h`.
const char *open = std::strstr(link, "|Hitem:");
if (open == nullptr)
return 0;
open += 2; // skip past `|H` so we start at `item:`
const char *close = std::strstr(open, "|h");
if (close == nullptr)
return 0;
const size_t payloadLen = static_cast<size_t>(close - open);
char payload[128];
if (payloadLen + 1 > sizeof(payload))
return 0;
std::memcpy(payload, open, payloadLen);
payload[payloadLen] = '\0';
// Replace stack[2] (the GUID) with the bare item link and
// dispatch to SetHyperlink with (self, "item:...").
Game::Lua::SetTop(L, 1);
Game::Lua::PushString(L, payload);
using Script_t = int(__fastcall *)(void *L);
auto fn = reinterpret_cast<Script_t>(Offsets::FUN_SCRIPT_GAMETOOLTIP_SET_HYPERLINK);
return fn(L);
}
static const Game::Lua::FrameMethodEntry g_methods[] = {
{"SetItemByID", &Script_GameTooltipSetItemByID},
{"SetItemByGUID", &Script_GameTooltipSetItemByGUID},
{"SetInventoryItemByID", &Script_GameTooltipSetInventoryItemByID},
{"GetItem", &Script_GameTooltipGetItem},
{"HasItem", &Script_GameTooltipHasItem},
+311
View File
@@ -0,0 +1,311 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `GetMacroIcons(list)` / `GetMacroItemIcons(list)` /
// `GetLooseMacroIcons(list)` / `GetLooseMacroItemIcons(list)` —
// the modern 4-function append-to-table icon enumeration surface.
// Each takes a Lua table as its only argument and appends icon
// basenames; returns nothing (mutation is the contract).
//
// Modern WoW's `IconDataProviderMixin` (see
// `AddOns/!!!ClassicAPI/Util/IconDataProvider.lua`) calls all four
// in this order to assemble its `BaseIconFilenames` cache:
//
// GetLooseMacroIcons(spellList)
// GetLooseMacroItemIcons(itemList)
// GetMacroIcons(spellList)
// GetMacroItemIcons(itemList)
//
// The split is `loose` (icons dropped onto disk under
// `Interface\Icons\` by the user) vs MPQ (icons baked into the game's
// MPQ archives), crossed with `Spell` (basenames starting with
// `Ability_` / `Spell_`) vs `Item` (basenames starting with `INV_`).
// Modern engines maintain four parallel arrays in their loader; we
// replicate the same scheme by hooking vanilla's three scan
// callbacks and tagging each captured filename by its callback
// source and basename prefix.
//
// **Data source (vanilla 1.12 / Octo):**
//
// - `FUN_MACRO_ICON_CB_DISK` (`LAB_004f0220`) — engine's disk-scan
// per-file callback. Files the user has dropped loose into
// `Interface\Icons\`. Filename arg is the full
// `"Interface\Icons\<name>.blp"` path. Tagged as `loose`.
// - `FUN_MACRO_ICON_CB_USER_MPQ` (`FUN_004f0350`) — user-mounted
// patch-MPQ callback. Already prefix-filtered to `Ability_*` /
// `Spell_*` by the engine. Tagged as `mpq`.
// - `FUN_MACRO_ICON_CB_INSTALL_MPQ` (`LAB_004f04f0`) — install-MPQ
// callback. Accepts any `.blp`/`.tga` (no prefix filter in the
// engine), and is the only source that surfaces `INV_*` filenames
// in vanilla. Tagged as `mpq`.
//
// Each callback gets a hook that examines the filename, classifies
// it by prefix (`INV_` → item, `Ability_`/`Spell_` → spell, else
// ignore), and appends to the matching side array. Capture-time
// dedup via per-array `unordered_set` ensures each unique basename
// only appears once per source even though most files flow through
// multiple callbacks. The arrays are sorted lazily on first Lua
// read so the output order matches modern engines.
//
// Entry shape (all four functions): uppercase basename stripped of
// the `Interface\Icons\` prefix and any `.blp`/`.tga` extension —
// e.g. `"INV_SWORD_25"`, `"ABILITY_KICK"`. Callers concat the
// prefix themselves before `texture:SetTexture(...)`.
#include "Game.h"
#include "Offsets.h"
#include <algorithm>
#include <cstdint>
#include <cstring>
#include <string>
#include <unordered_set>
#include <vector>
namespace Macro::Icons {
namespace {
using LoadIcons_t = void(__cdecl *)();
void EnsureLoaded() {
if (*reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_MACRO_ICON_COUNT)) != 0)
return;
reinterpret_cast<LoadIcons_t>(Offsets::FUN_LOAD_MACRO_ICONS)();
}
constexpr int ICON_BUF_LEN = 128;
// Last path-component of `s` (after the final `\` or `/`), or `s`
// itself if no separator. NULL-safe.
const char *Basename(const char *s) {
if (s == nullptr)
return nullptr;
const char *bn = s;
for (const char *p = s; *p; ++p) {
if (*p == '\\' || *p == '/')
bn = p + 1;
}
return bn;
}
// Case-insensitive prefix match. `prefix` must be uppercase ASCII.
bool StartsWithCI(const char *s, const char *prefix) {
for (int i = 0; prefix[i] != '\0'; ++i) {
char c = s[i];
if (c >= 'a' && c <= 'z')
c = static_cast<char>(c - ('a' - 'A'));
if (c != prefix[i])
return false;
}
return true;
}
// Normalize an icon path for dedup: strip `Interface\Icons\` (or any
// directory) prefix, strip extension at first `.`, uppercase ASCII.
// `dst` must be `ICON_BUF_LEN` bytes. Result is null-terminated.
void NormalizeIconKey(char *dst, const char *src) {
const char *bn = Basename(src);
if (bn == nullptr) {
dst[0] = '\0';
return;
}
int i = 0;
while (i + 1 < ICON_BUF_LEN && bn[i] != '\0' && bn[i] != '.') {
char c = bn[i];
if (c >= 'a' && c <= 'z')
c = static_cast<char>(c - ('a' - 'A'));
dst[i] = c;
++i;
}
dst[i] = '\0';
}
// =============================================================
// Four side arrays — (loose|mpq) × (spell|item). Each is a
// dedup-on-insert set plus a parallel vector preserving insertion
// order. We sort each vector once before the first Lua read so
// `GetMacroIcons` etc. return alphabetical results matching modern
// engines.
// =============================================================
enum Source { SRC_LOOSE = 0, SRC_MPQ = 1 };
enum Category { CAT_SPELL = 0, CAT_ITEM = 1 };
struct Bucket {
std::vector<std::string> vec;
std::unordered_set<std::string> set;
};
Bucket &BucketFor(Source src, Category cat) {
static Bucket buckets[2][2];
return buckets[src][cat];
}
void Capture(const char *raw, Source src) {
const char *bn = Basename(raw);
if (bn == nullptr || *bn == '\0')
return;
Category cat;
if (StartsWithCI(bn, "INV_"))
cat = CAT_ITEM;
else if (StartsWithCI(bn, "ABILITY_") || StartsWithCI(bn, "SPELL_"))
cat = CAT_SPELL;
else
return;
char key[ICON_BUF_LEN];
NormalizeIconKey(key, bn);
if (key[0] == '\0')
return;
Bucket &b = BucketFor(src, cat);
auto [it, inserted] = b.set.emplace(key);
if (inserted)
b.vec.emplace_back(*it);
}
// Sort all four buckets alphabetically. Called once before the first
// Lua read so output is stable and matches modern engines' sorted
// output. Idempotent — re-running re-sorts (cheap for already-sorted
// vectors) but `EnsureSorted` short-circuits via the flag.
bool g_sorted = false;
void EnsureSorted() {
if (g_sorted)
return;
for (int s = 0; s < 2; ++s) {
for (int c = 0; c < 2; ++c) {
auto &v = BucketFor(static_cast<Source>(s),
static_cast<Category>(c)).vec;
std::sort(v.begin(), v.end());
}
}
g_sorted = true;
}
// =============================================================
// Engine scan-callback hooks.
// =============================================================
// Disk-scan callback (`__fastcall(const char *fullPath)`). Path is
// `"Interface\Icons\<NAME>"`-shaped — Basename() strips the prefix
// before classification.
using DiskCb_t = int (__fastcall *)(const char *path);
DiskCb_t IconCbDisk_o = nullptr;
int __fastcall IconCbDisk_h(const char *path) {
Capture(path, SRC_LOOSE);
return IconCbDisk_o(path);
}
// MPQ-scan callbacks (`__fastcall(MpqRecord *)`). Record layout:
// `+0x04` = file flags (bit `0x10` = directory), `+0x08` = inline
// null-terminated filename (no prefix, no extension stripped).
using MpqCb_t = int (__fastcall *)(uint8_t *record);
MpqCb_t IconCbUserMpq_o = nullptr;
int __fastcall IconCbUserMpq_h(uint8_t *record) {
if (record != nullptr && (record[4] & 0x10) == 0)
Capture(reinterpret_cast<const char *>(record + 8), SRC_MPQ);
return IconCbUserMpq_o(record);
}
MpqCb_t IconCbInstallMpq_o = nullptr;
int __fastcall IconCbInstallMpq_h(uint8_t *record) {
if (record != nullptr && (record[4] & 0x10) == 0)
Capture(reinterpret_cast<const char *>(record + 8), SRC_MPQ);
return IconCbInstallMpq_o(record);
}
const Game::HookAutoRegister _hookCbDisk{
Offsets::FUN_MACRO_ICON_CB_DISK,
reinterpret_cast<void *>(&IconCbDisk_h),
reinterpret_cast<void **>(&IconCbDisk_o)};
const Game::HookAutoRegister _hookCbUserMpq{
Offsets::FUN_MACRO_ICON_CB_USER_MPQ,
reinterpret_cast<void *>(&IconCbUserMpq_h),
reinterpret_cast<void **>(&IconCbUserMpq_o)};
const Game::HookAutoRegister _hookCbInstallMpq{
Offsets::FUN_MACRO_ICON_CB_INSTALL_MPQ,
reinterpret_cast<void *>(&IconCbInstallMpq_h),
reinterpret_cast<void **>(&IconCbInstallMpq_o)};
// =============================================================
// Lua surface — four global mutators.
// =============================================================
// Walks `list[1..]` to find the first nil slot — that's where we
// start appending. Leaves the stack unchanged on return.
int FindAppendIndex(void *L) {
int idx = 1;
while (true) {
Game::Lua::PushNumber(L, static_cast<double>(idx));
Game::Lua::RawGet(L, 1);
const int t = Game::Lua::Type(L, -1);
Game::Lua::SetTop(L, -2);
if (t == Game::Lua::TYPE_NIL)
return idx;
++idx;
}
}
void AppendBucket(void *L, Source src, Category cat, const char *usage) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, usage);
return;
}
EnsureLoaded();
EnsureSorted();
const auto &v = BucketFor(src, cat).vec;
if (v.empty())
return;
int idx = FindAppendIndex(L);
for (const auto &name : v) {
Game::Lua::PushNumber(L, static_cast<double>(idx++));
Game::Lua::PushString(L, name.c_str());
Game::Lua::RawSet(L, 1);
}
}
int __fastcall Script_GetMacroIcons(void *L) {
AppendBucket(L, SRC_MPQ, CAT_SPELL, "Usage: GetMacroIcons(table)");
return 0;
}
int __fastcall Script_GetMacroItemIcons(void *L) {
AppendBucket(L, SRC_MPQ, CAT_ITEM, "Usage: GetMacroItemIcons(table)");
return 0;
}
int __fastcall Script_GetLooseMacroIcons(void *L) {
AppendBucket(L, SRC_LOOSE, CAT_SPELL, "Usage: GetLooseMacroIcons(table)");
return 0;
}
int __fastcall Script_GetLooseMacroItemIcons(void *L) {
AppendBucket(L, SRC_LOOSE, CAT_ITEM,
"Usage: GetLooseMacroItemIcons(table)");
return 0;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetMacroIcons", &Script_GetMacroIcons);
Game::Lua::RegisterGlobalFunction("GetMacroItemIcons",
&Script_GetMacroItemIcons);
Game::Lua::RegisterGlobalFunction("GetLooseMacroIcons",
&Script_GetLooseMacroIcons);
Game::Lua::RegisterGlobalFunction("GetLooseMacroItemIcons",
&Script_GetLooseMacroItemIcons);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Macro::Icons
+74
View File
@@ -0,0 +1,74 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `IsLoggedIn()` — exposed on both Lua states (glue and in-game).
// Returns `true` once a character has been loaded *and identified*
// (GUID populated), `false` on glue, pre-load, and the brief window
// between struct allocation and character-data unpack.
//
// Check is two-step:
// 1. `[VAR_LOCAL_PLAYER_PTR]` non-NULL — the player aggregator
// struct has been allocated
// 2. `*(uint64*)(player + OFF_LOCAL_PLAYER_GUID)` non-zero — the
// character's GUID has been populated into the struct
//
// The pointer alone is too lax: the engine allocates the struct
// shortly before the character data lands, so there's a window where
// the pointer is non-NULL but the GUID is still zero. Most addons
// that check `IsLoggedIn` go straight to `UnitGUID("player")` next,
// and we don't want them to see GUID==nil right after IsLoggedIn
// reported true.
//
// 3.3.5 uses a single byte flag at `[0x00BD0793]` cleared on world
// entry — we couldn't pin down the analogous 1.12 flag (the engine
// path that signals event 253 wires through CVar-init machinery and
// doesn't map cleanly across the binary). The pointer+GUID check is
// the same effective signal: both flip together at the moment the
// player object is ready, and stay stable across zone transitions.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Player::LoginStatus {
namespace {
int __fastcall Script_IsLoggedIn(void *L) {
auto *player = *reinterpret_cast<const uint8_t *const *>(
Offsets::VAR_LOCAL_PLAYER_PTR);
if (player == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
const uint64_t guid = *reinterpret_cast<const uint64_t *>(
player + Offsets::OFF_LOCAL_PLAYER_GUID);
Game::Lua::PushBool(L, guid != 0);
return 1;
}
void RegisterInGame() {
Game::Lua::RegisterGlobalFunction("IsLoggedIn", &Script_IsLoggedIn);
}
void RegisterGlue() {
Game::Lua::RegisterGlueFunction("IsLoggedIn", &Script_IsLoggedIn);
}
} // namespace
static const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
static const Game::GlueModuleAutoRegister _gluereg{&RegisterGlue};
} // namespace Player::LoginStatus
+155 -1
View File
@@ -40,15 +40,156 @@ inline const uint8_t *Peek(uint32_t questID) {
}
// Field offsets within the data block returned by `Lookup`.
constexpr int OFF_TITLE = 0x9C;
//
// Derived from the engine's own `Script_GetQuestLog*` accessors at
// `0x004DF930..0x004E12B0`. Each accessor calls `_GetRecord` then
// reads from one of these offsets; the existing comment block on each
// constant identifies the source function. See CLAUDE.md's
// "Quest static-info cache" section for the full trace.
// Numeric metadata fields, in wire order from `SMSG_QUEST_QUERY_RESPONSE`.
//
// The cache layout mirrors the packet's field order — the deserializer
// at the SMSG handler unpacks 15 uint32 fields at +0x00..+0x38 before
// the reward/choice item arrays. Names and semantics cross-referenced
// from the in-binary serializer (`FUN_006DBCEC`) plus the canonical
// vanilla 1.12 packet structure documented by vmangos/cmangos emulator
// projects. Fields without a direct binary-side accessor (so just
// known from wire-format alignment) are marked **(hypothesis)** —
// they should be verified empirically before being relied on for
// gameplay logic.
//
// QuestId — uint32, echo of the questID this entry caches.
constexpr int OFF_QUEST_ID = 0x00;
// QuestMethod — uint32. Server-side classification (typically `2` for
// active-and-available, `0` for unavailable). Vanilla doesn't use
// this for gameplay; it's stored but no engine accessor reads it.
// **(hypothesis)**
constexpr int OFF_QUEST_METHOD = 0x04;
// Quest level — int32. Source: `FUN_004df340` (called by
// `Script_GetQuestLogTitle`) does `mov eax, [block + 0x08]` and
// pushes it as `level`.
constexpr int OFF_LEVEL = 0x08;
// ZoneOrSort — int32. The packet's `ZoneOrSort` field, used by the
// quest log UI to group entries under headers:
// - Positive = `AreaTable.dbc` zone areaID (e.g. 132 = Coldridge
// Valley, 12 = Elwynn Forest)
// - Negative = `-QuestSort.dbc` row (e.g. -263 = "Druid" sort)
// `FUN_004dee60` reads this directly and matches it against
// `VAR_QUEST_LOG_CATEGORY_TABLE` to find the collapsible header index.
constexpr int OFF_ZONE_OR_SORT = 0x0C;
// QuestInfo.dbc row — uint32 row index into the small DBC at
// `0x00C0D9CC` (records) / `0x00C0D9D0` (count). Each record is
// `{id@+0, char *name[locale]@+4}`; the localized name is the
// quest-type tag string ("Dungeon", "Raid", "Group", "PvP", "Elite"
// — modern WoW's `frequency`/`questTag`). Source: `FUN_004df2a0`.
constexpr int OFF_QUEST_INFO_ROW = 0x10;
// +0x14, +0x18, +0x1C, +0x20, +0x24 — five uint32 cache slots that
// SMSG_QUEST_QUERY_RESPONSE in vanilla 1.12 doesn't populate. The
// struct has them (the serializer at `0x006DBCEC` writes them as part
// of the WDB save / network shape), but the response handler leaves
// them zero-filled. Verified empirically across three diverse quests
// (race-restricted starter, dungeon, authored-timer delivery) — all
// five slots zero in every case.
//
// Wire-format docs (vmangos / cmangos emulator quest_template) name
// them SuggestedPlayers, LimitTime, RequiredRaces, RequiredSkill,
// RequiredSkillValue respectively, but the vanilla server *enforces*
// these constraints server-side and filters quests before broadcasting
// them, so the client never needs the data. Addons that want race /
// class / skill / timer info must source from an external scraped DB
// (pfQuest-style). Constants intentionally omitted — no use case.
//
// Signed money delta — int32. Positive = reward copper, negative =
// `-required` copper. The engine has two accessors that share this
// field with complementary sign:
// - `Script_GetQuestLogRewardMoney` returns max(0, +money)
// - `Script_GetQuestLogRequiredMoney` returns max(0, -money)
constexpr int OFF_MONEY_DELTA = 0x28;
// Level-cap reward money bonus — int32. Added to the reward when
// the player's level is ≥ 60. Source: `Script_GetQuestLogRewardMoney`
// does `cmp [player.unit + 0x70], 0x3C` (level >= 60) then
// `add edx, [block + 0x2C]`.
constexpr int OFF_LEVEL_CAP_BONUS_MONEY = 0x2C;
// Reward spellID — int32. 0 = no spell reward. Source:
// `Script_GetQuestLogRewardSpell` does `mov eax, [block + 0x30]` then
// resolves the SpellIcon path and locale-applied name via `Spell.dbc`.
// This is a spell *learned by the player* on completion (recipes,
// taught spells); not to be confused with `OFF_SRC_ITEM_ID` which
// is the item the questgiver hands over at accept-time.
constexpr int OFF_REWARD_SPELL_ID = 0x30;
// SrcItemId — itemID given to the player when the quest is accepted.
// 0 = no source item. The questgiver hands this item over so the
// player can complete the quest objective (e.g. "Read the Verdant
// Sigil" gives item 9580; player uses it, then turns the same item
// back in).
//
// Empirically derived (the offset was originally interpreted as
// `RewardSpellCast` from one emulator wire-order variant, but a
// real-world dump of quest 3120 stored `9580` here while the quest's
// own item objective also references item 9580 — consistent with
// SrcItemId semantics, not with a spell-cast-at-turn-in).
constexpr int OFF_SRC_ITEM_ID = 0x34;
// Quest flags — uint32. Bit definitions match modern WoW's
// `QUEST_FLAGS_*` enum, of which only bit 3 (`0x08` SHARABLE) is
// confirmed-tested by the engine (`Script_GetQuestLogPushable`'s
// `test byte ptr [block + 0x38], 0x08`). Other bits are presumed
// stored but unverified.
constexpr int OFF_QUEST_FLAGS = 0x38;
// Reward and choice item arrays — up to 4 reward and 6 choice
// itemIDs stored inline (4 bytes each). 0 in any slot means
// "no item in this slot". Used by `GetQuestLogItemLink` /
// `GetQuestLogItemID`.
//
// Each itemID array is paired with a parallel uint32 count array
// (the "how many of this item" reward). Layout in cache memory:
// +0x3C RewardItems[4]
// +0x4C RewardItemsCount[4]
// +0x5C ChoiceItems[6]
// +0x74 ChoiceItemsCount[6]
// Verified from `Script_GetQuestLogRewardInfo` (`fild [block + slot*4 + 0x4C]`
// for numItems) and `Script_GetQuestLogChoiceInfo` (`fild [block + slot*4 + 0x74]`).
constexpr int OFF_REWARD_ITEMS = 0x3C;
constexpr int OFF_REWARD_ITEMS_COUNT = 0x4C;
constexpr int OFF_CHOICE_ITEMS = 0x5C;
constexpr int OFF_CHOICE_ITEMS_COUNT = 0x74;
constexpr int REWARD_COUNT = 4;
constexpr int CHOICE_COUNT = 6;
// Point-of-interest marker — server-supplied map coordinate hint
// used by the questgiver objective panel to display "go here" arrows
// in modern WoW. Set on quests that have a designated location;
// 0/0/0 = no POI. Read sequentially by the serializer at
// `0x006DBE5B..0x006DBE8E` using mixed integer + float wire methods.
// **(hypothesis)**
constexpr int OFF_POI_MAP_ID = 0x8C; // uint32
constexpr int OFF_POI_X = 0x90; // float
constexpr int OFF_POI_Y = 0x94; // float
constexpr int OFF_POI_OPT = 0x98; // uint32 — display option, or doubles as title length
constexpr int OFF_TITLE = 0x9C;
// Inline narrative text — both locale-applied and printf-style
// format-expanded by the engine's variable substituter at
// `0x00506F70` (handles tokens like `$N` for player name, `$C` for
// class, etc.). Source: `Script_GetQuestLogQuestText` reads both,
// pushes `description` first then `objectives`.
//
// We deliberately expose the *raw* inline bytes, not the format-
// expanded version, because the substituter wants a 1024-byte
// scratch buffer and the substitutions (player name, class) belong
// in the addon layer anyway. Addons can run `string.gsub` if they
// need the runtime values, otherwise raw text is more useful for
// guides / databases.
constexpr int OFF_OBJECTIVES = 0x29C;
constexpr int OFF_DESCRIPTION = 0xA9C;
// Completion text — the "talk to me now" body shown on the
// quest-reward UI panel after objectives are met. 0x200 (512) bytes
// inline. Modern API exposes this as `GetQuestLogCompletionText()`.
// Confirmed by the cache copy at `0x00562E1F..0x00562E36` (a 0x200-byte
// memcpy from src+0x129C to dst+0x129C) and by the serializer at
// `0x006DBEBE` (`lea eax, [edi+0x129C]` followed by the string-writer
// call). Vanilla LeaderBoard reads from this offset for some override
// cases but the engine never directly exposes the text to Lua —
// `Script_GetQuestLogQuestText` returns description + objectives only.
constexpr int OFF_COMPLETION_TEXT = 0x129C;
// Objective arrays — four uint32[4] parallel slabs carrying
// SMSG_QUEST_QUERY_RESPONSE's RequiredNPCOrGo / Count / Item / Count
// fields verbatim. NPC-or-GO is **signed**: positive = creature
@@ -66,5 +207,18 @@ constexpr int OFF_REQUIRED_NPC_OR_GO_COUNT = 0x14AC; // uint32[4]
constexpr int OFF_REQUIRED_ITEM = 0x14BC; // uint32[4]
constexpr int OFF_REQUIRED_ITEM_COUNT = 0x14CC; // uint32[4]
constexpr int OBJECTIVE_COUNT = 4;
// Per-objective override text — array of 4 inline char buffers,
// 256 (0x100) bytes per entry, indexed in objective declaration
// order (NPC/GO slots fill first, then item slots — same order the
// LeaderBoard accessor enumerates). Empty string = no override
// (engine auto-formats as `"<NPC name>: X/Y"`); otherwise the
// engine displays the buffer verbatim. Confirmed by the
// `shl ecx, 8; mov dl, [block + ecx + 0x14DC]` access pattern in
// `Script_GetQuestLogLeaderBoard` and by the four sequential reads
// in the packet serializer at `0x006DBF17..0x006DBF41` (0x14DC,
// 0x15DC, 0x16DC, 0x17DC).
constexpr int OFF_OBJECTIVE_TEXT = 0x14DC;
constexpr int OBJECTIVE_TEXT_STRIDE = 0x100;
constexpr int OBJECTIVE_TEXT_COUNT = 4;
} // namespace Quest::Cache
+254
View File
@@ -0,0 +1,254 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_QuestLog.GetQuestDetails(questID)` — single accessor that returns
// everything we know how to read from the quest static-info cache as a
// Lua table. Powers quest-helper-style addons that want one round-trip
// instead of N separate `C_QuestLog.GetTitleForQuestID` / etc. calls.
//
// Returns `nil` if the quest isn't cached. Caller should pair with
// `C_QuestLog.RequestLoadQuestByID` + listen for `QUEST_DATA_LOAD_RESULT`.
// All offsets read here come from the engine's own `Script_GetQuestLog*`
// accessors — see `Cache.h` for the per-field derivation.
#include "Game.h"
#include "Offsets.h"
#include "quest/Cache.h"
#include <cstdint>
namespace Quest::Details {
namespace {
// QuestInfo.dbc — 12-row table holding the localized quest-type tags
// ("Dungeon" / "Raid" / "Group" / "PvP" / "Elite"). Address pair from
// `docs/DBCs.md`.
constexpr uintptr_t VAR_QUESTINFO_DBC_RECORDS = 0x00C0D9CC;
constexpr uintptr_t VAR_QUESTINFO_DBC_COUNT = 0x00C0D9D0;
const char *LookupQuestType(uint32_t row) {
if (row == 0)
return nullptr;
const int count = *reinterpret_cast<const int *>(VAR_QUESTINFO_DBC_COUNT);
if (count <= 0 || row > static_cast<uint32_t>(count))
return nullptr;
const uint8_t *const *records =
*reinterpret_cast<const uint8_t *const *const *>(
VAR_QUESTINFO_DBC_RECORDS);
if (!records)
return nullptr;
const uint8_t *record = records[row];
if (!record)
return nullptr;
const int localeIdx =
*reinterpret_cast<const int *>(Offsets::VAR_LOCALE_INDEX);
if (localeIdx < 0 || localeIdx > 8)
return nullptr;
auto *names = reinterpret_cast<const char *const *>(record + 4);
return names[localeIdx];
}
// Pushes an array of `{id=itemID, count=N}` tables for each non-zero
// reward slot. `ids[count]` and `counts[count]` are parallel arrays
// in the cache block. Leaves exactly one table on top of the stack.
void PushItemArray(void *L, const uint32_t *ids, const uint32_t *counts,
int count) {
Game::Lua::NewTable(L);
int next = 1;
for (int i = 0; i < count; ++i) {
if (ids[i] == 0)
continue;
Game::Lua::PushNumber(L, static_cast<double>(next++));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "id", static_cast<double>(ids[i]));
Game::Lua::SetFieldNumber(L, "count", static_cast<double>(counts[i]));
Game::Lua::SetTable(L, -3);
}
}
// Per-objective override text — the 4-slot array at `+0x14DC` with
// 256-byte stride. Index in objective-declaration order (NPC/GO first,
// then item, same enumeration the LeaderBoard accessor walks).
// Returns empty string for slots past the cache's 4-entry capacity
// or for slots with an empty buffer (engine auto-formats those).
const char *OverrideTextFor(const uint8_t *data, int objectiveSlot) {
if (objectiveSlot < 0 || objectiveSlot >= Quest::Cache::OBJECTIVE_TEXT_COUNT)
return "";
return reinterpret_cast<const char *>(
data + Quest::Cache::OFF_OBJECTIVE_TEXT +
objectiveSlot * Quest::Cache::OBJECTIVE_TEXT_STRIDE);
}
void PushRequirements(void *L, const uint8_t *data) {
Game::Lua::NewTable(L);
int next = 1;
int overrideSlot = 0;
auto *npcOrGo = reinterpret_cast<const int32_t *>(
data + Quest::Cache::OFF_REQUIRED_NPC_OR_GO);
auto *npcOrGoCount = reinterpret_cast<const uint32_t *>(
data + Quest::Cache::OFF_REQUIRED_NPC_OR_GO_COUNT);
for (int i = 0; i < Quest::Cache::OBJECTIVE_COUNT; ++i) {
const int32_t v = npcOrGo[i];
if (v == 0)
continue;
Game::Lua::PushNumber(L, static_cast<double>(next++));
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "kind", v > 0 ? "monster" : "object");
Game::Lua::SetFieldNumber(L, "id",
static_cast<double>(v > 0 ? v : -v));
Game::Lua::SetFieldNumber(L, "count",
static_cast<double>(npcOrGoCount[i]));
Game::Lua::SetFieldString(L, "text",
OverrideTextFor(data, overrideSlot++));
Game::Lua::SetTable(L, -3);
}
auto *items = reinterpret_cast<const uint32_t *>(
data + Quest::Cache::OFF_REQUIRED_ITEM);
auto *itemCounts = reinterpret_cast<const uint32_t *>(
data + Quest::Cache::OFF_REQUIRED_ITEM_COUNT);
for (int i = 0; i < Quest::Cache::OBJECTIVE_COUNT; ++i) {
if (items[i] == 0)
continue;
Game::Lua::PushNumber(L, static_cast<double>(next++));
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "kind", "item");
Game::Lua::SetFieldNumber(L, "id", static_cast<double>(items[i]));
Game::Lua::SetFieldNumber(L, "count",
static_cast<double>(itemCounts[i]));
Game::Lua::SetFieldString(L, "text",
OverrideTextFor(data, overrideSlot++));
Game::Lua::SetTable(L, -3);
}
}
int __fastcall Script_GetQuestDetails(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: C_QuestLog.GetQuestDetails(questID)");
return 0;
}
const int questID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (questID <= 0)
return 0;
const uint8_t *data = Quest::Cache::Peek(static_cast<uint32_t>(questID));
if (data == nullptr)
return 0;
const auto u32 = [data](int off) {
return *reinterpret_cast<const uint32_t *>(data + off);
};
const auto i32 = [data](int off) {
return *reinterpret_cast<const int32_t *>(data + off);
};
const auto str = [data](int off) {
return reinterpret_cast<const char *>(data + off);
};
const auto f32 = [data](int off) {
return *reinterpret_cast<const float *>(data + off);
};
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "questID", static_cast<double>(questID));
Game::Lua::SetFieldString(L, "title", str(Quest::Cache::OFF_TITLE));
Game::Lua::SetFieldNumber(L, "level",
static_cast<double>(i32(Quest::Cache::OFF_LEVEL)));
if (const char *tag =
LookupQuestType(u32(Quest::Cache::OFF_QUEST_INFO_ROW))) {
if (*tag != '\0')
Game::Lua::SetFieldString(L, "questType", tag);
}
// Race / class / skill / timer / suggested-players gates aren't
// here intentionally — vanilla 1.12's quest-query response leaves
// those cache slots (+0x14..+0x24) zero-filled; the server enforces
// those constraints and only ships quests to clients who could
// accept them. Addons that need that data must source it from an
// external scraped DB (pfQuest-style).
const int32_t money = i32(Quest::Cache::OFF_MONEY_DELTA);
Game::Lua::SetFieldNumber(L, "rewardMoney",
money > 0 ? static_cast<double>(money) : 0.0);
Game::Lua::SetFieldNumber(L, "requiredMoney",
money < 0 ? static_cast<double>(-money) : 0.0);
Game::Lua::SetFieldNumber(
L, "rewardMoneyAtMaxLevel",
static_cast<double>(i32(Quest::Cache::OFF_LEVEL_CAP_BONUS_MONEY)));
Game::Lua::SetFieldNumber(
L, "rewardSpellID",
static_cast<double>(i32(Quest::Cache::OFF_REWARD_SPELL_ID)));
Game::Lua::SetFieldNumber(
L, "srcItemID",
static_cast<double>(u32(Quest::Cache::OFF_SRC_ITEM_ID)));
const uint32_t flags = u32(Quest::Cache::OFF_QUEST_FLAGS);
Game::Lua::SetFieldNumber(L, "questFlags", static_cast<double>(flags));
Game::Lua::SetFieldBool(L, "isSharable", (flags & 0x08) != 0);
Game::Lua::SetFieldString(L, "description",
str(Quest::Cache::OFF_DESCRIPTION));
Game::Lua::SetFieldString(L, "objectives",
str(Quest::Cache::OFF_OBJECTIVES));
Game::Lua::SetFieldString(L, "completionText",
str(Quest::Cache::OFF_COMPLETION_TEXT));
if (const uint32_t mapID = u32(Quest::Cache::OFF_POI_MAP_ID)) {
Game::Lua::PushString(L, "poi");
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "mapID", static_cast<double>(mapID));
Game::Lua::SetFieldNumber(L, "x", static_cast<double>(f32(Quest::Cache::OFF_POI_X)));
Game::Lua::SetFieldNumber(L, "y", static_cast<double>(f32(Quest::Cache::OFF_POI_Y)));
Game::Lua::SetFieldNumber(L, "opt",
static_cast<double>(u32(Quest::Cache::OFF_POI_OPT)));
Game::Lua::SetTable(L, -3);
}
Game::Lua::PushString(L, "rewardItems");
PushItemArray(L,
reinterpret_cast<const uint32_t *>(
data + Quest::Cache::OFF_REWARD_ITEMS),
reinterpret_cast<const uint32_t *>(
data + Quest::Cache::OFF_REWARD_ITEMS_COUNT),
Quest::Cache::REWARD_COUNT);
Game::Lua::SetTable(L, -3);
Game::Lua::PushString(L, "choiceItems");
PushItemArray(L,
reinterpret_cast<const uint32_t *>(
data + Quest::Cache::OFF_CHOICE_ITEMS),
reinterpret_cast<const uint32_t *>(
data + Quest::Cache::OFF_CHOICE_ITEMS_COUNT),
Quest::Cache::CHOICE_COUNT);
Game::Lua::SetTable(L, -3);
Game::Lua::PushString(L, "requirements");
PushRequirements(L, data);
Game::Lua::SetTable(L, -3);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_QuestLog", "GetQuestDetails",
&Script_GetQuestDetails);
}
} // namespace
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Quest::Details
+164
View File
@@ -0,0 +1,164 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Glue-side bindings for the saved-credentials API:
//
// SaveAccount(name, password) -> bool
// DeleteAccount(name) -> bool
// GetSavedAccounts() -> { "ACCT1", "ACCT2", ... }
// LoginWithSavedAccount(name) -> bool -- dispatched, plaintext
// never crosses C↔Lua
// boundary on the way out
//
// The first three are thin wrappers over `Security::CredStore`. The
// fourth is the load-bearing one: it pulls the encrypted blob, calls
// the engine's own `FUN_GLUE_LOGIN_ATTEMPT` (the function that
// `Script_DefaultServerLogin` tail-calls into), then scrubs the
// stack buffer. The engine itself zero-fills the password buffer
// after consuming it (see Offsets::FUN_GLUE_LOGIN_ATTEMPT docs), so
// the scrub here is belt-and-suspenders for any prefix bytes the
// engine's strlen-based wipe might miss.
//
// Registered on the glue Lua state only — there's no use for these
// outside the AccountLogin screen, and keeping them off the in-game
// state means no addon can call `LoginWithSavedAccount` to trigger
// an unexpected logout-and-login flow.
#include "CredStore.h"
#include "Game.h"
#include "Offsets.h"
#include <windows.h>
#include <cstring>
#include <vector>
namespace Security::Account {
namespace {
const char *ArgString(void *L, int idx) {
if (!Game::Lua::IsString(L, idx))
return nullptr;
return Game::Lua::ToString(L, idx);
}
// Engine login entry. `__fastcall(ecx = account, edx = mutable
// password buffer)`. The engine zero-fills the password buffer after
// SRP consumes it.
using LoginAttempt_t = void(__fastcall *)(const char *account, char *passwordMutable);
// `reinterpret_cast` from integer to function-pointer isn't constant
// in C++17, so the cast lives in the call site instead of a constexpr
// const.
inline void LoginAttempt(const char *account, char *passwordMutable) {
reinterpret_cast<LoginAttempt_t>(Offsets::FUN_GLUE_LOGIN_ATTEMPT)(
account, passwordMutable);
}
int __fastcall Script_SaveAccount(void *L) {
const char *name = ArgString(L, 1);
const char *password = ArgString(L, 2);
const bool ok =
name && *name && password && *password &&
Security::CredStore::Save(name, password);
Game::Lua::PushBool(L, ok);
return 1;
}
int __fastcall Script_DeleteAccount(void *L) {
const char *name = ArgString(L, 1);
const bool ok = name && *name && Security::CredStore::Delete(name);
Game::Lua::PushBool(L, ok);
return 1;
}
int __fastcall Script_GetSavedAccounts(void *L) {
auto accounts = Security::CredStore::List();
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
int key = 1;
for (const auto &acct : accounts) {
// Push outer index → inner table { name=..., lastUsed=... }.
Game::Lua::PushNumber(L, static_cast<double>(key++));
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "name", acct.name.c_str());
Game::Lua::SetFieldNumber(L, "lastUsed",
static_cast<double>(acct.lastUsedUnix));
Game::Lua::SetTable(L, -3);
}
return 1;
}
int __fastcall Script_LoginWithSavedAccount(void *L) {
const char *name = ArgString(L, 1);
if (!name || !*name) {
Game::Lua::PushBool(L, false);
return 1;
}
std::vector<char> password;
if (!Security::CredStore::Load(name, password) || password.empty()) {
Game::Lua::PushBool(L, false);
return 1;
}
// Keep a copy of the plaintext on the stack — the engine's login
// path zeros the buffer it consumes (see Offsets::FUN_GLUE_LOGIN_ATTEMPT
// notes), but we need the plaintext for one more thing: re-saving
// the credential so Windows updates LastWritten. Without that,
// `lastUsed` in `GetSavedAccounts()` would only reflect the
// original `SaveAccount` time, not actual logins.
std::vector<char> copyForTouch(password.begin(), password.end());
copyForTouch.push_back('\0');
// CredStore::Load gives us a vector sized to the password length
// with a trailing nul one past the end (resize to len after writing
// len+1 bytes). The engine login function reads via strlen, so we
// need the nul terminator accessible — `data()[size()]` is valid
// on std::vector (the spec guarantees a writable element there
// since C++11). Engine will zero out the bytes up through strlen
// after SRP consumes them.
LoginAttempt(name, password.data());
// Refresh LastWritten by re-saving the credential. Done after
// engine consumes so a no-op call (e.g. glue guards not satisfied)
// doesn't update the timestamp on a login that didn't happen.
// ... actually, the engine no-ops silently, so we can't gate on
// success. We update unconditionally; in practice this only fires
// from the AccountLogin screen, which has the guards satisfied.
Security::CredStore::Save(name, copyForTouch.data());
// Belt-and-suspenders scrub. Engine already zeroed `password.size()`
// bytes, but we re-scrub the full vector capacity in case the
// engine's wipe assumed an exact strlen match. Also scrub the
// touch copy that lived through the engine call.
SecureZeroMemory(password.data(), password.size());
SecureZeroMemory(copyForTouch.data(), copyForTouch.size());
Game::Lua::PushBool(L, true);
return 1;
}
void RegisterGlueFunctions() {
Game::Lua::RegisterGlueFunction("SaveAccount", &Script_SaveAccount);
Game::Lua::RegisterGlueFunction("DeleteAccount", &Script_DeleteAccount);
Game::Lua::RegisterGlueFunction("GetSavedAccounts", &Script_GetSavedAccounts);
Game::Lua::RegisterGlueFunction("LoginWithSavedAccount",
&Script_LoginWithSavedAccount);
}
const Game::GlueModuleAutoRegister _autoreg{&RegisterGlueFunctions};
} // namespace
} // namespace Security::Account
+238
View File
@@ -0,0 +1,238 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "CredStore.h"
#include "Offsets.h"
#include <windows.h>
#include <wincred.h>
namespace Security::CredStore {
namespace {
constexpr const wchar_t kTargetPrefix[] = L"ClassicAPI/WoW/";
constexpr size_t kTargetPrefixLen =
sizeof(kTargetPrefix) / sizeof(wchar_t) - 1;
// Cred Manager target-name max is 32767 wchars; vanilla WoW account
// names cap at 0x40 bytes (see VAR_GLUE_LOGIN_ATTEMPT prologue). We
// allow generous bounds for the realm + account, leaving the rest of
// the target-name budget unused.
constexpr size_t kMaxRealmWChars = 256;
constexpr size_t kMaxAccountWChars = 256;
using GetRealmList_t = const char *(__fastcall *)(int forceRefresh);
// Returns the current engine realmList CVar's value, or empty string
// if unset. This is the realmlist address (e.g.
// "logon.turtle-wow.org"), not the friendly display name — scoping
// credentials on the address means two private servers with the same
// display name still get distinct vault entries.
std::string CurrentRealm() {
const char *r = reinterpret_cast<GetRealmList_t>(
Offsets::FUN_CVAR_REALM_LIST)(0);
if (!r || !*r) return {};
return r;
}
// Append a UTF-8 string `s` to `out` as wide chars. No-op (and false
// return) on conversion error or empty input. `max` caps the wchar
// count we'll append.
bool AppendWide(std::wstring &out, const char *s, size_t max) {
if (!s || !*s) return false;
int needed = MultiByteToWideChar(CP_UTF8, 0, s, -1, nullptr, 0);
if (needed <= 1) return false;
if (static_cast<size_t>(needed) > max) return false;
size_t base = out.size();
out.resize(base + needed - 1);
MultiByteToWideChar(CP_UTF8, 0, s, -1, &out[base], needed);
return true;
}
// Builds the per-account target name:
// ClassicAPI/WoW/<realmName>/<accountName>
//
// Returns the bare prefix when the realm or account is missing —
// callers use `result.size() > kTargetPrefixLen` to detect that and
// abort.
std::wstring MakeTargetName(const char *accountName) {
std::wstring out(kTargetPrefix, kTargetPrefixLen);
std::string realm = CurrentRealm();
if (realm.empty()) return out;
if (!AppendWide(out, realm.c_str(), kMaxRealmWChars)) {
out.resize(kTargetPrefixLen);
return out;
}
out.push_back(L'/');
if (!AppendWide(out, accountName, kMaxAccountWChars)) {
out.resize(kTargetPrefixLen);
return out;
}
return out;
}
// Builds the enumeration filter for the current realm:
// ClassicAPI/WoW/<realmName>/*
//
// Returns empty wstring if no realm is set (callers treat that as
// "no entries to list").
std::wstring MakeEnumFilter() {
std::wstring out(kTargetPrefix, kTargetPrefixLen);
std::string realm = CurrentRealm();
if (realm.empty()) return {};
if (!AppendWide(out, realm.c_str(), kMaxRealmWChars)) return {};
out.push_back(L'/');
out.push_back(L'*');
return out;
}
// Extracts the account-name suffix from a target-name during enum.
// Returns empty string if the structural shape doesn't match — the
// CredEnumerate filter should already restrict to our namespace, but
// hostile or stale entries from a previous version of this code could
// in theory land here.
std::string ExtractAccount(const wchar_t *targetName) {
if (!targetName) return {};
if (wcsncmp(targetName, kTargetPrefix, kTargetPrefixLen) != 0) {
return {};
}
// After the prefix we expect `<realm>/<account>`. Skip past the
// realm by finding the next '/'.
const wchar_t *afterPrefix = targetName + kTargetPrefixLen;
const wchar_t *sep = wcschr(afterPrefix, L'/');
if (!sep || !sep[1]) return {};
const wchar_t *account = sep + 1;
int needed = WideCharToMultiByte(CP_UTF8, 0, account, -1,
nullptr, 0, nullptr, nullptr);
if (needed <= 1) return {};
std::string out(static_cast<size_t>(needed - 1), '\0');
WideCharToMultiByte(CP_UTF8, 0, account, -1, &out[0], needed,
nullptr, nullptr);
return out;
}
} // namespace
bool Save(const char *accountName, const char *password) {
if (!accountName || !*accountName) return false;
if (!password || !*password) return false;
std::wstring target = MakeTargetName(accountName);
if (target.size() == kTargetPrefixLen) return false; // empty account
size_t pwLen = strlen(password);
if (pwLen == 0 || pwLen > CRED_MAX_CREDENTIAL_BLOB_SIZE) return false;
CREDENTIALW cred{};
cred.Type = CRED_TYPE_GENERIC;
cred.TargetName = const_cast<LPWSTR>(target.c_str());
cred.CredentialBlobSize = static_cast<DWORD>(pwLen);
cred.CredentialBlob =
reinterpret_cast<LPBYTE>(const_cast<char *>(password));
cred.Persist = CRED_PERSIST_LOCAL_MACHINE;
// UserName is required by some advapi32 builds to be non-NULL even
// for generic credentials; reuse the target so the entry is
// visually labeled correctly in `control /name
// Microsoft.CredentialManager` even though we don't read it back.
cred.UserName = const_cast<LPWSTR>(target.c_str());
return CredWriteW(&cred, 0) != FALSE;
}
bool Delete(const char *accountName) {
if (!accountName || !*accountName) return false;
std::wstring target = MakeTargetName(accountName);
if (target.size() == kTargetPrefixLen) return false;
return CredDeleteW(target.c_str(), CRED_TYPE_GENERIC, 0) != FALSE;
}
namespace {
// FILETIME (100-ns intervals since 1601-01-01) → Unix epoch seconds.
// Returns 0 for zero/invalid input.
uint64_t FileTimeToUnix(FILETIME ft) {
const uint64_t v = (static_cast<uint64_t>(ft.dwHighDateTime) << 32) |
ft.dwLowDateTime;
if (v == 0) return 0;
// 11644473600 seconds between 1601-01-01 and 1970-01-01, expressed
// in 100-ns ticks.
constexpr uint64_t kEpochDiff100ns = 116444736000000000ULL;
if (v < kEpochDiff100ns) return 0;
return (v - kEpochDiff100ns) / 10000000ULL;
}
} // namespace
std::vector<Entry> List() {
std::vector<Entry> result;
// Enumeration filter is scoped to the current realm, so entries
// for other private servers are invisible. The wildcard pattern
// `ClassicAPI/WoW/<realm>/*` matches every account under it.
std::wstring filter = MakeEnumFilter();
if (filter.empty()) return result;
DWORD count = 0;
PCREDENTIALW *entries = nullptr;
if (!CredEnumerateW(filter.c_str(), 0, &count, &entries)) {
// ERROR_NOT_FOUND is the expected "no entries yet" outcome.
return result;
}
result.reserve(count);
for (DWORD i = 0; i < count; ++i) {
if (!entries[i] || !entries[i]->TargetName) continue;
std::string account = ExtractAccount(entries[i]->TargetName);
if (account.empty()) continue;
result.push_back({std::move(account),
FileTimeToUnix(entries[i]->LastWritten)});
}
CredFree(entries);
return result;
}
bool Load(const char *accountName, std::vector<char> &outPassword) {
outPassword.clear();
if (!accountName || !*accountName) return false;
std::wstring target = MakeTargetName(accountName);
if (target.size() == kTargetPrefixLen) return false;
PCREDENTIALW cred = nullptr;
if (!CredReadW(target.c_str(), CRED_TYPE_GENERIC, 0, &cred)) {
return false;
}
if (!cred || !cred->CredentialBlob || cred->CredentialBlobSize == 0) {
if (cred) CredFree(cred);
return false;
}
const DWORD len = cred->CredentialBlobSize;
outPassword.resize(static_cast<size_t>(len) + 1, '\0');
memcpy(outPassword.data(), cred->CredentialBlob, len);
outPassword[len] = '\0';
// size() should reflect the password length, not the trailing nul.
outPassword.resize(len);
// Zero the OS-returned buffer before freeing — CredFree itself
// doesn't scrub. Safe even though the blob is a const-ish field,
// because we own it until CredFree returns and the API never
// reuses the allocation.
SecureZeroMemory(cred->CredentialBlob, len);
CredFree(cred);
return true;
}
} // namespace Security::CredStore
+74
View File
@@ -0,0 +1,74 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
// Thin C++ wrapper over the Win32 Credential Manager API (advapi32.dll:
// CredWriteW / CredReadW / CredEnumerateW / CredDeleteW). Entries are
// stored under per-user, DPAPI-encrypted blobs that other Windows users
// on the same machine cannot read; any process running as the current
// user can decrypt them (DPAPI is per-user, not per-process), so this
// is "secure at rest", not "secure against same-user attackers".
//
// Every operation is scoped by the current engine `realmList` CVar
// (the realmlist address from `realmlist.wtf` — e.g.
// "logon.turtle-wow.org", not the friendly display name). The same
// account name on two different private servers therefore gets two
// distinct vault entries:
// ClassicAPI/WoW/logon.turtle-wow.org/MYACCT
// ClassicAPI/WoW/logon.other-server.com/MYACCT
// Users can inspect/wipe entries via
// `control /name Microsoft.CredentialManager` → Generic Credentials.
//
// When no realmList is set, every operation returns false / empty —
// there's no meaningful key to scope under.
namespace Security::CredStore {
// Save (or overwrite) the password for `accountName` under the current
// engine realm. Returns false if no realm is set, the account name is
// empty, the password is empty, or the OS rejects the write.
bool Save(const char *accountName, const char *password);
// Delete the saved entry for `accountName` under the current engine
// realm. Returns true if a matching entry existed and was deleted.
bool Delete(const char *accountName);
// One entry returned by `List()` — the account name plus the
// Windows-tracked `LastWritten` timestamp converted to Unix epoch
// seconds. The engine refreshes `LastWritten` on every `CredWriteW`,
// so `LoginWithSavedAccount` re-saves the credential after use to
// keep the timestamp current ("last written" == "last used").
struct Entry {
std::string name;
uint64_t lastUsedUnix;
};
// Enumerate every saved account for the current engine realm. Empty
// list if no realm is set or no entries exist.
std::vector<Entry> List();
// Load the password for `accountName` under the current engine realm
// into `outPassword`. The output is nul-terminated and the vector's
// `size()` reflects the password length (not including the trailing
// nul). Returns false if no realm is set or no such entry exists. The
// caller should `SecureZeroMemory(outPassword.data(), outPassword.size())`
// after use.
bool Load(const char *accountName, std::vector<char> &outPassword);
} // namespace Security::CredStore