mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-11 04:17:38 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e77e5e2a1 | ||
|
|
49d09cdf1e | ||
|
|
64d373a34e | ||
|
|
4b1a3d40f5 | ||
|
|
b1fa0172fd | ||
|
|
0a901d545d | ||
|
|
ffd208a578 | ||
|
|
ec73652b67 | ||
|
|
4c798ba58a | ||
|
|
fbe81b7f01 | ||
|
|
1c0e20cca8 | ||
|
|
1242f1dd9f | ||
|
|
de0c41dd1d | ||
|
|
3e22e2c087 | ||
|
|
beb9dec6e1 | ||
|
|
e760d5f07e | ||
|
|
d0b6a7c0f3 | ||
|
|
96b0cf6075 | ||
|
|
c0c14c6947 | ||
|
|
a0df98750b | ||
|
|
da8454c7c2 | ||
|
|
d5a45c73cb |
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
## Interface: 11200
|
||||
## Title: !!!ClassicAPI
|
||||
## Notes: Library to emulate aspects of the modern WoW API on 1.12.1 / Lua 5.0.
|
||||
## Version: 1.0
|
||||
## Version: 1.3
|
||||
## Author: Brues
|
||||
|
||||
GlobalStrings.lua
|
||||
@@ -27,3 +27,5 @@ Util\TimedCallback.lua
|
||||
Util\EquipmentManager.lua
|
||||
Util\PlayerUtil.lua
|
||||
Util\SlashCommandsRegistry.lua
|
||||
Util\LinkUtil.lua
|
||||
Util\IconDataProvider.lua
|
||||
|
||||
@@ -110,7 +110,7 @@ PLAYER_FACTION_COLORS =
|
||||
{
|
||||
[0] = PLAYER_FACTION_COLOR_HORDE,
|
||||
[1] = PLAYER_FACTION_COLOR_ALLIANCE,
|
||||
};-- Quest
|
||||
};
|
||||
|
||||
WOW_PROJECT_MAINLINE = 1;
|
||||
WOW_PROJECT_CLASSIC = 2;
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
-- Backport of modern Classic Era's IconDataProvider.lua (lives in
|
||||
-- Blizzard_FrameXMLBase/Classic/) to vanilla 1.12 / Lua 5.0.
|
||||
--
|
||||
-- Wraps the four engine icon-enumeration mutators
|
||||
-- (`GetLooseMacroIcons` / `GetLooseMacroItemIcons` / `GetMacroIcons`
|
||||
-- / `GetMacroItemIcons` — all surfaced by ClassicAPI.dll) into a
|
||||
-- stateful `Spell`/`Item` bucket pair, with an optional
|
||||
-- "extras" array of icons prepended ahead of the base DB. The two
|
||||
-- "extra" types are:
|
||||
--
|
||||
-- * `Spellbook` — icons of every spell currently in the player's
|
||||
-- spellbook UI. Used by the modern Macro UI's icon picker so
|
||||
-- "icons relevant to my class" surface first. In vanilla this
|
||||
-- misses spells that aren't on the spellbook UI (racials kept
|
||||
-- off the bar, profession recipes the engine doesn't list as
|
||||
-- spells, etc.) but covers the common case.
|
||||
-- * `Equipment` — icons of currently-equipped items (slots 1..19
|
||||
-- via `GetInventoryItemTexture("player", i)`). Used by the
|
||||
-- modern Equipment-Manager popup so the picker leads with what
|
||||
-- the player is wearing.
|
||||
--
|
||||
-- Implementation differences from the modern source:
|
||||
--
|
||||
-- * `EnumUtil.MakeEnum` is inlined as a plain table.
|
||||
-- * Modern's `C_SpecializationInfo.GetTalentInfo(query)` (Cata+
|
||||
-- spec-aware talent API) and `GetPvpTalentInfoByID` (MoP+ PvP
|
||||
-- talents) are replaced with vanilla's `GetTalentInfo(tab, idx)`
|
||||
-- across the 3 talent trees. Vanilla has no specs, no dual-spec
|
||||
-- groups, and no PvP talent system, so those layers are dropped.
|
||||
-- * `GetSpellBookItemTexture(slot, "player")` is replaced with
|
||||
-- vanilla's `GetSpellTexture(slot, "spell")`. Flyouts don't
|
||||
-- exist in vanilla so that branch is dropped entirely.
|
||||
-- * `extraIconsMap` is a Lua-set keyed by texture path or
|
||||
-- fileID — `GetKeysArray` flattens it to a numbered array.
|
||||
-- Same dedup semantics as modern: each texture appears once
|
||||
-- within the extras section, but no cross-dedup with the
|
||||
-- base DB walk (the player's equipped weapon icon may still
|
||||
-- appear twice in the final picker, once in extras and once
|
||||
-- in `GetMacroItemIcons`).
|
||||
|
||||
local QuestionMarkIconPath = "INTERFACE\\ICONS\\INV_MISC_QUESTIONMARK"
|
||||
|
||||
local NumActiveIconDataProviders = 0
|
||||
local BaseIconFilenames = nil
|
||||
|
||||
-- Builds the table BaseIconFilenames with engine icon DB.
|
||||
-- Lazy: shared across all active IconDataProvider instances, so
|
||||
-- the four engine calls (each of which scans thousands of files)
|
||||
-- only happens once per picker session.
|
||||
local function IconDataProvider_RefreshIconTextures()
|
||||
if BaseIconFilenames ~= nil then
|
||||
return
|
||||
end
|
||||
|
||||
BaseIconFilenames = {}
|
||||
BaseIconFilenames[IconDataProviderIconType.Spell] = {}
|
||||
BaseIconFilenames[IconDataProviderIconType.Item] = {}
|
||||
GetLooseMacroIcons(BaseIconFilenames[IconDataProviderIconType.Spell])
|
||||
GetLooseMacroItemIcons(BaseIconFilenames[IconDataProviderIconType.Item])
|
||||
GetMacroIcons(BaseIconFilenames[IconDataProviderIconType.Spell])
|
||||
GetMacroItemIcons(BaseIconFilenames[IconDataProviderIconType.Item])
|
||||
end
|
||||
|
||||
local function IconDataProvider_ClearIconTextures()
|
||||
BaseIconFilenames = nil
|
||||
collectgarbage()
|
||||
end
|
||||
|
||||
local function IconDataProvider_GetBaseIconTexture(iconType, index)
|
||||
local texture = BaseIconFilenames[iconType][index]
|
||||
local fileDataID = tonumber(texture)
|
||||
if fileDataID ~= nil then
|
||||
return fileDataID
|
||||
else
|
||||
return "INTERFACE\\ICONS\\" .. texture
|
||||
end
|
||||
end
|
||||
|
||||
function IconDataProvider_GetAllIconTypes()
|
||||
local iconTypeValues = GetValuesArray(IconDataProviderIconType)
|
||||
table.sort(iconTypeValues)
|
||||
return iconTypeValues
|
||||
end
|
||||
|
||||
IconDataProviderMixin = {}
|
||||
|
||||
-- `EnumUtil.MakeEnum("Spell", "Item")` produces `{ Spell = 1, Item = 2 }`.
|
||||
IconDataProviderIconType = { Spell = 1, Item = 2 }
|
||||
|
||||
IconDataProviderExtraType = {
|
||||
Spellbook = 1,
|
||||
Equipment = 2,
|
||||
None = 3,
|
||||
}
|
||||
|
||||
local function FillOutExtraIconsMapWithSpells(extraIconsMap)
|
||||
for tab = 1, GetNumSpellTabs() do
|
||||
local _, _, offset, numSpells = GetSpellTabInfo(tab)
|
||||
offset = offset + 1
|
||||
local tabEnd = offset + numSpells
|
||||
for slot = offset, tabEnd - 1 do
|
||||
local tex = GetSpellTexture(slot, "spell")
|
||||
if tex ~= nil and tex ~= "" then
|
||||
extraIconsMap[tex] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Walks all three vanilla talent trees, gathering every talent's
|
||||
-- icon — including talents the player hasn't put points into. The
|
||||
-- whole tree is "relevant to my class" for picker purposes, not
|
||||
-- just maxed talents. `GetTalentInfo(tab, idx)` returns
|
||||
-- `(name, icon, tier, column, rank, maxRank, ...)` — we read the
|
||||
-- icon (second return) directly.
|
||||
local function FillOutExtraIconsMapWithTalents(extraIconsMap)
|
||||
for tab = 1, GetNumTalentTabs() do
|
||||
local numTalents = GetNumTalents(tab)
|
||||
for talent = 1, numTalents do
|
||||
local _, icon = GetTalentInfo(tab, talent)
|
||||
if icon ~= nil and icon ~= "" then
|
||||
extraIconsMap[icon] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function FillOutExtraIconsMapWithEquipment(extraIconsMap)
|
||||
for slot = INVSLOT_FIRST_EQUIPPED, INVSLOT_LAST_EQUIPPED do
|
||||
for _, itemLink in pairs(GetInventoryItemsForSlot(slot, {})) do
|
||||
local _, _, _, _, itemTexture = C_Item.GetItemInfoInstant(itemLink)
|
||||
if itemTexture then
|
||||
extraIconsMap[itemTexture] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function IconDataProviderMixin:Init(extraType, extraIconsOnly, requestedIconTypes)
|
||||
self.extraIcons = {}
|
||||
self.extraIconType = extraType
|
||||
self.requestedIconTypes = requestedIconTypes or IconDataProvider_GetAllIconTypes()
|
||||
|
||||
if extraType == IconDataProviderExtraType.Spellbook then
|
||||
local extraIconsMap = {}
|
||||
FillOutExtraIconsMapWithSpells(extraIconsMap)
|
||||
FillOutExtraIconsMapWithTalents(extraIconsMap)
|
||||
self.extraIcons = GetKeysArray(extraIconsMap)
|
||||
elseif extraType == IconDataProviderExtraType.Equipment then
|
||||
local extraIconsMap = {}
|
||||
FillOutExtraIconsMapWithEquipment(extraIconsMap)
|
||||
self.extraIcons = GetKeysArray(extraIconsMap)
|
||||
end
|
||||
|
||||
if not extraIconsOnly then
|
||||
NumActiveIconDataProviders = NumActiveIconDataProviders + 1
|
||||
IconDataProvider_RefreshIconTextures()
|
||||
end
|
||||
end
|
||||
|
||||
function IconDataProviderMixin:SetIconTypes(iconTypes)
|
||||
self.requestedIconTypes = iconTypes or IconDataProvider_GetAllIconTypes()
|
||||
end
|
||||
|
||||
function IconDataProviderMixin:GetNumIcons()
|
||||
-- 1 to account for the leading `?` icon.
|
||||
local numIcons = 1
|
||||
if self:ShouldShowExtraIcons() then
|
||||
numIcons = numIcons + table.getn(self.extraIcons)
|
||||
end
|
||||
if BaseIconFilenames then
|
||||
for _, v in pairs(self.requestedIconTypes) do
|
||||
numIcons = numIcons + table.getn(BaseIconFilenames[v])
|
||||
end
|
||||
end
|
||||
return numIcons
|
||||
end
|
||||
|
||||
function IconDataProviderMixin:GetIconByIndex(index)
|
||||
if index == 1 then
|
||||
return QuestionMarkIconPath
|
||||
end
|
||||
|
||||
index = index - 1
|
||||
|
||||
local numExtraIcons = (self:ShouldShowExtraIcons() and
|
||||
table.getn(self.extraIcons)) or 0
|
||||
if index <= numExtraIcons then
|
||||
return self.extraIcons[index]
|
||||
end
|
||||
|
||||
local baseIndex = index - numExtraIcons
|
||||
local lookupIconType = nil
|
||||
-- Each icon type's table is indexed from 1, so loop through the
|
||||
-- tables to find which icon type we index to.
|
||||
for _, v in pairs(self.requestedIconTypes) do
|
||||
local numIconsForType = table.getn(BaseIconFilenames[v])
|
||||
if baseIndex <= numIconsForType then
|
||||
lookupIconType = v
|
||||
break
|
||||
end
|
||||
baseIndex = baseIndex - numIconsForType
|
||||
end
|
||||
|
||||
if lookupIconType then
|
||||
return IconDataProvider_GetBaseIconTexture(lookupIconType, baseIndex)
|
||||
else
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
function IconDataProviderMixin:GetIconForSaving(index)
|
||||
local icon = self:GetIconByIndex(index)
|
||||
if type(icon) == "string" then
|
||||
icon = string.gsub(icon, "INTERFACE\\ICONS\\", "")
|
||||
end
|
||||
return icon
|
||||
end
|
||||
|
||||
function IconDataProviderMixin:GetIndexOfIcon(icon)
|
||||
local numIcons = self:GetNumIcons()
|
||||
for i = 1, numIcons do
|
||||
if self:GetIconByIndex(i) == icon then
|
||||
return i
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function IconDataProviderMixin:ShouldShowExtraIcons()
|
||||
return (self.extraIconType == IconDataProviderExtraType.Spellbook and
|
||||
tContains(self.requestedIconTypes, IconDataProviderIconType.Spell))
|
||||
or (self.extraIconType == IconDataProviderExtraType.Equipment and
|
||||
tContains(self.requestedIconTypes, IconDataProviderIconType.Item))
|
||||
end
|
||||
|
||||
function IconDataProviderMixin:Release()
|
||||
NumActiveIconDataProviders = NumActiveIconDataProviders - 1
|
||||
if NumActiveIconDataProviders <= 0 then
|
||||
IconDataProvider_ClearIconTextures()
|
||||
end
|
||||
end
|
||||
@@ -62,7 +62,10 @@ LinkTypes = {
|
||||
LinkUtil = {};
|
||||
|
||||
function LinkUtil.FormatLink(linkType, linkDisplayText, ...)
|
||||
local linkFormatTable = { string.format("|H%s", linkType), ... };
|
||||
local linkFormatTable = { string.format("|H%s", linkType) };
|
||||
for i = 1, arg.n do
|
||||
linkFormatTable[i + 1] = arg[i];
|
||||
end
|
||||
local returnLink = table.concat(linkFormatTable, ":");
|
||||
if linkDisplayText then
|
||||
return returnLink .. string.format("|h%s|h", linkDisplayText);
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -23,22 +23,25 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
|
||||
| [Combat](docs/API.md#combat) | `InCombatLockdown` |
|
||||
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
|
||||
| [Container](docs/API.md#container) | `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetItemCooldown`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
|
||||
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
|
||||
| [Container](docs/API.md#container) | `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetItemCooldown`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
|
||||
| EncodingUtil | `C_EncodingUtil.CompressString`, `C_EncodingUtil.DecompressString`, `C_EncodingUtil.EncodeBase64`, `C_EncodingUtil.DecodeBase64`, `C_EncodingUtil.EncodeHex`, `C_EncodingUtil.DecodeHex`, `C_EncodingUtil.SerializeJSON`, `C_EncodingUtil.DeserializeJSON`, `C_EncodingUtil.SerializeCBOR`, `C_EncodingUtil.DeserializeCBOR` |
|
||||
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` (sets persist to `WTF\Account\...\ClassicAPI_EquipmentSets.txt`) |
|
||||
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid` |
|
||||
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
|
||||
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
|
||||
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
|
||||
| [FriendList](docs/API.md#friendlist) | `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName` |
|
||||
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex` |
|
||||
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura` |
|
||||
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
|
||||
| [Input](docs/API.md#input) | `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
|
||||
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
|
||||
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
|
||||
| [Macros](docs/API.md#macros) | `GetMacroSpell` |
|
||||
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
|
||||
| [Macros](docs/API.md#macros) | `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
|
||||
| [Map](docs/API.md#map) | `C_Map.GetBestMapForUnit` |
|
||||
| [MerchantFrame](docs/API.md#merchantframe) | `C_MerchantFrame.GetBuybackItemID`, `C_MerchantFrame.GetItemInfo`, `C_MerchantFrame.GetNumJunkItems`, `C_MerchantFrame.IsMerchantItemRefundable`, `C_MerchantFrame.IsSellAllJunkEnabled`, `C_MerchantFrame.SellAllJunkItems` |
|
||||
| [NamePlate](docs/API.md#nameplate) | `C_NamePlate.GetNamePlateForGUID`, `C_NamePlate.GetNamePlateForUnit`, `C_NamePlate.GetNamePlateGUIDs`, `C_NamePlate.GetNamePlates` |
|
||||
| [NameCache](docs/API.md#namecache) | `C_CreatureInfo.GetCreatureID`, `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `GetPlayerInfoByGUID` |
|
||||
| [Quest](docs/API.md#quest) | `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
|
||||
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellName`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
|
||||
@@ -64,7 +67,8 @@ the glue state because GlueXML had no way to reach them otherwise.
|
||||
|
||||
| Group | Calls |
|
||||
|-------|-------|
|
||||
| CharacterOrder | `GetSavedCharacterOrder`, `SetSavedCharacterOrder` (persist to `WTF\Account\...\ClassicAPI.txt`) |
|
||||
| [Account](docs/API.md#account) | `SaveAccount`, `DeleteAccount`, `GetSavedAccounts`, `LoginWithSavedAccount` (passwords encrypted in Windows Credential Manager, scoped per realmlist; plaintext never returned to Lua) |
|
||||
| [CharacterList](docs/API.md#characterlist) | `GetSavedCharacterOrder`, `SetSavedCharacterOrder` (persist to `WTF\Account\...\ClassicAPI.txt`) |
|
||||
| CVar | `GetCVar`, `SetCVar`, `RegisterCVar`, `GetCVarDefault`, `C_CVar.GetCVarBool` (storage is process-global — writes from glue are visible in-world and vice versa) |
|
||||
| Script | `RunScript` (compile and run a Lua chunk in the glue state's globals — useful for slash-command-style helpers in GlueXML) |
|
||||
|
||||
@@ -94,6 +98,10 @@ functions, just behavior the stock 1.12 engine didn't have. See the
|
||||
| `HEARTHSTONE_BOUND` | *(none)* |
|
||||
| `ITEM_DATA_LOAD_RESULT` | `itemID, success` |
|
||||
| `MODIFIER_STATE_CHANGED` | `keyName, down` |
|
||||
| `NAME_PLATE_CREATED` | `nameplateFrame` |
|
||||
| `NAME_PLATE_UNIT_ADDED` | `unitToken` ("nameplateN") |
|
||||
| `NAME_PLATE_UNIT_REMOVED` | `unitToken` ("nameplateN") |
|
||||
| `PLAYER_FOCUS_CHANGED` | *(none)* |
|
||||
| `PLAYER_STARTED_LOOKING` | *(none)* |
|
||||
| `PLAYER_STOPPED_LOOKING` | *(none)* |
|
||||
| `PLAYER_STARTED_MOVING` | *(none)* |
|
||||
@@ -116,6 +124,13 @@ functions, just behavior the stock 1.12 engine didn't have. See the
|
||||
| Addon security | `Enum.AddOnSecurityStatus.{Secure,Insecure,Banned,NotAvailable}` |
|
||||
| Power type | `Enum.PowerType.{HealthCost,None,Mana,Rage,Focus,Energy,Happiness}` |
|
||||
|
||||
### Unit tokens
|
||||
|
||||
| Token | Resolves to |
|
||||
|-------|-------------|
|
||||
| `nameplate1`..`nameplateN` | Unit behind the Nth visible nameplate, in creation-order. Works with every `UnitX` function — `UnitName`, `UnitGUID`, `UnitClass`, `UnitHealth`, etc. Suffix chains (`nameplate1target`, `nameplate1targettarget`) compose. See [NamePlate / Unit tokens](docs/API.md#unit-tokens-nameplaten). |
|
||||
| `focus` / `focustarget` | Sticky target set via [`FocusUnit`](docs/API.md#focusunitunit), cleared via [`ClearFocus`](docs/API.md#clearfocus). Same `UnitX` coverage as `nameplateN`. Fires [`PLAYER_FOCUS_CHANGED`](docs/API.md#player_focus_changed-event) on transition. See [Focus](docs/API.md#focus). |
|
||||
|
||||
## Installation
|
||||
|
||||
Use [VanillaFixes](https://github.com/hannesmann/vanillafixes) to load the
|
||||
@@ -149,8 +164,19 @@ The triple-`!` prefix is **load-order-significant** — WoW loads
|
||||
addons alphabetically, so `!!!ClassicAPI` is guaranteed to run before
|
||||
any consumer that depends on its globals. Don't rename the folder.
|
||||
|
||||
To install: copy [`AddOns/!!!ClassicAPI/`](AddOns/!!!ClassicAPI/) into
|
||||
your `Interface/AddOns/` directory.
|
||||
**You don't have to install the addon manually** — the DLL embeds
|
||||
the contents of [`AddOns/!!!ClassicAPI/`](AddOns/!!!ClassicAPI/) and
|
||||
registers them with the engine as a synthetic addon on startup, so
|
||||
the library is always available when the DLL is loaded. The engine
|
||||
sees it as a normal addon: it appears in `GetAddOnInfo`, fires
|
||||
`ADDON_LOADED`, supports `SavedVariables`, the works.
|
||||
|
||||
If you do drop the folder into your `Interface/AddOns/` directory,
|
||||
**that copy wins** — the DLL's embedded version is only registered
|
||||
when the engine's normal scan doesn't already have an entry under
|
||||
that name. Useful for editing the Lua locally without rebuilding the
|
||||
DLL. The dispatch is transparent: addons consuming `Mixin`,
|
||||
`ColorMixin`, `TableUtil`, etc. behave identically in both cases.
|
||||
|
||||
## Bundled addon: DebugTools
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# embed_addon.cmake — bundle an addon directory into a generated C++ header.
|
||||
#
|
||||
# Invoked from CMakeLists.txt via `cmake -P` as a custom command. Reads every
|
||||
# file under SRC_DIR recursively and emits OUT_HEADER containing:
|
||||
#
|
||||
# namespace Addons::Embedded {
|
||||
# struct File {
|
||||
# const char *path;
|
||||
# const unsigned char *data;
|
||||
# size_t size;
|
||||
# };
|
||||
# extern const File kFiles[];
|
||||
# extern const size_t kFileCount;
|
||||
# }
|
||||
#
|
||||
# Paths are normalized to lowercase backslash-form relative to
|
||||
# `Interface\AddOns\` so they match what the engine's TOC parser
|
||||
# constructs when it asks for the addon's files (see
|
||||
# `s_Interface_AddOns__s__s_toc_00853840` in the binary). The
|
||||
# manifest is hash-table-friendly for fast lookup but the lookup
|
||||
# walks the array linearly — fine for 25 files.
|
||||
#
|
||||
# Usage:
|
||||
# cmake -DSRC_DIR=... -DOUT_HEADER=... -DADDON_NAME=... -P embed_addon.cmake
|
||||
|
||||
if(NOT SRC_DIR)
|
||||
message(FATAL_ERROR "embed_addon.cmake: SRC_DIR required")
|
||||
endif()
|
||||
if(NOT OUT_HEADER)
|
||||
message(FATAL_ERROR "embed_addon.cmake: OUT_HEADER required")
|
||||
endif()
|
||||
if(NOT ADDON_NAME)
|
||||
message(FATAL_ERROR "embed_addon.cmake: ADDON_NAME required")
|
||||
endif()
|
||||
|
||||
file(GLOB_RECURSE INPUT_FILES RELATIVE "${SRC_DIR}" "${SRC_DIR}/*")
|
||||
list(SORT INPUT_FILES) # deterministic codegen
|
||||
|
||||
set(BODY "")
|
||||
set(MANIFEST "")
|
||||
set(INDEX 0)
|
||||
foreach(REL_PATH ${INPUT_FILES})
|
||||
set(ABS_PATH "${SRC_DIR}/${REL_PATH}")
|
||||
file(READ "${ABS_PATH}" CONTENT_HEX HEX)
|
||||
string(LENGTH "${CONTENT_HEX}" HEX_LEN)
|
||||
math(EXPR FILE_SIZE "${HEX_LEN} / 2")
|
||||
|
||||
# Convert hex string to comma-separated byte literals. 16 bytes per line
|
||||
# keeps the generated header readable.
|
||||
set(BYTES "")
|
||||
set(COL 0)
|
||||
set(POS 0)
|
||||
while(POS LESS HEX_LEN)
|
||||
string(SUBSTRING "${CONTENT_HEX}" ${POS} 2 BYTE)
|
||||
if(COL EQUAL 0)
|
||||
string(APPEND BYTES " ")
|
||||
endif()
|
||||
string(APPEND BYTES "0x${BYTE},")
|
||||
math(EXPR POS "${POS} + 2")
|
||||
math(EXPR COL "${COL} + 1")
|
||||
if(COL EQUAL 16)
|
||||
string(APPEND BYTES "\n")
|
||||
set(COL 0)
|
||||
else()
|
||||
string(APPEND BYTES " ")
|
||||
endif()
|
||||
endwhile()
|
||||
if(NOT COL EQUAL 0)
|
||||
string(APPEND BYTES "\n")
|
||||
endif()
|
||||
|
||||
# Normalize path to engine-style: `<AddonName>\Sub\Path.lua` with
|
||||
# backslashes. Engine constructs full path as
|
||||
# `Interface\AddOns\<AddonName>\<file>` — we strip the
|
||||
# `Interface\AddOns\<AddonName>\` prefix at lookup time so storing
|
||||
# just the relative form keeps the manifest small.
|
||||
string(REPLACE "/" "\\\\" REL_PATH_BS "${REL_PATH}")
|
||||
|
||||
string(APPEND BODY "static const unsigned char kData_${INDEX}[] = {\n${BYTES}};\n\n")
|
||||
string(APPEND MANIFEST " { \"${REL_PATH_BS}\", kData_${INDEX}, ${FILE_SIZE} },\n")
|
||||
|
||||
math(EXPR INDEX "${INDEX} + 1")
|
||||
endforeach()
|
||||
|
||||
set(HEADER_CONTENT "// Auto-generated by embed_addon.cmake — do not edit.
|
||||
// Source: ${SRC_DIR}
|
||||
//
|
||||
// Addon \"${ADDON_NAME}\" embedded as a fallback for users who don't have
|
||||
// it installed on disk. See src/addons/Embedded.cpp for the file-read
|
||||
// hook that surfaces these buffers to the engine.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace Addons::Embedded {
|
||||
|
||||
namespace ${ADDON_NAME}Files {
|
||||
|
||||
${BODY}static const struct File {
|
||||
const char *path;
|
||||
const unsigned char *data;
|
||||
size_t size;
|
||||
} kFiles[] = {
|
||||
${MANIFEST}};
|
||||
|
||||
static constexpr size_t kFileCount = sizeof(kFiles) / sizeof(kFiles[0]);
|
||||
|
||||
} // namespace ${ADDON_NAME}Files
|
||||
|
||||
} // namespace Addons::Embedded
|
||||
")
|
||||
|
||||
file(WRITE "${OUT_HEADER}" "${HEADER_CONTENT}")
|
||||
message(STATUS "embed_addon: wrote ${OUT_HEADER} (${INDEX} files)")
|
||||
+831
-20
@@ -6,6 +6,9 @@ build instructions.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Account](#account)
|
||||
- [`SaveAccount(name, password)` / `DeleteAccount(name)` / `GetSavedAccounts()` / `LoginWithSavedAccount(name)` — GlueXML only](#saveaccountname-password--deleteaccountname--getsavedaccounts--loginwithsavedaccountname--gluexml-only)
|
||||
|
||||
- [Action](#action)
|
||||
- [`GetActionInfo(slot)`](#getactioninfoslot)
|
||||
|
||||
@@ -34,7 +37,9 @@ build instructions.
|
||||
- [`GetItemCooldown(itemInfo)` / `C_Container.GetItemCooldown(itemID)`](#getitemcooldowniteminfo--c_containergetitemcooldownitemid)
|
||||
- [`C_Container.GetContainerItemDurability(containerIndex, slotIndex)`](#c_containergetcontaineritemdurabilitycontainerindex-slotindex)
|
||||
- [`C_Container.GetContainerItemRepairCost(containerIndex, slotIndex)`](#c_containergetcontaineritemrepaircostcontainerindex-slotindex)
|
||||
- [`C_Container.GetContainerItemCharges(containerIndex, slotIndex)`](#c_containergetcontaineritemchargescontainerindex-slotindex)
|
||||
- [`C_Container.GetContainerNumFreeSlots(bagID)`](#c_containergetcontainernumfreeslotsbagid)
|
||||
- [`C_Container.IsContainerItemOpenable(containerIndex, slotIndex)`](#c_containeriscontaineritemopenablecontainerindex-slotindex)
|
||||
- [`C_Container.PlayerHasHearthstone()`](#c_containerplayerhashearthstone)
|
||||
- [`C_Container.UseHearthstone()`](#c_containerusehearthstone)
|
||||
- [`C_Container.SwapItems(srcBag, srcSlot, dstBag, dstSlot)`](#c_containerswapitemssrcbag-srcslot-dstbag-dstslot)
|
||||
@@ -44,6 +49,9 @@ build instructions.
|
||||
- [CVar](#cvar)
|
||||
- [`C_CVar.GetCVarBool(cvar)`](#c_cvargetcvarboolcvar)
|
||||
|
||||
- [Cursor](#cursor)
|
||||
- [`GetCursorInfo()`](#getcursorinfo)
|
||||
|
||||
- [EquipmentSet](#equipmentset)
|
||||
- [Overview & file format](#overview--file-format)
|
||||
- [`C_EquipmentSet.CanUseEquipmentSets()`](#c_equipmentsetcanuseequipmentsets)
|
||||
@@ -73,6 +81,8 @@ build instructions.
|
||||
- [`EQUIPMENT_SWAP_FINISHED` event](#equipment_swap_finished-event)
|
||||
- [`FACTION_STANDING_CHANGED` event](#faction_standing_changed-event)
|
||||
- [`MODIFIER_STATE_CHANGED` event](#modifier_state_changed-event)
|
||||
- [`NAME_PLATE_CREATED` / `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` events](#name_plate_created--name_plate_unit_added--name_plate_unit_removed-events)
|
||||
- [`PLAYER_FOCUS_CHANGED` event](#player_focus_changed-event)
|
||||
- [`QUEST_ACCEPTED` event](#quest_accepted-event)
|
||||
- [`QUEST_TURNED_IN` event](#quest_turned_in-event)
|
||||
- [`UPDATE_SHAPESHIFT_FORM` event](#update_shapeshift_form-event)
|
||||
@@ -92,6 +102,11 @@ build instructions.
|
||||
- [`C_Reputation.SetWatchedFactionByID(factionID)`](#c_reputationsetwatchedfactionbyidfactionid)
|
||||
- [`C_Reputation.GetLastStandingChange()`](#c_reputationgetlaststandingchange)
|
||||
|
||||
- [Focus](#focus)
|
||||
- [`FocusUnit(unit)`](#focusunitunit)
|
||||
- [`ClearFocus()`](#clearfocus)
|
||||
- [Unit token (`focus` / `focustarget`)](#unit-token-focus--focustarget)
|
||||
|
||||
- [FriendList](#friendlist)
|
||||
- [`C_FriendList.SendWhoQueryByName(name)`](#c_friendlistsendwhoquerybynamename)
|
||||
- [`C_FriendList.IsWhoQueryPending()`](#c_friendlistiswhoquerypending)
|
||||
@@ -170,6 +185,7 @@ build instructions.
|
||||
- [`C_Item.GetItemUniqueness(itemLocation)` / `C_Item.GetItemUniquenessByID(item)`](#c_itemgetitemuniquenessitemlocation--c_itemgetitemuniquenessbyiditem)
|
||||
- [`C_Item.GetItemLink(itemLocation)`](#c_itemgetitemlinkitemlocation)
|
||||
- [`C_Item.GetItemInventoryType(itemLocation)` / `C_Item.GetItemInventoryTypeByID(item)`](#c_itemgetiteminventorytypeitemlocation--c_itemgetiteminventorytypebyiditem)
|
||||
- [`C_Item.IsItemOpenable(itemLocation)`](#c_itemisitemopenableitemlocation)
|
||||
- [`C_Item.IsLocked(itemLocation)`](#c_itemislockeditemlocation)
|
||||
- [`C_Item.LockItem(itemLocation)`](#c_itemlockitemitemlocation)
|
||||
- [`C_Item.LockItemByGUID(itemGUID)`](#c_itemlockitembyguiditemguid)
|
||||
@@ -183,6 +199,8 @@ build instructions.
|
||||
- [Numeric spellIDs in `/cast` and `CastSpellByName`](#numeric-spellids-in-cast-and-castspellbyname)
|
||||
- [`CastSpellNoToggle` as a macro cast line](#castspellnotoggle-as-a-macro-cast-line)
|
||||
- [`GetMacroSpell(macroSlot)`](#getmacrospellmacroslot)
|
||||
- [`GetMacroIcons` / `GetMacroItemIcons` / `GetLooseMacroIcons` / `GetLooseMacroItemIcons`](#getmacroicons--getmacroitemicons--getloosemacroicons--getloosemacroitemicons)
|
||||
- [`IconDataProviderMixin`](#icondataprovidermixin)
|
||||
|
||||
- [Map](#map)
|
||||
- [`C_Map.GetBestMapForUnit(unitToken)`](#c_mapgetbestmapforunitunittoken)
|
||||
@@ -195,6 +213,13 @@ build instructions.
|
||||
- [`C_MerchantFrame.IsMerchantItemRefundable(slot)`](#c_merchantframeismerchantitemrefundableslot)
|
||||
- [`C_MerchantFrame.IsSellAllJunkEnabled()`](#c_merchantframeissellalljunkenabled)
|
||||
|
||||
- [NamePlate](#nameplate)
|
||||
- [`C_NamePlate.GetNamePlates()`](#c_nameplategetnameplates)
|
||||
- [`C_NamePlate.GetNamePlateGUIDs()`](#c_nameplategetnameplateguids)
|
||||
- [`C_NamePlate.GetNamePlateForUnit(unitToken)`](#c_nameplategetnameplateforunitunittoken)
|
||||
- [`C_NamePlate.GetNamePlateForGUID(guidString)`](#c_nameplategetnameplateforguidguidstring)
|
||||
- [Unit tokens (`nameplateN`)](#unit-tokens-nameplaten)
|
||||
|
||||
- [NameCache](#namecache)
|
||||
- [`GetPlayerInfoByGUID(guid)`](#getplayerinfobyguidguid)
|
||||
- [`C_PlayerCache.GetPlayerInfoByName(name)`](#c_playercachegetplayerinfobynamename)
|
||||
@@ -314,6 +339,120 @@ build instructions.
|
||||
- [`C_UnitAuras.GetUnitAuras(unit [, filter])`](#c_unitaurasgetunitaurasunit--filter)
|
||||
- [`C_UnitAuras.GetAuraDispelTypeColor(dispelName)`](#c_unitaurasgetauradispeltypecolordispelname)
|
||||
|
||||
## Account
|
||||
|
||||
### `SaveAccount(name, password)` / `DeleteAccount(name)` / `GetSavedAccounts()` / `LoginWithSavedAccount(name)` — GlueXML only
|
||||
|
||||
Persists account credentials in Windows Credential Manager (per-user,
|
||||
DPAPI-encrypted) and dispatches login from C so the plaintext password
|
||||
never crosses the C↔Lua boundary on the way out. Designed for GlueXML
|
||||
rewrites of `AccountLogin.lua` that want a selection list of remembered
|
||||
accounts without persisting plaintext passwords to SavedVariables.
|
||||
|
||||
> **Only callable from the login screen** (the "glue" Lua state).
|
||||
> Calling them from an in-world addon errors with "attempt to call nil"
|
||||
> — they aren't registered there.
|
||||
|
||||
- `SaveAccount(name, password)` — encrypt and save under the current
|
||||
realmlist. Returns `true` on success, `false` if name or password is
|
||||
empty, no realmlist is set, or the OS rejects the write.
|
||||
- `DeleteAccount(name)` — remove the saved entry. Returns `true` if a
|
||||
matching entry existed and was deleted.
|
||||
- `GetSavedAccounts()` — returns a numeric-keyed table of entries for
|
||||
the current realmlist. Other realmlists' entries are invisible. Empty
|
||||
table if no realmlist is set. Each entry is a table:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `name` | string | Account name as saved. |
|
||||
| `lastUsed` | number | Unix epoch seconds of the last write to the vault entry. Refreshed automatically by `LoginWithSavedAccount`, so this functions as a "last used" timestamp. `0` if Windows didn't supply a timestamp (extremely rare, e.g. credentials manually injected). |
|
||||
|
||||
```lua
|
||||
for i, entry in ipairs(GetSavedAccounts()) do
|
||||
print(entry.name, date("%Y-%m-%d %H:%M", entry.lastUsed))
|
||||
end
|
||||
```
|
||||
|
||||
- `LoginWithSavedAccount(name)` — decrypt internally and feed the
|
||||
credentials to the engine's login function. Returns `true` if
|
||||
credentials were found and dispatched, `false` if no such entry
|
||||
exists. **Plaintext is never returned to Lua.** Equivalent to what
|
||||
`DefaultServerLogin(name, password)` does, but with the password
|
||||
fetched from the vault rather than passed in. Also re-saves the
|
||||
credential (same value) to refresh the `lastUsed` timestamp.
|
||||
|
||||
#### Scoping per realmlist
|
||||
|
||||
Every operation is scoped by the current `realmList` CVar (the address
|
||||
from `realmlist.wtf`, e.g. `"logon.turtle-wow.org"`), not the friendly
|
||||
display name. The same account name on two different private servers
|
||||
therefore gets two distinct vault entries:
|
||||
|
||||
```
|
||||
ClassicAPI/WoW/logon.turtle-wow.org/MYACCT
|
||||
ClassicAPI/WoW/logon.other-server.com/MYACCT
|
||||
```
|
||||
|
||||
Switching realmlists changes the visible account list automatically;
|
||||
no separate "select realm" step. If you need a refresh while still on
|
||||
the login screen, call `Autologin_Load()` (or `GetSavedAccounts()`
|
||||
directly) after the realmlist changes.
|
||||
|
||||
#### Storage
|
||||
|
||||
Entries live in the OS Credential Manager under the
|
||||
`ClassicAPI/WoW/<realmlist>/` namespace. Users can inspect and wipe
|
||||
them via Windows' `control /name Microsoft.CredentialManager` →
|
||||
Generic Credentials, the same way they'd manage any other Windows-
|
||||
stored credential.
|
||||
|
||||
#### Security caveats
|
||||
|
||||
Realistic about what this defends against:
|
||||
|
||||
- **Defeats**: casual file inspection of WTF, sharing the WTF folder,
|
||||
cloud-syncing it, another Windows user on the same machine, malware
|
||||
running as a different Windows user.
|
||||
- **Does not defeat**: any process running as the current Windows user
|
||||
can decrypt these via DPAPI — it's per-user, not per-process. No
|
||||
"decrypt and return plaintext" path is exposed to Lua, so a hostile
|
||||
addon can trigger a login with a saved account but can't extract the
|
||||
password. Plaintext is also unavoidably in process memory while the
|
||||
engine sends SRP.
|
||||
|
||||
#### Addon example
|
||||
|
||||
```lua
|
||||
-- GlueXML, e.g. AccountLogin.lua: save credentials on first manual
|
||||
-- login, one-click login on subsequent shows.
|
||||
|
||||
local function OnLoginClicked()
|
||||
local name = AccountLoginAccountEdit:GetText()
|
||||
local password = AccountLoginPasswordEdit:GetText()
|
||||
if not name or name == "" then return end
|
||||
|
||||
if password and password ~= "" then
|
||||
-- Fresh login or password change: persist + log in.
|
||||
SaveAccount(name, password)
|
||||
LoginWithSavedAccount(name)
|
||||
else
|
||||
-- Reuse stored credentials.
|
||||
LoginWithSavedAccount(name)
|
||||
end
|
||||
end
|
||||
|
||||
-- Populate a selection list from the vault, sorted by recency.
|
||||
local accounts = GetSavedAccounts()
|
||||
table.sort(accounts, function(a, b) return a.lastUsed > b.lastUsed end)
|
||||
for i = 1, table.getn(accounts) do
|
||||
local e = accounts[i]
|
||||
print(i, e.name, "last used:", date("%m/%d/%Y", e.lastUsed))
|
||||
end
|
||||
|
||||
-- Forget an account.
|
||||
DeleteAccount("OLDACCT")
|
||||
```
|
||||
|
||||
## Action
|
||||
|
||||
### `GetActionInfo(slot)`
|
||||
@@ -633,10 +772,6 @@ if UnitAffectingCombat("player") then
|
||||
end
|
||||
```
|
||||
|
||||
Equivalent to `UnitAffectingCombat("player")` but faster — reads the
|
||||
`UNIT_FLAG_IN_COMBAT` bit directly off the player CGUnit's
|
||||
m_objectFields, no token-resolution roundtrip.
|
||||
|
||||
## Container
|
||||
|
||||
### `C_Container.GetContainerItemID(bagIndex, slotIndex)`
|
||||
@@ -756,6 +891,62 @@ Useful for "repair only items above N copper" smart-repair logic
|
||||
without scanning tooltips. ClassicAPI addition; modern WoW has no
|
||||
direct equivalent.
|
||||
|
||||
### `C_Container.GetContainerItemCharges(containerIndex, slotIndex)`
|
||||
|
||||
Per-slot equivalent of
|
||||
[`C_Item.GetItemCount`](#c_itemgetitemcountitem-includebank-includecharges)'s
|
||||
`includeCharges=true` mode — returns the total uses available in
|
||||
*this single slot*, where `GetItemCount` totals the same value
|
||||
across every matching slot.
|
||||
|
||||
```
|
||||
uses = C_Container.GetContainerItemCharges(containerIndex, slotIndex)
|
||||
```
|
||||
|
||||
`containerIndex` accepts the same values as
|
||||
[`C_Container.GetContainerItemDurability`](#c_containergetcontaineritemdurabilitycontainerindex-slotindex)
|
||||
(`0` = backpack, `1..4` = equipped bags, `-1`/`5..11` = bank, etc.).
|
||||
`slotIndex` is 1-based.
|
||||
|
||||
Math is `stack * usesPerItem`, where `usesPerItem` is
|
||||
`abs(SPELL_CHARGES[0])` for items with a negative charges field
|
||||
(consume-on-use: wands, healthstones, mana gems, sapper charges)
|
||||
and `1` for everything else. Worked examples:
|
||||
|
||||
| Slot contents | Stack | Raw charges | Returns |
|
||||
|---|---:|---:|---:|
|
||||
| Wand of Decay at 50 charges | 1 | -50 | **50** |
|
||||
| Wand at 1 charge | 1 | -1 | **1** |
|
||||
| Healthstone | 1 | -1 | **1** |
|
||||
| Stack of 20 water | 20 | -1 | **20** |
|
||||
| Stack of 5 mana potions | 5 | -1 | **5** |
|
||||
| Hearthstone (cooldown-only) | 1 | 0 | **1** |
|
||||
| Any other item in the slot | 1 | 0 | **1** |
|
||||
|
||||
Returns `nil` only when the slot is empty.
|
||||
|
||||
```lua
|
||||
-- "how many charges does my wand have left?"
|
||||
local charges = C_Container.GetContainerItemCharges(0, 1)
|
||||
print("Wand charges remaining: " .. charges)
|
||||
|
||||
-- "how many drinks across the bags?" — same as GetItemCount(water, false, true)
|
||||
local total = 0
|
||||
for bag = 0, 4 do
|
||||
for slot = 1, GetContainerNumSlots(bag) do
|
||||
if C_Container.GetContainerItemID(bag, slot) == waterID then
|
||||
total = total + C_Container.GetContainerItemCharges(bag, slot)
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
ClassicAPI addition; modern WoW has no direct equivalent (modern
|
||||
addons read charges off tooltip text). Useful when you need a
|
||||
per-slot number rather than `GetItemCount`'s rollup — e.g. to
|
||||
display "X charges" on the slot UI for a wand without re-walking
|
||||
every other matching item.
|
||||
|
||||
### `C_Container.GetContainerNumFreeSlots(bagID)`
|
||||
|
||||
Returns the number of empty slots in the given bag, plus the bag's
|
||||
@@ -809,6 +1000,27 @@ internally and counts slots that resolve to a null `CGItem *` (i.e.,
|
||||
empty). Cross-checked in-game against a manual
|
||||
`C_Container.GetContainerItemID` walk; counts match.
|
||||
|
||||
### `C_Container.IsContainerItemOpenable(containerIndex, slotIndex)`
|
||||
|
||||
Positional-arg wrapper for [`C_Item.IsItemOpenable`](#c_itemisitemopenableitemlocation--c_itemisitemopenablebyiditem)
|
||||
against a bag/slot pair. Same `(isOpenable, canOpen)` tuple — see
|
||||
the linked section for full semantics. Both returns are `nil` for
|
||||
empty slots or items whose data hasn't been cached yet.
|
||||
|
||||
```lua
|
||||
-- Sweep main backpack for openables
|
||||
for slot = 1, GetContainerNumSlots(0) do
|
||||
if C_Container.IsContainerItemOpenable(0, slot) then
|
||||
-- ...
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Same cache-warming caveat as the underlying `C_Item.IsItemOpenable` —
|
||||
a freshly-seen item may report `false` for one call while the
|
||||
`SMSG_ITEM_QUERY_SINGLE` round-trips. Bag-resident items are almost
|
||||
always already cached.
|
||||
|
||||
### `C_Container.PlayerHasHearthstone()`
|
||||
|
||||
Returns the itemID of the hearthstone if one is in the player's bags,
|
||||
@@ -896,18 +1108,29 @@ C_Container.SwapItems(1, 3, 0, 7)
|
||||
C_Container.SwapItems(0, 1, 0, 2)
|
||||
```
|
||||
|
||||
Bag IDs match the rest of `C_Container.*`: `0` is the backpack, `1..4`
|
||||
are the equipped bags. Slots are 1-based. The engine accepts both
|
||||
directions:
|
||||
Bag IDs match the rest of `C_Container.*`:
|
||||
|
||||
- Same container (e.g. backpack ↔ backpack, bag1 ↔ bag1) sends opcode
|
||||
`0x10D` `CMSG_SWAP_INV_ITEM` with the two linear slot bytes.
|
||||
- Cross-container (e.g. backpack ↔ bag1, bag2 ↔ bag3) sends opcode
|
||||
`0x10C` `CMSG_SWAP_ITEM` with `(srcBag, srcSlot, dstBag, dstSlot)`.
|
||||
- `0` — backpack
|
||||
- `1..4` — equipped bags
|
||||
- `-1` — main bank (24 slots; **requires the bank window to be open**)
|
||||
- `5..10` — equipped bank bags (**requires the bank window to be open**)
|
||||
|
||||
Slots are 1-based. The engine accepts both directions:
|
||||
|
||||
- Same container (e.g. backpack ↔ backpack, bag1 ↔ bag1, bank ↔ bank)
|
||||
sends opcode `0x10D` `CMSG_SWAP_INV_ITEM` with the two linear slot bytes.
|
||||
- Cross-container (e.g. backpack ↔ bag1, bag2 ↔ bank, bank ↔ bank bag)
|
||||
sends opcode `0x10C` `CMSG_SWAP_ITEM` with `(srcBag, srcSlot, dstBag, dstSlot)`.
|
||||
|
||||
Destination empty becomes a move; destination occupied becomes an
|
||||
atomic swap.
|
||||
|
||||
> **Bank slots require the bank window to be open.** The engine
|
||||
> doesn't sync bank-side `CGContainer`s or bank inventory entries
|
||||
> before the first bank interaction, so any bank `EncodeBagSlot`
|
||||
> path returns false until then. Same constraint as
|
||||
> `C_Container.GetContainerItemInfo` on bank IDs.
|
||||
|
||||
> **ClassicAPI-only.** Modern Classic Era has no direct swap-two-slots
|
||||
> call — addons there drive the cursor with two `PickupContainerItem`
|
||||
> calls in sequence. This single-call form bypasses the cursor
|
||||
@@ -949,6 +1172,9 @@ consolidate partial stacks — `SwapItems` swaps the whole slots, which
|
||||
isn't what you want when both slots hold the same item but one has a
|
||||
partial stack.
|
||||
|
||||
Bank IDs (`-1`, `5..10`) work here too with the same bank-window
|
||||
constraint as `SwapItems`.
|
||||
|
||||
> **vs. the modern equivalent.** Modern Classic Era has no direct
|
||||
> one-call move — addons there string `SplitContainerItem(bag, slot,
|
||||
> count)` + `PickupContainerItem(dstBag, dstSlot)` together to drive
|
||||
@@ -1035,6 +1261,83 @@ immediately visible on the other (a `SetCVar("foo", "1")` from a
|
||||
GlueXML script will read back as `"1"` on the in-world side, and
|
||||
vice versa).
|
||||
|
||||
## Cursor
|
||||
|
||||
### `GetCursorInfo()`
|
||||
|
||||
Returns a tuple describing whatever the player has picked up on the
|
||||
cursor (item, money, spell, macro, merchant slot), or nothing when
|
||||
the cursor is empty. Reads the same engine globals `CursorHasItem` /
|
||||
`CursorHasSpell` / `GetCursorMoney` consult, so the answer always
|
||||
matches what the rest of the engine sees.
|
||||
|
||||
Return shape per cursor state:
|
||||
|
||||
| Cursor holds | Returns |
|
||||
|---|---|
|
||||
| nothing | *nil* |
|
||||
| a bag item (PickupContainerItem, mail/trade detach with GUID) | `"item"`, `itemID`, `itemLink` |
|
||||
| a drag-source item (equipment slot, action bar, trade, mail) | `"item"`, `itemID` |
|
||||
| money (gold-split UI, `PickupPlayerMoney`) | `"money"`, `copperAmount` |
|
||||
| a spell from the spellbook | `"spell"`, `spellbookSlot`, `bookType`, `spellID` |
|
||||
| a macro | `"macro"`, `macroIndex` |
|
||||
| a merchant slot | `"merchant"`, `merchantIndex` |
|
||||
|
||||
`bookType` is `"spell"` for player spells, `"pet"` for pet spells.
|
||||
`spellbookSlot` is the 1-based slot the spell lives at in the
|
||||
spellbook arrays, found by walking the player/pet books. `0` if the
|
||||
spellID isn't in either book (rare — passive talent-granted spells).
|
||||
|
||||
```lua
|
||||
local kind, a, b, c = GetCursorInfo()
|
||||
if kind == "item" then
|
||||
print(("cursor item: %d"):format(a))
|
||||
elseif kind == "money" then
|
||||
print(("cursor money: %d copper"):format(a))
|
||||
elseif kind == "spell" then
|
||||
print(("cursor spell: id=%d slot=%d (%s book)"):format(c, a, b))
|
||||
elseif kind == "macro" then
|
||||
print(("cursor macro index: %d"):format(a))
|
||||
end
|
||||
```
|
||||
|
||||
#### `itemLink` availability
|
||||
|
||||
Returned only when the cursor holds a **bag item** (type 1) —
|
||||
`PickupContainerItem` / `PickupInventoryItem` from a bag slot, mail
|
||||
or trade detach. The cursor stores the item's GUID in this case, so
|
||||
we resolve back to the live `CGItem *` and feed it to the engine's
|
||||
link builder, which produces the full per-instance form including
|
||||
enchant ID and random-suffix decoration:
|
||||
|
||||
```
|
||||
|cff1eff00|Hitem:16791:255:0:0|h[Silkstream Cuffs]|h|r
|
||||
|cff1eff00|Hitem:9877:0:767:0|h[Sorcerer Cloak of the Owl]|h|r
|
||||
```
|
||||
|
||||
Not returned for drag-source items (equipment slot, action bar
|
||||
slot, trade slot, mail attachment — types 5/6/7/9). In those cases
|
||||
the cursor only stores the itemID (no GUID, no live `CGItem *`), so
|
||||
the engine's link builder isn't reachable. Derive a basic link from
|
||||
the itemID via `GetItemInfo` if you need one for display:
|
||||
|
||||
```lua
|
||||
local kind, itemID, link = GetCursorInfo()
|
||||
if kind == "item" and not link then
|
||||
link = (select(2, GetItemInfo(itemID))) or ("item:" .. itemID)
|
||||
end
|
||||
```
|
||||
|
||||
#### Types we don't surface
|
||||
|
||||
The engine has cursor types beyond what `GetCursorInfo` advertises in
|
||||
modern WoW — pet action drag, trade-slot drag, mail-attach drag,
|
||||
ability-bar drag from `PickupAction`, etc. These don't map cleanly to
|
||||
any modern `GetCursorInfo` string, so we return `nil` for them.
|
||||
Consumers needing to detect those can use the engine-native
|
||||
`CursorHasItem` / `CursorHasSpell` / `CursorHasMoney` (which already
|
||||
exist in vanilla) and the source-side `PickupX` calls.
|
||||
|
||||
## EquipmentSet
|
||||
|
||||
Backports the modern `C_EquipmentSet.*` namespace on top of a
|
||||
@@ -1567,6 +1870,93 @@ renderer-state changes that recreate WoW's main window (e.g. toggling
|
||||
vertical sync), where an `SetWindowLongPtr`-style `WNDPROC` subclass
|
||||
would be left dangling.
|
||||
|
||||
### `NAME_PLATE_CREATED` / `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` events
|
||||
|
||||
Fire when nameplate state actually changes. Payloads:
|
||||
|
||||
| Event | `arg1` | Notes |
|
||||
|-------|--------|-------|
|
||||
| `NAME_PLATE_CREATED` | nameplate **Frame** | Matches modern WoW. Fires once per unique `CGNamePlateFrame` pointer — same frame re-used via pool recycle does NOT refire. |
|
||||
| `NAME_PLATE_UNIT_ADDED` | `"nameplateN"` **unit token** | Matches modern WoW. Pass straight to `UnitName` / `UnitGUID` / `UnitClass` / etc., or to [`GetNamePlateForUnit`](#c_nameplategetnameplateforunitunittoken) for the frame. The token is positional — see [Unit tokens](#unit-tokens-nameplaten) for ordering semantics. |
|
||||
| `NAME_PLATE_UNIT_REMOVED` | `"nameplateN"` **unit token** | Same as above. Computed from the plate's slot *before* it shifts out of the ordered list, so the token still resolves to the leaving unit during the event handler. |
|
||||
|
||||
```lua
|
||||
local f = CreateFrame("Frame")
|
||||
f:RegisterEvent("NAME_PLATE_CREATED")
|
||||
f:RegisterEvent("NAME_PLATE_UNIT_ADDED")
|
||||
f:RegisterEvent("NAME_PLATE_UNIT_REMOVED")
|
||||
f:SetScript("OnEvent", function()
|
||||
if event == "NAME_PLATE_CREATED" then
|
||||
-- arg1 = the nameplate Frame itself
|
||||
arg1:SetAlpha(0.8)
|
||||
elseif event == "NAME_PLATE_UNIT_ADDED" then
|
||||
-- arg1 = "nameplate1" / "nameplate2" / ...
|
||||
local name = UnitName(arg1)
|
||||
local plate = C_NamePlate.GetNamePlateForUnit(arg1)
|
||||
-- ... style based on the unit ...
|
||||
end
|
||||
end)
|
||||
```
|
||||
|
||||
> **CREATED timing with nameplate addons (pfUI / TidyPlates / etc).**
|
||||
> The event fires when the *engine* allocates the underlying
|
||||
> `CGNamePlateFrame`. Nameplate-mod addons typically decorate the
|
||||
> frame on their own per-frame update — so `arg1` at `CREATED` time
|
||||
> is a bare frame with no addon-side decorations yet. For
|
||||
> unit-specific work after the addon has decorated, use
|
||||
> `NAME_PLATE_UNIT_ADDED` (fires next tick at the latest) or fetch
|
||||
> the current frame on-demand via `GetNamePlateForUnit(arg1)`.
|
||||
|
||||
> **Token stability gotcha.** Like modern WoW, the `arg1` token is
|
||||
> positional — `"nameplate3"` today may resolve to a different unit
|
||||
> after the slot vacates and shifts. If you need a per-unit hash key
|
||||
> for cross-event bookkeeping, call `UnitGUID(arg1)` and store the
|
||||
> GUID instead. See [Unit tokens](#unit-tokens-nameplaten) for the
|
||||
> ordering rules.
|
||||
|
||||
**Implementation notes**
|
||||
|
||||
Detected by per-frame polling, not engine hooks. Each world tick we
|
||||
walk the object hash for nameplated units and diff against the
|
||||
previous tick's snapshot. Modern WoW also synthesizes these via
|
||||
diffing (the underlying engine has no event for "plate state
|
||||
changed"). The cost is ~20-50µs/frame even in busy raids — well
|
||||
below noise.
|
||||
|
||||
The diff approach absorbs the engine's transient hide/reshow cycle:
|
||||
vanilla has ~7 code paths that briefly zero `unit + 0xE60` (z-order
|
||||
rebuilds, anchor changes, flag-change re-eval) and the next frame's
|
||||
show path re-allocates from the pool. Those transient zeroes never
|
||||
become events because the unit appears in both the previous and
|
||||
current tick's snapshot.
|
||||
|
||||
### `PLAYER_FOCUS_CHANGED` event
|
||||
|
||||
Fires whenever the player's focus changes — assignment via
|
||||
[`FocusUnit`](#focusunitunit), clear via [`ClearFocus`](#clearfocus),
|
||||
or a `FocusUnit(token)` call where the token resolves to no GUID
|
||||
(implicit clear). No payload args; the new focus GUID is read off
|
||||
[`UnitGUID("focus")`](#unitguidunit) in the handler.
|
||||
|
||||
```lua
|
||||
local f = CreateFrame("Frame")
|
||||
f:RegisterEvent("PLAYER_FOCUS_CHANGED")
|
||||
f:SetScript("OnEvent", function()
|
||||
local guid = UnitGUID("focus")
|
||||
if guid then
|
||||
print("focusing", UnitName("focus"))
|
||||
else
|
||||
print("focus cleared")
|
||||
end
|
||||
end)
|
||||
```
|
||||
|
||||
**Identity-checked**: assigning the same GUID twice is a no-op
|
||||
(no event refires), matching 3.3.5's `FUN_0051FF20` behavior.
|
||||
Mirrors modern's documented semantics — "fired whenever the
|
||||
player's focus target is changed, including when the focus target
|
||||
is lost or cleared".
|
||||
|
||||
### `QUEST_ACCEPTED` event
|
||||
|
||||
Fires once per quest the player just accepted, with two payload args:
|
||||
@@ -1983,6 +2373,82 @@ This is a ClassicAPI-only call; modern WoW has no equivalent (modern
|
||||
addons get the factionID from `FACTION_STANDING_CHANGED` directly, so
|
||||
they don't need a separate getter).
|
||||
|
||||
## Focus
|
||||
|
||||
Polyfills modern WoW's focus-target system: a single sticky GUID
|
||||
that addons can pin to a unit and address via the `"focus"` unit
|
||||
token across the entire `UnitX` API surface. Backed by a hook on
|
||||
`FUN_TOKEN_TO_GUID` (shared with the [`nameplateN`](#unit-tokens-nameplaten)
|
||||
tokens — see `unit/TokenExtensions.cpp`).
|
||||
|
||||
State is **session-only** — drops on `/reload`, `/logout`, and zone
|
||||
loads that recreate Lua state. Modern Classic Era behaves the same;
|
||||
addons that want persistence have to re-`FocusUnit` from `SavedVariables`
|
||||
at `ADDON_LOADED`.
|
||||
|
||||
**Auto-clear on despawn.** When the focused unit leaves the client's
|
||||
object table — out of rendering range, full despawn, dies and
|
||||
decays — focus drops and [`PLAYER_FOCUS_CHANGED`](#player_focus_changed-event)
|
||||
fires. The unit re-entering range does NOT auto-refocus. Matches
|
||||
modern WoW's documented behavior. Implementation: per-tick
|
||||
`ObjectByGUID(g_focusGUID)` probe; on null result, `Set(0)`.
|
||||
|
||||
### `FocusUnit(unit)`
|
||||
|
||||
Sets focus to the given unit. Argument is a unit token —
|
||||
`"target"`, `"mouseover"`, `"party1"`, `"nameplate1"`, anything the
|
||||
resolver accepts. Calling with no argument is shorthand for
|
||||
`FocusUnit("target")` (matches modern's `/focus` slash command
|
||||
default).
|
||||
|
||||
```lua
|
||||
FocusUnit("target") -- pin the current target
|
||||
FocusUnit("nameplate1") -- pin the unit behind nameplate1
|
||||
FocusUnit() -- same as FocusUnit("target")
|
||||
```
|
||||
|
||||
Fires [`PLAYER_FOCUS_CHANGED`](#player_focus_changed-event) if the
|
||||
resolved GUID differs from the current focus. No-op (no event) if
|
||||
the same unit is already focused — matches the
|
||||
identity-check-first behavior of 3.3.5's `FUN_0051FF20`.
|
||||
|
||||
Passing a token that doesn't resolve to a unit (e.g. `"target"`
|
||||
with nothing targeted, or `"party5"` solo) clears focus — same as
|
||||
calling `ClearFocus()`.
|
||||
|
||||
### `ClearFocus()`
|
||||
|
||||
Drops the focus. Fires `PLAYER_FOCUS_CHANGED` if there was one;
|
||||
no-op otherwise.
|
||||
|
||||
```lua
|
||||
ClearFocus()
|
||||
```
|
||||
|
||||
### Unit token (`focus` / `focustarget`)
|
||||
|
||||
`"focus"` resolves to whatever GUID `FocusUnit` last stashed.
|
||||
Accepted by every `UnitX` function:
|
||||
|
||||
```lua
|
||||
local name = UnitName("focus") -- nil if no focus
|
||||
local hp = UnitHealth("focus")
|
||||
local _, class = UnitClass("focus")
|
||||
```
|
||||
|
||||
Chains compose through the engine's standard suffix walker:
|
||||
`"focustarget"`, `"focustargettarget"`, etc. — same behavior as
|
||||
`"targettarget"`, mirrored instruction-for-instruction in our hook
|
||||
so addons get the engine semantics they expect.
|
||||
|
||||
Returns `nil` cleanly when no focus is set; doesn't raise the
|
||||
"Unknown unit name" error.
|
||||
|
||||
[`UnitTokenFromGUID`](#unittokenfromguidguid) scans `"focus"` right
|
||||
after `"target"` (matching retail order), so a focused unit's GUID
|
||||
reverse-resolves to `"focus"` only if it isn't already addressable
|
||||
as `"player"` / `"party*"` / `"raid*"` / `"nameplate*"` / `"target"`.
|
||||
|
||||
## FriendList
|
||||
|
||||
### `C_FriendList.SendWhoQueryByName(name)`
|
||||
@@ -2470,14 +2936,28 @@ lines; re-saving a set repopulates itemIDs.
|
||||
|
||||
### `CLASSIC_API_VERSION`
|
||||
|
||||
Defined once FrameScript has booted. Addons can use this to detect that
|
||||
the DLL is loaded and which version is in use. The value is
|
||||
`X*10000 + Y*100 + Z` for a tag of `vX.Y.Z` passed to CMake at configure
|
||||
time via `-DCLASSICAPI_TAG=vX.Y.Z`. Unset versions resolve to `0`.
|
||||
Defined once FrameScript has booted. Addons can use this to detect
|
||||
that the DLL is loaded and which version is in use. The value is
|
||||
`X*10000 + Y*100 + Z` for a tag of `vX.Y.Z` passed to CMake at
|
||||
configure time via `-DCLASSICAPI_TAG=vX.Y.Z`.
|
||||
|
||||
Untagged builds (local dev, CI without a release tag) get a
|
||||
**sentinel value of `99999999`** — encoded as `v9999.99.99`, chosen
|
||||
to be higher than every plausible real release so addon-side
|
||||
feature gates like `CLASSIC_API_VERSION >= 10200` don't reject the
|
||||
dev build.
|
||||
|
||||
```lua
|
||||
if CLASSIC_API_VERSION and CLASSIC_API_VERSION >= 10200 then
|
||||
-- ClassicAPI v1.2.0 or newer is loaded
|
||||
-- ClassicAPI v1.2.0 or newer is loaded (or untagged dev build)
|
||||
end
|
||||
```
|
||||
|
||||
If you specifically want to detect a dev build:
|
||||
|
||||
```lua
|
||||
if CLASSIC_API_VERSION == 99999999 then
|
||||
-- running against a locally-built, untagged DLL
|
||||
end
|
||||
```
|
||||
|
||||
@@ -3808,6 +4288,55 @@ if t == 1 then -- head
|
||||
end
|
||||
```
|
||||
|
||||
### `C_Item.IsItemOpenable(itemLocation)`
|
||||
|
||||
Returns two values: `(isOpenable, canOpen)`.
|
||||
|
||||
- **`isOpenable`** — `true` if the item type is right-click-openable
|
||||
(sack, clam, simple chest, quest box, lockbox, etc. — anything
|
||||
whose tooltip *could* show `<Right Click to Open>`). Intrinsic to
|
||||
the item type. From `ItemStats.Flags & 0x4`.
|
||||
- **`canOpen`** — `true` if the player can right-click *this specific
|
||||
instance* and trigger the open action right now. For unlocked-by-
|
||||
default items (clams, sacks) this matches `isOpenable`. For items
|
||||
with a lock (lockboxes), it's only true after the lock has been
|
||||
removed (rogue Pick Lock, key item, etc.) — so a priest looking at
|
||||
a fresh lockbox sees `(true, false)`, while a rogue who just
|
||||
picked it sees `(true, true)`.
|
||||
|
||||
Both values are `nil` when the item data isn't cached yet (background
|
||||
`SMSG_ITEM_QUERY_SINGLE` fired) or when the location is empty /
|
||||
invalid. Lets callers distinguish "data unknown" from "definitely
|
||||
not openable".
|
||||
|
||||
There's no `ByID` variant — the `canOpen` check depends on a specific
|
||||
instance's `ITEM_FIELD_FLAGS`, which you can only have via an
|
||||
itemLocation. If you only have an itemID, use
|
||||
`C_Item.GetItemInfoInstant` to inspect the item type instead.
|
||||
|
||||
```lua
|
||||
-- Loop a bag and gather every openable the player can act on now
|
||||
for slot = 1, GetContainerNumSlots(0) do
|
||||
local _, canOpen = C_Container.IsContainerItemOpenable(0, slot)
|
||||
if canOpen then
|
||||
-- pickup + use; this slot is good to open
|
||||
end
|
||||
end
|
||||
|
||||
-- Show a different hint depending on the lock state
|
||||
local isOpenable, canOpen = C_Item.IsItemOpenable({bagID = 0, slotIndex = 1})
|
||||
if isOpenable and not canOpen then
|
||||
print("This item is locked.")
|
||||
end
|
||||
```
|
||||
|
||||
Implementation: `isOpenable` reads `ItemStats.Flags & 0x4` from the
|
||||
client-side ItemSparse cache. `canOpen` adds a check that either
|
||||
`ItemStats.LockID == 0` (no lock) or the CGItem instance's
|
||||
`ITEM_FIELD_FLAGS & 0x4` (UNLOCKED) bit is set. Same conditions the
|
||||
engine's tooltip builder uses at `0x0052E323` to gate the
|
||||
`ITEM_OPENABLE` line.
|
||||
|
||||
### `C_Item.IsLocked(itemLocation)`
|
||||
|
||||
Returns `true` if the item is in the client-side "in-transaction"
|
||||
@@ -4145,6 +4674,114 @@ section tags them with the same spellID a `/cast` line would, so
|
||||
> opening them in the Macro UI and clicking Okay re-runs the parser
|
||||
> and the new behavior takes effect.
|
||||
|
||||
### `GetMacroIcons` / `GetMacroItemIcons` / `GetLooseMacroIcons` / `GetLooseMacroItemIcons`
|
||||
|
||||
Modern Classic Era's 4-function append-to-table icon enumeration
|
||||
surface. Each takes a Lua table as its only argument and appends icon
|
||||
basenames; returns nothing (mutation is the contract).
|
||||
|
||||
```lua
|
||||
local spellList, itemList = {}, {}
|
||||
|
||||
-- Modern's canonical call order (mirrors IconDataProvider.lua):
|
||||
GetLooseMacroIcons(spellList)
|
||||
GetLooseMacroItemIcons(itemList)
|
||||
GetMacroIcons(spellList)
|
||||
GetMacroItemIcons(itemList)
|
||||
|
||||
-- spellList now has `Ability_*` / `Spell_*` basenames;
|
||||
-- itemList now has `INV_*` basenames.
|
||||
```
|
||||
|
||||
The split is `loose` (icons the user dropped into `Interface\Icons\`
|
||||
on disk) vs `mpq` (icons baked into the game's MPQ archives), crossed
|
||||
with `Spell` (basenames starting with `Ability_` / `Spell_`) vs
|
||||
`Item` (basenames starting with `INV_`). Modern engines maintain four
|
||||
parallel arrays in their loader; we replicate the scheme by hooking
|
||||
vanilla's three scan callbacks
|
||||
(`FUN_MACRO_ICON_CB_DISK` / `*_USER_MPQ` / `*_INSTALL_MPQ`) and tagging
|
||||
each captured filename by source + prefix.
|
||||
|
||||
**Vanilla quirk worth noting**: the engine's main icon DB (what
|
||||
`GetMacroIconInfo(i)` returns) is filtered down to `Ability_*` /
|
||||
`Spell_*` only — `INV_*` filenames flow through the scan callbacks
|
||||
(~2,500+ per session in the Octo client) but never land in the DB
|
||||
because something downstream of the callbacks rejects them. We
|
||||
capture before that rejection, so `GetMacroItemIcons` works even
|
||||
though no engine-level `GetMacroItemIconInfo(i)` exists.
|
||||
|
||||
Each appended entry is the uppercase basename stripped of the
|
||||
`Interface\Icons\` prefix and any `.blp`/`.tga` extension (e.g.
|
||||
`"INV_SWORD_25"`, `"ABILITY_KICK"`). Callers concat the prefix
|
||||
themselves before `texture:SetTexture(...)`. Output is alphabetically
|
||||
sorted within each function (sorted lazily on first read).
|
||||
|
||||
Capture-time dedup ensures each unique basename only appears once
|
||||
per source/prefix combination even though most files flow through
|
||||
multiple scan callbacks. There is **no cross-dedup** between loose
|
||||
and mpq, or between spell and item — matches modern engine
|
||||
behavior (a file that exists both as a loose drop-in and inside an
|
||||
MPQ will appear in both the loose and mpq lists).
|
||||
|
||||
The vanilla legacy globals `GetNumMacroIcons` / `GetMacroIconInfo`
|
||||
remain available unchanged.
|
||||
|
||||
### `IconDataProviderMixin`
|
||||
|
||||
Lua-side wrapper (ported from
|
||||
`Blizzard_FrameXMLBase/Classic/IconDataProvider.lua`) around the four
|
||||
mutators above. Bundles `Spell` and `Item` buckets into one stateful
|
||||
provider with an optional "extras" prepend section, and exposes the
|
||||
modern `GetIconByIndex(i)` / `GetNumIcons()` / `GetIndexOfIcon(icon)`
|
||||
/ `Release()` / `GetIconForSaving(i)` API the modern Macro UI and
|
||||
Equipment Manager icon-pickers consume.
|
||||
|
||||
```lua
|
||||
-- Equipment Manager icon picker (matches modern Cata code as-written):
|
||||
local provider = CreateAndInitFromMixin(IconDataProviderMixin,
|
||||
IconDataProviderExtraType.Equipment)
|
||||
provider:GetNumIcons() -- total count (1 + extras + base)
|
||||
provider:GetIconByIndex(1) -- always the `?` sentinel
|
||||
provider:GetIconByIndex(2) -- first equipped-item icon
|
||||
-- ...
|
||||
provider:Release() -- decref + collectgarbage when last ref drops
|
||||
```
|
||||
|
||||
`IconDataProviderExtraType`:
|
||||
|
||||
- `Spellbook` — pre-walks the spellbook tabs
|
||||
(`GetNumSpellTabs` / `GetSpellTabInfo` / `GetSpellTexture`) **and**
|
||||
the three vanilla talent trees
|
||||
(`GetNumTalentTabs` / `GetNumTalents` / `GetTalentInfo`), gathering
|
||||
every talent's icon — including talents the player hasn't put points
|
||||
into. Dedups via a set, prepends to the picker. Vanilla doesn't have
|
||||
spec groups or PvP talents, so dual-spec / PvP-talent walks present
|
||||
in modern's `IconDataProvider.lua` are omitted. Spells not surfaced
|
||||
in the spellbook UI (racials kept off the bar, profession recipes
|
||||
the engine doesn't list as spells) are still missed — that's a
|
||||
vanilla data limitation, not a port omission.
|
||||
- `Equipment` — pre-walks `INVSLOT_FIRST_EQUIPPED..INVSLOT_LAST_EQUIPPED`
|
||||
via `GetInventoryItemTexture("player", i)`.
|
||||
- `None` — no prepend; base DB only.
|
||||
|
||||
`IconDataProviderIconType`:
|
||||
|
||||
- `Spell` (`= 1`) — `Ability_*` / `Spell_*` set.
|
||||
- `Item` (`= 2`) — `INV_*` set.
|
||||
|
||||
The mixin doesn't cross-dedup between extras and the base DB — the
|
||||
player's currently-equipped weapon icon may appear twice in the
|
||||
final picker (once at the head as an "extra", once again later in
|
||||
its alphabetical spot inside the `INV_*` base list). That's
|
||||
deliberate; the modern engine has the same behavior. Pickers
|
||||
typically tolerate it because the extras section is short.
|
||||
|
||||
Memory model matches modern: `BaseIconFilenames` is module-level
|
||||
state shared across all active providers. The first `Init` triggers
|
||||
the four engine calls (which collectively walk ~30k icon paths); on
|
||||
the last `Release`, `BaseIconFilenames` is nil'd and `collectgarbage()`
|
||||
runs explicitly.
|
||||
|
||||
## Map
|
||||
|
||||
### `C_Map.GetBestMapForUnit(unitToken)`
|
||||
@@ -4325,6 +4962,174 @@ disable the sell-all-junk button; vanilla has no such setting, so
|
||||
the feature is always on. Function exists so retail addons that
|
||||
gate `SellAllJunkItems` on this don't no-op silently.
|
||||
|
||||
## NamePlate
|
||||
|
||||
Modern `C_NamePlate.*` returns nameplate `Frame` objects keyed off
|
||||
unit data. Vanilla 1.12 doesn't ship the API at all — but the
|
||||
underlying data (per-unit nameplate pointer at `CGUnit + 0xE60`)
|
||||
exists. We enumerate visible units via the local-player-anchored
|
||||
object hash table, filter by `TYPEMASK_UNIT`, and return matches.
|
||||
|
||||
Modern's `"nameplateN"` unit-token family is also supported — see
|
||||
[Unit tokens](#unit-tokens-nameplaten) below. `NAME_PLATE_UNIT_ADDED`
|
||||
/ `_REMOVED` / `_CREATED` events fire via a per-tick visible-plate
|
||||
diff (see [the events section](#name_plate_created--name_plate_unit_added--name_plate_unit_removed-events)).
|
||||
|
||||
### `C_NamePlate.GetNamePlates()`
|
||||
|
||||
Returns a 1-based table of nameplate `Frame` objects — one per
|
||||
CGUnit that currently has an allocated **Lua-registered** nameplate.
|
||||
The frames are real Lua tables with methods (`:GetName()`,
|
||||
`:GetWidth()`, `:SetAlpha()`, etc.) and any addon-added decorations
|
||||
on them.
|
||||
|
||||
```lua
|
||||
local plates = C_NamePlate.GetNamePlates()
|
||||
for i, plate in ipairs(plates) do
|
||||
print(i, plate:GetName(), plate:GetWidth())
|
||||
end
|
||||
```
|
||||
|
||||
Two kinds of plates can show up:
|
||||
|
||||
- **Addon-created plates** (pfUI, TidyPlates, NamePlateMod, etc.):
|
||||
registered with Lua via `CreateFrame`, so each has a real
|
||||
registry ref. We push `registry[plate + 0x08]`. Identity is
|
||||
stable across calls — caching is safe while the frame is alive.
|
||||
|
||||
- **Default vanilla plates**: created internally by the engine
|
||||
without ever calling `CreateFrame`. Their `+0x08` field holds the
|
||||
sentinel `LUA_NOREF` (`-2`), not a real registry key. We build a
|
||||
fresh wrapper table per call (`{[0] = lightuserdata(plate)}` with
|
||||
the global `__framescript_meta` metatable) so addons get the
|
||||
same method surface. The wrapper isn't cached engine-side, so
|
||||
identity isn't stable across calls — don't compare wrappers, and
|
||||
don't store them across the unit going out of range (the
|
||||
underlying frame may be freed). Call `GetNamePlates()` fresh
|
||||
each time you need plates.
|
||||
|
||||
### Reading region content from a default nameplate
|
||||
|
||||
Vanilla plates have six child regions in stable positions. Walk them
|
||||
with `:GetRegions()`:
|
||||
|
||||
```lua
|
||||
local plates = C_NamePlate.GetNamePlates()
|
||||
for _, plate in ipairs(plates) do
|
||||
local regions = {plate:GetRegions()}
|
||||
local name = regions[3]:GetText() -- e.g. "Joseph Dalton"
|
||||
local level = tonumber(regions[4]:GetText()) -- e.g. 60
|
||||
-- regions[1], [2], [5], [6] are textures (border, healthbar,
|
||||
-- glow, raid-icon — order depends on the engine's draw order)
|
||||
end
|
||||
```
|
||||
|
||||
Lua 5.0 has no `select()`, so collect into a table via
|
||||
`{plate:GetRegions()}` and index. Addon-created plates have
|
||||
different region layouts — those frames inherit whatever shape the
|
||||
addon built, not this one.
|
||||
|
||||
### `C_NamePlate.GetNamePlateForUnit(unitToken)`
|
||||
|
||||
Returns the nameplate Frame for a single unit (resolved via the
|
||||
engine's token-to-GUID path, so out-of-range party/raid members
|
||||
work too), or `nil` if the unit has no allocated nameplate.
|
||||
|
||||
```lua
|
||||
local plate = C_NamePlate.GetNamePlateForUnit("target")
|
||||
if plate then
|
||||
local regions = {plate:GetRegions()}
|
||||
print("targeting:", regions[3]:GetText()) -- e.g. "Santora"
|
||||
end
|
||||
```
|
||||
|
||||
Same registered-vs-fresh-wrapper behavior as `GetNamePlates()` —
|
||||
addon-created plates return their cached wrapper, default vanilla
|
||||
plates get a fresh per-call wrapper. Don't cache the result across
|
||||
the unit going out of range.
|
||||
|
||||
### `C_NamePlate.GetNamePlateForGUID(guidString)`
|
||||
|
||||
Same as `GetNamePlateForUnit` but takes the `"0xHHHHHHHHHHHHHHHH"`
|
||||
GUID-string form. Useful when you've stored a unit GUID across
|
||||
events (e.g., converted the positional `"nameplateN"` token to a
|
||||
GUID via `UnitGUID(arg1)` at `NAME_PLATE_UNIT_ADDED` time) and need
|
||||
the frame later.
|
||||
|
||||
```lua
|
||||
local platesByGuid = {}
|
||||
|
||||
local f = CreateFrame("Frame")
|
||||
f:RegisterEvent("NAME_PLATE_UNIT_ADDED")
|
||||
f:SetScript("OnEvent", function()
|
||||
-- arg1 = "nameplateN" token; convert to stable GUID for storage
|
||||
local guid = UnitGUID(arg1)
|
||||
platesByGuid[guid] = C_NamePlate.GetNamePlateForGUID(guid)
|
||||
end)
|
||||
```
|
||||
|
||||
Returns `nil` if the GUID doesn't parse, doesn't resolve to a
|
||||
visible CGUnit, or the unit has no allocated nameplate.
|
||||
|
||||
### `C_NamePlate.GetNamePlateGUIDs()`
|
||||
|
||||
Returns a 1-based table of GUID strings (modern
|
||||
`"0xHHHHHHHHHHHHHHHH"` format) — one per CGUnit with an allocated
|
||||
nameplate, **regardless** of whether the frame has been registered
|
||||
with Lua. Catches default vanilla nameplates that
|
||||
[`GetNamePlates`](#c_nameplategetnameplates) can't surface as
|
||||
frames.
|
||||
|
||||
```lua
|
||||
/dump C_NamePlate.GetNamePlateGUIDs()
|
||||
-- { "0xF13000C36C26FD02", "0xF130000009276912", ... }
|
||||
```
|
||||
|
||||
Walks the local-player-anchored object hash table for `TYPEMASK_UNIT`
|
||||
entries, filters by `*(unit + 0xE60) != nullptr`. The per-unit
|
||||
nameplate pointer is set by `FUN_006086E0`'s "show nameplate" path
|
||||
regardless of which nameplate system rendered it. Order follows
|
||||
hash-bucket iteration and isn't stable across calls.
|
||||
|
||||
### Unit tokens (`nameplateN`)
|
||||
|
||||
`"nameplate1"`, `"nameplate2"`, … work as unit tokens against every
|
||||
`UnitX` function: `UnitName`, `UnitGUID`, `UnitClass`, `UnitHealth`,
|
||||
`UnitHealthMax`, `UnitLevel`, `UnitFaction`, `UnitReaction`,
|
||||
`UnitExists`, `UnitIsPlayer`, `UnitIsEnemy`, `UnitIsDead`, etc. —
|
||||
~30 functions for free.
|
||||
|
||||
```lua
|
||||
for i = 1, 40 do
|
||||
if not UnitExists("nameplate" .. i) then break end
|
||||
print(i, UnitName("nameplate" .. i), UnitClass("nameplate" .. i))
|
||||
end
|
||||
```
|
||||
|
||||
Ordering is **creation-order** — each new plate appends to the end of
|
||||
the list and stays at its index until the unit goes out of range or
|
||||
the plate is removed, at which point later indices shift down.
|
||||
Stable for the lifetime of a single plate; no mid-frame reordering.
|
||||
Same semantics as modern WoW.
|
||||
|
||||
Token chains work too — `"nameplate1target"`, `"nameplate1targettarget"`,
|
||||
etc. — by mirroring the engine's own `targettarget`-style suffix
|
||||
walker (read `UNIT_FIELD_TARGET` off `m_objectFields`, loop). Other
|
||||
suffixes (`pet`, `master`) aren't supported by the vanilla engine's
|
||||
own walker either, so they don't compose.
|
||||
|
||||
Out-of-range indices return `nil` cleanly without raising "Unknown
|
||||
unit name" — `UnitExists("nameplate99")` just returns `false`.
|
||||
|
||||
**Implementation note.** We hook `FUN_TOKEN_TO_GUID` (the central
|
||||
token→GUID resolver) so the entire `Script_Unit*` surface gains the
|
||||
new token form transparently. The hook is gated by an `SStrCmpI`
|
||||
prefix check against `"nameplate"`; non-nameplate tokens
|
||||
(`"player"`, `"target"`, `"partyN"`, etc.) fall straight through to
|
||||
the unmodified resolver. The ordered list is maintained alongside
|
||||
the existing `NAME_PLATE_UNIT_ADDED` / `_REMOVED` diff in the
|
||||
per-tick nameplate walker.
|
||||
|
||||
## NameCache
|
||||
|
||||
GUID-keyed cache of player names and classes. The engine itself
|
||||
@@ -6519,13 +7324,19 @@ known unit tokens and return the first one currently mapped to that
|
||||
GUID, or `nil` if none of them point at it.
|
||||
|
||||
The search order matches modern retail with post-1.12 tokens
|
||||
omitted (`vehicle`, `nameplateN`, `arenaN`, `arenapetN`, `bossN`,
|
||||
`focus`, `softenemy`, `softfriend`, `softinteract` all post-date
|
||||
vanilla and the engine's resolver doesn't recognize them):
|
||||
omitted (`vehicle`, `arenaN`, `arenapetN`, `bossN`, `softenemy`,
|
||||
`softfriend`, `softinteract` all post-date vanilla and the engine's
|
||||
resolver doesn't recognize them). `nameplateN` and `focus` are
|
||||
included — we hook the resolver so the engine recognizes both token
|
||||
forms. As with the other positional tokens, a returned
|
||||
`"nameplate3"` result is only valid at that instant; the slot may
|
||||
shift to a different unit between calls, so re-verify with
|
||||
`UnitGUID(token)` before reusing.
|
||||
|
||||
```
|
||||
player → pet → party1..4 → partypet1..4 → raid1..40
|
||||
→ raidpet1..40 → target → npc → mouseover
|
||||
→ raidpet1..40 → nameplate1..N → target → focus
|
||||
→ npc → mouseover
|
||||
```
|
||||
|
||||
```lua
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#include "MinHook.h"
|
||||
#include "Offsets.h"
|
||||
#include "event/Custom.h"
|
||||
#include "nameplate/Walk.h"
|
||||
#include "player/NameCache.h"
|
||||
|
||||
static Game::FrameScript_Initialize_t FrameScript_Initialize_o = nullptr;
|
||||
@@ -37,6 +38,15 @@ static bool __fastcall FrameScript_Initialize_h() {
|
||||
// table is rebuilt at a fresh allocation; the old slots are stale.
|
||||
Event::Custom::PrepareForReload();
|
||||
|
||||
// Clear the nameplate diff state so currently-visible plates
|
||||
// refire CREATED and UNIT_ADDED on the next tick — re-presents
|
||||
// them to the freshly reloaded UI, matching modern WoW. The
|
||||
// wrappers themselves now live in the engine's own registry
|
||||
// (via `FrameScript_Object::ScriptRegister`), which the engine
|
||||
// tears down and rebuilds across the Lua reset — no per-module
|
||||
// cache to clear here.
|
||||
NamePlate::Events::PrepareForReload();
|
||||
|
||||
// Persist the name cache before the engine starts tearing down.
|
||||
// This hook fires on both `/reload` and `/logout` (the engine
|
||||
// re-initializes Lua state in both cases), giving us a clean
|
||||
|
||||
+284
@@ -200,6 +200,30 @@ enum Offsets {
|
||||
// Registers a single global Lua function. __fastcall(name, func).
|
||||
FUN_FRAMESCRIPT_REGISTER_FUNCTION = 0x00704120,
|
||||
|
||||
// `FrameScript_Object::ScriptRegister(this, name)` — `__thiscall`,
|
||||
// `this` = a `CFrameScriptObject *`. On first call (when `this+0x04`
|
||||
// is zero) builds a Lua wrapper table `{[0] = lightuserdata(this)}`
|
||||
// with `_G["__framescript_meta"]` as metatable, `luaL_ref`s it into
|
||||
// the registry, stores the refkey at `this+0x08`. Always increments
|
||||
// `this+0x04` (the Lua-side refcount). Optional `name` argument
|
||||
// installs `_G[name] = wrapper` for engine-named frames.
|
||||
//
|
||||
// We call this in `PushNamePlateFrame` so the engine and our own
|
||||
// C_NamePlate getters operate on the **same** wrapper table —
|
||||
// every push through `lua_rawgeti(REGISTRY, this+0x08)` (the
|
||||
// canonical engine path) lands on the same Lua object pfUI
|
||||
// received in `NAME_PLATE_CREATED`, so addon-set fields
|
||||
// (`plate.nameplate = decoratedButton`) survive engine-side
|
||||
// re-fetches. Earlier note in `Info.cpp` warned about pinning the
|
||||
// refcount; for pool-managed nameplates the engine never
|
||||
// un-registers them anyway, so the pin is benign.
|
||||
FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER = 0x00701BD0,
|
||||
|
||||
// `this+0x04` Lua refcount, incremented by `ScriptRegister`. We
|
||||
// read it as a "has the engine ever exposed this CObject to Lua"
|
||||
// probe — equivalent to checking `this+0x08 > 0` but more direct.
|
||||
OFF_COBJECT_LUA_REFCOUNT = 0x04,
|
||||
|
||||
// Direct cvar lookup — `__fastcall(const char *name) → CVar* | NULL`.
|
||||
// Hash-table by-name lookup over the CVar registry; same call
|
||||
// `Script_GetCVar` makes internally before the engine wraps the
|
||||
@@ -281,6 +305,28 @@ enum Offsets {
|
||||
// input it's the right primitive.
|
||||
FUN_TOKEN_TO_GUID = 0x00515970,
|
||||
|
||||
// `SStrCmpI(a, b, n)` — Storm's case-insensitive memcmp-style
|
||||
// comparator. **`int __stdcall(const char *a, const char *b, int n)`**
|
||||
// — the function ends with `ret 0xc`, so the callee pops the
|
||||
// 3-arg stack frame. Declaring it as `__cdecl` and calling makes
|
||||
// MSVC emit a redundant `add esp, 12` post-call, drifting ESP
|
||||
// upward by 12 per call and corrupting the caller's stack frame
|
||||
// — manifested as a deep-Lua crash whose `L` pointer landed in
|
||||
// `.text`. Returns 0 when the first `n` characters match
|
||||
// (ignoring case) or both strings end before `n`.
|
||||
FUN_SSTR_CMP_I = 0x0064A4C0,
|
||||
|
||||
// UNIT_FIELD_TARGET within `m_objectFields` — 64-bit GUID at byte
|
||||
// offsets +0x28 (lo) / +0x2C (hi). Verified by disassembling
|
||||
// `FUN_TOKEN_TO_GUID`'s suffix walker at `0x00515A1C-A2C`:
|
||||
// `mov eax, [obj + 0x110]; mov edi, [eax + 0x28]; mov ebx, [eax + 0x2c]`
|
||||
// — reads the target GUID to chain `targettarget`-style tokens.
|
||||
// Distinct from the higher field offsets in the
|
||||
// `OFF_UNIT_FIELD_*` block; UNIT_FIELD_TARGET is one of the few
|
||||
// 1.12 offsets that matches the CMaNGOS-documented vanilla
|
||||
// layout.
|
||||
OFF_UNIT_FIELD_TARGET = 0x28,
|
||||
|
||||
// Party / raid roster counts and the party GUID array referenced
|
||||
// in the `FUN_TOKEN_TO_GUID` dispatch comment above. Used by
|
||||
// `UnitTokenFromGUID` to cap its candidate iteration — solo
|
||||
@@ -847,6 +893,14 @@ enum Offsets {
|
||||
// descriptor bytes for a worn-and-bound item.
|
||||
OFF_DESCRIPTOR_FLAGS = 0x3C,
|
||||
ITEM_FLAG_SOULBOUND = 0x01,
|
||||
// Bit 2 — instance is "unlocked", i.e. the lock referenced by
|
||||
// `ItemStats.LockID` has been opened (rogue Pick Lock, key item,
|
||||
// etc.). For unlockable items, the engine gates the
|
||||
// `<Right Click to Open>` tooltip on this bit: a fresh lockbox
|
||||
// has it clear (priest sees no openable affordance), a picked
|
||||
// lockbox has it set. Items with `LockID == 0` (clams, simple
|
||||
// sacks) never need the bit.
|
||||
ITEM_FLAG_UNLOCKED = 0x04,
|
||||
|
||||
// Client-side "in-transaction" lock — set when the engine puts an
|
||||
// item on the cursor / mail-attaches it / trade-attaches it, cleared
|
||||
@@ -1014,7 +1068,44 @@ enum Offsets {
|
||||
OFF_ITEMSTATS_NAME = 0x08,
|
||||
OFF_ITEMSTATS_DISPLAY_INFO_ID = 0x18,
|
||||
OFF_ITEMSTATS_QUALITY = 0x1C, // u32 — 0=Poor … 5=Legendary
|
||||
|
||||
// Item-quality color-prefix table. Array of 7 `const char *`
|
||||
// pointers, indexed by quality 0..6 (POOR..ARTIFACT); each
|
||||
// points to a `"|cffRRGGBB"` markup string used by the engine's
|
||||
// own link builder. Discovered via the inner color helper at
|
||||
// `0x0052AD90` which does `(&table)[quality]` with a fallback
|
||||
// to white (`"|cffffffff"`, the index-1 entry) for quality > 6.
|
||||
// Quality 7 (Heirloom) doesn't exist in vanilla; consumers
|
||||
// should mirror the engine's fallback for safety.
|
||||
VAR_QUALITY_COLOR_TABLE = 0x00854124,
|
||||
QUALITY_COLOR_TABLE_SIZE = 7,
|
||||
// `m_flags` — item-class flags bitfield. Bits we read:
|
||||
// `ITEMSTATS_FLAG_OPENABLE` (`0x4`, bit 2): freely right-click-
|
||||
// openable container (sack, clam, simple chest, quest box).
|
||||
// The tooltip line `<Right Click to Open>` (`ITEM_OPENABLE`
|
||||
// string) renders for items with this bit set, gated on the
|
||||
// player's lock skill when the item also carries a non-zero
|
||||
// LockID. Same bit modern WoW's `C_Item.IsItemOpenable`
|
||||
// surfaces.
|
||||
// Other documented bits (`0x1` = BIND_ON_PICKUP, `0x2` = CONJURED,
|
||||
// `0x10` = LOOTABLE, `0x200` = WRAPPER, etc.) live in the same
|
||||
// field but we don't read them here. Add named constants as
|
||||
// consumers materialize.
|
||||
// Reader at `0x0052E323` (the tooltip-builder's openable branch in
|
||||
// `FUN_0052B650`) does `(ItemStats[+0x20] & 0x4)` against this
|
||||
// field; verified by checking the field offset matches the
|
||||
// 5-DWORD-per-row layout `puVar4[8]` resolves to in the decompile.
|
||||
OFF_ITEMSTATS_FLAGS = 0x20,
|
||||
ITEMSTATS_FLAG_OPENABLE = 0x4,
|
||||
OFF_ITEMSTATS_SELL_PRICE = 0x28,
|
||||
// `m_lockID` — references `Lock.dbc`. Zero means "no lock"; any
|
||||
// nonzero value gates the openable interaction on the player
|
||||
// successfully unlocking the item (instance flag bit 2 in
|
||||
// `ITEM_FIELD_FLAGS`). Same field the tooltip builder reads as
|
||||
// `ItemStats[+0x1AC]` (puVar4[0x6b]) when deciding whether the
|
||||
// `<Right Click to Open>` line should require the lock to be
|
||||
// picked first.
|
||||
OFF_ITEMSTATS_LOCK_ID = 0x1AC,
|
||||
OFF_ITEMSTATS_INVENTORY_TYPE = 0x2C,
|
||||
OFF_ITEMSTATS_ITEM_LEVEL = 0x38, // u32 — base ilvl from ItemSparse
|
||||
// `m_stackable` — max stack size for this item type (1 for non-
|
||||
@@ -1692,6 +1783,41 @@ enum Offsets {
|
||||
// without re-parsing the body. See `Macro::Spell::Script_GetMacroSpell`.
|
||||
FUN_MACRO_SLOT_TO_ENTRY = 0x004F0E40,
|
||||
|
||||
// Macro-icon database. Populated lazily by `FUN_LOAD_MACRO_ICONS`
|
||||
// on the first `GetNumMacroIcons` call — enumerates `Interface\Icons\`
|
||||
// for `*.blp` files plus a wildcard match, sorts, and de-dupes. Each
|
||||
// entry is the basename (e.g. `"Ability_Kick"`) without the
|
||||
// `Interface\Icons\` prefix; vanilla's `Script_GetMacroIconInfo`
|
||||
// joins the prefix via sprintf before pushing. Verified by reading
|
||||
// `Script_GetNumMacroIcons` (`0x004F19F0`) and
|
||||
// `Script_GetMacroIconInfo` (`0x004F1A30`).
|
||||
//
|
||||
// Vanilla's loader has 3 enumeration passes, each with a per-file
|
||||
// callback. The first two (`FUN_MACRO_ICON_CB_DISK`,
|
||||
// `FUN_MACRO_ICON_CB_USER_MPQ`) prefix-filter on `"Ability_"` and
|
||||
// `"Spell_"` — anything else (including `INV_*` item icons) is
|
||||
// rejected. The third (`FUN_MACRO_ICON_CB_INSTALL_MPQ`) reads as
|
||||
// extension-only filter in the disassembly (any `.blp`/`.tga` is
|
||||
// accepted), but the engine's main icon DB ends up with zero
|
||||
// `INV_*` entries regardless (`GetNumMacroIcons() == 746`, all
|
||||
// `Ability_*`/`Spell_*`). Best guess: a check inside the
|
||||
// `SStrDup`/array-append helpers downstream of all three callbacks
|
||||
// filters them out — but the per-file callbacks themselves DO see
|
||||
// `INV_*` filenames (verified by hook capture: 5,226 unique
|
||||
// `INV_*` basenames flow through the callbacks per session).
|
||||
//
|
||||
// For `C_Macro::GetMacroItemIcons` we hook each callback at its
|
||||
// entry, capture any `INV_*` filename into a DLL-owned side array,
|
||||
// then forward to the original — dodges whichever downstream
|
||||
// filter the engine applies and matches the parallel item-icon
|
||||
// array 4.3.4 exposes (via `Script_GetMacroItemIcons`).
|
||||
VAR_MACRO_ICON_COUNT = 0x00BDCC1C, // uint32 count of loaded icons
|
||||
VAR_MACRO_ICON_ARRAY = 0x00BDCC20, // char ** — pointer to flat array of icon-name C strings (4-byte stride)
|
||||
FUN_LOAD_MACRO_ICONS = 0x004F0090, // `__cdecl()` lazy populate; no-op if already loaded
|
||||
FUN_MACRO_ICON_CB_DISK = 0x004F0220, // disk enumerator callback — `__fastcall(const char *fullPath)` — prefix-filtered
|
||||
FUN_MACRO_ICON_CB_USER_MPQ = 0x004F0350, // user-MPQ enumerator callback — `__fastcall(MpqRecord *r)` — prefix-filtered
|
||||
FUN_MACRO_ICON_CB_INSTALL_MPQ = 0x004F04F0, // install-MPQ enumerator callback — `__fastcall(MpqRecord *r)` — extension-only filter
|
||||
|
||||
// Quest log: 16-byte-stride entry array and active count.
|
||||
// Field +0 of each entry is the questID for real quests (a category index
|
||||
// for headers); field +8 is the header indicator: non-NULL = header,
|
||||
@@ -2089,6 +2215,22 @@ enum Offsets {
|
||||
// to L->top → `add [ecx+8], 0x10` incr_top). `0x6F32B0` is
|
||||
// `lua_replace` (single TValue copy + decr_top).
|
||||
LUA_PUSH_VALUE = 0x6F3350,
|
||||
// `luaL_ref(L, t)` — pops the top, stores it in the table at `t`
|
||||
// at a freshly-allocated integer key, returns the key. Use with
|
||||
// `LUA_REGISTRY_INDEX` to stash Lua values across C-side scopes;
|
||||
// pair with `LUA_REF_UNREF` to release. Verified by decompiling
|
||||
// `FUN_006f5310`: classic luaL_ref shape — nil-top early-out
|
||||
// returning `LUA_REFNIL = -1`, FREELIST_REF chain pop (pushvalue +
|
||||
// tonumber on table[0]), else objlen-based new slot, finally
|
||||
// table[ref] = popped value.
|
||||
LUA_REF_REF = 0x6F5310,
|
||||
// `luaL_unref(L, t, ref)` — releases a ref previously returned
|
||||
// by `luaL_ref`, freeing the slot for future allocations. Verified
|
||||
// by decompiling `FUN_006f5400`: `ref >= 0` guard (skips
|
||||
// `LUA_NOREF = -2` / `LUA_REFNIL = -1`), reads current FREELIST_REF
|
||||
// head, writes it to `table[ref]`, then updates FREELIST_REF = ref
|
||||
// — the canonical freelist-link operation.
|
||||
LUA_REF_UNREF = 0x6F5400,
|
||||
LUA_PUSH_CCLOSURE = 0x6F3920,
|
||||
LUA_NEW_TABLE = 0x6F3C90,
|
||||
LUA_GET_TABLE = 0x6F3A40, // (was 0x6F3EA0, which is lua_rawset)
|
||||
@@ -2253,6 +2395,44 @@ enum Offsets {
|
||||
FUN_STORM_SMEM_ALLOC = 0x006462E0,
|
||||
FUN_STORM_SMEM_FREE = 0x00646430,
|
||||
|
||||
// Engine's main file-content reader. Used by the TOC parser at
|
||||
// `FUN_TOC_PARSER` to slurp addon `.toc` files and by the addon
|
||||
// load pass for `.lua` source. Allocates a Storm buffer via
|
||||
// `FUN_STORM_SMEM_ALLOC`, writes the file content + `extraBytes`
|
||||
// trailing zeros, hands the buffer to the caller. Caller frees
|
||||
// via `FUN_STORM_SMEM_FREE`.
|
||||
//
|
||||
// __cdecl int FileRead(
|
||||
// int unused, // always 0 from engine call sites
|
||||
// const char *path, // "Interface\AddOns\X\Y.lua"
|
||||
// void **outBuf, // Storm-allocated content
|
||||
// size_t *outSize, // optional, may be NULL
|
||||
// size_t extraBytes, // trailing zero-fill (1 for nul-term)
|
||||
// int flag1, // always 1 from TOC parser
|
||||
// int flag2); // 0
|
||||
// Returns 1 on success, 0 on failure.
|
||||
//
|
||||
// We hook this to redirect reads of `Interface\AddOns\!!!ClassicAPI\*`
|
||||
// to embedded buffers (see src/addons/Embedded.cpp). Allocating via
|
||||
// the same Storm allocator the engine uses means the caller's
|
||||
// normal `SMemFree` works without us tracking lifetime.
|
||||
FUN_FILE_READ = 0x00648620,
|
||||
|
||||
// TOC parser — `__fastcall(this=addonName)`. Already documented in
|
||||
// CLAUDE.md under "AddOn registry & hot-reload". Dedup-safe: if
|
||||
// the addon is already in the hash table, returns immediately
|
||||
// without re-parsing. Reads the TOC file via `FUN_FILE_READ`. We
|
||||
// call this from the post-`FUN_ADDON_INIT` hook to register the
|
||||
// embedded `!!!ClassicAPI` addon — the dedup behavior means our
|
||||
// call is a no-op when the user has the addon installed on disk.
|
||||
FUN_TOC_PARSER = 0x0051C9B0,
|
||||
|
||||
// AddOn registry init — `__fastcall(accountName)`. Documented in
|
||||
// CLAUDE.md ("AddOn registry & hot-reload"). Hooked post-call so
|
||||
// we can splice the embedded addon entry into the freshly-populated
|
||||
// registry before the load pass.
|
||||
FUN_ADDON_INIT = 0x0051C740,
|
||||
|
||||
// `SStrDup(const char *src, const char *file, int line)`. `__stdcall`.
|
||||
// Storm's string-copy wrapper around `SMemAlloc` — used by the engine
|
||||
// itself to populate event entry names. We use it from `Event::Custom`
|
||||
@@ -3043,4 +3223,108 @@ enum Offsets {
|
||||
SMSG_OPCODE_MIRROR_TIMER_PAUSE = 0x1DA,
|
||||
SMSG_OPCODE_MIRROR_TIMER_STOP = 0x1DB,
|
||||
MIRROR_TIMER_SLOT_COUNT = 3,
|
||||
|
||||
// `realmList` CVar reader — returns the configured realmlist
|
||||
// address (e.g. "logon.turtle-wow.org") as a `const char *`, or
|
||||
// NULL when unset. `__fastcall(int forceRefresh)`; pass 0 to read
|
||||
// the cached value (callers in the engine pass non-zero only
|
||||
// around CMSG_AUTH_SESSION reconnects). Lazily registers the CVar
|
||||
// on first call.
|
||||
//
|
||||
// This is the realmlist *address*, not the display name (the
|
||||
// friendly server name visible in the realm-select dialog lives
|
||||
// in the `realmName` CVar at `FUN_005AB7D0`, read by
|
||||
// `Script_GetServerName`). Credentials are scoped on this value
|
||||
// because two different private servers may share the same
|
||||
// friendly name but never the same realmlist address.
|
||||
FUN_CVAR_REALM_LIST = 0x005AB680,
|
||||
|
||||
// Engine login entry — the C function `Script_DefaultServerLogin`
|
||||
// (the glue Lua binding at `0x0046D160`) tail-calls into here with
|
||||
// the account/password as raw C strings. We invoke it directly from
|
||||
// C++ when dispatching a saved-credential login, bypassing the
|
||||
// round-trip through Lua so the plaintext never crosses the
|
||||
// Lua boundary on the way out.
|
||||
//
|
||||
// __fastcall void(ecx = const char *account, edx = char *password)
|
||||
//
|
||||
// The function:
|
||||
// 1. Gates on three globals — VAR_GLUE_LOGIN_READY1,
|
||||
// VAR_GLUE_LOGIN_READY2 (both must be nonzero) and
|
||||
// VAR_GLUE_LOGIN_INPROGRESS (must be zero). All set up by the
|
||||
// normal glue boot flow; a call made while the AccountLogin
|
||||
// screen is showing is safe, an off-screen call silently no-ops.
|
||||
// 2. Copies the account into a 0x40-byte buffer at `0x00B41DB0`
|
||||
// and uppercases it in place.
|
||||
// 3. Fires the "connecting" status event.
|
||||
// 4. Hands the (uppercased account, password) pair to the SRP
|
||||
// initiator at `FUN_005AB4B0`.
|
||||
// 5. **Zeros the caller's password buffer in place** via an
|
||||
// unrolled `REP STOSD` loop counting strlen bytes — so the
|
||||
// decrypted-on-stack buffer we feed in gets scrubbed for free
|
||||
// after the SRP step consumes it.
|
||||
// 6. Calls into the AddOn-registry init (`FUN_0051C740`) with the
|
||||
// uppercased account name. Same call documented in CLAUDE.md
|
||||
// under "AddOn registry & hot-reload".
|
||||
FUN_GLUE_LOGIN_ATTEMPT = 0x0046AFB0,
|
||||
|
||||
// Glue-state readiness flags read by `FUN_GLUE_LOGIN_ATTEMPT`'s
|
||||
// prologue. Names reflect the gate-condition logic, not anything
|
||||
// intrinsic to the engine — the engine just checks `R1 && R2 && !IP`.
|
||||
VAR_GLUE_LOGIN_READY1 = 0x00B41DFC,
|
||||
VAR_GLUE_LOGIN_READY2 = 0x00B41E04,
|
||||
VAR_GLUE_LOGIN_INPROGRESS = 0x00B41DA0,
|
||||
|
||||
// Cursor state — single global type-tag at VAR_CURSOR_TYPE
|
||||
// dispatches reads through one of several payload slots depending
|
||||
// on what's picked up. The engine's `CursorHasItem` /
|
||||
// `CursorHasSpell` / `GetCursorMoney` and the various `Pickup*`
|
||||
// functions all read/write through these. Discovered by tracing
|
||||
// `Script_CursorHasItem` (`0x004895D0`) and the writers in
|
||||
// `FUN_00494B60` (item), `FUN_00494CC0` (money), `FUN_00494D20`
|
||||
// (spell), `FUN_00494E20` (pet action), `FUN_00494F80` (macro),
|
||||
// `FUN_004950F0` (action/inv/etc.), `FUN_00495010` (merchant).
|
||||
//
|
||||
// Type values:
|
||||
// 0 empty (nothing on cursor)
|
||||
// 1 bag item — GUID at `VAR_CURSOR_ITEM_GUID_LO/_HI`,
|
||||
// itemID resolved by feeding the GUID to
|
||||
// `FUN_OBJECT_FROM_GUID` (filter 0x2).
|
||||
// 2 money — copper at `VAR_CURSOR_MONEY_COPPER`
|
||||
// 3 player spell — spellID at `VAR_CURSOR_SPELL_ID`
|
||||
// 4 pet action — packed at `VAR_CURSOR_PETACTION_PACKED`
|
||||
// 5 merchant item — itemID at `VAR_CURSOR_GENERIC_SLOT`,
|
||||
// 0-based merchant slot at `VAR_CURSOR_GENERIC_SOURCE`.
|
||||
// Written by `Script_PickupMerchantItem` (`0x004FB760`) →
|
||||
// `FUN_004950F0(itemID, ?, slot0, 5)`.
|
||||
// 6/7 other drag-source items — itemID at
|
||||
// `VAR_CURSOR_GENERIC_SLOT`. Type 7 is action-bar pickup
|
||||
// (`FUN_004E6130`); type 6 is another drag path (mail/etc.).
|
||||
// 8 macro — 1-based index at `VAR_CURSOR_MACRO_INDEX`
|
||||
// 9 inventory item (equipped slot) — itemID stored at
|
||||
// `VAR_CURSOR_GENERIC_SLOT`. Bare itemID rather than a GUID
|
||||
// because the item is out of the descriptor stream while
|
||||
// on the cursor.
|
||||
// 10 stable pet — 1-based index at `VAR_CURSOR_STABLEPET_INDEX`.
|
||||
// Written by `Script_ClickStablePet` (`FUN_004CB420`) →
|
||||
// `FUN_00495010(slot)`.
|
||||
//
|
||||
// `CursorHasItem` returns true for types 1 *and* 9 only.
|
||||
VAR_CURSOR_TYPE = 0x00B4D900,
|
||||
VAR_CURSOR_ITEM_GUID_LO = 0x00B4E248,
|
||||
VAR_CURSOR_ITEM_GUID_HI = 0x00B4E24C,
|
||||
VAR_CURSOR_MONEY_COPPER = 0x00B4E2F0,
|
||||
VAR_CURSOR_SPELL_ID = 0x00B4E2F4,
|
||||
VAR_CURSOR_PETACTION_PACKED = 0x00B4E2F8,
|
||||
VAR_CURSOR_MACRO_INDEX = 0x00B4E2FC,
|
||||
VAR_CURSOR_STABLEPET_INDEX = 0x00B4E300,
|
||||
VAR_CURSOR_GENERIC_SLOT = 0x00B4B41C,
|
||||
VAR_CURSOR_GENERIC_DISPLAY = 0x00B4D8EC,
|
||||
VAR_CURSOR_GENERIC_SOURCE = 0x00B4B420,
|
||||
|
||||
// GUID → CGObject resolver. `__fastcall(type_filter, guid_lo,
|
||||
// guid_hi) → CGObject *` at `0x00468460`. Type filter 0x2 = item,
|
||||
// matching what `FUN_00494B60` and the trade-attached paths pass
|
||||
// when re-resolving the cursor GUID to a `CGItem *`.
|
||||
FUN_OBJECT_FROM_GUID = 0x00468460,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Embedded `!!!ClassicAPI` addon fallback. When the user doesn't have
|
||||
// the addon installed on disk, this module makes the engine think it
|
||||
// does — without writing anything to the filesystem.
|
||||
//
|
||||
// How:
|
||||
//
|
||||
// 1. The CMake build embeds every file under `AddOns/!!!ClassicAPI/`
|
||||
// into a generated header (`embedded_classicapi.h`) as a byte
|
||||
// array per file plus a `{path, data, size}` manifest.
|
||||
//
|
||||
// 2. We hook the engine's file-read function at `FUN_FILE_READ`.
|
||||
// When the engine asks for a path under
|
||||
// `Interface\AddOns\!!!ClassicAPI\`, we allocate a Storm buffer
|
||||
// via the same allocator the engine itself uses, copy the
|
||||
// embedded content in, and hand it back. The caller's normal
|
||||
// `SMemFree` reclaims it on completion — no special lifetime.
|
||||
//
|
||||
// 3. We post-hook the engine's `FUN_ADDON_INIT`. After the engine's
|
||||
// own `Interface\AddOns\` scan finishes, we call the TOC parser
|
||||
// directly with `"!!!ClassicAPI"`. The parser opens its TOC via
|
||||
// our hooked read function and registers the addon as a normal
|
||||
// entry. The TOC parser is dedup-safe: if the user already has
|
||||
// the addon on disk, the engine's scan picked it up, the
|
||||
// hash-table lookup hits, and our call is a no-op. So the
|
||||
// conditional "only if not on disk" behavior is free.
|
||||
//
|
||||
// Same pattern WeirdUtils.dll uses (we reverse-engineered it from
|
||||
// `0x10015A74` while figuring this out). The addon load pass then
|
||||
// reads each `.lua` file through the same hooked read path —
|
||||
// `GlobalStrings.lua`, `Util\Print.lua`, etc. all surface from
|
||||
// embedded buffers.
|
||||
|
||||
#include "embedded_classicapi.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
namespace Addons::Embedded {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kAddonName = "!!!ClassicAPI";
|
||||
constexpr const char *kAddonTocFile = "!!!ClassicAPI.toc";
|
||||
|
||||
// `Interface\AddOns\!!!ClassicAPI\` — the prefix the engine
|
||||
// constructs for any of our addon's files. Comparing case-
|
||||
// insensitively because the TOC parser uses `Interface\AddOns\%s\…`
|
||||
// where %s comes from the on-disk directory name, and Windows file
|
||||
// paths are case-insensitive.
|
||||
constexpr const char *kAddonPathPrefix = "Interface\\AddOns\\!!!ClassicAPI\\";
|
||||
constexpr size_t kAddonPathPrefixLen = 30; // length of the literal above
|
||||
|
||||
char NormalizeChar(char c) {
|
||||
if (c == '/') return '\\';
|
||||
if (c >= 'A' && c <= 'Z') return static_cast<char>(c + 32);
|
||||
return c;
|
||||
}
|
||||
|
||||
bool PathEqualsCI(const char *a, const char *b) {
|
||||
while (*a && *b) {
|
||||
if (NormalizeChar(*a) != NormalizeChar(*b)) return false;
|
||||
++a; ++b;
|
||||
}
|
||||
return *a == *b;
|
||||
}
|
||||
|
||||
// Strip the `Interface\AddOns\!!!ClassicAPI\` prefix and return the
|
||||
// suffix (e.g. `Util\Print.lua`) on a match, NULL otherwise.
|
||||
const char *StripAddonPrefix(const char *path) {
|
||||
if (path == nullptr) return nullptr;
|
||||
const char *p = path;
|
||||
const char *q = kAddonPathPrefix;
|
||||
while (*q) {
|
||||
if (*p == '\0') return nullptr;
|
||||
if (NormalizeChar(*p) != NormalizeChar(*q)) return nullptr;
|
||||
++p; ++q;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
const ClassicAPIFiles::File *LookupEmbedded(const char *suffix) {
|
||||
for (size_t i = 0; i < ClassicAPIFiles::kFileCount; ++i) {
|
||||
if (PathEqualsCI(suffix, ClassicAPIFiles::kFiles[i].path))
|
||||
return &ClassicAPIFiles::kFiles[i];
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Storm allocator — same one `FUN_FILE_READ` uses internally. Buffer
|
||||
// allocated here is freed cleanly by the caller's standard `SMemFree`
|
||||
// (`FUN_STORM_SMEM_FREE`). `__stdcall` per the function's `RET 0x10`
|
||||
// (4 args × 4 bytes = 16) epilogue.
|
||||
// __stdcall void *SMemAlloc(size_t size, const char *file, int line, int flags)
|
||||
using SMemAlloc_t = void *(__stdcall *)(size_t size, const char *file, int line, int flags);
|
||||
|
||||
// `FUN_FILE_READ` — same shape derived from the Octo decompile.
|
||||
// Calling convention is `__stdcall` (callee cleans 28 bytes via
|
||||
// `RET 0x1C`), not `__cdecl` — confirmed via the function epilogue.
|
||||
// Getting this wrong silently corrupts the caller's stack frame.
|
||||
// __stdcall int FileRead(int unused, const char *path, void **outBuf,
|
||||
// size_t *outSize, size_t extraBytes,
|
||||
// int flag1, int flag2)
|
||||
using FileRead_t = int(__stdcall *)(int unused, const char *path, void **outBuf,
|
||||
size_t *outSize, size_t extraBytes,
|
||||
int flag1, int flag2);
|
||||
FileRead_t FileRead_o = nullptr;
|
||||
|
||||
// Storm-allocator free. Used to release the disk-TOC buffer after we
|
||||
// scrape its version line — same path the engine itself uses when
|
||||
// `FileRead_o` succeeded and the caller's done with the result.
|
||||
// __stdcall void SMemFree(void *buf, const char *file, int line, int flags)
|
||||
using SMemFree_t = void(__stdcall *)(void *buf, const char *file, int line, int flags);
|
||||
|
||||
// Extracts the value of the `## Version: X` line from a TOC byte
|
||||
// buffer. Writes into `out` (size `outSize`) and returns true on
|
||||
// success. The TOC format is `## Key: Value`, one per line, case-
|
||||
// sensitive on the key. Returns false if no Version line is found.
|
||||
bool ExtractTocVersion(const char *content, size_t size,
|
||||
char *out, size_t outSize) {
|
||||
static const char kKey[] = "## Version:";
|
||||
constexpr size_t kKeyLen = sizeof(kKey) - 1;
|
||||
for (size_t i = 0; i + kKeyLen <= size; ++i) {
|
||||
const bool atLineStart = (i == 0) || content[i - 1] == '\n';
|
||||
if (!atLineStart) continue;
|
||||
if (std::memcmp(content + i, kKey, kKeyLen) != 0) continue;
|
||||
const char *p = content + i + kKeyLen;
|
||||
const char *end = content + size;
|
||||
while (p < end && (*p == ' ' || *p == '\t')) ++p;
|
||||
size_t j = 0;
|
||||
while (p < end && *p != '\r' && *p != '\n' && j + 1 < outSize) {
|
||||
out[j++] = *p++;
|
||||
}
|
||||
while (j > 0 && (out[j - 1] == ' ' || out[j - 1] == '\t')) --j;
|
||||
out[j] = '\0';
|
||||
return j > 0;
|
||||
}
|
||||
if (outSize > 0) out[0] = '\0';
|
||||
return false;
|
||||
}
|
||||
|
||||
// Returns -1/0/+1 for `a < b` / `a == b` / `a > b`. "DEV" is a
|
||||
// sentinel meaning "newer than any real release" — local dev builds
|
||||
// always win against installed-on-disk versions. Otherwise the two
|
||||
// strings are walked as dot-separated numeric semver components
|
||||
// (`1.2` < `1.10`).
|
||||
int CompareVersions(const char *a, const char *b) {
|
||||
const bool aDev = std::strcmp(a, "DEV") == 0;
|
||||
const bool bDev = std::strcmp(b, "DEV") == 0;
|
||||
if (aDev && bDev) return 0;
|
||||
if (aDev) return 1;
|
||||
if (bDev) return -1;
|
||||
while (*a || *b) {
|
||||
int va = 0, vb = 0;
|
||||
while (*a >= '0' && *a <= '9') { va = va * 10 + (*a - '0'); ++a; }
|
||||
while (*b >= '0' && *b <= '9') { vb = vb * 10 + (*b - '0'); ++b; }
|
||||
if (va != vb) return va < vb ? -1 : 1;
|
||||
if (*a == '.') ++a;
|
||||
if (*b == '.') ++b;
|
||||
if (!*a && !*b) break;
|
||||
if (!*a) return -1;
|
||||
if (!*b) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Pre-extracted embedded TOC version, populated lazily on first
|
||||
// FileRead_h call under our prefix. Empty until populated.
|
||||
char g_embeddedVersion[64] = "";
|
||||
|
||||
// Which source wins the embed-vs-disk comparison, decided once on
|
||||
// first relevant file read.
|
||||
enum class Source { Undecided, Disk, Embedded };
|
||||
Source g_source = Source::Undecided;
|
||||
|
||||
void EnsureEmbeddedVersionExtracted() {
|
||||
if (g_embeddedVersion[0] != '\0') return;
|
||||
for (size_t i = 0; i < ClassicAPIFiles::kFileCount; ++i) {
|
||||
if (PathEqualsCI(kAddonTocFile, ClassicAPIFiles::kFiles[i].path)) {
|
||||
ExtractTocVersion(
|
||||
reinterpret_cast<const char *>(ClassicAPIFiles::kFiles[i].data),
|
||||
ClassicAPIFiles::kFiles[i].size,
|
||||
g_embeddedVersion, sizeof(g_embeddedVersion));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decide whether disk or embedded should serve all reads for this
|
||||
// addon. Called on the first FileRead_h call with a matching prefix.
|
||||
// Reads the on-disk TOC via the original `FUN_FILE_READ` (bypassing
|
||||
// our hook), parses its version, compares to the embedded version,
|
||||
// and caches the winner. If disk has no TOC at all, embedded wins by
|
||||
// default.
|
||||
void DecideSource() {
|
||||
if (g_source != Source::Undecided) return;
|
||||
EnsureEmbeddedVersionExtracted();
|
||||
|
||||
char fullPath[256];
|
||||
std::snprintf(fullPath, sizeof(fullPath), "%s%s",
|
||||
kAddonPathPrefix, kAddonTocFile);
|
||||
|
||||
void *diskBuf = nullptr;
|
||||
size_t diskSize = 0;
|
||||
const int ok = FileRead_o(0, fullPath, &diskBuf, &diskSize, 1, 1, 0);
|
||||
if (ok == 0 || diskBuf == nullptr) {
|
||||
g_source = Source::Embedded;
|
||||
return;
|
||||
}
|
||||
|
||||
char diskVersion[64] = "";
|
||||
ExtractTocVersion(static_cast<const char *>(diskBuf), diskSize,
|
||||
diskVersion, sizeof(diskVersion));
|
||||
|
||||
// Free the disk buffer we just borrowed — we only needed it for
|
||||
// the version-line scrape. The actual content for the engine's
|
||||
// TOC read comes through the regular hook path below.
|
||||
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
|
||||
SMemFree(diskBuf, __FILE__, __LINE__, 0);
|
||||
|
||||
// Missing or unparseable disk version → assume it's older than
|
||||
// anything we ship, embedded wins.
|
||||
if (diskVersion[0] == '\0') {
|
||||
g_source = Source::Embedded;
|
||||
return;
|
||||
}
|
||||
|
||||
const int cmp = CompareVersions(g_embeddedVersion, diskVersion);
|
||||
g_source = (cmp > 0) ? Source::Embedded : Source::Disk;
|
||||
}
|
||||
|
||||
int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
|
||||
size_t *outSize, size_t extraBytes,
|
||||
int flag1, int flag2) {
|
||||
const char *suffix = StripAddonPrefix(path);
|
||||
if (suffix == nullptr) {
|
||||
// Path isn't under `Interface\AddOns\!!!ClassicAPI\` — straight
|
||||
// pass-through, we don't care.
|
||||
return FileRead_o(unused, path, outBuf, outSize, extraBytes,
|
||||
flag1, flag2);
|
||||
}
|
||||
|
||||
DecideSource();
|
||||
|
||||
if (g_source == Source::Disk) {
|
||||
// Disk version is at least as new — serve disk. If disk
|
||||
// doesn't have this specific file (e.g. embedded has files
|
||||
// disk doesn't), fall through and try embedded.
|
||||
const int diskResult = FileRead_o(unused, path, outBuf, outSize,
|
||||
extraBytes, flag1, flag2);
|
||||
if (diskResult != 0) return diskResult;
|
||||
}
|
||||
|
||||
// Source::Embedded path, OR Source::Disk-but-file-missing fallback.
|
||||
// Look up the embedded version and synthesize a Storm buffer the
|
||||
// caller can free normally.
|
||||
const auto *entry = LookupEmbedded(suffix);
|
||||
if (entry == nullptr) {
|
||||
// Not in our embedded set either — let the engine try disk
|
||||
// one more time (returns 0 if that also fails). Covers
|
||||
// pathological cases like an empty disk install + embedded
|
||||
// missing some file.
|
||||
return FileRead_o(unused, path, outBuf, outSize, extraBytes,
|
||||
flag1, flag2);
|
||||
}
|
||||
|
||||
auto SMemAlloc = reinterpret_cast<SMemAlloc_t>(
|
||||
Offsets::FUN_STORM_SMEM_ALLOC);
|
||||
const size_t totalSize = entry->size + extraBytes;
|
||||
void *buf = SMemAlloc(totalSize, __FILE__, __LINE__, 0);
|
||||
if (buf == nullptr) return 0;
|
||||
std::memcpy(buf, entry->data, entry->size);
|
||||
if (extraBytes > 0) {
|
||||
std::memset(static_cast<uint8_t *>(buf) + entry->size, 0, extraBytes);
|
||||
}
|
||||
if (outBuf != nullptr) *outBuf = buf;
|
||||
if (outSize != nullptr) *outSize = entry->size;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `FUN_ADDON_INIT` — `__fastcall(accountName)`. We post-hook it: let
|
||||
// the engine's normal `Interface\AddOns\` scan run first, then call
|
||||
// the TOC parser with `"!!!ClassicAPI"`. Dedup-safe — see file header.
|
||||
using AddonInit_t = void(__fastcall *)(const char *accountName);
|
||||
AddonInit_t AddonInit_o = nullptr;
|
||||
|
||||
using TocParser_t = void(__fastcall *)(const char *name);
|
||||
|
||||
void __fastcall AddonInit_h(const char *accountName) {
|
||||
AddonInit_o(accountName);
|
||||
auto TocParser = reinterpret_cast<TocParser_t>(Offsets::FUN_TOC_PARSER);
|
||||
TocParser(kAddonName);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookFileRead{
|
||||
Offsets::FUN_FILE_READ,
|
||||
reinterpret_cast<void *>(&FileRead_h),
|
||||
reinterpret_cast<void **>(&FileRead_o)};
|
||||
|
||||
const Game::HookAutoRegister _hookAddonInit{
|
||||
Offsets::FUN_ADDON_INIT,
|
||||
reinterpret_cast<void *>(&AddonInit_h),
|
||||
reinterpret_cast<void **>(&AddonInit_o)};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Addons::Embedded
|
||||
@@ -0,0 +1,55 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Container.GetContainerItemCharges(containerIndex, slotIndex)` —
|
||||
// returns the `abs(SPELL_CHARGES[0])` value of the item in the
|
||||
// specified bag slot, or `nil` for empty slots and items that don't
|
||||
// carry a charge concept. Wands return their remaining cast count
|
||||
// (e.g. 50 → 49 → …); single-use items (Healthstone, Mana Gem,
|
||||
// Goblin Sapper Charge) return `1`.
|
||||
//
|
||||
// Modern equivalent: none — modern WoW exposes charges only through
|
||||
// tooltip text scanning. `C_Item.GetItemCount(includeBank, includeCharges)`
|
||||
// totals charges across every matching stack; this is the per-slot
|
||||
// reader.
|
||||
|
||||
#include "Game.h"
|
||||
#include "item/Charges.h"
|
||||
#include "item/Location.h"
|
||||
|
||||
namespace Container::Charges {
|
||||
|
||||
namespace {
|
||||
|
||||
int __fastcall Script_C_Container_GetContainerItemCharges(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
|
||||
Game::Lua::Error(L,
|
||||
"Usage: C_Container.GetContainerItemCharges(containerIndex, slotIndex)");
|
||||
return 0;
|
||||
}
|
||||
const int bagID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
const int slotIndex = static_cast<int>(Game::Lua::ToNumber(L, 2));
|
||||
return Item::Charges::PushChargesForItem(
|
||||
L, Item::Location::ResolveBag(L, bagID, slotIndex));
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Container", "GetContainerItemCharges",
|
||||
&Script_C_Container_GetContainerItemCharges);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Container::Charges
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,39 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#include "Charges.h"
|
||||
|
||||
#include "../Game.h"
|
||||
#include "../Offsets.h"
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
namespace Item::Charges {
|
||||
|
||||
int PushChargesForItem(void *L, const uint8_t *item) {
|
||||
if (item == nullptr)
|
||||
return 0;
|
||||
auto *descriptor = *reinterpret_cast<const uint8_t *const *>(
|
||||
item + Offsets::OFF_ITEM_DESCRIPTOR);
|
||||
if (descriptor == nullptr)
|
||||
return 0;
|
||||
const int32_t stack = static_cast<int32_t>(*reinterpret_cast<const uint32_t *>(
|
||||
descriptor + Offsets::OFF_DESCRIPTOR_STACK_COUNT));
|
||||
const int32_t rawCharges = *reinterpret_cast<const int32_t *>(
|
||||
descriptor + Offsets::OFF_DESCRIPTOR_SPELL_CHARGES_0);
|
||||
const int32_t usesPerItem = (rawCharges < 0) ? -rawCharges : 1;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(stack * usesPerItem));
|
||||
return 1;
|
||||
}
|
||||
|
||||
} // namespace Item::Charges
|
||||
@@ -0,0 +1,44 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Item::Charges {
|
||||
|
||||
// Pushes the total uses available in this single slot — the
|
||||
// per-slot equivalent of `GetItemCount(_, _, includeCharges=true)`.
|
||||
// Returns `1` (pushed one value) on success, `0` (pushes nothing,
|
||||
// caller surfaces as `nil`) when the item is missing or the
|
||||
// descriptor is null.
|
||||
//
|
||||
// Math is `stack * usesPerItem` where `usesPerItem` follows
|
||||
// `Item::Count::GetUsesPerItem`:
|
||||
// - `SPELL_CHARGES[0] >= 0` → `1` (no destroy-on-zero behavior;
|
||||
// stack count alone reflects "how many uses").
|
||||
// - `SPELL_CHARGES[0] < 0` → `abs(value)` (consume-on-use items;
|
||||
// wand at 50 has `-50` so each item contributes 50).
|
||||
//
|
||||
// Worked examples:
|
||||
// - Wand at 50 charges (stack=1, raw=-50): `1 * 50 = 50`
|
||||
// - Hearthstone (stack=1, raw=0): `1 * 1 = 1` (cooldown-
|
||||
// only, no charges concept; we still surface "1 use").
|
||||
// - Water stack of 20 (stack=20, raw=-1): `20 * 1 = 20`
|
||||
// - Equipped sword (stack=1, raw=0): `1 * 1 = 1`
|
||||
//
|
||||
// Caller can `return PushChargesForItem(L, item);` to forward the
|
||||
// count directly.
|
||||
int PushChargesForItem(void *L, const uint8_t *item);
|
||||
|
||||
} // namespace Item::Charges
|
||||
@@ -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;
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,42 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Item::QualityColor {
|
||||
|
||||
// Returns the engine's `"|cffRRGGBB"` color-prefix string for the
|
||||
// given item quality. Reads directly from the engine table at
|
||||
// `Offsets::VAR_QUALITY_COLOR_TABLE` so we don't have to duplicate
|
||||
// the hex palette across modules. Mirrors the engine's own fallback
|
||||
// at `0x0052AD90`: quality > 6 returns white (index 1).
|
||||
inline const char *Prefix(int quality) {
|
||||
auto *table = reinterpret_cast<const char *const *>(
|
||||
Offsets::VAR_QUALITY_COLOR_TABLE);
|
||||
if (quality < 0 || quality >= Offsets::QUALITY_COLOR_TABLE_SIZE)
|
||||
return table[1]; // white fallback, matches engine
|
||||
return table[quality];
|
||||
}
|
||||
|
||||
// Returns just the 6-char hex (e.g. "9d9d9d"), pointing into the
|
||||
// engine string past the `|cff` prefix. Useful for callers that
|
||||
// build their own markup format and need the bare hex.
|
||||
inline const char *Hex(int quality) {
|
||||
return Prefix(quality) + 4;
|
||||
}
|
||||
|
||||
} // namespace Item::QualityColor
|
||||
+45
-2
@@ -64,12 +64,34 @@ constexpr int FIRST_BAG_CONTAINER_LINEAR = 19;
|
||||
// player invMgr — i.e. 1-based bag-0 slot S maps to linear 22+S.
|
||||
constexpr int BACKPACK_LINEAR_OFFSET = 22;
|
||||
|
||||
// Main bank contents (bagID -1) live at linear slots 39..62 in the
|
||||
// player invMgr — i.e. 1-based bank slot S maps to linear 38+S.
|
||||
// Same encoding shape as the backpack (container is the player
|
||||
// itself), different base offset. Range valid only when the bank
|
||||
// window has been opened in the current session.
|
||||
constexpr int BANK_MAIN_LINEAR_OFFSET = 38;
|
||||
constexpr int BANK_MAIN_NUM_SLOTS = 24;
|
||||
|
||||
// Bank bag containers occupy 1-based equipment slots 64..69 (linear
|
||||
// 63..68). Addressed by bagID 5..10 — bagID 5 = first bank bag.
|
||||
// Requires bank window open; otherwise the engine's CGContainer for
|
||||
// the bag is unsynced and `ResolveEquipmentSlot` returns null.
|
||||
constexpr int FIRST_BANK_BAG_EQ_SLOT = 64;
|
||||
constexpr int FIRST_BANK_BAG_ID = 5;
|
||||
constexpr int LAST_BANK_BAG_ID = 10;
|
||||
|
||||
// (bagID, 1-based slot) → (containerGUID, engine linear slot). Returns
|
||||
// false for unequipped bags or out-of-range slots; does NOT validate
|
||||
// that the slot is occupied (caller checks separately).
|
||||
//
|
||||
// bagID 0 → container = player, linear = BACKPACK_LINEAR_OFFSET + slot
|
||||
// bagID 1..4 → container = equipped bag's CGContainer, linear = slot - 1
|
||||
// bagID 0 → container = player, linear = 22 + slot (backpack)
|
||||
// bagID 1..4 → container = equipped bag's CGContainer, linear = slot - 1
|
||||
// bagID -1 → container = player, linear = 38 + slot (bank main)
|
||||
// bagID 5..10 → container = bank bag's CGContainer, linear = slot - 1
|
||||
//
|
||||
// Bank-side encodings only resolve once the bank window has been
|
||||
// opened in the current session (the engine doesn't sync the bank
|
||||
// CGContainers or bank-slot inventory entries before then).
|
||||
bool EncodeBagSlot(int bagID, int slotInBag,
|
||||
uint32_t *containerLo, uint32_t *containerHi,
|
||||
uint32_t *linearSlot) {
|
||||
@@ -94,6 +116,27 @@ bool EncodeBagSlot(int bagID, int slotInBag,
|
||||
*linearSlot = static_cast<uint32_t>(slotInBag - 1);
|
||||
return true;
|
||||
}
|
||||
if (bagID == -1) {
|
||||
if (slotInBag > BANK_MAIN_NUM_SLOTS)
|
||||
return false;
|
||||
void *player = ResolvePlayer();
|
||||
if (player == nullptr)
|
||||
return false;
|
||||
if (!ReadGuid(player, containerLo, containerHi))
|
||||
return false;
|
||||
*linearSlot = static_cast<uint32_t>(BANK_MAIN_LINEAR_OFFSET + slotInBag);
|
||||
return true;
|
||||
}
|
||||
if (bagID >= FIRST_BANK_BAG_ID && bagID <= LAST_BANK_BAG_ID) {
|
||||
const uint8_t *bag = Item::Location::ResolveEquipmentSlot(
|
||||
FIRST_BANK_BAG_EQ_SLOT + (bagID - FIRST_BANK_BAG_ID));
|
||||
if (bag == nullptr)
|
||||
return false;
|
||||
if (!ReadGuid(bag, containerLo, containerHi))
|
||||
return false;
|
||||
*linearSlot = static_cast<uint32_t>(slotInBag - 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
+4
-21
@@ -17,6 +17,7 @@
|
||||
#include "item/ID.h"
|
||||
#include "item/Link.h"
|
||||
#include "item/Location.h"
|
||||
#include "item/QualityColor.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
@@ -134,25 +135,6 @@ static int __fastcall Script_GameTooltipSetInventoryItemByID(void *L) {
|
||||
return fn(L);
|
||||
}
|
||||
|
||||
// Modern `Enum.ItemQuality` color codes for the SetItemByID fallback
|
||||
// path (no CGItem → no engine link helper). For the CGItem-resolved
|
||||
// path we delegate to the engine's own builder, which uses the same
|
||||
// palette internally.
|
||||
static const char *QualityHex(int quality) {
|
||||
static const char *const kHex[] = {
|
||||
"9d9d9d", // POOR
|
||||
"ffffff", // COMMON
|
||||
"1eff00", // UNCOMMON
|
||||
"0070dd", // RARE
|
||||
"a335ee", // EPIC
|
||||
"ff8000", // LEGENDARY
|
||||
"e6cc80", // ARTIFACT (TBC+)
|
||||
"00ccff", // HEIRLOOM (WotLK+)
|
||||
};
|
||||
if (quality < 0 || quality > 7)
|
||||
return kHex[1];
|
||||
return kHex[quality];
|
||||
}
|
||||
|
||||
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
|
||||
const uint64_t *guid, void *callback,
|
||||
@@ -277,8 +259,9 @@ static int __fastcall Script_GameTooltipGetItem(void *L) {
|
||||
|
||||
char link[256];
|
||||
std::snprintf(link, sizeof(link),
|
||||
"|cff%s|Hitem:%d:0:0:0:0:0:0:0|h[%s]|h|r",
|
||||
QualityHex(static_cast<int>(quality)), itemID, name);
|
||||
"%s|Hitem:%d:0:0:0:0:0:0:0|h[%s]|h|r",
|
||||
Item::QualityColor::Prefix(static_cast<int>(quality)),
|
||||
itemID, name);
|
||||
|
||||
Game::Lua::PushString(L, name);
|
||||
Game::Lua::PushString(L, link);
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,253 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Nameplate lifecycle events: NAME_PLATE_CREATED, NAME_PLATE_UNIT_ADDED,
|
||||
// NAME_PLATE_UNIT_REMOVED.
|
||||
//
|
||||
// Per-frame poll: walk visible CGUnits with allocated nameplates, diff
|
||||
// against last frame's snapshot, fire events on real transitions only.
|
||||
// The engine's internal hide/show cycle (z-order rebuilds, anchor
|
||||
// changes, ~7 callers of `FUN_00608A10` that transiently zero `+0xE60`)
|
||||
// is absorbed because we only compare with the previous *frame's*
|
||||
// state, not every transient `unit + 0xE60` write.
|
||||
//
|
||||
// Payload shape (matches modern WoW exactly):
|
||||
// - `NAME_PLATE_CREATED` — `arg1` is the nameplate **Frame**. The
|
||||
// engine's printf-style dispatcher (`FUN_FIRE_EVENT`) only knows
|
||||
// `%s`/`%d`/`%u`/`%f` format codes with no "push Lua value" option,
|
||||
// so we route this event through a pre-set path: save current
|
||||
// `_G.arg1`, set it to the frame, fire with empty format
|
||||
// (dispatcher leaves `_G.arg<N>` alone when no codes are parsed),
|
||||
// restore.
|
||||
// - `NAME_PLATE_UNIT_ADDED` / `_REMOVED` — `arg1` is the
|
||||
// `"nameplateN"` unit token (formatted from the plate's index in
|
||||
// `g_orderedGUIDs` at fire time). The token resolves to the unit
|
||||
// via the `nameplateN`-aware token resolver in `unit/TokenExtensions.cpp`
|
||||
// — addons can pass it straight to `UnitName`, `UnitGUID`, etc.,
|
||||
// or to `GetNamePlateForUnit` for the frame.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "event/Custom.h"
|
||||
#include "nameplate/Walk.h"
|
||||
#include "tick/WorldTick.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace NamePlate::Events {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kEventCreated = "NAME_PLATE_CREATED";
|
||||
constexpr const char *kEventUnitAdded = "NAME_PLATE_UNIT_ADDED";
|
||||
constexpr const char *kEventUnitRemoved = "NAME_PLATE_UNIT_REMOVED";
|
||||
|
||||
const Event::Custom::AutoReserve _r1{kEventCreated};
|
||||
const Event::Custom::AutoReserve _r2{kEventUnitAdded};
|
||||
const Event::Custom::AutoReserve _r3{kEventUnitRemoved};
|
||||
|
||||
// Previous tick's snapshot — GUID → nameplate-frame pointer for each
|
||||
// nameplated unit. Compared against the next tick's walk to compute
|
||||
// ADDED/REMOVED diffs.
|
||||
std::unordered_map<uint64_t, const void *> g_lastTickPlates;
|
||||
|
||||
// Scratch map reused each tick, swapped into `g_lastTickPlates` at
|
||||
// the end. File-static so we don't pay the constructor/destructor
|
||||
// cycle every frame — `clear()` keeps the existing bucket capacity.
|
||||
std::unordered_map<uint64_t, const void *> g_currentTickPlates;
|
||||
|
||||
// Frame pointers we've ever surfaced as nameplate plates. First
|
||||
// sighting fires NAME_PLATE_CREATED; same pointer reappearing (pool
|
||||
// reuse) doesn't refire.
|
||||
//
|
||||
// Bounded by the engine's CGNamePlateFrame freelist high-water mark:
|
||||
// `FUN_006087F0` first checks the global freelist head at
|
||||
// `DAT_00c4d920` and recycles any waiting frame before falling back
|
||||
// to `SMemAlloc(0x518)`. So the set grows only up to the peak
|
||||
// simultaneous-visible-plate count for the session — typically
|
||||
// <80 even in AV-scale scenes (matching the `reserve(64)` below) —
|
||||
// then tops out as pool reuse covers all subsequent shows.
|
||||
std::unordered_set<const void *> g_seenPlates;
|
||||
|
||||
// Ordered list of currently-visible nameplate GUIDs, in
|
||||
// creation-order. Append on UNIT_ADDED, erase on UNIT_REMOVED. Backs
|
||||
// the `nameplateN` unit-token resolver in `unit/TokenExtensions.cpp`. Order
|
||||
// matches modern WoW semantics: stable for the lifetime of each
|
||||
// plate, gaps when middle plates vanish (until the next REMOVED
|
||||
// shifts later entries down).
|
||||
std::vector<uint64_t> g_orderedGUIDs;
|
||||
|
||||
// Fire `eventName` with a pre-formatted string as `arg1`. The engine
|
||||
// dispatcher's `%s` format code pushes the C string into `_G.arg1`
|
||||
// as a Lua string — no escaping concerns for our own input
|
||||
// (`"nameplateN"`).
|
||||
void FireWithString(const char *eventName, const char *value) {
|
||||
if (value == nullptr)
|
||||
return;
|
||||
const int slot = Event::Custom::Lookup(eventName);
|
||||
if (slot < 0)
|
||||
return;
|
||||
Event::Custom::Fire(slot, "%s", value);
|
||||
}
|
||||
|
||||
// Format a 1-based nameplate index as `"nameplateN"` for ADDED /
|
||||
// REMOVED event payloads. Buffer should be at least 24 bytes — 9
|
||||
// for the prefix, up to 10 digits for the index, room for null.
|
||||
// Returns `buf` for convenient inline use.
|
||||
const char *FormatNamePlateToken(char *buf, size_t bufSize, int oneBasedIndex) {
|
||||
std::snprintf(buf, bufSize, "nameplate%d", oneBasedIndex);
|
||||
return buf;
|
||||
}
|
||||
|
||||
// Fire `eventName` with the nameplate `Frame` set as `_G.arg1`.
|
||||
// `FUN_FIRE_EVENT`'s format-string parser only handles primitive
|
||||
// types, but it only mutates `_G.arg<N>` for codes it actually
|
||||
// parses. With an empty format we pre-set `_G.arg1` and the
|
||||
// dispatcher leaves it alone. Restore the previous value after
|
||||
// fire so we don't leak our frame into unrelated global state.
|
||||
//
|
||||
// Lua-stack-clean: stack depth on entry == stack depth on exit.
|
||||
using LuaRefRef_t = int(__fastcall *)(void *L, int t);
|
||||
using LuaRefUnref_t = void(__fastcall *)(void *L, int t, int ref);
|
||||
using LuaRawGetI_t = void(__fastcall *)(void *L, int t, int n);
|
||||
|
||||
void FireWithFrame(const char *eventName, void *frame) {
|
||||
if (frame == nullptr)
|
||||
return;
|
||||
const int slot = Event::Custom::Lookup(eventName);
|
||||
if (slot < 0)
|
||||
return;
|
||||
|
||||
void *L = Game::Lua::State();
|
||||
if (L == nullptr)
|
||||
return;
|
||||
|
||||
auto refRef = reinterpret_cast<LuaRefRef_t>(
|
||||
static_cast<uintptr_t>(Offsets::LUA_REF_REF));
|
||||
auto refUnref = reinterpret_cast<LuaRefUnref_t>(
|
||||
static_cast<uintptr_t>(Offsets::LUA_REF_UNREF));
|
||||
auto rawgeti = reinterpret_cast<LuaRawGetI_t>(
|
||||
Offsets::FUN_FRAMESCRIPT_PUSH_OBJECT);
|
||||
|
||||
// Save current `_G.arg1` to the registry.
|
||||
Game::Lua::PushString(L, "arg1");
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
|
||||
const int savedRef = refRef(L, Game::Lua::REGISTRY_INDEX);
|
||||
|
||||
// Set `_G.arg1 = frame`.
|
||||
Game::Lua::PushString(L, "arg1");
|
||||
NamePlate::Info::PushNamePlateFrame(L, frame);
|
||||
Game::Lua::SetTable(L, Game::Lua::GLOBALS_INDEX);
|
||||
|
||||
// Fire — empty format, so the dispatcher doesn't touch `arg1`.
|
||||
Event::Custom::Fire(slot, "");
|
||||
|
||||
// Restore previous `_G.arg1` from the saved registry ref.
|
||||
Game::Lua::PushString(L, "arg1");
|
||||
rawgeti(L, Game::Lua::REGISTRY_INDEX, savedRef);
|
||||
Game::Lua::SetTable(L, Game::Lua::GLOBALS_INDEX);
|
||||
refUnref(L, Game::Lua::REGISTRY_INDEX, savedRef);
|
||||
}
|
||||
|
||||
void OnWorldTick() {
|
||||
g_currentTickPlates.clear();
|
||||
g_currentTickPlates.reserve(64); // typical visible-nameplate ceiling
|
||||
NamePlate::Walk::ForEachNamePlatedUnit(
|
||||
[](const uint8_t *, const uint8_t *nameplate,
|
||||
const uint8_t *instance) {
|
||||
const uint64_t guid = *reinterpret_cast<const uint64_t *>(instance);
|
||||
if (guid == 0)
|
||||
return;
|
||||
g_currentTickPlates.emplace(guid, nameplate);
|
||||
});
|
||||
|
||||
// Fire CREATED (with the Frame as arg1) for never-before-seen
|
||||
// frame pointers; ADDED (with `"nameplateN"` token as arg1) for
|
||||
// GUIDs not in last tick's snapshot. New entries are appended to
|
||||
// the ordered list *before* firing so the token resolves to the
|
||||
// newly-added plate during the event handler.
|
||||
for (const auto &kv : g_currentTickPlates) {
|
||||
if (g_seenPlates.insert(kv.second).second)
|
||||
FireWithFrame(kEventCreated, const_cast<void *>(kv.second));
|
||||
if (g_lastTickPlates.find(kv.first) == g_lastTickPlates.end()) {
|
||||
g_orderedGUIDs.push_back(kv.first);
|
||||
char tokenBuf[24];
|
||||
FireWithString(kEventUnitAdded,
|
||||
FormatNamePlateToken(tokenBuf, sizeof tokenBuf,
|
||||
static_cast<int>(g_orderedGUIDs.size())));
|
||||
}
|
||||
}
|
||||
|
||||
// Fire REMOVED for GUIDs in last tick's snapshot but not current.
|
||||
// We compute the token from the position *before* erasing so the
|
||||
// event payload reflects the slot the unit just vacated; the
|
||||
// handler can still resolve the token to the unit via
|
||||
// `g_orderedGUIDs[slot]` during dispatch. Later plates shift down
|
||||
// when we erase — matches modern semantics.
|
||||
for (const auto &kv : g_lastTickPlates) {
|
||||
if (g_currentTickPlates.find(kv.first) == g_currentTickPlates.end()) {
|
||||
auto it = std::find(g_orderedGUIDs.begin(), g_orderedGUIDs.end(),
|
||||
kv.first);
|
||||
if (it == g_orderedGUIDs.end())
|
||||
continue;
|
||||
const int oneBased = static_cast<int>(it - g_orderedGUIDs.begin()) + 1;
|
||||
char tokenBuf[24];
|
||||
FireWithString(kEventUnitRemoved,
|
||||
FormatNamePlateToken(tokenBuf, sizeof tokenBuf, oneBased));
|
||||
g_orderedGUIDs.erase(it);
|
||||
}
|
||||
}
|
||||
|
||||
g_lastTickPlates.swap(g_currentTickPlates);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
|
||||
|
||||
// Called from `FrameScript_Initialize_h` ahead of the engine's Lua
|
||||
// teardown. Clears the diff state alongside `NamePlate::Info`'s
|
||||
// wrapper-cache reset so that on the first post-reload tick every
|
||||
// currently-visible plate refires `NAME_PLATE_CREATED` and
|
||||
// `NAME_PLATE_UNIT_ADDED`. Without this, addons that decorate via
|
||||
// CREATED (pfUI: builds its overlay button per-pointer) never see
|
||||
// the existing plates after a `/reload` — `g_seenPlates` would
|
||||
// suppress every refire, and the freshly-built wrapper would lack
|
||||
// the addon's `.nameplate` field.
|
||||
//
|
||||
// `g_orderedGUIDs` is also cleared so the post-reload token indices
|
||||
// start at `nameplate1` again, matching the order plates re-fire in.
|
||||
void PrepareForReload() {
|
||||
g_seenPlates.clear();
|
||||
g_lastTickPlates.clear();
|
||||
g_orderedGUIDs.clear();
|
||||
}
|
||||
|
||||
// Exposed via `nameplate/Walk.h` so the `nameplateN` token resolver
|
||||
// in `unit/TokenExtensions.cpp` can map an index to a GUID without seeing
|
||||
// the internal vector.
|
||||
uint64_t GetGUIDByIndex(int oneBased) {
|
||||
if (oneBased <= 0)
|
||||
return 0;
|
||||
const size_t idx = static_cast<size_t>(oneBased - 1);
|
||||
if (idx >= g_orderedGUIDs.size())
|
||||
return 0;
|
||||
return g_orderedGUIDs[idx];
|
||||
}
|
||||
|
||||
} // namespace NamePlate::Events
|
||||
@@ -0,0 +1,265 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_NamePlate.GetNamePlates()` — returns nameplate Frame objects,
|
||||
// matching modern WoW's signature. Two paths because vanilla has two
|
||||
// kinds of nameplates:
|
||||
//
|
||||
// 1. **Addon-created** (pfUI, TidyPlates, etc.) — already registered
|
||||
// with Lua via `CreateFrame`. Their Lua-registry ref-key sits at
|
||||
// `+0x08`; we push `registry[refKey]` to return the cached
|
||||
// wrapper. Identity stable across calls.
|
||||
//
|
||||
// 2. **Default vanilla nameplates** — created internally without ever
|
||||
// calling `CreateFrame`, so `+0x08` is 0. We build a fresh wrapper
|
||||
// table per call: `{[0] = lightuserdata(frame)}` with the global
|
||||
// `__framescript_meta` metatable. Methods work
|
||||
// (`:GetWidth()` / `:GetAlpha()` / etc.) but the wrapper isn't
|
||||
// cached engine-side — calling `GetNamePlates()` again returns a
|
||||
// different table for the same frame. Don't compare wrappers by
|
||||
// identity, and don't cache them across the unit going out of
|
||||
// range (the underlying frame may be freed).
|
||||
//
|
||||
// We deliberately don't call the engine's frame-registration helper
|
||||
// (`FUN_00701BD0`) for the unregistered case — it increments a Lua
|
||||
// refcount on the frame that's never decremented, pinning the frame
|
||||
// in memory.
|
||||
//
|
||||
// `C_NamePlate.GetNamePlateGUIDs()` — companion returning the GUID
|
||||
// strings of the same set of units, regardless of registration
|
||||
// state. Cheapest enumeration when an addon only needs the GUIDs.
|
||||
//
|
||||
// Vanilla 1.12 stores each unit's nameplate pointer at `CGUnit + 0xE60`
|
||||
// (verified via `FUN_006086E0`'s "ensure nameplate exists" path). The
|
||||
// nameplate also caches the unit's GUID at `+0x4E8` for back-lookup.
|
||||
// There's no central "active nameplates" list — the engine maintains
|
||||
// per-unit pointers updated by per-unit state-change handlers.
|
||||
//
|
||||
// To enumerate, we walk the local-player-anchored object hash table
|
||||
// (`player + 0x1C` = bucket array, `player + 0x24` = mask). Each
|
||||
// bucket header stores the link-field offset at byte 0 and the
|
||||
// chain-head pointer at byte 8 — Storm's intrusive-hash pattern.
|
||||
// Filter by `TYPEMASK_UNIT` (`flags & 0x08` at `*(entry+8) + 8`) and
|
||||
// check `+0xE60` for a non-null nameplate pointer.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "guid/Guid.h"
|
||||
#include "nameplate/Walk.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace NamePlate::Info {
|
||||
|
||||
namespace {
|
||||
|
||||
using NamePlate::Walk::ForEachNamePlatedUnit;
|
||||
using NamePlate::Walk::kOffUnitNamePlate;
|
||||
|
||||
using LuaRawGetI_t = void(__fastcall *)(void *L, int idx, int n);
|
||||
using ScriptRegister_t = void(__fastcall *)(void *this_, void *edx_unused,
|
||||
const char *name);
|
||||
using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
|
||||
using ResolveByGUID_t = void *(__fastcall *)(int type, const char *debugName,
|
||||
uint32_t guidLo, uint32_t guidHi,
|
||||
int priority);
|
||||
|
||||
} // namespace
|
||||
|
||||
static int __fastcall Script_GetNamePlates(void *L) {
|
||||
Game::Lua::NewTable(L);
|
||||
int nextIndex = 1;
|
||||
ForEachNamePlatedUnit(
|
||||
[L, &nextIndex](const uint8_t *, const uint8_t *nameplate,
|
||||
const uint8_t *) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(nextIndex++));
|
||||
// Shared helper validates refkey freshness (defensive vs.
|
||||
// stale-across-reload) and falls back to a fresh wrapper
|
||||
// if the registry slot doesn't actually hold the frame
|
||||
// table any more.
|
||||
PushNamePlateFrame(L, const_cast<uint8_t *>(nameplate));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
});
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int __fastcall Script_GetNamePlateGUIDs(void *L) {
|
||||
Game::Lua::NewTable(L);
|
||||
int nextIndex = 1;
|
||||
ForEachNamePlatedUnit(
|
||||
[L, &nextIndex](const uint8_t *, const uint8_t *,
|
||||
const uint8_t *instance) {
|
||||
const uint64_t guid = *reinterpret_cast<const uint64_t *>(instance);
|
||||
if (guid == 0)
|
||||
return;
|
||||
char buf[Guid::STRING_SIZE];
|
||||
Game::Lua::PushNumber(L, static_cast<double>(nextIndex++));
|
||||
Game::Lua::PushString(L,
|
||||
Guid::FormatAsString(guid, buf, sizeof buf));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
});
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Exported via `nameplate/Walk.h` so `Events.cpp` can reuse the same
|
||||
// frame-push path. Internal callers (Script_GetNamePlates etc.) call
|
||||
// it through the unqualified name (they live in the same namespace).
|
||||
//
|
||||
// Delegate wrapper construction to the engine's own
|
||||
// `FrameScript_Object::ScriptRegister`. First call for a never-seen
|
||||
// nameplate builds a `{[0] = lightuserdata}` table with the
|
||||
// framescript metatable, `luaL_ref`s it into the registry, and
|
||||
// writes the refkey to `nameplate + 0x08` (`OFF_COBJECT_LUA_REGISTRY_REF`).
|
||||
// Subsequent calls find a populated refkey and just rawgeti.
|
||||
//
|
||||
// Why delegate instead of building our own wrapper:
|
||||
//
|
||||
// - **Single source of truth.** Every code path that pushes this
|
||||
// frame — ours via this function, addons' `CreateFrame("Type",
|
||||
// "name", plate)` extracting the parent, the engine's own internal
|
||||
// pushers — funnels through `rawgeti(REGISTRY, plate+0x08)` and
|
||||
// gets the same Lua table. Earlier "build our own wrapper +
|
||||
// `luaL_ref` it" approach kept a side cache, but pfUI's
|
||||
// `CreateFrame("Button", "name", parent)` inside `NAME_PLATE_CREATED`
|
||||
// ends up causing the engine to register the parent itself —
|
||||
// the engine's wrapper diverges from ours, and later
|
||||
// `GetNamePlateForUnit` calls return the engine's bare wrapper
|
||||
// instead of our decorated one. Addon fields on the wrapper
|
||||
// (`plate.nameplate = styledButton`) disappear from the API's
|
||||
// perspective even though they still exist on the orphaned
|
||||
// table.
|
||||
//
|
||||
// - **Refcount-pinning is benign here.** `ScriptRegister`
|
||||
// unconditionally increments `nameplate + 0x04`, which normally
|
||||
// keeps the engine from GC-ing the CObject. Nameplates are
|
||||
// pool-managed and never destroyed during a session, so pinning
|
||||
// doesn't actually leak anything. We call `ScriptRegister` at
|
||||
// most once per nameplate pointer (gated by `refKey <= 0`), so
|
||||
// the refcount tops out at one increment per pool slot.
|
||||
//
|
||||
// Defensive fallback: if the registry slot exists but isn't a table
|
||||
// any more (some other code freed it), we re-register. This shouldn't
|
||||
// happen in practice — engines only unref on frame destruction —
|
||||
// but a stale type check keeps callers from ever seeing a non-table.
|
||||
void PushNamePlateFrame(void *L, void *nameplate) {
|
||||
auto rawgeti = reinterpret_cast<LuaRawGetI_t>(
|
||||
Offsets::FUN_FRAMESCRIPT_PUSH_OBJECT);
|
||||
auto scriptRegister = reinterpret_cast<ScriptRegister_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER));
|
||||
|
||||
int refKey = *reinterpret_cast<const int *>(
|
||||
static_cast<uint8_t *>(nameplate) + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
|
||||
if (refKey <= 0) {
|
||||
scriptRegister(nameplate, nullptr, nullptr);
|
||||
refKey = *reinterpret_cast<const int *>(
|
||||
static_cast<uint8_t *>(nameplate) + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
|
||||
}
|
||||
|
||||
rawgeti(L, Game::Lua::REGISTRY_INDEX, refKey);
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE)
|
||||
return;
|
||||
Game::Lua::SetTop(L, -2);
|
||||
|
||||
// Slot got freed somewhere — re-register to rebuild a fresh
|
||||
// wrapper and slot. Zero the refcount so `ScriptRegister`
|
||||
// re-enters its build branch.
|
||||
*reinterpret_cast<int *>(
|
||||
static_cast<uint8_t *>(nameplate) + Offsets::OFF_COBJECT_LUA_REFCOUNT) = 0;
|
||||
scriptRegister(nameplate, nullptr, nullptr);
|
||||
refKey = *reinterpret_cast<const int *>(
|
||||
static_cast<uint8_t *>(nameplate) + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
|
||||
rawgeti(L, Game::Lua::REGISTRY_INDEX, refKey);
|
||||
}
|
||||
|
||||
// GUID → nameplate Frame pushed on stack. Pushes nil if the GUID
|
||||
// doesn't resolve to a visible CGUnit, or the unit has no allocated
|
||||
// nameplate.
|
||||
static void PushNamePlateForGUID(void *L, uint64_t guid) {
|
||||
if (guid == 0) {
|
||||
Game::Lua::PushNil(L);
|
||||
return;
|
||||
}
|
||||
auto resolve = reinterpret_cast<ResolveByGUID_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID));
|
||||
auto *unit = static_cast<uint8_t *>(
|
||||
resolve(Offsets::OBJ_TYPE_UNIT, "NamePlate",
|
||||
static_cast<uint32_t>(guid),
|
||||
static_cast<uint32_t>(guid >> 32),
|
||||
0x172));
|
||||
if (unit == nullptr) {
|
||||
Game::Lua::PushNil(L);
|
||||
return;
|
||||
}
|
||||
auto *nameplate = *reinterpret_cast<uint8_t *const *>(
|
||||
unit + kOffUnitNamePlate);
|
||||
if (nameplate == nullptr) {
|
||||
Game::Lua::PushNil(L);
|
||||
return;
|
||||
}
|
||||
PushNamePlateFrame(L, nameplate);
|
||||
}
|
||||
|
||||
// `C_NamePlate.GetNamePlateForUnit(unitToken)` — returns the
|
||||
// nameplate Frame for the given unit, or `nil` if the unit has no
|
||||
// nameplate (out of range, hidden, etc.). Resolves the token to a
|
||||
// GUID via the engine's `FUN_TOKEN_TO_GUID` so distant party/raid
|
||||
// members can be queried.
|
||||
static int __fastcall Script_GetNamePlateForUnit(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1)) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
const char *token = Game::Lua::ToString(L, 1);
|
||||
if (token == nullptr) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
auto tokenToGuid = reinterpret_cast<TokenToGUID_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_TOKEN_TO_GUID));
|
||||
PushNamePlateForGUID(L, tokenToGuid(token));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_NamePlate.GetNamePlateForGUID(guidString)` — same as
|
||||
// `GetNamePlateForUnit` but takes a `"0xHHHHHHHHHHHHHHHH"` GUID
|
||||
// string. Useful for handling the `NAME_PLATE_UNIT_ADDED` /
|
||||
// `_REMOVED` events whose payload is a GUID, not a unit token.
|
||||
static int __fastcall Script_GetNamePlateForGUID(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1)) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
const char *guidStr = Game::Lua::ToString(L, 1);
|
||||
uint64_t guid = 0;
|
||||
if (guidStr == nullptr || !Guid::Parse(guidStr, &guid)) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
PushNamePlateForGUID(L, guid);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_NamePlate", "GetNamePlates",
|
||||
&Script_GetNamePlates);
|
||||
Game::Lua::RegisterTableFunction("C_NamePlate", "GetNamePlateGUIDs",
|
||||
&Script_GetNamePlateGUIDs);
|
||||
Game::Lua::RegisterTableFunction("C_NamePlate", "GetNamePlateForUnit",
|
||||
&Script_GetNamePlateForUnit);
|
||||
Game::Lua::RegisterTableFunction("C_NamePlate", "GetNamePlateForGUID",
|
||||
&Script_GetNamePlateForGUID);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace NamePlate::Info
|
||||
@@ -0,0 +1,121 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// Shared nameplate enumeration — walks the local-player-anchored
|
||||
// object hash table for visible `TYPEMASK_UNIT` entries and invokes
|
||||
// `emit(unit, nameplate, instance)` for each unit that currently has
|
||||
// an allocated nameplate (`unit + 0xE60` non-null).
|
||||
//
|
||||
// Header-only template — used by both the Lua-facing accessors in
|
||||
// `nameplate/Info.cpp` and the per-tick differ in `nameplate/Events.cpp`.
|
||||
|
||||
namespace NamePlate::Info {
|
||||
|
||||
// Pushes the nameplate Frame at `nameplate` onto the Lua stack.
|
||||
// Returns the engine's canonical wrapper table — the same one
|
||||
// `rawgeti(REGISTRY, nameplate + 0x08)` resolves to anywhere else
|
||||
// in the engine — so addon-set fields (pfUI: `plate.nameplate`)
|
||||
// survive every roundtrip through our API. Defined in `Info.cpp`.
|
||||
void PushNamePlateFrame(void *L, void *nameplate);
|
||||
|
||||
} // namespace NamePlate::Info
|
||||
|
||||
namespace NamePlate::Events {
|
||||
|
||||
// Returns the GUID currently bound to `nameplateN` (1-based, matching
|
||||
// the modern Lua token form). `0` means no nameplate at that index.
|
||||
// Backed by the per-tick UNIT_ADDED / UNIT_REMOVED diff in
|
||||
// `Events.cpp` — order is creation-order (append on ADDED, erase on
|
||||
// REMOVED), stable within a frame.
|
||||
uint64_t GetGUIDByIndex(int oneBased);
|
||||
|
||||
// Clears the per-tick diff state (seen pointers, last tick's
|
||||
// snapshot, ordered GUID list) so the post-`/reload` tick refires
|
||||
// CREATED + UNIT_ADDED for every currently-visible plate. Called
|
||||
// from `FrameScript_Initialize_h` alongside the wrapper-cache reset
|
||||
// in `NamePlate::Info::PrepareForReload`.
|
||||
void PrepareForReload();
|
||||
|
||||
} // namespace NamePlate::Events
|
||||
|
||||
namespace NamePlate::Walk {
|
||||
|
||||
// `0x00B41414` is a player-related global that overlays multiple
|
||||
// sub-objects at different offsets. We read the object-hash-table
|
||||
// fields at `+0x1C` (bucket array) and `+0x24` (bucket mask) — that
|
||||
// sub-struct is safe to walk for any unit the engine knows about.
|
||||
// **Do not feed this pointer to inventory routines** (`GetItemBySlot`
|
||||
// etc.); the CGPlayer_C inventory manager lives at a different
|
||||
// offset (`+0x1D38`) and is sourced via `ResolveUnitToken("player")`,
|
||||
// not via this global. See CLAUDE.md "Resolving an item-location"
|
||||
// for the inventory-crash story.
|
||||
constexpr uintptr_t kLocalPlayerGlobal = 0x00B41414;
|
||||
constexpr int kOffPlayerBucketArray = 0x1C;
|
||||
constexpr int kOffPlayerBucketMask = 0x24;
|
||||
constexpr int kBucketStride = 12;
|
||||
constexpr int kBucketLinkOffsetField = 0;
|
||||
constexpr int kBucketChainHeadField = 8;
|
||||
constexpr int kOffEntryInstanceBlock = 0x08;
|
||||
constexpr int kOffInstanceTypeMask = 0x08;
|
||||
constexpr uint32_t kTypeMaskUnit = 0x08;
|
||||
constexpr int kOffUnitNamePlate = 0xE60;
|
||||
|
||||
template <typename F>
|
||||
int ForEachNamePlatedUnit(F &&emit) {
|
||||
auto *player = *reinterpret_cast<uint8_t *const *>(kLocalPlayerGlobal);
|
||||
if (player == nullptr)
|
||||
return 0;
|
||||
|
||||
auto *buckets = *reinterpret_cast<uint8_t *const *>(
|
||||
player + kOffPlayerBucketArray);
|
||||
const uint32_t mask = *reinterpret_cast<const uint32_t *>(
|
||||
player + kOffPlayerBucketMask);
|
||||
if (buckets == nullptr || mask == 0xFFFFFFFFu)
|
||||
return 0;
|
||||
|
||||
int count = 0;
|
||||
for (uint32_t b = 0; b <= mask; ++b) {
|
||||
const uint8_t *bucket = buckets + b * kBucketStride;
|
||||
const uint32_t linkOffset = *reinterpret_cast<const uint32_t *>(
|
||||
bucket + kBucketLinkOffsetField);
|
||||
uintptr_t entry = *reinterpret_cast<const uintptr_t *>(
|
||||
bucket + kBucketChainHeadField);
|
||||
|
||||
while (entry != 0 && (entry & 1) == 0) {
|
||||
auto *obj = reinterpret_cast<const uint8_t *>(entry);
|
||||
auto *instance = *reinterpret_cast<const uint8_t *const *>(
|
||||
obj + kOffEntryInstanceBlock);
|
||||
if (instance != nullptr) {
|
||||
const uint32_t typeMask = *reinterpret_cast<const uint32_t *>(
|
||||
instance + kOffInstanceTypeMask);
|
||||
if ((typeMask & kTypeMaskUnit) != 0) {
|
||||
const auto *nameplate = *reinterpret_cast<const uint8_t *const *>(
|
||||
obj + kOffUnitNamePlate);
|
||||
if (nameplate != nullptr) {
|
||||
emit(obj, nameplate, instance);
|
||||
++count;
|
||||
}
|
||||
}
|
||||
}
|
||||
entry = *reinterpret_cast<const uintptr_t *>(
|
||||
obj + linkOffset + 4);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
} // namespace NamePlate::Walk
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,127 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `FocusUnit(unit)` / `ClearFocus()` / `focus` unit token. Polyfills
|
||||
// the post-vanilla focus-target system on top of the engine's
|
||||
// existing token resolver via the hook in
|
||||
// `unit/TokenExtensions.cpp`.
|
||||
//
|
||||
// Storage: a single 64-bit GUID held in-process. The 3.3.5 engine
|
||||
// keeps the same shape (two globals at `DAT_00BD07D0`/`D4`); we
|
||||
// don't share those because vanilla never reads them. The resolver
|
||||
// hook reads our `Unit::Focus::Get()` directly.
|
||||
//
|
||||
// Setter mirrors 3.3.5's `FUN_0051FF20` minus the post-vanilla
|
||||
// `UnitFlag bit 0x2000` ("focus glow" rendering hint, introduced in
|
||||
// TBC for the default focus frame). Vanilla addons (pfUI) render
|
||||
// their own focus indicator and don't need the hint.
|
||||
//
|
||||
// Auto-clear on despawn: modern WoW fires `PLAYER_FOCUS_CHANGED`
|
||||
// when the focused unit leaves the client's object table (out of
|
||||
// rendering range, despawn) and does NOT auto-refocus when they
|
||||
// come back. We mirror that by probing `ObjectByGUID(g_focusGUID)`
|
||||
// every world tick — when it returns null, we `Set(0)` which fires
|
||||
// the event. Cost is one hash-table lookup per tick while focus is
|
||||
// set; zero when no focus is active.
|
||||
|
||||
#include "Focus.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "event/Custom.h"
|
||||
#include "tick/WorldTick.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Unit::Focus {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kEventName = "PLAYER_FOCUS_CHANGED";
|
||||
const Event::Custom::AutoReserve _reserve{kEventName};
|
||||
|
||||
uint64_t g_focusGUID = 0;
|
||||
|
||||
using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
|
||||
using ResolveByGUID_t = void *(__fastcall *)(int type, const char *debugName,
|
||||
uint32_t guidLo, uint32_t guidHi,
|
||||
int priority);
|
||||
|
||||
uint64_t ResolveTokenGUID(const char *token) {
|
||||
if (token == nullptr || *token == '\0')
|
||||
return 0;
|
||||
auto fn = reinterpret_cast<TokenToGUID_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_TOKEN_TO_GUID));
|
||||
return fn(token);
|
||||
}
|
||||
|
||||
// Per-tick despawn watcher. Probes the engine's object table for
|
||||
// `g_focusGUID`; when it disappears (out of range, fully despawned),
|
||||
// clear focus and fire PLAYER_FOCUS_CHANGED — matches modern's
|
||||
// "leaves render distance → focus drops" behavior. Won't refocus
|
||||
// when the unit comes back, also matching modern.
|
||||
void OnWorldTick() {
|
||||
if (g_focusGUID == 0)
|
||||
return;
|
||||
auto resolve = reinterpret_cast<ResolveByGUID_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID));
|
||||
if (resolve(Offsets::OBJ_TYPE_UNIT, "Focus",
|
||||
static_cast<uint32_t>(g_focusGUID),
|
||||
static_cast<uint32_t>(g_focusGUID >> 32),
|
||||
0x172) == nullptr)
|
||||
Set(0);
|
||||
}
|
||||
|
||||
// `FocusUnit(unit)` — sets focus to whatever GUID `unit` currently
|
||||
// resolves to. Modern signature: `FocusUnit(unit)` with the token
|
||||
// argument. Modern also allows `FocusUnit()` (no arg) to mean
|
||||
// `"target"`, matching the `/focus` slash command's default.
|
||||
int __fastcall Script_FocusUnit(void *L) {
|
||||
if (Game::Lua::IsString(L, 1)) {
|
||||
const char *token = Game::Lua::ToString(L, 1);
|
||||
Set(ResolveTokenGUID(token));
|
||||
} else {
|
||||
Set(ResolveTokenGUID("target"));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// `ClearFocus()` — drops the focus, fires PLAYER_FOCUS_CHANGED.
|
||||
int __fastcall Script_ClearFocus(void *L) {
|
||||
(void)L;
|
||||
Set(0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("FocusUnit", &Script_FocusUnit);
|
||||
Game::Lua::RegisterGlobalFunction("ClearFocus", &Script_ClearFocus);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
|
||||
|
||||
} // namespace
|
||||
|
||||
uint64_t Get() { return g_focusGUID; }
|
||||
|
||||
void Set(uint64_t guid) {
|
||||
if (guid == g_focusGUID)
|
||||
return; // no-op: same target → no event fire (matches 3.3.5)
|
||||
g_focusGUID = guid;
|
||||
const int slot = Event::Custom::Lookup(kEventName);
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "");
|
||||
}
|
||||
|
||||
} // namespace Unit::Focus
|
||||
@@ -0,0 +1,31 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Unit::Focus {
|
||||
|
||||
// Current focus GUID, or `0` when nothing is focused. Read by the
|
||||
// `focus` / `focustarget*` branch of the token resolver in
|
||||
// `unit/TokenExtensions.cpp`.
|
||||
uint64_t Get();
|
||||
|
||||
// Sets focus to `guid` and fires `PLAYER_FOCUS_CHANGED` (no args).
|
||||
// Identity-checks first — assigning the same GUID is a no-op,
|
||||
// matching 3.3.5's `FUN_0051FF20` behavior so the event only fires
|
||||
// on a real transition. Pass `0` to clear focus.
|
||||
void Set(uint64_t guid);
|
||||
|
||||
} // namespace Unit::Focus
|
||||
+27
-5
@@ -14,6 +14,7 @@
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "guid/Guid.h"
|
||||
#include "nameplate/Walk.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
@@ -105,13 +106,17 @@ static int __fastcall Script_UnitGUID(void *L) {
|
||||
// nil if none of the known tokens point at it.
|
||||
//
|
||||
// The search walks the modern retail token order with post-vanilla
|
||||
// tokens dropped (no `vehicle`, `nameplateN`, `arenaN`, `arenapetN`,
|
||||
// `bossN`, `focus`, `softenemy`, `softfriend`, `softinteract` —
|
||||
// those all post-date 1.12 and `FUN_TOKEN_TO_GUID` would raise
|
||||
// "Unknown unit name" for them). Order:
|
||||
// tokens dropped (no `vehicle`, `arenaN`, `arenapetN`, `bossN`,
|
||||
// `softenemy`, `softfriend`, `softinteract` — those all post-date
|
||||
// 1.12 and `FUN_TOKEN_TO_GUID` would raise "Unknown unit name" for
|
||||
// them). `nameplateN` and `focus` are included — we hook the token
|
||||
// resolver so the engine recognizes them (see
|
||||
// `unit/TokenExtensions.cpp` and `unit/Focus.cpp`). Order matches
|
||||
// retail with the dropped tokens skipped in place:
|
||||
//
|
||||
// player → pet → party1..4 → partypet1..4 → raid1..40
|
||||
// → raidpet1..40 → target → npc → mouseover
|
||||
// → raidpet1..40 → nameplate1..N → target → focus
|
||||
// → npc → mouseover
|
||||
//
|
||||
// The result is inherently unstable — multiple tokens can map to the
|
||||
// same GUID at once (your target IS your mouseover IS your party1), and
|
||||
@@ -183,7 +188,24 @@ static int __fastcall Script_UnitTokenFromGUID(void *L) {
|
||||
if (check(buf)) return 1;
|
||||
}
|
||||
|
||||
// Visible nameplates. The ordered list (see
|
||||
// `NamePlate::Events::g_orderedGUIDs`) is dense — `GetGUIDByIndex`
|
||||
// returns 0 once we step past the populated count, terminating
|
||||
// the loop. Skips the token-resolver hook + SStrCmpI cost per
|
||||
// index by comparing GUIDs directly.
|
||||
for (int i = 1; ; ++i) {
|
||||
const uint64_t plateGuid = NamePlate::Events::GetGUIDByIndex(i);
|
||||
if (plateGuid == 0)
|
||||
break;
|
||||
if (plateGuid == target) {
|
||||
std::snprintf(buf, sizeof buf, "nameplate%d", i);
|
||||
Game::Lua::PushString(L, buf);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (check("target")) return 1;
|
||||
if (check("focus")) return 1;
|
||||
if (check("npc")) return 1;
|
||||
if (check("mouseover")) return 1;
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Extend `FUN_TOKEN_TO_GUID` (`0x00515970`) with post-vanilla unit
|
||||
// tokens. Hooking the central token→GUID resolver means every
|
||||
// `Script_Unit*` (UnitName, UnitGUID, UnitHealth, UnitClass,
|
||||
// UnitExists, …) gains support for the new token families for free,
|
||||
// since they all funnel here.
|
||||
//
|
||||
// Currently extended families:
|
||||
//
|
||||
// - `nameplate1`..`nameplateN` — index into the ordered list of
|
||||
// visible nameplates maintained in `nameplate/Events.cpp`. See
|
||||
// `Unit tokens (nameplateN)` in docs/API.md.
|
||||
//
|
||||
// - `focus` — single-slot sticky target backed by `Unit::Focus`.
|
||||
// See `unit/Focus.cpp` for the storage + `PLAYER_FOCUS_CHANGED`
|
||||
// event firing.
|
||||
//
|
||||
// Both families compose with the engine's own `target`-suffix walker
|
||||
// (`focustarget`, `nameplate1targettarget`) via the shared
|
||||
// `WalkSuffix` helper, which mirrors the engine's
|
||||
// `LAB_005159d3..0x00515A2C` walker instruction-for-instruction
|
||||
// (SStrCmpI "target" + ObjectByGUID + read UNIT_FIELD_TARGET).
|
||||
//
|
||||
// Resolution order in the hook: focus check first (literal-equal,
|
||||
// short token), then nameplate prefix (longer, requires digit).
|
||||
// Both branches short-circuit; on no-match the original engine
|
||||
// resolver runs unchanged.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "nameplate/Walk.h"
|
||||
#include "unit/Focus.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Unit::TokenExtensions {
|
||||
|
||||
namespace {
|
||||
|
||||
using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
|
||||
// `__stdcall`, not `__cdecl` — `FUN_00064A4C0` ends with `ret 0xc`,
|
||||
// callee cleans 3-arg stack frame. Calling it through a cdecl typedef
|
||||
// makes MSVC emit `add esp, 12` after the call on top of the callee's
|
||||
// own cleanup, drifting ESP +12 per call and corrupting the caller's
|
||||
// frame.
|
||||
using SStrCmpI_t = int(__stdcall *)(const char *a, const char *b, int n);
|
||||
using ResolveByGUID_t = void *(__fastcall *)(int type, const char *debugName,
|
||||
uint32_t guidLo, uint32_t guidHi,
|
||||
int priority);
|
||||
|
||||
constexpr const char kNamePlatePrefix[] = "nameplate";
|
||||
constexpr int kNamePlatePrefixLen = static_cast<int>(sizeof(kNamePlatePrefix) - 1);
|
||||
constexpr const char kFocusPrefix[] = "focus";
|
||||
constexpr int kFocusPrefixLen = static_cast<int>(sizeof(kFocusPrefix) - 1);
|
||||
constexpr const char kSuffixTarget[] = "target";
|
||||
constexpr int kSuffixTargetLen = static_cast<int>(sizeof(kSuffixTarget) - 1);
|
||||
|
||||
// Engine's own priority value for the suffix walker's ObjectByGUID
|
||||
// calls (verified at `0x005159FE: PUSH 0x6E`). Different from the
|
||||
// `0x172` we use elsewhere — matching the engine here so the
|
||||
// resolver's lookup path is identical to the native `targettarget`
|
||||
// walker.
|
||||
constexpr int kResolvePriority = 0x6e;
|
||||
|
||||
TokenToGUID_t Original_o = nullptr;
|
||||
|
||||
uint64_t WalkSuffix(uint64_t guid, const char *suffix) {
|
||||
auto resolve = reinterpret_cast<ResolveByGUID_t>(
|
||||
Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
|
||||
auto sstrcmpi = reinterpret_cast<SStrCmpI_t>(Offsets::FUN_SSTR_CMP_I);
|
||||
while (guid != 0 && *suffix != '\0') {
|
||||
if (sstrcmpi(suffix, kSuffixTarget, kSuffixTargetLen) != 0)
|
||||
return 0; // unknown suffix component — modern returns nil
|
||||
suffix += kSuffixTargetLen;
|
||||
auto *obj = static_cast<uint8_t *>(
|
||||
resolve(Offsets::OBJ_TYPE_UNIT, "ClassicAPI",
|
||||
static_cast<uint32_t>(guid),
|
||||
static_cast<uint32_t>(guid >> 32),
|
||||
kResolvePriority));
|
||||
if (obj == nullptr)
|
||||
return 0;
|
||||
// `m_objectFields` is a *pointer* stored at `obj + 0x110`,
|
||||
// not an inline array. Engine's walker does
|
||||
// `mov eax, [obj+0x110]; mov edi, [eax+0x28]` — two
|
||||
// dereferences. Earlier version of this code missed the
|
||||
// first one and read instruction bytes interpreted as GUIDs.
|
||||
auto *fields = *reinterpret_cast<const uint8_t *const *>(
|
||||
obj + Offsets::OFF_CGUNIT_OBJECT_FIELDS);
|
||||
if (fields == nullptr)
|
||||
return 0;
|
||||
guid = *reinterpret_cast<const uint64_t *>(
|
||||
fields + Offsets::OFF_UNIT_FIELD_TARGET);
|
||||
}
|
||||
return guid;
|
||||
}
|
||||
|
||||
uint64_t __fastcall Hook_h(const char *token) {
|
||||
if (token == nullptr || *token == '\0')
|
||||
return Original_o(token);
|
||||
|
||||
auto sstrcmpi = reinterpret_cast<SStrCmpI_t>(Offsets::FUN_SSTR_CMP_I);
|
||||
|
||||
// `focus` / `focustarget*`. The 5-char prefix is unique among
|
||||
// unit tokens (no `focuscast` / `focused*` etc. live in vanilla
|
||||
// or modern resolvers), so any match continues as our token.
|
||||
if (sstrcmpi(token, kFocusPrefix, kFocusPrefixLen) == 0) {
|
||||
const uint64_t guid = Unit::Focus::Get();
|
||||
if (guid == 0)
|
||||
return 0;
|
||||
return WalkSuffix(guid, token + kFocusPrefixLen);
|
||||
}
|
||||
|
||||
// `nameplateN` / `nameplate1target*`. Requires a digit after the
|
||||
// prefix; anything else (`nameplate`, `nameplateX`) falls through
|
||||
// to the engine for its standard "Unknown unit name" error.
|
||||
if (sstrcmpi(token, kNamePlatePrefix, kNamePlatePrefixLen) == 0) {
|
||||
const char *p = token + kNamePlatePrefixLen;
|
||||
if (*p >= '0' && *p <= '9') {
|
||||
int index = 0;
|
||||
while (*p >= '0' && *p <= '9') {
|
||||
index = index * 10 + (*p - '0');
|
||||
++p;
|
||||
}
|
||||
const uint64_t guid = NamePlate::Events::GetGUIDByIndex(index);
|
||||
if (guid == 0)
|
||||
return 0;
|
||||
return WalkSuffix(guid, p);
|
||||
}
|
||||
}
|
||||
|
||||
return Original_o(token);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static const Game::HookAutoRegister _hook{
|
||||
Offsets::FUN_TOKEN_TO_GUID,
|
||||
reinterpret_cast<void *>(&Hook_h),
|
||||
reinterpret_cast<void **>(&Original_o)};
|
||||
|
||||
} // namespace Unit::TokenExtensions
|
||||
Reference in New Issue
Block a user