mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-11 20:37:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93b4dc5db8 | ||
|
|
d786c607b8 | ||
|
|
598c66894e | ||
|
|
75b4ea3863 | ||
|
|
5f66e381d2 | ||
|
|
4dd892297d | ||
|
|
51cb7a5674 | ||
|
|
cc0bf769c4 | ||
|
|
ad13440743 | ||
|
|
999a5846c4 | ||
|
|
9b5d12c8a1 | ||
|
|
d1bab7a3a4 | ||
|
|
e657bac266 | ||
|
|
0248661bb7 | ||
|
|
56fef8ec08 | ||
|
|
09ac68e674 | ||
|
|
19ad6e6514 | ||
|
|
41bb687608 | ||
|
|
d9cc3f9d7e | ||
|
|
6b19c8bbb5 | ||
|
|
09e57c3d97 | ||
|
|
2f3c67be1a | ||
|
|
f94af6cb73 | ||
|
|
1d370ada65 | ||
|
|
35c78ea6be | ||
|
|
ad83eb9575 | ||
|
|
8995e82627 | ||
|
|
94b521881b | ||
|
|
634a440def | ||
|
|
4dce58a457 | ||
|
|
99e9621590 | ||
|
|
e8fc583f19 | ||
|
|
03f63d391a | ||
|
|
6df403444c | ||
|
|
cb7953b503 | ||
|
|
774df8d0d4 | ||
|
|
36e1aa87e4 | ||
|
|
40532aca8e | ||
|
|
8592254b2d | ||
|
|
289f82a7e7 | ||
|
|
5e77e5e2a1 | ||
|
|
49d09cdf1e | ||
|
|
64d373a34e | ||
|
|
4b1a3d40f5 | ||
|
|
b1fa0172fd | ||
|
|
0a901d545d | ||
|
|
ffd208a578 |
@@ -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,10 +1,11 @@
|
||||
## Interface: 11200
|
||||
## Title: !!!ClassicAPI
|
||||
## Notes: Library to emulate aspects of the modern WoW API on 1.12.1 / Lua 5.0.
|
||||
## Version: 1.0
|
||||
## Version: DEV
|
||||
## Author: Brues
|
||||
|
||||
GlobalStrings.lua
|
||||
locales\locales.xml
|
||||
|
||||
Compat.lua
|
||||
Util\Print.lua
|
||||
Util\Constants.lua
|
||||
@@ -28,3 +29,5 @@ Util\EquipmentManager.lua
|
||||
Util\PlayerUtil.lua
|
||||
Util\SlashCommandsRegistry.lua
|
||||
Util\LinkUtil.lua
|
||||
Util\IconDataProvider.lua
|
||||
Util\GameTooltip.lua
|
||||
|
||||
@@ -83,12 +83,11 @@ function EventUtil.TriggerOnVariablesLoaded()
|
||||
end
|
||||
|
||||
function EventUtil.ContinueOnAddOnLoaded(addOnName, callback)
|
||||
-- 1.12's IsAddOnLoaded returns a single bool. 3.3.5 split that into
|
||||
-- (isLoadedOrLoading, isLoaded); treat truthy as "ready to use".
|
||||
if IsAddOnLoaded(addOnName) then
|
||||
callback()
|
||||
return
|
||||
end
|
||||
local isLoadedOrLoading, isLoaded = C_AddOns.IsAddOnLoaded(addOnName)
|
||||
if isLoaded then
|
||||
callback();
|
||||
return;
|
||||
end
|
||||
|
||||
EventUtil.RegisterOnceFrameEventAndCallback("ADDON_LOADED", callback, addOnName)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
function GameTooltip_Hide()
|
||||
GameTooltip:Hide()
|
||||
end
|
||||
@@ -0,0 +1,242 @@
|
||||
-- Backport of modern Classic Era's IconDataProvider.lua (lives in
|
||||
-- Blizzard_FrameXMLBase/Classic/) to vanilla 1.12 / Lua 5.0.
|
||||
--
|
||||
-- Wraps the four engine icon-enumeration mutators
|
||||
-- (`GetLooseMacroIcons` / `GetLooseMacroItemIcons` / `GetMacroIcons`
|
||||
-- / `GetMacroItemIcons` — all surfaced by ClassicAPI.dll) into a
|
||||
-- stateful `Spell`/`Item` bucket pair, with an optional
|
||||
-- "extras" array of icons prepended ahead of the base DB. The two
|
||||
-- "extra" types are:
|
||||
--
|
||||
-- * `Spellbook` — icons of every spell currently in the player's
|
||||
-- spellbook UI. Used by the modern Macro UI's icon picker so
|
||||
-- "icons relevant to my class" surface first. In vanilla this
|
||||
-- misses spells that aren't on the spellbook UI (racials kept
|
||||
-- off the bar, profession recipes the engine doesn't list as
|
||||
-- spells, etc.) but covers the common case.
|
||||
-- * `Equipment` — icons of currently-equipped items (slots 1..19
|
||||
-- via `GetInventoryItemTexture("player", i)`). Used by the
|
||||
-- modern Equipment-Manager popup so the picker leads with what
|
||||
-- the player is wearing.
|
||||
--
|
||||
-- Implementation differences from the modern source:
|
||||
--
|
||||
-- * `EnumUtil.MakeEnum` is inlined as a plain table.
|
||||
-- * Modern's `C_SpecializationInfo.GetTalentInfo(query)` (Cata+
|
||||
-- spec-aware talent API) and `GetPvpTalentInfoByID` (MoP+ PvP
|
||||
-- talents) are replaced with vanilla's `GetTalentInfo(tab, idx)`
|
||||
-- across the 3 talent trees. Vanilla has no specs, no dual-spec
|
||||
-- groups, and no PvP talent system, so those layers are dropped.
|
||||
-- * `GetSpellBookItemTexture(slot, "player")` is replaced with
|
||||
-- vanilla's `GetSpellTexture(slot, "spell")`. Flyouts don't
|
||||
-- exist in vanilla so that branch is dropped entirely.
|
||||
-- * `extraIconsMap` is a Lua-set keyed by texture path or
|
||||
-- fileID — `GetKeysArray` flattens it to a numbered array.
|
||||
-- Same dedup semantics as modern: each texture appears once
|
||||
-- within the extras section, but no cross-dedup with the
|
||||
-- base DB walk (the player's equipped weapon icon may still
|
||||
-- appear twice in the final picker, once in extras and once
|
||||
-- in `GetMacroItemIcons`).
|
||||
|
||||
local QuestionMarkIconPath = "INTERFACE\\ICONS\\INV_MISC_QUESTIONMARK"
|
||||
|
||||
local NumActiveIconDataProviders = 0
|
||||
local BaseIconFilenames = nil
|
||||
|
||||
-- Builds the table BaseIconFilenames with engine icon DB.
|
||||
-- Lazy: shared across all active IconDataProvider instances, so
|
||||
-- the four engine calls (each of which scans thousands of files)
|
||||
-- only happens once per picker session.
|
||||
local function IconDataProvider_RefreshIconTextures()
|
||||
if BaseIconFilenames ~= nil then
|
||||
return
|
||||
end
|
||||
|
||||
BaseIconFilenames = {}
|
||||
BaseIconFilenames[IconDataProviderIconType.Spell] = {}
|
||||
BaseIconFilenames[IconDataProviderIconType.Item] = {}
|
||||
GetLooseMacroIcons(BaseIconFilenames[IconDataProviderIconType.Spell])
|
||||
GetLooseMacroItemIcons(BaseIconFilenames[IconDataProviderIconType.Item])
|
||||
GetMacroIcons(BaseIconFilenames[IconDataProviderIconType.Spell])
|
||||
GetMacroItemIcons(BaseIconFilenames[IconDataProviderIconType.Item])
|
||||
end
|
||||
|
||||
local function IconDataProvider_ClearIconTextures()
|
||||
BaseIconFilenames = nil
|
||||
collectgarbage()
|
||||
end
|
||||
|
||||
local function IconDataProvider_GetBaseIconTexture(iconType, index)
|
||||
local texture = BaseIconFilenames[iconType][index]
|
||||
local fileDataID = tonumber(texture)
|
||||
if fileDataID ~= nil then
|
||||
return fileDataID
|
||||
else
|
||||
return "INTERFACE\\ICONS\\" .. texture
|
||||
end
|
||||
end
|
||||
|
||||
function IconDataProvider_GetAllIconTypes()
|
||||
local iconTypeValues = GetValuesArray(IconDataProviderIconType)
|
||||
table.sort(iconTypeValues)
|
||||
return iconTypeValues
|
||||
end
|
||||
|
||||
IconDataProviderMixin = {}
|
||||
|
||||
-- `EnumUtil.MakeEnum("Spell", "Item")` produces `{ Spell = 1, Item = 2 }`.
|
||||
IconDataProviderIconType = { Spell = 1, Item = 2 }
|
||||
|
||||
IconDataProviderExtraType = {
|
||||
Spellbook = 1,
|
||||
Equipment = 2,
|
||||
None = 3,
|
||||
}
|
||||
|
||||
local function FillOutExtraIconsMapWithSpells(extraIconsMap)
|
||||
for tab = 1, GetNumSpellTabs() do
|
||||
local _, _, offset, numSpells = GetSpellTabInfo(tab)
|
||||
offset = offset + 1
|
||||
local tabEnd = offset + numSpells
|
||||
for slot = offset, tabEnd - 1 do
|
||||
local tex = GetSpellTexture(slot, "spell")
|
||||
if tex ~= nil and tex ~= "" then
|
||||
extraIconsMap[tex] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Walks all three vanilla talent trees, gathering every talent's
|
||||
-- icon — including talents the player hasn't put points into. The
|
||||
-- whole tree is "relevant to my class" for picker purposes, not
|
||||
-- just maxed talents. `GetTalentInfo(tab, idx)` returns
|
||||
-- `(name, icon, tier, column, rank, maxRank, ...)` — we read the
|
||||
-- icon (second return) directly.
|
||||
local function FillOutExtraIconsMapWithTalents(extraIconsMap)
|
||||
for tab = 1, GetNumTalentTabs() do
|
||||
local numTalents = GetNumTalents(tab)
|
||||
for talent = 1, numTalents do
|
||||
local _, icon = GetTalentInfo(tab, talent)
|
||||
if icon ~= nil and icon ~= "" then
|
||||
extraIconsMap[icon] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function FillOutExtraIconsMapWithEquipment(extraIconsMap)
|
||||
for slot = INVSLOT_FIRST_EQUIPPED, INVSLOT_LAST_EQUIPPED do
|
||||
for _, itemLink in pairs(GetInventoryItemsForSlot(slot, {})) do
|
||||
local _, _, _, _, itemTexture = C_Item.GetItemInfoInstant(itemLink)
|
||||
if itemTexture then
|
||||
extraIconsMap[itemTexture] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function IconDataProviderMixin:Init(extraType, extraIconsOnly, requestedIconTypes)
|
||||
self.extraIcons = {}
|
||||
self.extraIconType = extraType
|
||||
self.requestedIconTypes = requestedIconTypes or IconDataProvider_GetAllIconTypes()
|
||||
|
||||
if extraType == IconDataProviderExtraType.Spellbook then
|
||||
local extraIconsMap = {}
|
||||
FillOutExtraIconsMapWithSpells(extraIconsMap)
|
||||
FillOutExtraIconsMapWithTalents(extraIconsMap)
|
||||
self.extraIcons = GetKeysArray(extraIconsMap)
|
||||
elseif extraType == IconDataProviderExtraType.Equipment then
|
||||
local extraIconsMap = {}
|
||||
FillOutExtraIconsMapWithEquipment(extraIconsMap)
|
||||
self.extraIcons = GetKeysArray(extraIconsMap)
|
||||
end
|
||||
|
||||
if not extraIconsOnly then
|
||||
NumActiveIconDataProviders = NumActiveIconDataProviders + 1
|
||||
IconDataProvider_RefreshIconTextures()
|
||||
end
|
||||
end
|
||||
|
||||
function IconDataProviderMixin:SetIconTypes(iconTypes)
|
||||
self.requestedIconTypes = iconTypes or IconDataProvider_GetAllIconTypes()
|
||||
end
|
||||
|
||||
function IconDataProviderMixin:GetNumIcons()
|
||||
-- 1 to account for the leading `?` icon.
|
||||
local numIcons = 1
|
||||
if self:ShouldShowExtraIcons() then
|
||||
numIcons = numIcons + table.getn(self.extraIcons)
|
||||
end
|
||||
if BaseIconFilenames then
|
||||
for _, v in pairs(self.requestedIconTypes) do
|
||||
numIcons = numIcons + table.getn(BaseIconFilenames[v])
|
||||
end
|
||||
end
|
||||
return numIcons
|
||||
end
|
||||
|
||||
function IconDataProviderMixin:GetIconByIndex(index)
|
||||
if index == 1 then
|
||||
return QuestionMarkIconPath
|
||||
end
|
||||
|
||||
index = index - 1
|
||||
|
||||
local numExtraIcons = (self:ShouldShowExtraIcons() and
|
||||
table.getn(self.extraIcons)) or 0
|
||||
if index <= numExtraIcons then
|
||||
return self.extraIcons[index]
|
||||
end
|
||||
|
||||
local baseIndex = index - numExtraIcons
|
||||
local lookupIconType = nil
|
||||
-- Each icon type's table is indexed from 1, so loop through the
|
||||
-- tables to find which icon type we index to.
|
||||
for _, v in pairs(self.requestedIconTypes) do
|
||||
local numIconsForType = table.getn(BaseIconFilenames[v])
|
||||
if baseIndex <= numIconsForType then
|
||||
lookupIconType = v
|
||||
break
|
||||
end
|
||||
baseIndex = baseIndex - numIconsForType
|
||||
end
|
||||
|
||||
if lookupIconType then
|
||||
return IconDataProvider_GetBaseIconTexture(lookupIconType, baseIndex)
|
||||
else
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
function IconDataProviderMixin:GetIconForSaving(index)
|
||||
local icon = self:GetIconByIndex(index)
|
||||
if type(icon) == "string" then
|
||||
icon = string.gsub(icon, "INTERFACE\\ICONS\\", "")
|
||||
end
|
||||
return icon
|
||||
end
|
||||
|
||||
function IconDataProviderMixin:GetIndexOfIcon(icon)
|
||||
local numIcons = self:GetNumIcons()
|
||||
for i = 1, numIcons do
|
||||
if self:GetIconByIndex(i) == icon then
|
||||
return i
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function IconDataProviderMixin:ShouldShowExtraIcons()
|
||||
return (self.extraIconType == IconDataProviderExtraType.Spellbook and
|
||||
tContains(self.requestedIconTypes, IconDataProviderIconType.Spell))
|
||||
or (self.extraIconType == IconDataProviderExtraType.Equipment and
|
||||
tContains(self.requestedIconTypes, IconDataProviderIconType.Item))
|
||||
end
|
||||
|
||||
function IconDataProviderMixin:Release()
|
||||
NumActiveIconDataProviders = NumActiveIconDataProviders - 1
|
||||
if NumActiveIconDataProviders <= 0 then
|
||||
IconDataProvider_ClearIconTextures()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
if GetLocale() ~= "deDE" then return end
|
||||
|
||||
BINDING_NAME_FOCUSTARGET = "Fokus setzen";
|
||||
BINDING_NAME_TARGETFOCUS = "Fokus anvisieren";
|
||||
FOCUSTARGET = "Ziel als Fokus festlegen";
|
||||
ITEMS_VARIABLE_QUANTITY = "%d |4IGegenstand:Gegenstände;";
|
||||
ITEMS_EQUIPPED = "%d |4Gegenstand:Gegenstände; ausgerüstet";
|
||||
ITEMS_IN_INVENTORY = "%d |4Gegenstand:Gegenstände; im Inventar";
|
||||
ITEM_SLOTS_IGNORED = "%d |4Platz:Plätze; ignoriert";
|
||||
ITEM_MISSING = "%s fehlt";
|
||||
ITEMS_NOT_IN_INVENTORY = "%d |4Gegenstand fehlt:Gegenstände fehlen;";
|
||||
TARGETFOCUS = "Fokussierte Einheit anvisieren";
|
||||
@@ -1,6 +1,12 @@
|
||||
if GetLocale() ~= "enUS" then return end
|
||||
|
||||
BINDING_NAME_FOCUSTARGET = "Focus Target";
|
||||
BINDING_NAME_TARGETFOCUS = "Target Focus";
|
||||
FOCUSTARGET = "Focus Target";
|
||||
ITEMS_VARIABLE_QUANTITY = "%d |4Item:Items;";
|
||||
ITEMS_EQUIPPED = "%d |4item:items; equipped";
|
||||
ITEMS_IN_INVENTORY = "%d |4item:items; in inventory";
|
||||
ITEM_SLOTS_IGNORED = "%d |4slot:slots; ignored";
|
||||
ITEM_MISSING = "%s missing";
|
||||
ITEMS_NOT_IN_INVENTORY = "%d |4item:items; missing";
|
||||
TARGETFOCUS = "Target Focus";
|
||||
@@ -0,0 +1,12 @@
|
||||
if GetLocale() ~= "esES" then return end
|
||||
|
||||
BINDING_NAME_FOCUSTARGET = "Establecer objetivo";
|
||||
BINDING_NAME_TARGETFOCUS = "Seleccionar enfocado";
|
||||
FOCUSTARGET = "Establecer objetivo";
|
||||
ITEMS_VARIABLE_QUANTITY = "%d |4objeto:objetos;";
|
||||
ITEMS_EQUIPPED = "Equipado: %d |4objeto:objetos;";
|
||||
ITEMS_IN_INVENTORY = "En inventario: %d |4objeto:objetos;";
|
||||
ITEM_SLOTS_IGNORED = "Ignorado: %d |4casilla:casillas;";
|
||||
ITEM_MISSING = "Falta: %s";
|
||||
ITEMS_NOT_IN_INVENTORY = "Faltan: %d |4objeto:objetos;";
|
||||
TARGETFOCUS = "Fijar unidad objetivo";
|
||||
@@ -0,0 +1,12 @@
|
||||
if GetLocale() ~= "frFR" then return end
|
||||
|
||||
BINDING_NAME_FOCUSTARGET = "Définir cible";
|
||||
BINDING_NAME_TARGETFOCUS = "Reprendre cible";
|
||||
FOCUSTARGET = "Définir la cible de focalisation";
|
||||
ITEMS_VARIABLE_QUANTITY = "%d |4objet:objets;";
|
||||
ITEMS_EQUIPPED = "%d |4objet:objets; équipés";
|
||||
ITEMS_IN_INVENTORY = "%d |4objet:objets; dans l'inventaire";
|
||||
ITEM_SLOTS_IGNORED = "%d |4emplacement ignoré:emplacements ignorés;";
|
||||
ITEM_MISSING = "Objet manquant : %s";
|
||||
ITEMS_NOT_IN_INVENTORY = "%d |4objet:objets; manquants";
|
||||
TARGETFOCUS = "Cibler l'unité focalisée";
|
||||
@@ -0,0 +1,9 @@
|
||||
<Ui xmlns="http://www.blizzard.com/wow/ui/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.blizzard.com/wow/ui/..\FrameXML\UI.xsd">
|
||||
<Script file="deDE.lua"/>
|
||||
<Script file="enUS.lua"/>
|
||||
<Script file="esES.lua"/>
|
||||
<Script file="frFR.lua"/>
|
||||
<Script file="ruRU.lua"/>
|
||||
<Script file="zhCN.lua"/>
|
||||
<Script file="zhTW.lua"/>
|
||||
</Ui>
|
||||
@@ -0,0 +1,12 @@
|
||||
if GetLocale() ~= "ruRU" then return end
|
||||
|
||||
BINDING_NAME_FOCUSTARGET = "Запомненная цель";
|
||||
BINDING_NAME_TARGETFOCUS = "Восстановить цель";
|
||||
FOCUSTARGET = "Запомнить цель";
|
||||
ITEMS_VARIABLE_QUANTITY = "%d |4предмет:предмета:предметов;";
|
||||
ITEMS_EQUIPPED = "%d |4предмет экипирован:предмета экипировано:предметов экипировано;";
|
||||
ITEMS_IN_INVENTORY = "%d |4предмет:предмета:предметов; в сумках";
|
||||
ITEM_SLOTS_IGNORED = "%d |4ячейка пропущена:ячейки пропущены:ячейки пропущены;";
|
||||
ITEM_MISSING = "Предмет для ячейки %s отсутствует";
|
||||
ITEMS_NOT_IN_INVENTORY = "%d |4предмет отсутствует:предмета отсутствует:предметов отсутствуют;";
|
||||
TARGETFOCUS = "Восстановить цель";
|
||||
@@ -0,0 +1,12 @@
|
||||
if GetLocale() ~= "zhCN" then return end
|
||||
|
||||
BINDING_NAME_FOCUSTARGET = "焦点目标";
|
||||
BINDING_NAME_TARGETFOCUS = "目标焦点";
|
||||
FOCUSTARGET = "设定焦点目标";
|
||||
ITEMS_VARIABLE_QUANTITY = "%d件物品";
|
||||
ITEMS_EQUIPPED = "已装备%d件物品";
|
||||
ITEMS_IN_INVENTORY = "背包中有%d件物品";
|
||||
ITEM_SLOTS_IGNORED = "忽略%d个栏位";
|
||||
ITEM_MISSING = "%s缺失";
|
||||
ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
|
||||
TARGETFOCUS = "选中焦点目标";
|
||||
@@ -0,0 +1,12 @@
|
||||
if GetLocale() ~= "zhTW" then return end
|
||||
|
||||
BINDING_NAME_FOCUSTARGET = "鎖定目標";
|
||||
BINDING_NAME_TARGETFOCUS = "選定鎖定目標";
|
||||
FOCUSTARGET = "設定鎖定目標";
|
||||
ITEMS_VARIABLE_QUANTITY = "%d件物品";
|
||||
ITEMS_EQUIPPED = "裝備%d件物品";
|
||||
ITEMS_IN_INVENTORY = "%d件物品在物品欄中";
|
||||
ITEM_SLOTS_IGNORED = "忽略%d個欄位";
|
||||
ITEM_MISSING = "缺少%s物品";
|
||||
ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
|
||||
TARGETFOCUS = "選定鎖定單位";
|
||||
+47
-2
@@ -25,7 +25,51 @@ set(TINYCBOR_SOURCES
|
||||
external/tinycbor/src/cborparser_float.c
|
||||
external/tinycbor/src/cborerrorstrings.c)
|
||||
|
||||
add_library(${PROJECT_NAME} SHARED ${SOURCES} ${HEADERS} ${TINYCBOR_SOURCES})
|
||||
# Bundle `AddOns/!!!ClassicAPI/` into a generated header so the DLL can
|
||||
# surface the addon to the engine even when the user doesn't have it
|
||||
# installed on disk. The file-read hook in `src/addons/Embedded.cpp`
|
||||
# returns these buffers when the engine asks for paths under
|
||||
# `Interface\AddOns\!!!ClassicAPI\`.
|
||||
set(EMBEDDED_CLASSICAPI_SRC "${CMAKE_CURRENT_SOURCE_DIR}/AddOns/!!!ClassicAPI")
|
||||
set(EMBEDDED_CLASSICAPI_HEADER "${CMAKE_BINARY_DIR}/generated/embedded_classicapi.h")
|
||||
file(GLOB_RECURSE EMBEDDED_CLASSICAPI_FILES CONFIGURE_DEPENDS
|
||||
"${EMBEDDED_CLASSICAPI_SRC}/*")
|
||||
add_custom_command(
|
||||
OUTPUT "${EMBEDDED_CLASSICAPI_HEADER}"
|
||||
COMMAND ${CMAKE_COMMAND}
|
||||
-DSRC_DIR=${EMBEDDED_CLASSICAPI_SRC}
|
||||
-DOUT_HEADER=${EMBEDDED_CLASSICAPI_HEADER}
|
||||
-DADDON_NAME=ClassicAPI
|
||||
-P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_addon.cmake"
|
||||
DEPENDS
|
||||
${EMBEDDED_CLASSICAPI_FILES}
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_addon.cmake"
|
||||
COMMENT "Embedding !!!ClassicAPI addon into ${EMBEDDED_CLASSICAPI_HEADER}"
|
||||
VERBATIM)
|
||||
|
||||
# Bundle `src/bindings/Bindings.xml` (our TARGETING-group additions)
|
||||
# into a generated header. `src/bindings/Inject.cpp` extracts the
|
||||
# inner `<Binding>` elements from the embedded buffer and splices them
|
||||
# into the engine's `Interface\FrameXML\Bindings.xml` at load time.
|
||||
set(EMBEDDED_BINDINGS_SRC "${CMAKE_CURRENT_SOURCE_DIR}/src/bindings/Bindings.xml")
|
||||
set(EMBEDDED_BINDINGS_HEADER "${CMAKE_BINARY_DIR}/generated/embedded_bindings.h")
|
||||
add_custom_command(
|
||||
OUTPUT "${EMBEDDED_BINDINGS_HEADER}"
|
||||
COMMAND ${CMAKE_COMMAND}
|
||||
-DINPUT_FILE=${EMBEDDED_BINDINGS_SRC}
|
||||
-DOUT_HEADER=${EMBEDDED_BINDINGS_HEADER}
|
||||
-DSYMBOL=kEmbeddedBindingsXml
|
||||
-P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_file.cmake"
|
||||
DEPENDS
|
||||
"${EMBEDDED_BINDINGS_SRC}"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_file.cmake"
|
||||
COMMENT "Embedding bindings XML into ${EMBEDDED_BINDINGS_HEADER}"
|
||||
VERBATIM)
|
||||
|
||||
add_library(${PROJECT_NAME} SHARED
|
||||
${SOURCES} ${HEADERS} ${TINYCBOR_SOURCES}
|
||||
${EMBEDDED_CLASSICAPI_HEADER}
|
||||
${EMBEDDED_BINDINGS_HEADER})
|
||||
|
||||
# Dev-build sentinel — represents "newer than every plausible
|
||||
# release". Encoded as v9999.99.99 = 9999 * 10000 + 99 * 100 + 99 =
|
||||
@@ -55,10 +99,11 @@ target_compile_definitions(
|
||||
${PROJECT_NAME}
|
||||
PRIVATE CLASSICAPI_VERSION_VALUE=${CLASSICAPI_VERSION_VALUE})
|
||||
|
||||
target_link_libraries(${PROJECT_NAME} PRIVATE minhook)
|
||||
target_link_libraries(${PROJECT_NAME} PRIVATE minhook advapi32)
|
||||
|
||||
target_include_directories(
|
||||
${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||
${CMAKE_BINARY_DIR}/generated
|
||||
external/minhook/include
|
||||
external
|
||||
external/tinycbor/src)
|
||||
|
||||
@@ -18,34 +18,36 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| Namespace | Calls |
|
||||
|-----------|-------|
|
||||
| [Action](docs/API.md#action) | `GetActionInfo` |
|
||||
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable` |
|
||||
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
|
||||
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
|
||||
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
|
||||
| [Combat](docs/API.md#combat) | `InCombatLockdown` |
|
||||
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
|
||||
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
|
||||
| [Container](docs/API.md#container) | `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetItemCooldown`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
|
||||
| [Coroutine](docs/API.md#coroutine) | `coroutine.create`, `coroutine.resume`, `coroutine.yield`, `coroutine.status`, `coroutine.wrap` |
|
||||
| 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`) |
|
||||
| [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` |
|
||||
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid` |
|
||||
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
|
||||
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
|
||||
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
|
||||
| [FriendList](docs/API.md#friendlist) | `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName` |
|
||||
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex` |
|
||||
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura` |
|
||||
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura` |
|
||||
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
|
||||
| [Input](docs/API.md#input) | `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
|
||||
| [Input](docs/API.md#input) | `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
|
||||
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
|
||||
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
|
||||
| [Macros](docs/API.md#macros) | `GetMacroSpell` |
|
||||
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
|
||||
| [Macros](docs/API.md#macros) | `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
|
||||
| [Map](docs/API.md#map) | `C_Map.GetBestMapForUnit` |
|
||||
| [MerchantFrame](docs/API.md#merchantframe) | `C_MerchantFrame.GetBuybackItemID`, `C_MerchantFrame.GetItemInfo`, `C_MerchantFrame.GetNumJunkItems`, `C_MerchantFrame.IsMerchantItemRefundable`, `C_MerchantFrame.IsSellAllJunkEnabled`, `C_MerchantFrame.SellAllJunkItems` |
|
||||
| [NamePlate](docs/API.md#nameplate) | `C_NamePlate.GetNamePlateForGUID`, `C_NamePlate.GetNamePlateForUnit`, `C_NamePlate.GetNamePlateGUIDs`, `C_NamePlate.GetNamePlates` |
|
||||
| [NameCache](docs/API.md#namecache) | `C_CreatureInfo.GetCreatureID`, `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `GetPlayerInfoByGUID` |
|
||||
| [Quest](docs/API.md#quest) | `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
|
||||
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellName`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
|
||||
| [Quest](docs/API.md#quest) | `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
|
||||
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellName`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
|
||||
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
|
||||
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsMounted`, `IsStealthed`, `IsSwimming` |
|
||||
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsStealthed`, `IsSwimming` |
|
||||
| [Table](docs/API.md#table) | `table.wipe` |
|
||||
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
|
||||
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime` |
|
||||
@@ -66,9 +68,11 @@ the glue state because GlueXML had no way to reach them otherwise.
|
||||
|
||||
| Group | Calls |
|
||||
|-------|-------|
|
||||
| [Account](docs/API.md#account) | `SaveAccount`, `DeleteAccount`, `GetSavedAccounts`, `LoginWithSavedAccount` (passwords encrypted in Windows Credential Manager, scoped per realmlist; plaintext never returned to Lua) |
|
||||
| [CharacterList](docs/API.md#characterlist) | `GetSavedCharacterOrder`, `SetSavedCharacterOrder` (persist to `WTF\Account\...\ClassicAPI.txt`) |
|
||||
| CVar | `GetCVar`, `SetCVar`, `RegisterCVar`, `GetCVarDefault`, `C_CVar.GetCVarBool` (storage is process-global — writes from glue are visible in-world and vice versa) |
|
||||
| Script | `RunScript` (compile and run a Lua chunk in the glue state's globals — useful for slash-command-style helpers in GlueXML) |
|
||||
| State | `IsLoggedIn` |
|
||||
|
||||
### Macros
|
||||
|
||||
@@ -129,6 +133,19 @@ functions, just behavior the stock 1.12 engine didn't have. See the
|
||||
| `nameplate1`..`nameplateN` | Unit behind the Nth visible nameplate, in creation-order. Works with every `UnitX` function — `UnitName`, `UnitGUID`, `UnitClass`, `UnitHealth`, etc. Suffix chains (`nameplate1target`, `nameplate1targettarget`) compose. See [NamePlate / Unit tokens](docs/API.md#unit-tokens-nameplaten). |
|
||||
| `focus` / `focustarget` | Sticky target set via [`FocusUnit`](docs/API.md#focusunitunit), cleared via [`ClearFocus`](docs/API.md#clearfocus). Same `UnitX` coverage as `nameplateN`. Fires [`PLAYER_FOCUS_CHANGED`](docs/API.md#player_focus_changed-event) on transition. See [Focus](docs/API.md#focus). |
|
||||
|
||||
### Bindings
|
||||
|
||||
Injected into the engine's **Targeting Functions** group at FrameXML
|
||||
Bindings.xml load time, so they appear in the keybind UI alongside
|
||||
native targeting bindings instead of orphaned at the bottom.
|
||||
|
||||
| Binding | Action |
|
||||
|---------|--------|
|
||||
| `FOCUSTARGET` | `FocusUnit("target")` — pin current target as focus |
|
||||
| `TARGETFOCUS` | `TargetUnit("focus")` — switch target to the focus |
|
||||
|
||||
See [Focus / Bindings](docs/API.md#bindings-focustarget--targetfocus) for the implementation note.
|
||||
|
||||
## Installation
|
||||
|
||||
Use [VanillaFixes](https://github.com/hannesmann/vanillafixes) to load the
|
||||
@@ -162,8 +179,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)")
|
||||
@@ -0,0 +1,62 @@
|
||||
# embed_file.cmake — bundle a single file into a generated C++ header.
|
||||
#
|
||||
# Invoked from CMakeLists.txt via `cmake -P` as a custom command. Reads
|
||||
# INPUT_FILE and emits OUT_HEADER containing a constexpr byte array
|
||||
# named SYMBOL plus a `SYMBOL ## Size` size constant. Both live at file
|
||||
# scope; the header is meant to be included exactly once.
|
||||
#
|
||||
# Usage:
|
||||
# cmake -DINPUT_FILE=... -DOUT_HEADER=... -DSYMBOL=... -P embed_file.cmake
|
||||
|
||||
if(NOT INPUT_FILE)
|
||||
message(FATAL_ERROR "embed_file.cmake: INPUT_FILE required")
|
||||
endif()
|
||||
if(NOT OUT_HEADER)
|
||||
message(FATAL_ERROR "embed_file.cmake: OUT_HEADER required")
|
||||
endif()
|
||||
if(NOT SYMBOL)
|
||||
message(FATAL_ERROR "embed_file.cmake: SYMBOL required")
|
||||
endif()
|
||||
|
||||
file(READ "${INPUT_FILE}" CONTENT_HEX HEX)
|
||||
string(LENGTH "${CONTENT_HEX}" HEX_LEN)
|
||||
math(EXPR FILE_SIZE "${HEX_LEN} / 2")
|
||||
|
||||
# Convert hex string to comma-separated byte literals. 16 bytes per
|
||||
# line keeps the generated header readable.
|
||||
set(BYTES "")
|
||||
set(COL 0)
|
||||
set(POS 0)
|
||||
while(POS LESS HEX_LEN)
|
||||
string(SUBSTRING "${CONTENT_HEX}" ${POS} 2 BYTE)
|
||||
if(COL EQUAL 0)
|
||||
string(APPEND BYTES " ")
|
||||
endif()
|
||||
string(APPEND BYTES "0x${BYTE},")
|
||||
math(EXPR POS "${POS} + 2")
|
||||
math(EXPR COL "${COL} + 1")
|
||||
if(COL EQUAL 16)
|
||||
string(APPEND BYTES "\n")
|
||||
set(COL 0)
|
||||
else()
|
||||
string(APPEND BYTES " ")
|
||||
endif()
|
||||
endwhile()
|
||||
if(NOT COL EQUAL 0)
|
||||
string(APPEND BYTES "\n")
|
||||
endif()
|
||||
|
||||
set(HEADER_CONTENT "// Auto-generated by embed_file.cmake — do not edit.
|
||||
// Source: ${INPUT_FILE}
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
static constexpr unsigned char ${SYMBOL}[] = {
|
||||
${BYTES}};
|
||||
static constexpr size_t ${SYMBOL}Size = sizeof(${SYMBOL});
|
||||
")
|
||||
|
||||
file(WRITE "${OUT_HEADER}" "${HEADER_CONTENT}")
|
||||
message(STATUS "embed_file: wrote ${OUT_HEADER} (${FILE_SIZE} bytes)")
|
||||
+1664
-741
File diff suppressed because it is too large
Load Diff
+9
-2
@@ -3,7 +3,9 @@
|
||||
Catalogue of the Lua C API functions linked into the client. Vanilla WoW is
|
||||
built against **Lua 5.0** (not 5.1), and the entire API plus the auxiliary
|
||||
library lives in a contiguous block of `.text` from roughly `0x006F2000` to
|
||||
`0x006F5100`. ~120 functions; the public C API is the core of that.
|
||||
`0x006F5100`. ~120 functions; the public C API is the core of that. The
|
||||
thread / coroutine machinery (`lua_resume`, `lua_yield`, the inner
|
||||
`luaE_newthread`) lives slightly higher at `0x006F6000`–`0x006F6900`.
|
||||
|
||||
**Cross-verified against two binaries:**
|
||||
|
||||
@@ -75,6 +77,8 @@ context but not byte-by-byte verified.
|
||||
|
||||
| VA | Function | Conv. | Notes | Conf |
|
||||
|--------------|-------------------------|----------|--------------------------------------------------------------|------|
|
||||
| `0x006F2F80` | `lua_xmove` | fastcall | `(from, to, n)` — decr `from->top` by `n*0x10`, copy each 16-byte TValue, incr `to->top`. Used by `coroutine.*` to shuttle args/returns between threads | ★ |
|
||||
| `0x006F3030` | `lua_newthread` | fastcall | GC check then calls inner `luaE_newthread` at `0x006F6B10`; pushes the new state onto L with tag 8 (LUA_TTHREAD). Returns `lua_State *` in eax — Ghidra reports `void` (decompiler artifact) | ★ |
|
||||
| `0x006F3070` | `lua_gettop` | fastcall | `(top - base) >> 4` | ★ |
|
||||
| `0x006F3080` | `lua_settop` | fastcall | | ✓ |
|
||||
| `0x006F30D0` | `lua_remove` | fastcall | shift-down loop + decr_top; previously misidentified as `lua_pushvalue` | ★ |
|
||||
@@ -155,7 +159,10 @@ behaviour the comment describes (`push 0; mov edx, 1; mov ecx, L`) is
|
||||
| `0x006F4290` | *internal CFunction trampoline* | | called via `lua_cpcall` | ★ |
|
||||
| `0x006F4320` | `lua_load` | fastcall | reader / loader entry | ★ |
|
||||
| `0x006F4370` | `lua_dump` | fastcall | requires tag 6 + `Closure.isC == 0` | ★ |
|
||||
| `0x006F4940` | `lua_error` | cdecl | `(L, msg)` — args on stack; reaches `luaD_throw` via wrappers | ✓ |
|
||||
| `0x006F4940` | `lua_error` | **cdecl** varargs | `(L, fmt, ...)` — printf-style; engine prepends `luaL_where` then `lua_concat`. Single-string callers OK (cdecl tolerates extra-arg-less calls); user-controlled strings should use `Error(L, "%s", msg)` to avoid format-string interpretation | ✓ |
|
||||
| `0x006F6620` | `lua_resume` | fastcall | `(L_co, nargs) → int`. Zero internal callers (coroutine lib was stripped from registration tables); linker kept the symbol. Stores nargs in a local, calls `luaD_rawrunprotected(L, &resume_inner, &nargs)` at `0x006F5DAC`; inner static helper at `0x006F67B0` | ★ |
|
||||
| `0x006F6860` | `lua_yield` | fastcall | `(L, nresults) → int (-1)`. Sets `0x10` flag on CallInfo state field rather than directly writing `L->status` like later Lua versions. Errors with "attempt to yield across metamethod/C-call boundary" or "cannot yield a C function" when called from the wrong context | ★ |
|
||||
| `0x006F6B10` | *internal* `luaE_newthread` | fastcall | Inner allocator called by `lua_newthread` at `0x006F3030`. Allocates the new state, sets tag 8 via the push helper at `0x006F7B20`, inits via `0x006F6C40`, copies global-state fields from the parent. Returns `lua_State *` | ★ |
|
||||
|
||||
### Misc / GC / version
|
||||
|
||||
|
||||
+42
-6
@@ -48,7 +48,15 @@ namespace Lua {
|
||||
F(PCall, lua_pcall, LUA_PCALL) \
|
||||
F(Next, lua_next, LUA_NEXT) \
|
||||
F(Type, lua_type, LUA_TYPE) \
|
||||
F(Error, lua_error, LUA_ERROR)
|
||||
F(Error, lua_error, LUA_ERROR) \
|
||||
F(ToPointer, lua_topointer, LUA_TO_POINTER) \
|
||||
F(ToThread, lua_tothread, LUA_TO_THREAD) \
|
||||
F(IsCFunction, lua_iscfunction, LUA_IS_C_FUNCTION)\
|
||||
F(XMove, lua_xmove, LUA_X_MOVE) \
|
||||
F(NewThread, lua_newthread, LUA_NEW_THREAD) \
|
||||
F(Resume, lua_resume, LUA_RESUME) \
|
||||
F(Yield, lua_yield, LUA_YIELD) \
|
||||
F(ArgError, luaL_argerror, LUAL_ARG_ERROR)
|
||||
|
||||
#define CLASSICAPI_BIND_LUA(Name, Typedef, Offset) \
|
||||
const Typedef##_t Name = reinterpret_cast<Typedef##_t>(Offsets::Offset);
|
||||
@@ -186,17 +194,45 @@ void PushLocalizedString(void *L, const char *globalName, const char *fallback)
|
||||
}
|
||||
}
|
||||
|
||||
// In-place `string.gsub(stack[-1], pattern, replacement)` — replaces
|
||||
// the string at the top of the stack with the substitution result.
|
||||
// Discards gsub's second return (the substitution count) by
|
||||
// requesting only one return from `Call`.
|
||||
static void GsubInPlace(void *L, const char *pattern, const char *replacement) {
|
||||
PushString(L, "string");
|
||||
GetTable(L, GLOBALS_INDEX);
|
||||
PushString(L, "gsub");
|
||||
GetTable(L, -2); // [..., str, string, gsub]
|
||||
Remove(L, -2); // [..., str, gsub]
|
||||
Insert(L, -2); // [..., gsub, str]
|
||||
PushString(L, pattern);
|
||||
PushString(L, replacement);
|
||||
Call(L, 3, 1); // [..., result]
|
||||
}
|
||||
|
||||
void PushLocalizedFormatInt(void *L, const char *globalName,
|
||||
const char *fallback, int n) {
|
||||
// Resolve `string.format` from _G. The engine ships it as both a
|
||||
// global and as `string.format`; the global is faster (one
|
||||
// gettable instead of two) and equally available across builds.
|
||||
// Resolve `string.format` from _G.
|
||||
PushString(L, "format");
|
||||
GetTable(L, GLOBALS_INDEX);
|
||||
|
||||
PushLocalizedString(L, globalName, fallback);
|
||||
|
||||
// Expand WoW's `|4singular:plural;` (and 3-form
|
||||
// `|4nom:gen_sing:plural;`) pluralization template by piggybacking
|
||||
// on Lua's `string.gsub`. The 2- vs 3-form distinction is locale
|
||||
// grammar (Slavic counting has a separate genitive-singular form
|
||||
// for 2..4); we collapse to nominative/plural since vanilla has
|
||||
// no proper locale-aware counting rules. 3-form pattern runs
|
||||
// first because it's more specific. Vanilla's `string.format`
|
||||
// doesn't process `|4` natively, so without this the template
|
||||
// would render literally.
|
||||
const char *picker3 = (n == 1) ? "%1" : "%3";
|
||||
const char *picker2 = (n == 1) ? "%1" : "%2";
|
||||
GsubInPlace(L, "|4([^:;]+):([^:;]+):([^;]+);", picker3);
|
||||
GsubInPlace(L, "|4([^:;]+):([^;]+);", picker2);
|
||||
|
||||
PushNumber(L, static_cast<double>(n));
|
||||
Call(L, 2, 1); // format(fmt, n) → string
|
||||
Call(L, 2, 1); // format(expandedFmt, n) → string
|
||||
}
|
||||
} // namespace Lua
|
||||
|
||||
|
||||
+31
-1
@@ -49,6 +49,8 @@ constexpr int TYPE_NUMBER = 3;
|
||||
constexpr int TYPE_STRING = 4;
|
||||
constexpr int TYPE_TABLE = 5;
|
||||
constexpr int TYPE_FUNCTION = 6;
|
||||
constexpr int TYPE_USERDATA = 7;
|
||||
constexpr int TYPE_THREAD = 8;
|
||||
|
||||
using lua_isnumber_t = bool(__fastcall *)(void *L, int index);
|
||||
using lua_isstring_t = bool(__fastcall *)(void *L, int index);
|
||||
@@ -76,7 +78,27 @@ using lua_call_t = void(__fastcall *)(void *L, int nargs, int nresults);
|
||||
using lua_pcall_t = int(__fastcall *)(void *L, int nargs, int nresults, int errfunc);
|
||||
using lua_next_t = int(__fastcall *)(void *L, int idx);
|
||||
using lua_type_t = int(__fastcall *)(void *L, int index);
|
||||
using lua_error_t = void(__cdecl *)(void *L, const char *);
|
||||
// The 1.12 `lua_error` wrapper at `0x6F4940` is actually variadic —
|
||||
// it does `lua_pushvfstring(L, fmt, args)` then prepends `luaL_where`
|
||||
// and throws. Existing callers pass a single literal (no `%`), which
|
||||
// is fine because cdecl tolerates extra-arg-less calls. New callers
|
||||
// forwarding user-supplied error strings should use `Error(L, "%s",
|
||||
// userMsg)` to avoid format-string interpretation of `%` in the
|
||||
// message body.
|
||||
using lua_error_t = void(__cdecl *)(void *L, const char *fmt, ...);
|
||||
using lua_topointer_t = const void *(__fastcall *)(void *L, int idx);
|
||||
using lua_tothread_t = void *(__fastcall *)(void *L, int idx);
|
||||
using lua_iscfunction_t = int(__fastcall *)(void *L, int idx);
|
||||
using lua_xmove_t = void(__fastcall *)(void *from, void *to, int n);
|
||||
// `lua_newthread` returns the new `lua_State *` in eax, but Ghidra
|
||||
// infers `void` because the inner call's value just flows through.
|
||||
// We declare the return type honestly here; callers that don't want
|
||||
// to rely on the calling convention preserving eax can read the
|
||||
// thread back from L's top via `ToPointer(L, -1)`.
|
||||
using lua_newthread_t = void *(__fastcall *)(void *L);
|
||||
using lua_resume_t = int(__fastcall *)(void *L, int nargs);
|
||||
using lua_yield_t = int(__fastcall *)(void *L, int nresults);
|
||||
using luaL_argerror_t = void(__fastcall *)(void *L, int narg, const char *msg);
|
||||
|
||||
extern const lua_isnumber_t IsNumber;
|
||||
extern const lua_isstring_t IsString;
|
||||
@@ -110,6 +132,14 @@ extern const lua_pcall_t PCall;
|
||||
extern const lua_next_t Next;
|
||||
extern const lua_type_t Type;
|
||||
extern const lua_error_t Error;
|
||||
extern const lua_topointer_t ToPointer;
|
||||
extern const lua_tothread_t ToThread;
|
||||
extern const lua_iscfunction_t IsCFunction;
|
||||
extern const lua_xmove_t XMove;
|
||||
extern const lua_newthread_t NewThread;
|
||||
extern const lua_resume_t Resume;
|
||||
extern const lua_yield_t Yield;
|
||||
extern const luaL_argerror_t ArgError;
|
||||
|
||||
// Returns the global `lua_State *` (read on demand from the engine's global).
|
||||
// Callable outside a Lua callback, e.g. during LoadScriptFunctions setup.
|
||||
|
||||
+558
-4
@@ -18,6 +18,18 @@ enum Offsets {
|
||||
FUN_INVALID_FUNCTION_PTR_CHECK = 0x42A320,
|
||||
FUN_LOAD_SCRIPT_FUNCTIONS = 0x490250,
|
||||
|
||||
// Fatal-error dispatcher. `__fastcall(uint code)`. Writes `code`
|
||||
// to `DAT_00882738` and chains into the process-teardown path;
|
||||
// `FUN_00403BC0` reads the code at exit and shows a localized
|
||||
// popup keyed off it. Code `10` = "interface files are corrupt"
|
||||
// — the engine fires this from FrameXML's `FUN_0048FBF0` hash
|
||||
// mismatch and per-addon `FUN_0051F240` hash mismatch (plus
|
||||
// other call sites we haven't pinned down by byte pattern).
|
||||
// Hooked by `Security::FrameXMLBypass` to suppress code 10 so
|
||||
// our in-memory file modifications (Bindings.xml splice +
|
||||
// embedded `!!!ClassicAPI` addon) don't trigger termination.
|
||||
FUN_FATAL_ERROR = 0x00401560,
|
||||
|
||||
// Master glue Lua init — clean linear caller of all 5 glue batch
|
||||
// trampolines (60 main + 11 char-select + 24 char-create + 10 realm
|
||||
// + 4 frame globals = 109 total). Body: 0x0046ABB0..0x0046ABD2,
|
||||
@@ -1056,6 +1068,26 @@ enum Offsets {
|
||||
// With `requestIfMissing=false`, returns NULL if not in cache (no server
|
||||
// round-trip) — exactly the "instant" semantics we want.
|
||||
FUN_DBCACHE_ITEMSTATS_GET_RECORD = 0x0055BA30,
|
||||
// Item-cache response handler — the function called from
|
||||
// `0x00555140` (success path for `SMSG_ITEM_QUERY_SINGLE_RESPONSE`)
|
||||
// and `0x00555160` (failure path). Parses one or more itemIDs out
|
||||
// of the packet, looks up (or creates) the cache entry per ID,
|
||||
// copies the item data block via `FUN_007c9640` to `entry+0x18`,
|
||||
// sets `[entry+0x1F0]=1` (the loaded byte), then walks the entry's
|
||||
// pending callback list at `[entry+0x1FC]` and invokes each with
|
||||
// `(userData, success)`. High-bit-on itemID in the packet signals
|
||||
// a per-item failure for the batch.
|
||||
//
|
||||
// We hook this to observe any item-cache fill the engine performs
|
||||
// — covers the engine's natural inventory prefetch and any
|
||||
// addon-triggered queries without having to register our own
|
||||
// engine callbacks. Lets `RequestLoadItemData` be purely passive:
|
||||
// we track itemIDs, the engine (or whoever) issues the query, we
|
||||
// see the fill via this hook and fire `ITEM_DATA_LOAD_RESULT`.
|
||||
//
|
||||
// Signature: `__thiscall(void *cache, void *packetReader, int flag)`.
|
||||
// `cache` is the same `VAR_ITEMDB_CACHE` we already use.
|
||||
FUN_ITEMSTATS_CACHE_RESPONSE = 0x0055BDB0,
|
||||
// ItemStats_C field offsets we read. Full struct layout in
|
||||
// VanillaHelpers's `Game.h` (`struct ItemStats_C`); we only need these.
|
||||
OFF_ITEMSTATS_CLASS = 0x00,
|
||||
@@ -1068,6 +1100,17 @@ enum Offsets {
|
||||
OFF_ITEMSTATS_NAME = 0x08,
|
||||
OFF_ITEMSTATS_DISPLAY_INFO_ID = 0x18,
|
||||
OFF_ITEMSTATS_QUALITY = 0x1C, // u32 — 0=Poor … 5=Legendary
|
||||
|
||||
// Item-quality color-prefix table. Array of 7 `const char *`
|
||||
// pointers, indexed by quality 0..6 (POOR..ARTIFACT); each
|
||||
// points to a `"|cffRRGGBB"` markup string used by the engine's
|
||||
// own link builder. Discovered via the inner color helper at
|
||||
// `0x0052AD90` which does `(&table)[quality]` with a fallback
|
||||
// to white (`"|cffffffff"`, the index-1 entry) for quality > 6.
|
||||
// Quality 7 (Heirloom) doesn't exist in vanilla; consumers
|
||||
// should mirror the engine's fallback for safety.
|
||||
VAR_QUALITY_COLOR_TABLE = 0x00854124,
|
||||
QUALITY_COLOR_TABLE_SIZE = 7,
|
||||
// `m_flags` — item-class flags bitfield. Bits we read:
|
||||
// `ITEMSTATS_FLAG_OPENABLE` (`0x4`, bit 2): freely right-click-
|
||||
// openable container (sack, clam, simple chest, quest box).
|
||||
@@ -1159,6 +1202,10 @@ enum Offsets {
|
||||
// always at index 0 (some Turtle WoW custom items put it later).
|
||||
OFF_ITEMSTATS_SPELL_ID = 0x11C, // u32[5] — spell ID per slot
|
||||
OFF_ITEMSTATS_SPELL_TRIGGER = 0x130, // u32[5] — trigger code per slot
|
||||
OFF_ITEMSTATS_SPELL_CHARGES = 0x144, // i32[5] — charges per slot (-1 = infinite)
|
||||
OFF_ITEMSTATS_SPELL_COOLDOWN = 0x158, // u32[5] — cooldown ms per slot
|
||||
OFF_ITEMSTATS_SPELL_CATEGORY = 0x16C, // u32[5] — category (groups shared cooldowns)
|
||||
OFF_ITEMSTATS_SPELL_CATEGORY_CD = 0x180, // u32[5] — category cooldown ms
|
||||
ITEMSTATS_SPELL_SLOT_COUNT = 5,
|
||||
ITEM_SPELLTRIGGER_ON_USE = 0,
|
||||
|
||||
@@ -1170,6 +1217,63 @@ enum Offsets {
|
||||
// members in VanillaHelpers's `ItemStats_C` definition.
|
||||
OFF_ITEMSTATS_MAX_COUNT = 0x5C,
|
||||
|
||||
// Remaining `ItemStats_C` fields used by `C_Item.GetItemData(ByID)`.
|
||||
// Offsets derived from VanillaHelpers's `struct ItemStats_C`
|
||||
// definition (`C:\Git\VanillaHelpers\src\Game.h`), sized to match
|
||||
// the verified anchors above (`+0x60 m_stackable`, `+0x1D0
|
||||
// m_bagFamily`, `+0x1AC m_lockID`, etc.). Block follows the same
|
||||
// ordering as the struct so it's easy to cross-reference.
|
||||
OFF_ITEMSTATS_BUY_PRICE = 0x24, // u32
|
||||
OFF_ITEMSTATS_ALLOWABLE_CLASS = 0x30, // u32 — class bitmask, -1 = all
|
||||
OFF_ITEMSTATS_ALLOWABLE_RACE = 0x34, // u32 — race bitmask, -1 = all
|
||||
OFF_ITEMSTATS_REQUIRED_LEVEL = 0x3C, // u32
|
||||
OFF_ITEMSTATS_REQUIRED_SKILL = 0x40, // u32 — SkillLine.dbc row
|
||||
OFF_ITEMSTATS_REQUIRED_SKILL_RANK = 0x44, // u32
|
||||
OFF_ITEMSTATS_REQUIRED_SPELL = 0x48, // u32 — Spell.dbc row
|
||||
OFF_ITEMSTATS_REQUIRED_HONOR_RANK = 0x4C, // u32
|
||||
OFF_ITEMSTATS_REQUIRED_CITY_RANK = 0x50, // u32
|
||||
OFF_ITEMSTATS_REQUIRED_FACTION = 0x54, // u32 — Faction.dbc row
|
||||
OFF_ITEMSTATS_REQUIRED_FACTION_RANK = 0x58, // u32 — Friendly/Honored/etc.
|
||||
OFF_ITEMSTATS_STAT_TYPE = 0x68, // u32[10] — ItemModType enum per slot
|
||||
OFF_ITEMSTATS_STAT_VALUE = 0x90, // i32[10] — magnitude per slot
|
||||
ITEMSTATS_STAT_SLOT_COUNT = 10,
|
||||
OFF_ITEMSTATS_DAMAGE_MIN = 0xB8, // f32[5]
|
||||
OFF_ITEMSTATS_DAMAGE_MAX = 0xCC, // f32[5]
|
||||
OFF_ITEMSTATS_DAMAGE_TYPE = 0xE0, // u32[5] — schools per damage slot
|
||||
ITEMSTATS_DAMAGE_SLOT_COUNT = 5,
|
||||
OFF_ITEMSTATS_ARMOR = 0xF4, // u32
|
||||
OFF_ITEMSTATS_RES_HOLY = 0xF8, // u32 (six resists contiguous)
|
||||
OFF_ITEMSTATS_RES_FIRE = 0xFC,
|
||||
OFF_ITEMSTATS_RES_NATURE = 0x100,
|
||||
OFF_ITEMSTATS_RES_FROST = 0x104,
|
||||
OFF_ITEMSTATS_RES_SHADOW = 0x108,
|
||||
OFF_ITEMSTATS_RES_ARCANE = 0x10C,
|
||||
OFF_ITEMSTATS_DELAY = 0x110, // u32 — weapon swing time (ms)
|
||||
OFF_ITEMSTATS_AMMO_TYPE = 0x114, // u32 — ammo subclass for ranged
|
||||
OFF_ITEMSTATS_RANGED_MOD_RANGE = 0x118, // f32 — range modifier on bows/guns
|
||||
OFF_ITEMSTATS_BONDING = 0x194, // u32 — 0=none, 1=BoP, 2=BoE, 3=BoU, 4=Quest
|
||||
OFF_ITEMSTATS_DESCRIPTION = 0x198, // char * — flavor text (locale-applied)
|
||||
OFF_ITEMSTATS_PAGE_TEXT = 0x19C, // u32 — PageText.dbc row (readable books)
|
||||
OFF_ITEMSTATS_LANGUAGE_ID = 0x1A0, // u32 — language for book text
|
||||
OFF_ITEMSTATS_PAGE_MATERIAL = 0x1A4, // u32 — book material
|
||||
OFF_ITEMSTATS_START_QUEST = 0x1A8, // u32 — questID started by right-click
|
||||
OFF_ITEMSTATS_MATERIAL = 0x1B0, // i32 — material type
|
||||
OFF_ITEMSTATS_SHEATH = 0x1B4, // u32 — weapon sheath style
|
||||
OFF_ITEMSTATS_RANDOM_PROPERTY = 0x1B8, // i32 — random property template
|
||||
OFF_ITEMSTATS_BLOCK = 0x1BC, // u32 — shield block value
|
||||
OFF_ITEMSTATS_MAX_DURABILITY = 0x1C4, // u32
|
||||
OFF_ITEMSTATS_AREA = 0x1C8, // u32 — bound area (AreaTable.dbc)
|
||||
OFF_ITEMSTATS_MAP = 0x1CC, // u32 — bound map (Map.dbc)
|
||||
|
||||
// `m_flags` bits we surface as decoded booleans. The OPENABLE bit
|
||||
// (0x4) is verified by the tooltip-builder's reader at 0x0052E323;
|
||||
// the others are documented in the standard 1.12 protocol
|
||||
// (CMaNGOS / TrinityCore `ITEM_FLAG_*`) and exposed as-is. Add
|
||||
// verification anchors as consumers materialize.
|
||||
ITEM_FLAG_CONJURED = 0x2,
|
||||
ITEM_FLAG_LOOTABLE = 0x10,
|
||||
ITEM_FLAG_WRAPPER = 0x200,
|
||||
|
||||
// `Spell.dbc` `Name[9]` field at record +0x1E0 (9-locale string
|
||||
// array, indexed by `[VAR_LOCALE_INDEX]`). Used by `GetItemSpell`
|
||||
// to push the spell name half of its return.
|
||||
@@ -1477,6 +1581,35 @@ enum Offsets {
|
||||
VAR_SKILL_LINE_COUNT = 0x00C0D930,
|
||||
OFF_SKILL_LINE_NAME = 0x0C,
|
||||
|
||||
// Player's known-skills table — separate from the DBC. Populated
|
||||
// by the engine on login from server data; entries are pointers
|
||||
// to per-skill records. Each record's `+0x00` is the
|
||||
// `SkillLine.dbc` row (or `0` for header / category rows), and
|
||||
// `+0x10` is the current skill rank (other rank/modifier fields
|
||||
// follow). Source: `Script_GetSkillLineInfo` at `0x004D3610`,
|
||||
// which walks this array indexed by the Lua arg.
|
||||
VAR_PLAYER_SKILL_LIST = 0x00B7318C,
|
||||
VAR_PLAYER_SKILL_COUNT = 0x00B731B8,
|
||||
OFF_PLAYER_SKILL_LINE_ID = 0x00,
|
||||
OFF_PLAYER_SKILL_RANK = 0x10,
|
||||
|
||||
// Per-itemClass proficiency bitmap maintained by the engine.
|
||||
// 17 u32 entries (indexed by Item.dbc `m_class`, 0..16). Each
|
||||
// entry is a bitmask where bit N is set if the player has
|
||||
// proficiency in subclass N of that item class — i.e.
|
||||
// `table[4] & (1 << 3)` = "can wear Mail" (armor=class 4,
|
||||
// mail=subclass 3).
|
||||
//
|
||||
// Populated by SMSG_SET_PROFICIENCY's handler at
|
||||
// `FUN_005E7B70`, which writes `table[itemClass] = mask`
|
||||
// verbatim from the packet. Same source the engine itself uses
|
||||
// for equip eligibility (the tooltip "Mail" red-text logic
|
||||
// and the cursor-drop-on-paperdoll check both read this). Note:
|
||||
// covers **weapons too** (class 2), making it strictly more
|
||||
// general than walking the per-skill-line table for individual
|
||||
// armor proficiencies.
|
||||
VAR_PROFICIENCY_TABLE = 0x00C4D4A0,
|
||||
|
||||
// Spell.dbc `BaseLevel` — the level a spell becomes available
|
||||
// (trainer offers it, quest reward grants it, etc.). Distinct
|
||||
// from `SpellLevel` at +0x74 which is the effective level used
|
||||
@@ -1651,6 +1784,10 @@ enum Offsets {
|
||||
// Note: the bit's MEANING is "can't toggle" — `canToggleAtWar` is
|
||||
// its inverse, NOT its value.
|
||||
REP_SLOT_FLAG_PEACE_FORCED = 0x10,
|
||||
// INACTIVE flag — set by `Script_SetFactionInactive` (the "Inactive"
|
||||
// collapsible category in FactionFrame). Toggles via
|
||||
// `FUN_FACTION_SET_INACTIVE`; same byte as AT_WAR and PEACE_FORCED.
|
||||
REP_SLOT_FLAG_INACTIVE = 0x20,
|
||||
MAX_REP_SLOTS = 64,
|
||||
|
||||
// Static reaction-band threshold tables, indexed by reaction band
|
||||
@@ -1670,6 +1807,81 @@ enum Offsets {
|
||||
// against `FUN_004D5620(slot)`'s return.
|
||||
OFF_CGPLAYER_INFO_WATCHED_REP_LIST_ID = 0x10C4,
|
||||
|
||||
// `FactionSetAtWar(factionID, newState)` — `__fastcall(ecx = factionID,
|
||||
// edx = char newState)`. The sole worker behind
|
||||
// `Script_FactionToggleAtWar` (`0x004D6950`); the script wrapper just
|
||||
// reads the current at-war flag, negates it, and tail-calls here.
|
||||
//
|
||||
// Validates (only when turning OFF) that current standing ≥ -3000,
|
||||
// resolves factionID → repListID, bails if the player is in a loot
|
||||
// session (UNIT_FIELD_FLAGS bit `0x80000`, error code `0x1A1`),
|
||||
// mutates the rep-slot flag byte (`AT_WAR` gated by `PEACE_FORCED`),
|
||||
// and sends `CMSG_SET_FACTION_ATWAR` (opcode `0x125`).
|
||||
//
|
||||
// **Does NOT fire `UNIT_FACTION`** — a vanilla engine omission. 3.3.5
|
||||
// added a `FUN_0071F8F0(player, 0)` call here, whose inner
|
||||
// `FUN_0060BF10(playerGUID, UNIT_FACTION_id)` dispatches the event
|
||||
// for every unit token referencing the player. We polyfill by
|
||||
// hooking this function and firing `UNIT_FACTION("player")` post-
|
||||
// original (see `src/faction/UnitFactionPolyfill.cpp`).
|
||||
FUN_FACTION_SET_AT_WAR = 0x004D5FD0,
|
||||
|
||||
// `FactionSetInactive(factionID, newState)` — `__fastcall(ecx = factionID,
|
||||
// edx = char newState)`. Inner setter behind `Script_SetFactionInactive`
|
||||
// (`0x004D69B0`, sets INACTIVE bit) and `Script_SetFactionActive`
|
||||
// (`0x004D6A00`, clears it) — both script wrappers tail-call here.
|
||||
//
|
||||
// Resolves factionID → repListID via `FUN_004D5600`, mutates the
|
||||
// rep-slot flag byte (sets/clears `INACTIVE` = `0x20`), writes via
|
||||
// `FUN_004D5FC0`, sends `CMSG_SET_FACTION_INACTIVE` (opcode `0x317`),
|
||||
// then calls `FUN_004D5C40` (recompute displayed-list, fire
|
||||
// `UPDATE_FACTION` event `0x174`).
|
||||
//
|
||||
// **Does NOT fire `UNIT_FACTION`** — same engine omission as the at-
|
||||
// war setter. Polyfilled in `src/faction/UnitFactionPolyfill.cpp`.
|
||||
FUN_FACTION_SET_INACTIVE = 0x004D60F0,
|
||||
|
||||
// SMSG_SET_FACTION_ATWAR handler — `__stdcall(uint32_t opcode,
|
||||
// void *packet)`. Fires when the server force-changes the player's
|
||||
// at-war state on a faction (e.g., a flag the player can't toggle
|
||||
// changes server-side, or faction reset). Reads `(u32 repListID,
|
||||
// u8 flags)`, mutates AT_WAR on the slot accordingly, writes via
|
||||
// `FUN_004D5FC0`, then fires `UPDATE_FACTION` (event `0x174`).
|
||||
//
|
||||
// **Does NOT fire `UNIT_FACTION`** — 3.3.5 does (unconditionally
|
||||
// after the byte write). Polyfilled in
|
||||
// `src/faction/UnitFactionPolyfill.cpp`.
|
||||
FUN_SMSG_SET_FACTION_ATWAR = 0x004D56B0,
|
||||
|
||||
// SMSG_INITIALIZE_FACTIONS handler — `__stdcall(uint32_t opcode,
|
||||
// void *packet)`. Fires once at login from the SMSG dispatcher. Reads
|
||||
// a count, then loops `(u8 flags, i32 standing)` pairs into the
|
||||
// 64-entry rep slot array: `FUN_004D5FC0(slot, flags)` +
|
||||
// `FUN_004D6330(slot, standing, 0 /*notify*/)`. Trailing
|
||||
// `FUN_004D5430` rebuilds the displayed-list snapshot, then fires
|
||||
// `UPDATE_FACTION` (event `0x174`).
|
||||
//
|
||||
// **Does NOT fire `UNIT_FACTION`** — modern WoW does. Addons that
|
||||
// observe UNIT_FACTION miss the initial sync entirely in vanilla.
|
||||
// Polyfilled in `src/faction/UnitFactionPolyfill.cpp`.
|
||||
FUN_SMSG_INITIALIZE_FACTIONS = 0x004D5640,
|
||||
|
||||
// SMSG_SET_FACTION_STANDINGS handler — `__stdcall(uint32_t opcode,
|
||||
// void *packet)`. Fires once per server-pushed rep change (kills,
|
||||
// quest turn-ins, etc.). Reads count, loops `(flags, standing)`
|
||||
// pairs, calling `FUN_004D6330(slot, standing, 1 /*notify*/)` which
|
||||
// routes through `FUN_REPUTATION_FIRE_NOTIFY` for the
|
||||
// `CHAT_MSG_COMBAT_FACTION_CHANGE` dispatch.
|
||||
//
|
||||
// Also mutates AT_WAR / VISIBLE flag bits as standing crosses
|
||||
// thresholds (e.g., recovering to -2999 clears the force-set AT_WAR
|
||||
// from "below Hated"). **Vanilla doesn't fire `UNIT_FACTION` for
|
||||
// these side-effect flag flips**; modern 3.3.5 does. Polyfilled in
|
||||
// `src/faction/UnitFactionPolyfill.cpp` — snapshots the 64-entry
|
||||
// flag-byte array before/after and fires a single
|
||||
// `UNIT_FACTION("player")` if any bit changed.
|
||||
FUN_SMSG_SET_FACTION_STANDINGS = 0x004D5760,
|
||||
|
||||
// `__fastcall(ecx = factionID, edx = signedDelta)`. This is the
|
||||
// engine's "reputation changed, fire the chat event" notify
|
||||
// helper — it formats the localized chat message and dispatches
|
||||
@@ -1772,6 +1984,41 @@ enum Offsets {
|
||||
// without re-parsing the body. See `Macro::Spell::Script_GetMacroSpell`.
|
||||
FUN_MACRO_SLOT_TO_ENTRY = 0x004F0E40,
|
||||
|
||||
// Macro-icon database. Populated lazily by `FUN_LOAD_MACRO_ICONS`
|
||||
// on the first `GetNumMacroIcons` call — enumerates `Interface\Icons\`
|
||||
// for `*.blp` files plus a wildcard match, sorts, and de-dupes. Each
|
||||
// entry is the basename (e.g. `"Ability_Kick"`) without the
|
||||
// `Interface\Icons\` prefix; vanilla's `Script_GetMacroIconInfo`
|
||||
// joins the prefix via sprintf before pushing. Verified by reading
|
||||
// `Script_GetNumMacroIcons` (`0x004F19F0`) and
|
||||
// `Script_GetMacroIconInfo` (`0x004F1A30`).
|
||||
//
|
||||
// Vanilla's loader has 3 enumeration passes, each with a per-file
|
||||
// callback. The first two (`FUN_MACRO_ICON_CB_DISK`,
|
||||
// `FUN_MACRO_ICON_CB_USER_MPQ`) prefix-filter on `"Ability_"` and
|
||||
// `"Spell_"` — anything else (including `INV_*` item icons) is
|
||||
// rejected. The third (`FUN_MACRO_ICON_CB_INSTALL_MPQ`) reads as
|
||||
// extension-only filter in the disassembly (any `.blp`/`.tga` is
|
||||
// accepted), but the engine's main icon DB ends up with zero
|
||||
// `INV_*` entries regardless (`GetNumMacroIcons() == 746`, all
|
||||
// `Ability_*`/`Spell_*`). Best guess: a check inside the
|
||||
// `SStrDup`/array-append helpers downstream of all three callbacks
|
||||
// filters them out — but the per-file callbacks themselves DO see
|
||||
// `INV_*` filenames (verified by hook capture: 5,226 unique
|
||||
// `INV_*` basenames flow through the callbacks per session).
|
||||
//
|
||||
// For `C_Macro::GetMacroItemIcons` we hook each callback at its
|
||||
// entry, capture any `INV_*` filename into a DLL-owned side array,
|
||||
// then forward to the original — dodges whichever downstream
|
||||
// filter the engine applies and matches the parallel item-icon
|
||||
// array 4.3.4 exposes (via `Script_GetMacroItemIcons`).
|
||||
VAR_MACRO_ICON_COUNT = 0x00BDCC1C, // uint32 count of loaded icons
|
||||
VAR_MACRO_ICON_ARRAY = 0x00BDCC20, // char ** — pointer to flat array of icon-name C strings (4-byte stride)
|
||||
FUN_LOAD_MACRO_ICONS = 0x004F0090, // `__cdecl()` lazy populate; no-op if already loaded
|
||||
FUN_MACRO_ICON_CB_DISK = 0x004F0220, // disk enumerator callback — `__fastcall(const char *fullPath)` — prefix-filtered
|
||||
FUN_MACRO_ICON_CB_USER_MPQ = 0x004F0350, // user-MPQ enumerator callback — `__fastcall(MpqRecord *r)` — prefix-filtered
|
||||
FUN_MACRO_ICON_CB_INSTALL_MPQ = 0x004F04F0, // install-MPQ enumerator callback — `__fastcall(MpqRecord *r)` — extension-only filter
|
||||
|
||||
// Quest log: 16-byte-stride entry array and active count.
|
||||
// Field +0 of each entry is the questID for real quests (a category index
|
||||
// for headers); field +8 is the header indicator: non-NULL = header,
|
||||
@@ -1966,6 +2213,67 @@ enum Offsets {
|
||||
// to cast next", so we check this slot too.
|
||||
VAR_QUEUED_CAST_SPELL = 0x00CECAA8,
|
||||
|
||||
// Spell-placement state bitmask. Non-zero when the engine is
|
||||
// waiting on user input for an in-progress cast — the AoE reticle,
|
||||
// unit-target cursor, trade-target cursor, etc. Bits encode what
|
||||
// kind of input is needed; the engine's click handlers read this
|
||||
// to decide whether a click was relevant. Read by
|
||||
// `Script_SpellIsTargeting` via `FUN_006E48A0` (returns
|
||||
// `state != 0`). Bits the engine OR-sets via `FUN_006E5250` when
|
||||
// entering placement (subset relevant to us):
|
||||
// - `0x20` source world location needed
|
||||
// - `0x40` dest world location needed
|
||||
// The `0x60` mask therefore identifies "ground-target placement"
|
||||
// (vs. unit-target, trade-target, etc.). See `Spell::AtCursor`.
|
||||
VAR_SPELL_PLACEMENT_STATE = 0x00CECAC0,
|
||||
SPELL_PLACEMENT_GROUND_MASK = 0x60,
|
||||
|
||||
// `FUN_006E60F0(float *xyz)` — commits the in-flight ground-target
|
||||
// placement at the given world position. Reads three contiguous
|
||||
// floats from `xyz`; checks the placement-state mask
|
||||
// (`VAR_SPELL_PLACEMENT_STATE`) to decide whether this is a
|
||||
// source-coord (`0x20`) or dest-coord (`0x40`) commit; clears the
|
||||
// matching bit and stores the coords into engine globals which the
|
||||
// cast-finalize path then reads. Self-gates when neither bit is
|
||||
// set — safe no-op for unit-target placement. Called by the engine
|
||||
// itself when a player left-clicks ground while the reticle is up.
|
||||
FUN_COMMIT_PLACEMENT_COORDS = 0x006E60F0,
|
||||
|
||||
// `FUN_006E4900()` — cancels in-flight placement mode. Internally
|
||||
// checks `VAR_SPELL_PLACEMENT_STATE != 0` and, if so, fires the
|
||||
// cast-cancel path via `FUN_006E4940(0x1C)`. Safe to call when no
|
||||
// placement is active (no-ops). Wraps the action behind
|
||||
// `Script_SpellStopTargeting`.
|
||||
FUN_STOP_PLACEMENT = 0x006E4900,
|
||||
|
||||
// `*VAR_WORLDFRAME_CLICK_INFO` is a pointer to the engine's
|
||||
// CWorldFrame "click info" struct — the data block where the input
|
||||
// dispatcher stashes "what is the cursor pointed at right now"
|
||||
// after every mouse motion. Allocated at world enter, populated
|
||||
// continuously by the rendering pipeline (viewport bounds and
|
||||
// camera position at fixed offsets) and the OS message pump
|
||||
// (cursor screen pos). NULL when not in world.
|
||||
// - `+0x350` (uint32) click type after last raycast:
|
||||
// `0` = no hit
|
||||
// `1` = world / terrain hit
|
||||
// `2` = unit / object hit
|
||||
// - `+0x360..+0x368` (3 floats) hit world position (when type == 1)
|
||||
VAR_WORLDFRAME_CLICK_INFO = 0x00B4B2BC,
|
||||
OFF_CLICK_INFO_TYPE = 0x350,
|
||||
OFF_CLICK_INFO_COORDS = 0x360,
|
||||
|
||||
// `FUN_00481F00(void *clickInfo)` — refreshes the cursor's "what
|
||||
// am I pointed at?" state on demand. Internally reads cursor
|
||||
// screen pos from input state at `[clickInfo+0xA0]+0x1118/+0x111C`,
|
||||
// calls the screen→world ray builder at `FUN_004813B0` (uses
|
||||
// viewport bounds at `[clickInfo+0x390..+0x39C]` plus camera
|
||||
// position at `[clickInfo+0x65B8]+8/+0xC/+0x10`), then raycasts
|
||||
// via `FUN_0069BFF0` (terrain + WMO intersection). Writes click
|
||||
// type to `[clickInfo+0x350]` and hit coords to
|
||||
// `[clickInfo+0x360..+0x368]`. Safe to call from arbitrary code
|
||||
// as long as the player is in-world.
|
||||
FUN_REFRESH_CURSOR_RAYCAST = 0x00481F00,
|
||||
|
||||
// `Script_CastSpellByName` — engine's Lua wrapper for the
|
||||
// `CastSpellByName(name [, onSelf])` global. `int __fastcall(void *L)`
|
||||
// — standard Lua C function ABI. Reads `name` from stack[1] (string)
|
||||
@@ -1975,6 +2283,16 @@ enum Offsets {
|
||||
// results). Callable directly from C++ — push args on stack, call.
|
||||
FUN_SCRIPT_CAST_SPELL_BY_NAME = 0x004B4AB0,
|
||||
|
||||
// Inner spell-cast dispatcher — `__fastcall(slot, bookType, targetGuidLo,
|
||||
// targetGuidHi)`. `Script_CastSpellByName` calls this after resolving
|
||||
// the spell name to a spellbook slot. For ground-target spells, the
|
||||
// engine enters placement mode (sets `VAR_SPELL_PLACEMENT_STATE`)
|
||||
// and returns; placement is resolved later by mouse-click or
|
||||
// programmatically via `FUN_COMMIT_PLACEMENT_COORDS`. Passing (0, 0)
|
||||
// for the GUID is the "no implicit target" form — engine falls back
|
||||
// to the player's current selected target.
|
||||
FUN_SPELL_CAST_DISPATCH = 0x004B3300,
|
||||
|
||||
// Macro "primary spell" parser — `__fastcall(MacroEntry *entry)`.
|
||||
// Walks the macro body (stored at `entry + OFF_MACRO_BODY`) line
|
||||
// by line, looking for `/cast <name>`, `/castsequence <name>`,
|
||||
@@ -2101,6 +2419,7 @@ enum Offsets {
|
||||
// `Script_IsInInstance`).
|
||||
VAR_CURRENT_MAP_ID = 0x00B4E378,
|
||||
|
||||
|
||||
// Local player's current AreaTable.dbc area ID (u32 storage; only
|
||||
// the low 16 bits are used — the value is broadcast over the wire
|
||||
// as a u16 in `SMSG_PARTY_MEMBER_STATS`). Written by the zone-change
|
||||
@@ -2206,6 +2525,55 @@ enum Offsets {
|
||||
LUA_NEXT = 0x6F4450,
|
||||
LUA_ERROR = 0x6F4940,
|
||||
|
||||
// `lua_xmove(from, to, n)` — moves the top `n` TValues from one
|
||||
// thread to another. Decrements `from->top` by `n*16`, copies each
|
||||
// 16-byte TValue, increments `to->top`. Needed by the `coroutine.*`
|
||||
// trampolines to shuttle args/returns between the main state and a
|
||||
// suspended coroutine.
|
||||
LUA_X_MOVE = 0x6F2F80,
|
||||
// `lua_newthread(L)` — does GC check, calls inner `luaE_newthread`
|
||||
// at `0x006F6B10` to allocate a new state, pushes it onto L's stack
|
||||
// with type tag 8 (LUA_TTHREAD). Ghidra reports the return as
|
||||
// `void` (decompiler limitation — the inner call's eax flows
|
||||
// through), so callers should retrieve the new state via
|
||||
// `ToPointer(L, -1)` rather than relying on the return value.
|
||||
LUA_NEW_THREAD = 0x6F3030,
|
||||
// `lua_resume(L_co, nargs)` — runs a coroutine. Zero internal
|
||||
// callers in the engine (coroutine library was stripped from the
|
||||
// standard-library registration tables at build time); linker
|
||||
// kept the symbol so it's callable from C. Goes through
|
||||
// `luaD_rawrunprotected` at `0x006F5DAC` for setjmp/longjmp
|
||||
// safety; inner static `resume` helper lives at `0x006F67B0`.
|
||||
LUA_RESUME = 0x6F6620,
|
||||
// `lua_yield(L, nresults)` — flags the current CallInfo to yield
|
||||
// and returns -1. Sets a `0x10` bit on the CI->state field rather
|
||||
// than writing `L->status` directly in this build. Zero internal
|
||||
// callers (same reason as `lua_resume`). Errors with
|
||||
// "attempt to yield across metamethod/C-call boundary" or
|
||||
// "cannot yield a C function" when called from the wrong context.
|
||||
LUA_YIELD = 0x6F6860,
|
||||
// `lua_topointer(L, idx)` — returns the pointer value of a
|
||||
// pointer-valued TValue: type 5 (table), 6 (function), 8 (thread)
|
||||
// return `tvalue[2]` directly; type 2 (lightuserdata) returns its
|
||||
// value; type 7 (userdata) returns the userdata payload (record +
|
||||
// 0x10). Returns 0 for anything else. We use it as `lua_tothread`
|
||||
// — for a known-thread TValue, returns the embedded `lua_State *`.
|
||||
LUA_TO_POINTER = 0x6F3790,
|
||||
// `luaL_argerror(L, narg, msg)` — formats "bad argument #%d to
|
||||
// '%s' (%s)" (or "calling '%s' on bad self (%s)" for method-style
|
||||
// calls) and calls `lua_error`. Doesn't return.
|
||||
LUAL_ARG_ERROR = 0x6F4810,
|
||||
// `lua_iscfunction(L, idx)` — returns 1 if value is a function
|
||||
// closure with the `isC` byte set (`Closure.c.isC == 1`), 0
|
||||
// otherwise. Used by `coroutine.create` to reject C functions
|
||||
// (Lua 5.0 only allows Lua-defined functions as coroutine bodies).
|
||||
LUA_IS_C_FUNCTION = 0x6F34A0,
|
||||
// `lua_tothread(L, idx)` — returns the embedded `lua_State *` for
|
||||
// type-8 (LUA_TTHREAD) TValues, NULL for anything else. Used by
|
||||
// the `coroutine` library to extract the coroutine pointer from a
|
||||
// stack slot.
|
||||
LUA_TO_THREAD = 0x6F3770,
|
||||
|
||||
// Global `lua_State *`. The engine keeps one main thread state here; we
|
||||
// read it on demand in helpers that run outside a Lua callback (e.g.
|
||||
// RegisterTableFunction during LoadScriptFunctions).
|
||||
@@ -2349,6 +2717,86 @@ enum Offsets {
|
||||
FUN_STORM_SMEM_ALLOC = 0x006462E0,
|
||||
FUN_STORM_SMEM_FREE = 0x00646430,
|
||||
|
||||
// Engine's main file-content reader. Used by the TOC parser at
|
||||
// `FUN_TOC_PARSER` to slurp addon `.toc` files and by the addon
|
||||
// load pass for `.lua` source. Allocates a Storm buffer via
|
||||
// `FUN_STORM_SMEM_ALLOC`, writes the file content + `extraBytes`
|
||||
// trailing zeros, hands the buffer to the caller. Caller frees
|
||||
// via `FUN_STORM_SMEM_FREE`.
|
||||
//
|
||||
// __cdecl int FileRead(
|
||||
// int unused, // always 0 from engine call sites
|
||||
// const char *path, // "Interface\AddOns\X\Y.lua"
|
||||
// void **outBuf, // Storm-allocated content
|
||||
// size_t *outSize, // optional, may be NULL
|
||||
// size_t extraBytes, // trailing zero-fill (1 for nul-term)
|
||||
// int flag1, // always 1 from TOC parser
|
||||
// int flag2); // 0
|
||||
// Returns 1 on success, 0 on failure.
|
||||
//
|
||||
// We hook this to redirect reads of `Interface\AddOns\!!!ClassicAPI\*`
|
||||
// to embedded buffers (see src/addons/Embedded.cpp). Allocating via
|
||||
// the same Storm allocator the engine uses means the caller's
|
||||
// normal `SMemFree` works without us tracking lifetime.
|
||||
FUN_FILE_READ = 0x00648620,
|
||||
|
||||
// TOC parser — `__fastcall(this=addonName)`. Already documented in
|
||||
// CLAUDE.md under "AddOn registry & hot-reload". Dedup-safe: if
|
||||
// the addon is already in the hash table, returns immediately
|
||||
// without re-parsing. Reads the TOC file via `FUN_FILE_READ`. We
|
||||
// call this from the post-`FUN_ADDON_INIT` hook to register the
|
||||
// embedded `!!!ClassicAPI` addon — the dedup behavior means our
|
||||
// call is a no-op when the user has the addon installed on disk.
|
||||
FUN_TOC_PARSER = 0x0051C9B0,
|
||||
|
||||
// AddOn registry init — `__fastcall(accountName)`. Documented in
|
||||
// CLAUDE.md ("AddOn registry & hot-reload"). Hooked post-call so
|
||||
// we can splice the embedded addon entry into the freshly-populated
|
||||
// registry before the load pass.
|
||||
FUN_ADDON_INIT = 0x0051C740,
|
||||
|
||||
// Generic intrusive-list insert/move helper used by `FUN_TOC_PARSER`
|
||||
// to thread new addon entries into the registry's doubly-linked
|
||||
// list. Signature (`__thiscall`):
|
||||
// `(ListCtrl *this, void *entry, int position, int anchor)`
|
||||
// Where:
|
||||
// - `this` — list-control struct (for the addon registry,
|
||||
// this is `&VAR_ADDON_LIST_CTRL`).
|
||||
// - `entry` — entry to insert. If already linked, the
|
||||
// function removes it from its current
|
||||
// position first.
|
||||
// - `position` — `1` = insert at head, anything else (engine
|
||||
// passes `2`) = insert at tail.
|
||||
// - `anchor` — `0` = anchor against the list sentinel
|
||||
// (default), nonzero = anchor relative to
|
||||
// another entry's link field at that offset.
|
||||
//
|
||||
// Used by `Addons::Embedded` to move our injected `!!!ClassicAPI`
|
||||
// entry from the tail (where `FUN_TOC_PARSER` puts it) to the head
|
||||
// — vanilla's addon load pass at `FUN_0051F600` walks the linked
|
||||
// list in insertion order, so without this fix our addon would
|
||||
// load *last*, breaking dependent addons that expect our globals
|
||||
// to be ready in their main chunk.
|
||||
FUN_INTRUSIVE_LIST_INSERT = 0x00521AD0,
|
||||
|
||||
// Addon-registry intrusive-list control. Layout
|
||||
// (`__thiscall`-friendly, generic across all intrusive lists in the
|
||||
// binary):
|
||||
// +0x00 (= `0x00BE1B64`) — `int` next-pointer offset within each
|
||||
// entry (= `0xC` at runtime; entries
|
||||
// store their next-link at
|
||||
// `entry+0xC..0x10`).
|
||||
// +0x04 (= `0x00BE1B68`) — sentinel `next` field
|
||||
// (points back into &sentinel+1 when
|
||||
// the list is empty).
|
||||
// +0x08 (= `0x00BE1B6C`) — list head (alias for sentinel `prev`
|
||||
// when treated as circular).
|
||||
//
|
||||
// `VAR_ADDON_LIST_CTRL` is just the address of the control struct
|
||||
// (i.e., `0x00BE1B64`). Pass `&VAR_ADDON_LIST_CTRL` as the `this`
|
||||
// arg to `FUN_INTRUSIVE_LIST_INSERT`.
|
||||
VAR_ADDON_LIST_CTRL = 0x00BE1B64,
|
||||
|
||||
// `SStrDup(const char *src, const char *file, int line)`. `__stdcall`.
|
||||
// Storm's string-copy wrapper around `SMemAlloc` — used by the engine
|
||||
// itself to populate event entry names. We use it from `Event::Custom`
|
||||
@@ -2483,9 +2931,11 @@ enum Offsets {
|
||||
// We dispatch to these directly so the wrappers don't need to
|
||||
// bounce through `Script_GetAddOnInfo` and unwind 7 returns.
|
||||
//
|
||||
// IS_LOAD_ON_DEMAND `__fastcall(entry) → byte`
|
||||
// Hash-table lookup in the addon registry. Non-zero ⇒
|
||||
// `## LoadOnDemand: 1` in the .toc.
|
||||
// IS_LOADED `__fastcall(entry) → byte`
|
||||
// Hash-table lookup in the addon registry. Returns the byte
|
||||
// at `entry+0x18`, which the addon loader (`FUN_0051F240`)
|
||||
// writes `1` to when the addon has finished loading. Non-zero
|
||||
// ⇒ the addon is loaded.
|
||||
//
|
||||
// CAN_LOAD `__fastcall(entry, char fullCheck=1, int *outStatus,
|
||||
// int *outDepHandle, const char *accountName) → byte`
|
||||
@@ -2506,7 +2956,7 @@ enum Offsets {
|
||||
// Hash-table lookup that returns the security category index
|
||||
// (0=SECURE, 1=INSECURE, 2=BANNED). Defaults to `1` (INSECURE)
|
||||
// when the entry isn't in the override table.
|
||||
FUN_ADDON_IS_LOAD_ON_DEMAND = 0x0051E6F0,
|
||||
FUN_ADDON_IS_LOADED = 0x0051E6F0,
|
||||
FUN_ADDON_CAN_LOAD = 0x0051E780,
|
||||
FUN_ADDON_GET_SECURITY_INDEX = 0x0051E990,
|
||||
|
||||
@@ -3139,4 +3589,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,379 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Embedded `!!!ClassicAPI` addon fallback. When the user doesn't have
|
||||
// the addon installed on disk, this module makes the engine think it
|
||||
// does — without writing anything to the filesystem.
|
||||
//
|
||||
// How:
|
||||
//
|
||||
// 1. The CMake build embeds every file under `AddOns/!!!ClassicAPI/`
|
||||
// into a generated header (`embedded_classicapi.h`) as a byte
|
||||
// array per file plus a `{path, data, size}` manifest.
|
||||
//
|
||||
// 2. We hook the engine's file-read function at `FUN_FILE_READ`.
|
||||
// When the engine asks for a path under
|
||||
// `Interface\AddOns\!!!ClassicAPI\`, we allocate a Storm buffer
|
||||
// via the same allocator the engine itself uses, copy the
|
||||
// embedded content in, and hand it back. The caller's normal
|
||||
// `SMemFree` reclaims it on completion — no special lifetime.
|
||||
//
|
||||
// 3. We post-hook the engine's `FUN_ADDON_INIT`. After the engine's
|
||||
// own `Interface\AddOns\` scan finishes, we call the TOC parser
|
||||
// directly with `"!!!ClassicAPI"`. The parser opens its TOC via
|
||||
// our hooked read function and registers the addon as a normal
|
||||
// entry. The TOC parser is dedup-safe: if the user already has
|
||||
// the addon on disk, the engine's scan picked it up, the
|
||||
// hash-table lookup hits, and our call is a no-op. So the
|
||||
// conditional "only if not on disk" behavior is free.
|
||||
//
|
||||
// Same pattern WeirdUtils.dll uses (we reverse-engineered it from
|
||||
// `0x10015A74` while figuring this out). The addon load pass then
|
||||
// reads each `.lua` file through the same hooked read path —
|
||||
// `Compat.lua`, `Util\Print.lua`, etc. all surface from
|
||||
// embedded buffers.
|
||||
|
||||
#include "embedded_classicapi.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "bindings/Inject.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
namespace Addons::Embedded {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kAddonName = "!!!ClassicAPI";
|
||||
constexpr const char *kAddonTocFile = "!!!ClassicAPI.toc";
|
||||
|
||||
// `Interface\AddOns\!!!ClassicAPI\` — the prefix the engine
|
||||
// constructs for any of our addon's files. Comparing case-
|
||||
// insensitively because the TOC parser uses `Interface\AddOns\%s\…`
|
||||
// where %s comes from the on-disk directory name, and Windows file
|
||||
// paths are case-insensitive.
|
||||
constexpr const char *kAddonPathPrefix = "Interface\\AddOns\\!!!ClassicAPI\\";
|
||||
constexpr size_t kAddonPathPrefixLen = 30; // length of the literal above
|
||||
|
||||
char NormalizeChar(char c) {
|
||||
if (c == '/') return '\\';
|
||||
if (c >= 'A' && c <= 'Z') return static_cast<char>(c + 32);
|
||||
return c;
|
||||
}
|
||||
|
||||
bool PathEqualsCI(const char *a, const char *b) {
|
||||
while (*a && *b) {
|
||||
if (NormalizeChar(*a) != NormalizeChar(*b)) return false;
|
||||
++a; ++b;
|
||||
}
|
||||
return *a == *b;
|
||||
}
|
||||
|
||||
// Strip the `Interface\AddOns\!!!ClassicAPI\` prefix and return the
|
||||
// suffix (e.g. `Util\Print.lua`) on a match, NULL otherwise.
|
||||
const char *StripAddonPrefix(const char *path) {
|
||||
if (path == nullptr) return nullptr;
|
||||
const char *p = path;
|
||||
const char *q = kAddonPathPrefix;
|
||||
while (*q) {
|
||||
if (*p == '\0') return nullptr;
|
||||
if (NormalizeChar(*p) != NormalizeChar(*q)) return nullptr;
|
||||
++p; ++q;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
const ClassicAPIFiles::File *LookupEmbedded(const char *suffix) {
|
||||
for (size_t i = 0; i < ClassicAPIFiles::kFileCount; ++i) {
|
||||
if (PathEqualsCI(suffix, ClassicAPIFiles::kFiles[i].path))
|
||||
return &ClassicAPIFiles::kFiles[i];
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Storm allocator — same one `FUN_FILE_READ` uses internally. Buffer
|
||||
// allocated here is freed cleanly by the caller's standard `SMemFree`
|
||||
// (`FUN_STORM_SMEM_FREE`). `__stdcall` per the function's `RET 0x10`
|
||||
// (4 args × 4 bytes = 16) epilogue.
|
||||
// __stdcall void *SMemAlloc(size_t size, const char *file, int line, int flags)
|
||||
using SMemAlloc_t = void *(__stdcall *)(size_t size, const char *file, int line, int flags);
|
||||
|
||||
// `FUN_FILE_READ` — same shape derived from the Octo decompile.
|
||||
// Calling convention is `__stdcall` (callee cleans 28 bytes via
|
||||
// `RET 0x1C`), not `__cdecl` — confirmed via the function epilogue.
|
||||
// Getting this wrong silently corrupts the caller's stack frame.
|
||||
// __stdcall int FileRead(int unused, const char *path, void **outBuf,
|
||||
// size_t *outSize, size_t extraBytes,
|
||||
// int flag1, int flag2)
|
||||
using FileRead_t = int(__stdcall *)(int unused, const char *path, void **outBuf,
|
||||
size_t *outSize, size_t extraBytes,
|
||||
int flag1, int flag2);
|
||||
FileRead_t FileRead_o = nullptr;
|
||||
|
||||
// Storm-allocator free. Used to release the disk-TOC buffer after we
|
||||
// scrape its version line — same path the engine itself uses when
|
||||
// `FileRead_o` succeeded and the caller's done with the result.
|
||||
// __stdcall void SMemFree(void *buf, const char *file, int line, int flags)
|
||||
using SMemFree_t = void(__stdcall *)(void *buf, const char *file, int line, int flags);
|
||||
|
||||
// Extracts the value of the `## Version: X` line from a TOC byte
|
||||
// buffer. Writes into `out` (size `outSize`) and returns true on
|
||||
// success. The TOC format is `## Key: Value`, one per line, case-
|
||||
// sensitive on the key. Returns false if no Version line is found.
|
||||
bool ExtractTocVersion(const char *content, size_t size,
|
||||
char *out, size_t outSize) {
|
||||
static const char kKey[] = "## Version:";
|
||||
constexpr size_t kKeyLen = sizeof(kKey) - 1;
|
||||
for (size_t i = 0; i + kKeyLen <= size; ++i) {
|
||||
const bool atLineStart = (i == 0) || content[i - 1] == '\n';
|
||||
if (!atLineStart) continue;
|
||||
if (std::memcmp(content + i, kKey, kKeyLen) != 0) continue;
|
||||
const char *p = content + i + kKeyLen;
|
||||
const char *end = content + size;
|
||||
while (p < end && (*p == ' ' || *p == '\t')) ++p;
|
||||
size_t j = 0;
|
||||
while (p < end && *p != '\r' && *p != '\n' && j + 1 < outSize) {
|
||||
out[j++] = *p++;
|
||||
}
|
||||
while (j > 0 && (out[j - 1] == ' ' || out[j - 1] == '\t')) --j;
|
||||
out[j] = '\0';
|
||||
return j > 0;
|
||||
}
|
||||
if (outSize > 0) out[0] = '\0';
|
||||
return false;
|
||||
}
|
||||
|
||||
// Returns -1/0/+1 for `a < b` / `a == b` / `a > b`. "DEV" is a
|
||||
// sentinel meaning "newer than any real release" — local dev builds
|
||||
// always win against installed-on-disk versions. Otherwise the two
|
||||
// strings are walked as dot-separated numeric semver components
|
||||
// (`1.2` < `1.10`).
|
||||
int CompareVersions(const char *a, const char *b) {
|
||||
const bool aDev = std::strcmp(a, "DEV") == 0;
|
||||
const bool bDev = std::strcmp(b, "DEV") == 0;
|
||||
if (aDev && bDev) return 0;
|
||||
if (aDev) return 1;
|
||||
if (bDev) return -1;
|
||||
while (*a || *b) {
|
||||
int va = 0, vb = 0;
|
||||
while (*a >= '0' && *a <= '9') { va = va * 10 + (*a - '0'); ++a; }
|
||||
while (*b >= '0' && *b <= '9') { vb = vb * 10 + (*b - '0'); ++b; }
|
||||
if (va != vb) return va < vb ? -1 : 1;
|
||||
if (*a == '.') ++a;
|
||||
if (*b == '.') ++b;
|
||||
if (!*a && !*b) break;
|
||||
if (!*a) return -1;
|
||||
if (!*b) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Pre-extracted embedded TOC version, populated lazily on first
|
||||
// FileRead_h call under our prefix. Empty until populated.
|
||||
char g_embeddedVersion[64] = "";
|
||||
|
||||
// Which source wins the embed-vs-disk comparison, decided once on
|
||||
// first relevant file read.
|
||||
enum class Source { Undecided, Disk, Embedded };
|
||||
Source g_source = Source::Undecided;
|
||||
|
||||
void EnsureEmbeddedVersionExtracted() {
|
||||
if (g_embeddedVersion[0] != '\0') return;
|
||||
for (size_t i = 0; i < ClassicAPIFiles::kFileCount; ++i) {
|
||||
if (PathEqualsCI(kAddonTocFile, ClassicAPIFiles::kFiles[i].path)) {
|
||||
ExtractTocVersion(
|
||||
reinterpret_cast<const char *>(ClassicAPIFiles::kFiles[i].data),
|
||||
ClassicAPIFiles::kFiles[i].size,
|
||||
g_embeddedVersion, sizeof(g_embeddedVersion));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decide whether disk or embedded should serve all reads for this
|
||||
// addon. Called on the first FileRead_h call with a matching prefix.
|
||||
// Reads the on-disk TOC via the original `FUN_FILE_READ` (bypassing
|
||||
// our hook), parses its version, compares to the embedded version,
|
||||
// and caches the winner. If disk has no TOC at all, embedded wins by
|
||||
// default.
|
||||
void DecideSource() {
|
||||
if (g_source != Source::Undecided) return;
|
||||
EnsureEmbeddedVersionExtracted();
|
||||
|
||||
char fullPath[256];
|
||||
std::snprintf(fullPath, sizeof(fullPath), "%s%s",
|
||||
kAddonPathPrefix, kAddonTocFile);
|
||||
|
||||
void *diskBuf = nullptr;
|
||||
size_t diskSize = 0;
|
||||
const int ok = FileRead_o(0, fullPath, &diskBuf, &diskSize, 1, 1, 0);
|
||||
if (ok == 0 || diskBuf == nullptr) {
|
||||
g_source = Source::Embedded;
|
||||
return;
|
||||
}
|
||||
|
||||
char diskVersion[64] = "";
|
||||
ExtractTocVersion(static_cast<const char *>(diskBuf), diskSize,
|
||||
diskVersion, sizeof(diskVersion));
|
||||
|
||||
// Free the disk buffer we just borrowed — we only needed it for
|
||||
// the version-line scrape. The actual content for the engine's
|
||||
// TOC read comes through the regular hook path below.
|
||||
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
|
||||
SMemFree(diskBuf, __FILE__, __LINE__, 0);
|
||||
|
||||
// Missing or unparseable disk version → assume it's older than
|
||||
// anything we ship, embedded wins.
|
||||
if (diskVersion[0] == '\0') {
|
||||
g_source = Source::Embedded;
|
||||
return;
|
||||
}
|
||||
|
||||
const int cmp = CompareVersions(g_embeddedVersion, diskVersion);
|
||||
g_source = (cmp > 0) ? Source::Embedded : Source::Disk;
|
||||
}
|
||||
|
||||
int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
|
||||
size_t *outSize, size_t extraBytes,
|
||||
int flag1, int flag2) {
|
||||
// FrameXML Bindings.xml gets an inline splice for our TARGETING
|
||||
// additions — see `bindings/Inject.cpp`. Returns true with the
|
||||
// patched buffer ready for the engine's binding parser to consume.
|
||||
if (Bindings::Inject::TryHandle(unused, path, outBuf, outSize,
|
||||
extraBytes, flag1, flag2, FileRead_o)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const char *suffix = StripAddonPrefix(path);
|
||||
if (suffix == nullptr) {
|
||||
// Path isn't under `Interface\AddOns\!!!ClassicAPI\` — straight
|
||||
// pass-through, we don't care.
|
||||
return FileRead_o(unused, path, outBuf, outSize, extraBytes,
|
||||
flag1, flag2);
|
||||
}
|
||||
|
||||
DecideSource();
|
||||
|
||||
if (g_source == Source::Disk) {
|
||||
// Disk version is at least as new — serve disk. If disk
|
||||
// doesn't have this specific file (e.g. embedded has files
|
||||
// disk doesn't), fall through and try embedded.
|
||||
const int diskResult = FileRead_o(unused, path, outBuf, outSize,
|
||||
extraBytes, flag1, flag2);
|
||||
if (diskResult != 0) return diskResult;
|
||||
}
|
||||
|
||||
// Source::Embedded path, OR Source::Disk-but-file-missing fallback.
|
||||
// Look up the embedded version and synthesize a Storm buffer the
|
||||
// caller can free normally.
|
||||
const auto *entry = LookupEmbedded(suffix);
|
||||
if (entry == nullptr) {
|
||||
// Not in our embedded set either — let the engine try disk
|
||||
// one more time (returns 0 if that also fails). Covers
|
||||
// pathological cases like an empty disk install + embedded
|
||||
// missing some file.
|
||||
return FileRead_o(unused, path, outBuf, outSize, extraBytes,
|
||||
flag1, flag2);
|
||||
}
|
||||
|
||||
auto SMemAlloc = reinterpret_cast<SMemAlloc_t>(
|
||||
Offsets::FUN_STORM_SMEM_ALLOC);
|
||||
const size_t totalSize = entry->size + extraBytes;
|
||||
void *buf = SMemAlloc(totalSize, __FILE__, __LINE__, 0);
|
||||
if (buf == nullptr) return 0;
|
||||
std::memcpy(buf, entry->data, entry->size);
|
||||
if (extraBytes > 0) {
|
||||
std::memset(static_cast<uint8_t *>(buf) + entry->size, 0, extraBytes);
|
||||
}
|
||||
if (outBuf != nullptr) *outBuf = buf;
|
||||
if (outSize != nullptr) *outSize = entry->size;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `FUN_ADDON_INIT` — `__fastcall(accountName)`. We post-hook it: let
|
||||
// the engine's normal `Interface\AddOns\` scan run first, then call
|
||||
// the TOC parser with `"!!!ClassicAPI"`. Dedup-safe — see file header.
|
||||
using AddonInit_t = void(__fastcall *)(const char *accountName);
|
||||
AddonInit_t AddonInit_o = nullptr;
|
||||
|
||||
using TocParser_t = void(__fastcall *)(const char *name);
|
||||
using ListInsert_t = void(__thiscall *)(void *listCtrl, void *entry,
|
||||
int position, int anchor);
|
||||
|
||||
// `FUN_TOC_PARSER` appends new entries to the *tail* of the addon
|
||||
// registry's linked list, and the engine's addon-load pass at
|
||||
// `FUN_0051F600` walks that list in *insertion order* (NOT
|
||||
// alphabetical order — the alphabetical sort only applies to the
|
||||
// flat array `GetAddOnInfo(i)` reads from). So our post-hook
|
||||
// injection lands at the very end of the load order, after every
|
||||
// disk-scanned addon. Addons that consume our globals from their
|
||||
// main chunk (idTip's `FrameWatcher`, etc.) would see `nil` and
|
||||
// hard-error during their boot.
|
||||
//
|
||||
// Fix: walk the linked list, find our entry by name, and re-link
|
||||
// it to the head via `FUN_INTRUSIVE_LIST_INSERT(position=1)`. The
|
||||
// engine's own insert helper handles remove-then-insert atomically,
|
||||
// so it's safe to call on an entry that's already in the list.
|
||||
//
|
||||
// Entries are pointed to by `[VAR_ADDON_LIST_HEAD]` (`0x00BE1B6C`),
|
||||
// with the next-pointer field at `entry + 0x10` (computed as
|
||||
// `*(value-at-VAR_ADDON_LIST_CTRL) + entry + 4` = `0xC + entry + 4`
|
||||
// = `entry + 0x10`). Low-bit-1 means "sentinel" (end of list).
|
||||
// Name pointer lives at `entry + 0x14`.
|
||||
constexpr uintptr_t VAR_ADDON_LIST_HEAD = 0x00BE1B6C;
|
||||
constexpr int OFF_ADDON_ENTRY_NAME_PTR = 0x14;
|
||||
|
||||
void MoveEmbeddedToListHead() {
|
||||
uintptr_t entry = *reinterpret_cast<uintptr_t *>(VAR_ADDON_LIST_HEAD);
|
||||
const int linkOffset = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL));
|
||||
while ((entry & 1) == 0 && entry != 0) {
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
entry + OFF_ADDON_ENTRY_NAME_PTR);
|
||||
if (name != nullptr && std::strcmp(name, kAddonName) == 0) {
|
||||
auto fn = reinterpret_cast<ListInsert_t>(
|
||||
Offsets::FUN_INTRUSIVE_LIST_INSERT);
|
||||
fn(reinterpret_cast<void *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL)),
|
||||
reinterpret_cast<void *>(entry),
|
||||
/*position=*/ 1, /*anchor=*/ 0);
|
||||
return;
|
||||
}
|
||||
entry = *reinterpret_cast<const uintptr_t *>(
|
||||
entry + linkOffset + 4);
|
||||
}
|
||||
}
|
||||
|
||||
void __fastcall AddonInit_h(const char *accountName) {
|
||||
AddonInit_o(accountName);
|
||||
auto TocParser = reinterpret_cast<TocParser_t>(Offsets::FUN_TOC_PARSER);
|
||||
TocParser(kAddonName);
|
||||
MoveEmbeddedToListHead();
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookFileRead{
|
||||
Offsets::FUN_FILE_READ,
|
||||
reinterpret_cast<void *>(&FileRead_h),
|
||||
reinterpret_cast<void **>(&FileRead_o)};
|
||||
|
||||
const Game::HookAutoRegister _hookAddonInit{
|
||||
Offsets::FUN_ADDON_INIT,
|
||||
reinterpret_cast<void *>(&AddonInit_h),
|
||||
reinterpret_cast<void **>(&AddonInit_o)};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Addons::Embedded
|
||||
+51
-4
@@ -115,13 +115,24 @@ int PushFieldOrNil(void *L, FieldByName_t accessor) {
|
||||
// `C_AddOns.GetAddOnName(indexOrName)` — the addon's directory name.
|
||||
// For numeric input, returns the engine's canonical casing. For
|
||||
// string input, returns the input verbatim once existence is
|
||||
// confirmed.
|
||||
// confirmed; nil for an unregistered name.
|
||||
//
|
||||
// String-input existence isn't free: `ResolveAddOnName` echoes the
|
||||
// string for the field accessors below (`GetAddOnTitle`, etc.) which
|
||||
// short-circuit cleanly on unknown names via the engine's own NULL-
|
||||
// return paths. `GetAddOnName` doesn't call any engine accessor, so
|
||||
// it'd otherwise echo any string. Probe the registry explicitly.
|
||||
int __fastcall Script_GetAddOnName(void *L) {
|
||||
const uint8_t *entry = ResolveAddOnName(L);
|
||||
if (entry == nullptr) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_STRING &&
|
||||
!AddOnExistsByName(reinterpret_cast<const char *>(entry))) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
Game::Lua::PushString(L, reinterpret_cast<const char *>(entry));
|
||||
return 1;
|
||||
}
|
||||
@@ -163,9 +174,13 @@ int __fastcall Script_IsAddOnLoadable(void *L) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
auto isLoD = reinterpret_cast<EntryByteFn_t>(
|
||||
Offsets::FUN_ADDON_IS_LOAD_ON_DEMAND);
|
||||
if (isLoD(entry) != 0) {
|
||||
// Already-loaded addons are trivially loadable — skip the
|
||||
// recursive dep walk and return (true, nil) directly. `IS_LOADED`
|
||||
// reads the byte at `entry+0x18` (the loader sets that to 1 on
|
||||
// successful load).
|
||||
auto isLoaded = reinterpret_cast<EntryByteFn_t>(
|
||||
Offsets::FUN_ADDON_IS_LOADED);
|
||||
if (isLoaded(entry) != 0) {
|
||||
Game::Lua::PushBoolean(L, 1);
|
||||
Game::Lua::PushNil(L);
|
||||
return 2;
|
||||
@@ -229,6 +244,36 @@ int __fastcall Script_GetAddOnSecurity(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_AddOns.IsAddOnLoaded(indexOrName)` → `(loadedOrLoading, loaded)`.
|
||||
//
|
||||
// Modern WoW distinguishes "load-in-progress" from "fully loaded" —
|
||||
// matters for LoD addons whose load is split across multiple
|
||||
// `LoadAddOn` callbacks. Vanilla 1.12's `FUN_0051F240` is fully
|
||||
// synchronous: the loaded byte at `entry+0x18` flips from 0 → 1
|
||||
// inside a single call, so the "loading" state is never observable
|
||||
// from Lua. We return the same boolean twice — `loadedOrLoading`
|
||||
// and `loaded` are identical here.
|
||||
//
|
||||
// Resolves both numeric indices (via `FUN_ADDON_GET_BY_INDEX`) and
|
||||
// string names. `IS_LOADED` accepts an entry pointer as its name arg
|
||||
// because the entry's first 12 bytes are the inline NUL-terminated
|
||||
// directory name (same as the other per-field accessors), so we can
|
||||
// feed it either an entry or a Lua-side string transparently.
|
||||
int __fastcall Script_IsAddOnLoaded(void *L) {
|
||||
const uint8_t *entry = ResolveAddOnName(L);
|
||||
if (entry == nullptr) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 2;
|
||||
}
|
||||
auto isLoaded = reinterpret_cast<EntryByteFn_t>(
|
||||
Offsets::FUN_ADDON_IS_LOADED);
|
||||
const bool loaded = isLoaded(entry) != 0;
|
||||
Game::Lua::PushBool(L, loaded);
|
||||
Game::Lua::PushBool(L, loaded);
|
||||
return 2;
|
||||
}
|
||||
|
||||
// `C_AddOns.DoesAddOnExist(indexOrName)` — true iff the engine's
|
||||
// addon registry has a matching entry. For numeric input we trust
|
||||
// `GetByIndex`'s OOR-NULL; for string input we walk the flat array,
|
||||
@@ -276,6 +321,8 @@ static void RegisterLuaFunctions() {
|
||||
&Script_GetAddOnSecurity);
|
||||
Game::Lua::RegisterTableFunction("C_AddOns", "DoesAddOnExist",
|
||||
&Script_DoesAddOnExist);
|
||||
Game::Lua::RegisterTableFunction("C_AddOns", "IsAddOnLoaded",
|
||||
&Script_IsAddOnLoaded);
|
||||
Game::Lua::RegisterIntegerEnum(
|
||||
"Enum", "AddOnSecurityStatus",
|
||||
kAddOnSecurityStatusEntries,
|
||||
|
||||
+11
-4
@@ -110,6 +110,13 @@ const uint8_t *FindPlayerBuffEntry(uint32_t spellID) {
|
||||
// what Lua's `GetTime()` returns (engineMs * 0.001), so converting
|
||||
// `expirationMs * 0.001` lands directly on the same timeline addons
|
||||
// compare against on the Lua side. No epoch reconciliation needed.
|
||||
//
|
||||
// **Read as `uint32_t`**: GetTickCount overflows `INT_MAX` at ~24.86
|
||||
// days of system uptime. A signed read flips negative past that
|
||||
// point, which broke a `<= 0` early-bail that was supposed to catch
|
||||
// only the "no timing" (0) sentinel. The engine's own arithmetic
|
||||
// works on the unsigned tick because both sides of `expiration -
|
||||
// currentMs` wrap together.
|
||||
double PlayerBuffExpirationSeconds(const uint8_t *entry) {
|
||||
if (entry == nullptr)
|
||||
return 0.0;
|
||||
@@ -117,12 +124,12 @@ double PlayerBuffExpirationSeconds(const uint8_t *entry) {
|
||||
entry + Offsets::OFF_PLAYER_BUFF_SLOT_CODE);
|
||||
if (slotCode < 0)
|
||||
return 0.0;
|
||||
auto *expirationTable = reinterpret_cast<const int *>(
|
||||
auto *expirationTable = reinterpret_cast<const uint32_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_PLAYER_BUFF_EXPIRATION_TABLE));
|
||||
const int expirationMs = expirationTable[slotCode];
|
||||
if (expirationMs <= 0)
|
||||
const uint32_t expirationMs = expirationTable[slotCode];
|
||||
if (expirationMs == 0)
|
||||
return 0.0;
|
||||
return static_cast<double>(static_cast<uint32_t>(expirationMs)) * 0.001;
|
||||
return static_cast<double>(expirationMs) * 0.001;
|
||||
}
|
||||
|
||||
// Computes the spell's base duration in seconds via Spell.dbc →
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<!-- Spliced into the engine's Interface\FrameXML\Bindings.xml at
|
||||
load time by src/bindings/Inject.cpp. The `header` attribute on
|
||||
the root tag tells the splicer which engine section to extend —
|
||||
it picks the appropriate insertion point so the bindings appear
|
||||
at the tail of that group in the keybind UI. The engine's own
|
||||
XML parser ignores attributes on the root tag, so this is
|
||||
metadata for the splicer only. -->
|
||||
<Bindings header="TARGETING">
|
||||
<Binding name="FOCUSTARGET">
|
||||
FocusUnit("target");
|
||||
</Binding>
|
||||
<Binding name="TARGETFOCUS">
|
||||
TargetUnit("focus");
|
||||
</Binding>
|
||||
</Bindings>
|
||||
@@ -0,0 +1,297 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Inject ClassicAPI-owned bindings into a chosen engine section.
|
||||
//
|
||||
// Why a file-read hook rather than an addon-side Bindings.xml: vanilla
|
||||
// stores every binding in a single linearly-indexed table, and the
|
||||
// keybind UI walks that table in index order with `HEADER_*` pseudo-
|
||||
// entries splitting groups. Addon Bindings.xml files get parsed
|
||||
// AFTER FrameXML, so even with `header="TARGETING"` an addon binding
|
||||
// receives a display index higher than every engine binding — it
|
||||
// renders orphaned at the very bottom of the keybind list rather than
|
||||
// under TARGETING.
|
||||
//
|
||||
// To land inside a specific section, the binding's display index has
|
||||
// to fall between that section's `HEADER_*` entry and the next one.
|
||||
// The cleanest way to arrange that is to splice the bindings into the
|
||||
// FrameXML file the engine itself parses, so they take their indices
|
||||
// from the same running counter every native binding does.
|
||||
//
|
||||
// What gets spliced is driven by `src/bindings/Bindings.xml`. The
|
||||
// root `<Bindings header="X">` tag names the engine section to extend
|
||||
// (e.g. `"TARGETING"`); the inner `<Binding>` children are spliced
|
||||
// verbatim. The splicer locates `header="X"` in the engine file
|
||||
// (which appears on the first binding of section X), then the next
|
||||
// `header="..."` after that (start of the following section), and
|
||||
// inserts our content just before that next-section marker — so our
|
||||
// entries land at the tail of section X. If X is the last section in
|
||||
// the file, the splice goes just before `</Bindings>`.
|
||||
//
|
||||
// The engine's binding parser is `FUN_004b6f70`. It calls
|
||||
// `FUN_FILE_READ` (`0x00648620`) to load the file, then walks `<Binding>`
|
||||
// elements assigning each the next display index from a running
|
||||
// counter. By the time our injected bindings are processed, the
|
||||
// engine has already emitted the section's `HEADER_*` and a sequence
|
||||
// of native entries; ours append to that sequence and the next
|
||||
// section's header follows.
|
||||
|
||||
#include "Inject.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include "embedded_bindings.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
namespace Bindings::Inject {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kFrameXMLBindingsPath = "Interface\\FrameXML\\Bindings.xml";
|
||||
|
||||
// Parsed view of `src/bindings/Bindings.xml`:
|
||||
// - `header` is the value of `header="..."` on the root `<Bindings>`
|
||||
// tag. Tells the splicer which engine section to extend.
|
||||
// - `innerData / innerSize` point at the bytes between the
|
||||
// `<Bindings ...>` opening and `</Bindings>` closing tags.
|
||||
//
|
||||
// All-zero fields mean "embedded buffer is malformed" — defensive,
|
||||
// shouldn't happen since the source is build-time-validated. Splice
|
||||
// is skipped (engine sees its original Bindings.xml unchanged).
|
||||
struct EmbeddedConfig {
|
||||
char header[64];
|
||||
const char *innerData;
|
||||
size_t innerSize;
|
||||
};
|
||||
|
||||
// Advances `p` past any `<!-- ... -->` comment block starting at the
|
||||
// current position. Returns the new position (past the closing `-->`)
|
||||
// if a comment was skipped, or `p` unchanged if no comment was at
|
||||
// the current position. Caps at `end` if the comment is unterminated.
|
||||
const char *SkipCommentIfAny(const char *p, const char *end) {
|
||||
if (p + 4 > end) return p;
|
||||
if (std::memcmp(p, "<!--", 4) != 0) return p;
|
||||
p += 4;
|
||||
while (p + 3 <= end) {
|
||||
if (std::memcmp(p, "-->", 3) == 0)
|
||||
return p + 3;
|
||||
++p;
|
||||
}
|
||||
return end;
|
||||
}
|
||||
|
||||
// Linear scan for `marker` in `[start, end)`, skipping any `<!--...-->`
|
||||
// comment blocks (where unrelated occurrences of XML tags might live).
|
||||
const char *FindOutsideComment(const char *start, const char *end, const char *marker) {
|
||||
const size_t markerLen = std::strlen(marker);
|
||||
const char *p = start;
|
||||
while (p + markerLen <= end) {
|
||||
const char *afterComment = SkipCommentIfAny(p, end);
|
||||
if (afterComment != p) {
|
||||
p = afterComment;
|
||||
continue;
|
||||
}
|
||||
if (std::memcmp(p, marker, markerLen) == 0)
|
||||
return p;
|
||||
++p;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Parses `header="X"` out of the byte range `[tagStart, tagEnd)` (the
|
||||
// opening `<Bindings ...>` tag). Copies the value into `out` (size
|
||||
// `outSize`) and returns true on success.
|
||||
bool ExtractHeaderAttr(const char *tagStart, const char *tagEnd,
|
||||
char *out, size_t outSize) {
|
||||
static constexpr const char kAttr[] = "header=\"";
|
||||
constexpr size_t kAttrLen = sizeof(kAttr) - 1;
|
||||
for (const char *p = tagStart; p + kAttrLen < tagEnd; ++p) {
|
||||
if (std::memcmp(p, kAttr, kAttrLen) != 0) continue;
|
||||
const char *valStart = p + kAttrLen;
|
||||
const char *valEnd = valStart;
|
||||
while (valEnd < tagEnd && *valEnd != '"') ++valEnd;
|
||||
const size_t len = static_cast<size_t>(valEnd - valStart);
|
||||
if (len == 0 || len + 1 > outSize) return false;
|
||||
std::memcpy(out, valStart, len);
|
||||
out[len] = '\0';
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
EmbeddedConfig ResolveEmbeddedConfig() {
|
||||
EmbeddedConfig out = {};
|
||||
const char *content = reinterpret_cast<const char *>(kEmbeddedBindingsXml);
|
||||
const char *end = content + kEmbeddedBindingsXmlSize;
|
||||
|
||||
// Locate `<Bindings` opening tag (skipping any comment blocks
|
||||
// that mention the tag literally for documentation purposes).
|
||||
const char *open = FindOutsideComment(content, end, "<Bindings");
|
||||
if (open == nullptr) return out;
|
||||
|
||||
// Find the closing `>` of the opening tag.
|
||||
const char *tagEnd = open;
|
||||
while (tagEnd < end && *tagEnd != '>') ++tagEnd;
|
||||
if (tagEnd == end) return out;
|
||||
|
||||
// Pull out the `header="..."` attribute. Required — this tells
|
||||
// the splicer which engine section to extend.
|
||||
if (!ExtractHeaderAttr(open, tagEnd, out.header, sizeof(out.header)))
|
||||
return out;
|
||||
|
||||
const char *innerStart = tagEnd + 1;
|
||||
const char *close = FindOutsideComment(innerStart, end, "</Bindings>");
|
||||
if (close == nullptr || close < innerStart) {
|
||||
out.header[0] = '\0';
|
||||
return out;
|
||||
}
|
||||
|
||||
out.innerData = innerStart;
|
||||
out.innerSize = static_cast<size_t>(close - innerStart);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Computes the byte offset in the engine's Bindings.xml buffer where
|
||||
// our entries should be spliced — i.e., the tail of the section
|
||||
// identified by `headerName`. Strategy:
|
||||
//
|
||||
// 1. Find `header="<HEADER>"` in the engine's buffer (skipping
|
||||
// comments). This is the attribute the engine itself uses on
|
||||
// the FIRST binding of each section, so this finds where that
|
||||
// section *starts*.
|
||||
// 2. Find the NEXT `header="` after that. That marks the start of
|
||||
// the FOLLOWING section.
|
||||
// 3. Back up from that next-header position to the `<` of the
|
||||
// `<Binding` tag containing it — that's our splice point.
|
||||
// 4. If there's no next header (last section in the file), splice
|
||||
// just before `</Bindings>`.
|
||||
//
|
||||
// Returns `nullptr` if step 1 fails (header missing from engine file
|
||||
// — e.g., a heavily-modified FrameXML). Caller falls back to passing
|
||||
// the original buffer through unchanged.
|
||||
const char *FindSplicePosition(const char *content, size_t size,
|
||||
const char *headerName) {
|
||||
const char *end = content + size;
|
||||
|
||||
char headerNeedle[80];
|
||||
std::snprintf(headerNeedle, sizeof(headerNeedle),
|
||||
"header=\"%s\"", headerName);
|
||||
|
||||
const char *sectionStart = FindOutsideComment(content, end, headerNeedle);
|
||||
if (sectionStart == nullptr) return nullptr;
|
||||
|
||||
// Walk past the matched attribute, then look for the next
|
||||
// `header="` anywhere later in the file.
|
||||
const char *afterCurrent = sectionStart + std::strlen(headerNeedle);
|
||||
const char *nextHeader = FindOutsideComment(afterCurrent, end, "header=\"");
|
||||
|
||||
if (nextHeader != nullptr) {
|
||||
// Back up to the `<` of the `<Binding` tag that owns this
|
||||
// next-header attribute — that `<` is where the next section
|
||||
// visually starts, and we splice immediately before it.
|
||||
const char *p = nextHeader;
|
||||
while (p > content && *p != '<') --p;
|
||||
return p;
|
||||
}
|
||||
|
||||
// Last section in the file — splice just before `</Bindings>`.
|
||||
return FindOutsideComment(content, end, "</Bindings>");
|
||||
}
|
||||
|
||||
char NormalizeChar(char c) {
|
||||
if (c == '/') return '\\';
|
||||
if (c >= 'A' && c <= 'Z') return static_cast<char>(c + 32);
|
||||
return c;
|
||||
}
|
||||
|
||||
bool PathEqualsCI(const char *a, const char *b) {
|
||||
while (*a != '\0' && *b != '\0') {
|
||||
if (NormalizeChar(*a) != NormalizeChar(*b)) return false;
|
||||
++a; ++b;
|
||||
}
|
||||
return *a == *b;
|
||||
}
|
||||
|
||||
using SMemAlloc_t = void *(__stdcall *)(size_t size, const char *file, int line, int flags);
|
||||
using SMemFree_t = void(__stdcall *)(void *buf, const char *file, int line, int flags);
|
||||
|
||||
} // namespace
|
||||
|
||||
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
|
||||
size_t extraBytes, int flag1, int flag2, FileReadFn original) {
|
||||
if (path == nullptr || !PathEqualsCI(path, kFrameXMLBindingsPath))
|
||||
return false;
|
||||
|
||||
// Pull the real file through the original reader with the caller's
|
||||
// requested `extraBytes` honored — the original allocates a Storm
|
||||
// buffer of `fileSize + extraBytes` and zero-fills the tail.
|
||||
void *origBuf = nullptr;
|
||||
size_t origSize = 0;
|
||||
const int readOk = original(unused, path, &origBuf, &origSize,
|
||||
extraBytes, flag1, flag2);
|
||||
if (readOk == 0 || origBuf == nullptr)
|
||||
return false;
|
||||
|
||||
static const EmbeddedConfig config = ResolveEmbeddedConfig();
|
||||
if (config.innerData == nullptr || config.innerSize == 0 ||
|
||||
config.header[0] == '\0') {
|
||||
// Embedded XML is missing the `<Bindings header="...">` root
|
||||
// or the `</Bindings>` close tag. Pass the original through.
|
||||
if (outBuf != nullptr) *outBuf = origBuf;
|
||||
if (outSize != nullptr) *outSize = origSize;
|
||||
return true;
|
||||
}
|
||||
|
||||
const char *content = static_cast<const char *>(origBuf);
|
||||
const char *splice = FindSplicePosition(content, origSize, config.header);
|
||||
if (splice == nullptr) {
|
||||
// Requested section isn't present in this engine file (heavily
|
||||
// modified FrameXML or different build). Pass through unchanged
|
||||
// rather than risking corruption.
|
||||
if (outBuf != nullptr) *outBuf = origBuf;
|
||||
if (outSize != nullptr) *outSize = origSize;
|
||||
return true;
|
||||
}
|
||||
|
||||
const size_t splicePos = static_cast<size_t>(splice - content);
|
||||
const size_t newSize = origSize + config.innerSize;
|
||||
|
||||
auto SMemAlloc = reinterpret_cast<SMemAlloc_t>(Offsets::FUN_STORM_SMEM_ALLOC);
|
||||
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
|
||||
|
||||
void *newBuf = SMemAlloc(newSize + extraBytes, __FILE__, __LINE__, 0);
|
||||
if (newBuf == nullptr) {
|
||||
// Allocation failed — fall back to the original buffer so the
|
||||
// engine still loads its own bindings.
|
||||
if (outBuf != nullptr) *outBuf = origBuf;
|
||||
if (outSize != nullptr) *outSize = origSize;
|
||||
return true;
|
||||
}
|
||||
|
||||
auto *dest = static_cast<char *>(newBuf);
|
||||
std::memcpy(dest, content, splicePos);
|
||||
std::memcpy(dest + splicePos, config.innerData, config.innerSize);
|
||||
std::memcpy(dest + splicePos + config.innerSize,
|
||||
content + splicePos, origSize - splicePos);
|
||||
if (extraBytes > 0)
|
||||
std::memset(dest + newSize, 0, extraBytes);
|
||||
|
||||
SMemFree(origBuf, __FILE__, __LINE__, 0);
|
||||
|
||||
if (outBuf != nullptr) *outBuf = newBuf;
|
||||
if (outSize != nullptr) *outSize = newSize;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace Bindings::Inject
|
||||
@@ -0,0 +1,37 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace Bindings::Inject {
|
||||
|
||||
// `FUN_FILE_READ` shape — Storm-style: __stdcall, returns 1 on success
|
||||
// with the buffer allocated via SMemAlloc into `*outBuf` (size in
|
||||
// `*outSize`, plus `extraBytes` of trailing zero padding).
|
||||
using FileReadFn = int(__stdcall *)(int unused, const char *path,
|
||||
void **outBuf, size_t *outSize,
|
||||
size_t extraBytes,
|
||||
int flag1, int flag2);
|
||||
|
||||
// Path-matches `Interface\FrameXML\Bindings.xml`. On match: fetches
|
||||
// the real file via `original`, splices our ClassicAPI bindings into
|
||||
// the TARGETING block, and returns `true` with `*outBuf` / `*outSize`
|
||||
// set to a fresh Storm buffer the caller can SMemFree normally.
|
||||
// Returns `false` for any other path — caller should fall through to
|
||||
// its own logic.
|
||||
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
|
||||
size_t extraBytes, int flag1, int flag2, FileReadFn original);
|
||||
|
||||
} // namespace Bindings::Inject
|
||||
@@ -0,0 +1,216 @@
|
||||
// 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/>.
|
||||
//
|
||||
// Restores Lua 5.0's stripped `coroutine.*` library to the in-game Lua
|
||||
// state. WoW 1.12.1 ships with the C-level coroutine machinery linked
|
||||
// in (`lua_resume`, `lua_yield`, `lua_newthread`, threads as a real
|
||||
// type tag 8) but the `coroutine` global table was never registered.
|
||||
// The five trampolines below mirror Lua 5.0's `lbaselib.c` shapes,
|
||||
// cross-checked against 5.4.8's wired-up library via Ghidra (see the
|
||||
// "Cross-binary reference" technique in CLAUDE.md).
|
||||
|
||||
#include "Game.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Coroutine {
|
||||
|
||||
// Lua 5.0 result codes returned by `lua_resume`.
|
||||
static constexpr int LUA_OK = 0;
|
||||
static constexpr int LUA_YIELD = 1;
|
||||
|
||||
// Field offsets in `lua_State`, confirmed from `lua_resume`'s error
|
||||
// cleanup path (`L->ci = L->base_ci; L->base = base_ci->base`):
|
||||
// *(L+0x14) = *(L+0x28)
|
||||
// *(L+0x0C) = **(L+0x28)
|
||||
static constexpr uintptr_t OFF_L_CI = 0x14;
|
||||
static constexpr uintptr_t OFF_L_BASE_CI = 0x28;
|
||||
|
||||
// Reads `L->ci` (the current CallInfo). When greater than `L->base_ci`
|
||||
// the coroutine's body function is mid-execution (paused at a yield);
|
||||
// when equal, no Lua function is on this thread's call stack.
|
||||
static void *LuaCI(void *L) {
|
||||
return *reinterpret_cast<void **>(reinterpret_cast<uintptr_t>(L) + OFF_L_CI);
|
||||
}
|
||||
static void *LuaBaseCI(void *L) {
|
||||
return *reinterpret_cast<void **>(reinterpret_cast<uintptr_t>(L) + OFF_L_BASE_CI);
|
||||
}
|
||||
|
||||
// Coroutine status codes returned by `CoStatus` below — fed into the
|
||||
// `kStatusNames` table for the `status` API. The dense 0..3 encoding
|
||||
// also lets us label "unsuspended" states for resume's error path.
|
||||
static constexpr int CO_RUNNING = 0;
|
||||
static constexpr int CO_SUSPENDED = 1;
|
||||
static constexpr int CO_NORMAL = 2;
|
||||
static constexpr int CO_DEAD = 3;
|
||||
|
||||
static const char *const kStatusNames[] = {
|
||||
"running",
|
||||
"suspended",
|
||||
"normal",
|
||||
"dead",
|
||||
};
|
||||
|
||||
// Inner status classifier. Equivalent to Lua 5.0's `auxstatus` from
|
||||
// lbaselib.c, but we can't rely on `L->status` here: this build's
|
||||
// `lua_yield` doesn't write status (it sets a CI state flag bit
|
||||
// `0x10` instead), so the canonical "ttype(status) == LUA_YIELD"
|
||||
// check fails. We use the `ci > base_ci` signal instead — true iff
|
||||
// a Lua function is currently paused on this thread's call stack.
|
||||
//
|
||||
// State map:
|
||||
// co->ci > co->base_ci → SUSPENDED (yielded; body paused)
|
||||
// co->ci == co->base_ci, top==0 → DEAD (returned, all consumed)
|
||||
// co->ci == co->base_ci, top>0 → SUSPENDED (initial: fn pushed,
|
||||
// never resumed)
|
||||
// After AuxResume's XMove cycle, both "returned" and "yielded" leave
|
||||
// co's stack empty; the ci comparison is what distinguishes them.
|
||||
// "Normal" (coroutine that resumed a deeper coroutine — appears as
|
||||
// the parent in the call chain) is rare and would require walking
|
||||
// the global thread list; we collapse it into "suspended".
|
||||
static int CoStatus(void *L, void *co) {
|
||||
if (co == L)
|
||||
return CO_RUNNING;
|
||||
if (LuaCI(co) > LuaBaseCI(co))
|
||||
return CO_SUSPENDED;
|
||||
return Game::Lua::GetTop(co) == 0 ? CO_DEAD : CO_SUSPENDED;
|
||||
}
|
||||
|
||||
// Common back-end for resume / wrap. Returns nres ≥ 0 on success
|
||||
// (with values already xmove'd onto L), or -1 on error (with the
|
||||
// error message left on L's stack).
|
||||
//
|
||||
// WoW's modified Lua doesn't follow the standard `lua_resume` status
|
||||
// contract: it returns `0` for normal completion and `1` for both
|
||||
// "yielded" AND "errored mid-execution". We disambiguate the two
|
||||
// `s==1` cases by checking the call-stack depth — a yielded
|
||||
// coroutine still has its body paused (`ci > base_ci`), while an
|
||||
// errored coroutine has been unwound back to `base_ci`. Errors hit
|
||||
// from the "dead/already-resumed" early-out (e.g. the inner
|
||||
// `cannot resume dead coroutine` path) also land in `s != 0` with
|
||||
// `ci == base_ci`, so the same branch covers them.
|
||||
static int AuxResume(void *L, void *co, int nargs) {
|
||||
const int st = CoStatus(L, co);
|
||||
if (st != CO_SUSPENDED) {
|
||||
Game::Lua::PushString(L, st == CO_DEAD
|
||||
? "cannot resume dead coroutine"
|
||||
: "cannot resume non-suspended coroutine");
|
||||
return -1;
|
||||
}
|
||||
Game::Lua::XMove(L, co, nargs);
|
||||
const int s = Game::Lua::Resume(co, nargs);
|
||||
const bool yielded = LuaCI(co) > LuaBaseCI(co);
|
||||
if (s == LUA_OK || yielded) {
|
||||
const int nres = Game::Lua::GetTop(co);
|
||||
Game::Lua::XMove(co, L, nres);
|
||||
return nres;
|
||||
}
|
||||
// Error path: engine left a single error message on co's stack.
|
||||
Game::Lua::XMove(co, L, 1);
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int __fastcall Script_Coroutine_Create(void *L) {
|
||||
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_FUNCTION || Game::Lua::IsCFunction(L, 1)) {
|
||||
Game::Lua::ArgError(L, 1, "Lua function expected");
|
||||
return 0; // unreachable
|
||||
}
|
||||
Game::Lua::NewThread(L); // [arg1, NL]
|
||||
void *NL = Game::Lua::ToThread(L, -1);
|
||||
if (NL == nullptr) {
|
||||
Game::Lua::Error(L, "coroutine.create: failed to allocate thread");
|
||||
return 0; // unreachable
|
||||
}
|
||||
Game::Lua::PushValue(L, 1); // [arg1, NL, fn]
|
||||
Game::Lua::XMove(L, NL, 1); // [arg1, NL]; NL has fn
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int __fastcall Script_Coroutine_Yield(void *L) {
|
||||
return Game::Lua::Yield(L, Game::Lua::GetTop(L));
|
||||
}
|
||||
|
||||
static int __fastcall Script_Coroutine_Resume(void *L) {
|
||||
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_THREAD) {
|
||||
Game::Lua::ArgError(L, 1, "coroutine expected");
|
||||
return 0; // unreachable
|
||||
}
|
||||
void *co = Game::Lua::ToThread(L, 1);
|
||||
const int nargs = Game::Lua::GetTop(L) - 1;
|
||||
const int r = AuxResume(L, co, nargs);
|
||||
if (r < 0) {
|
||||
// Error message is on top of L's stack. Push `false` and
|
||||
// shuffle it below the message: [..., msg] -> [..., false, msg].
|
||||
Game::Lua::PushBool(L, false);
|
||||
Game::Lua::Insert(L, -2);
|
||||
return 2;
|
||||
}
|
||||
// Success: `r` values on top of L's stack. Push `true` and slide
|
||||
// it into position so returns are [..., true, v1, v2, ..., vr].
|
||||
Game::Lua::PushBool(L, true);
|
||||
Game::Lua::Insert(L, -(r + 1));
|
||||
return r + 1;
|
||||
}
|
||||
|
||||
static int __fastcall Script_Coroutine_Status(void *L) {
|
||||
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_THREAD) {
|
||||
Game::Lua::ArgError(L, 1, "coroutine expected");
|
||||
return 0; // unreachable
|
||||
}
|
||||
void *co = Game::Lua::ToThread(L, 1);
|
||||
Game::Lua::PushString(L, kStatusNames[CoStatus(L, co)]);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `coroutine.wrap`'s returned closure. The captured coroutine lives
|
||||
// in upvalue 1; each invocation forwards args via resume and either
|
||||
// returns the yielded/returned values directly (no { ok, ... }
|
||||
// wrapping like resume does) or re-raises on error.
|
||||
static int __fastcall WrapAux(void *L) {
|
||||
void *co = Game::Lua::ToThread(L, Game::Lua::UpvalueIndex(1));
|
||||
if (co == nullptr) {
|
||||
Game::Lua::Error(L, "coroutine.wrap: missing thread upvalue");
|
||||
return 0; // unreachable
|
||||
}
|
||||
const int nargs = Game::Lua::GetTop(L);
|
||||
const int r = AuxResume(L, co, nargs);
|
||||
if (r < 0) {
|
||||
// The error message is at -1. `lua_error`'s 1.12 wrapper takes
|
||||
// a printf-format string + va args; we pass `%s` so any `%`
|
||||
// characters in the message are treated as literal.
|
||||
const char *msg = Game::Lua::ToString(L, -1);
|
||||
Game::Lua::Error(L, "%s", msg != nullptr ? msg : "coroutine error");
|
||||
return 0; // unreachable
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
static int __fastcall Script_Coroutine_Wrap(void *L) {
|
||||
// Reuse cocreate to build the thread (validates arg, allocates,
|
||||
// moves fn into the new state). Leaves the thread on top of L.
|
||||
Script_Coroutine_Create(L);
|
||||
Game::Lua::PushCClosure(L, &WrapAux, 1); // captures thread as upvalue 1
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("coroutine", "create", &Script_Coroutine_Create);
|
||||
Game::Lua::RegisterTableFunction("coroutine", "resume", &Script_Coroutine_Resume);
|
||||
Game::Lua::RegisterTableFunction("coroutine", "yield", &Script_Coroutine_Yield);
|
||||
Game::Lua::RegisterTableFunction("coroutine", "status", &Script_Coroutine_Status);
|
||||
Game::Lua::RegisterTableFunction("coroutine", "wrap", &Script_Coroutine_Wrap);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace Coroutine
|
||||
@@ -0,0 +1,276 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `GetCursorInfo()` — backport of the modern WoW global. Surfaces
|
||||
// whatever the player has picked up (item, money, spell, macro,
|
||||
// merchant slot) as a tuple. Read from the same cursor-state globals
|
||||
// the engine's `CursorHasItem` / `CursorHasSpell` / `GetCursorMoney`
|
||||
// consult — see the cursor type table in [[src/Offsets.h]].
|
||||
//
|
||||
// Return shape per type (matches modern Lua API where it makes sense
|
||||
// for 1.12's actual storage):
|
||||
//
|
||||
// nothing on cursor — nil
|
||||
// "item", itemID, link — bag item (type 1; link is the full
|
||||
// per-instance form via the engine's link
|
||||
// builder, including enchantID and
|
||||
// random-suffix decoration)
|
||||
// "item", itemID — drag-source item (types 5/6/7 trade /
|
||||
// mail / action bar; type 9 equipment
|
||||
// slot) — only the itemID is in cursor
|
||||
// storage, no live CGItem to feed the
|
||||
// link builder
|
||||
// "money", amount — type 2; amount in copper
|
||||
// "spell", slot, bookType, spellID
|
||||
// — type 3; bookType is "spell" or "pet"
|
||||
// (1.12 only writes player-spell IDs to
|
||||
// this slot, so bookType is always
|
||||
// "spell" here; the cursor's "pet
|
||||
// action" path is a distinct type)
|
||||
// "macro", index — type 8; 1-based macro index
|
||||
// "merchant", index — type 10; 1-based merchant-roster index
|
||||
//
|
||||
// Type 4 (pet action) is not surfaced — doesn't map cleanly to a
|
||||
// modern `GetCursorInfo` string. Consumers can fall back to
|
||||
// `CursorHasItem` / engine `PickupX` calls when they need to detect
|
||||
// that.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "item/ID.h"
|
||||
#include "item/Link.h"
|
||||
#include "item/Location.h"
|
||||
#include "item/QualityColor.h"
|
||||
#include "spell/Lookup.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
|
||||
namespace Cursor::Info {
|
||||
|
||||
namespace {
|
||||
|
||||
// Engine signature is `__thiscall(this = typeFilter, guidLo, guidHi,
|
||||
// unused)`. The "this" param is just a small integer filter — Ghidra
|
||||
// shows it cast as `(void *)0x2` at every call site for items. The
|
||||
// function reads only the first two stack args (`[ebp+8]` and
|
||||
// `[ebp+0xC]`) but cleans 12 bytes via `ret 0xc`, so the caller must
|
||||
// push a third dword for stack balance — engine callers push a
|
||||
// scratch dword (`0x90`, `0x1C13`, etc.) for this. Declaring a 4th
|
||||
// arg here makes MSVC emit the matching push; without it, stack
|
||||
// imbalance of -4 bytes silently corrupts the caller's frame and
|
||||
// (downstream) wedges things like chat input.
|
||||
using ObjectFromGUID_t = uint8_t *(__thiscall *)(uintptr_t typeFilter,
|
||||
uint32_t guidLo, uint32_t guidHi,
|
||||
uint32_t unused);
|
||||
|
||||
constexpr uint32_t TYPE_EMPTY = 0;
|
||||
constexpr uint32_t TYPE_BAG_ITEM = 1;
|
||||
constexpr uint32_t TYPE_MONEY = 2;
|
||||
constexpr uint32_t TYPE_SPELL = 3;
|
||||
// Type 5 = merchant pickup. Written by `Script_PickupMerchantItem`
|
||||
// (`0x004FB760`) via `FUN_004950F0(itemID, ?, slot0Based, 5)`. The
|
||||
// itemID lands in `VAR_CURSOR_GENERIC_SLOT` and the 0-based merchant
|
||||
// slot in `VAR_CURSOR_GENERIC_SOURCE` — we push slot+1 to Lua to
|
||||
// match modern's 1-based merchant convention.
|
||||
constexpr uint32_t TYPE_MERCHANT = 5;
|
||||
// Types 6/7/9 — drag-source items from various pickup paths. All
|
||||
// share the same storage trio. Type 7 is action-bar item pickup
|
||||
// (`FUN_004E6130`), type 9 is equipment-slot pickup (`FUN_004C7300`),
|
||||
// and type 6 is another drag-source path (mail or similar). In all
|
||||
// cases the itemID is at `VAR_CURSOR_GENERIC_SLOT`. For modern API
|
||||
// compat we surface them as `"item"`.
|
||||
constexpr uint32_t TYPE_GENERIC_ITEM_LO = 6;
|
||||
constexpr uint32_t TYPE_GENERIC_ITEM_HI = 7;
|
||||
constexpr uint32_t TYPE_MACRO = 8;
|
||||
constexpr uint32_t TYPE_INVENTORY_ITEM = 9;
|
||||
// Type 10 is stable-pet pickup (`FUN_00495010`, the source of the
|
||||
// earlier misidentification as "merchant"). Modern WoW has no
|
||||
// equivalent `GetCursorInfo` return for stable pets, so we leave it
|
||||
// as nil.
|
||||
|
||||
constexpr uint32_t GUID_TYPE_FILTER_ITEM = 0x2;
|
||||
|
||||
uint32_t ReadVar(uintptr_t va) {
|
||||
return *reinterpret_cast<const uint32_t *>(va);
|
||||
}
|
||||
|
||||
int PushItem(void *L, const uint8_t *cgItem) {
|
||||
if (cgItem == nullptr)
|
||||
return 0;
|
||||
const int itemID = Item::ID::FromCGItem(cgItem);
|
||||
if (itemID <= 0)
|
||||
return 0;
|
||||
Game::Lua::PushString(L, "item");
|
||||
Game::Lua::PushNumber(L, static_cast<double>(itemID));
|
||||
// Engine link builder works fine on cursored items — the cursor
|
||||
// leaves the instance block at `+0x08` and the m_objectFields
|
||||
// descriptor at `+0x114` intact, which is all the builder reads
|
||||
// (plus a virtual call into the sub-object at `+0x110`, whose
|
||||
// vftable is also untouched while held on the cursor). Earlier
|
||||
// crash repros were the stack-imbalance bug in the GUID resolver
|
||||
// — see Offsets::FUN_OBJECT_FROM_GUID.
|
||||
const char *link = Item::Link::FromCGItem(cgItem);
|
||||
if (link != nullptr && *link != '\0') {
|
||||
Game::Lua::PushString(L, link);
|
||||
return 3;
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
|
||||
int PushBagItemCursor(void *L) {
|
||||
const uint32_t lo = ReadVar(Offsets::VAR_CURSOR_ITEM_GUID_LO);
|
||||
const uint32_t hi = ReadVar(Offsets::VAR_CURSOR_ITEM_GUID_HI);
|
||||
if (lo == 0 && hi == 0)
|
||||
return 0;
|
||||
auto resolver = reinterpret_cast<ObjectFromGUID_t>(Offsets::FUN_OBJECT_FROM_GUID);
|
||||
const uint8_t *cgItem = resolver(GUID_TYPE_FILTER_ITEM, lo, hi, 0);
|
||||
return PushItem(L, cgItem);
|
||||
}
|
||||
|
||||
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
|
||||
const uint64_t *guid, void *callback,
|
||||
void *userData, int unused);
|
||||
|
||||
const uint8_t *PeekItemRecord(uint32_t itemID) {
|
||||
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
|
||||
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
|
||||
const uint64_t zeroGuid = 0;
|
||||
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
|
||||
}
|
||||
|
||||
// Builds a basic `"|cffRRGGBB|Hitem:N:0:0:0|h[Name]|h|r"` link from
|
||||
// the cached ItemStats record for `itemID`. No enchant / random-
|
||||
// suffix decoration — the cursor only stores the bare itemID for
|
||||
// drag-source items (types 6/7/9) with no live `CGItem *` to read
|
||||
// instance state from. Returns true and writes into `out` (size
|
||||
// `outSize`) on success; returns false if the item isn't cached.
|
||||
bool BuildBasicItemLink(uint32_t itemID, char *out, size_t outSize) {
|
||||
const uint8_t *record = PeekItemRecord(itemID);
|
||||
if (record == nullptr) return false;
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSTATS_NAME);
|
||||
if (name == nullptr || *name == '\0') return false;
|
||||
const uint32_t quality = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_QUALITY);
|
||||
const int n = std::snprintf(out, outSize,
|
||||
"%s|Hitem:%u:0:0:0|h[%s]|h|r",
|
||||
Item::QualityColor::Prefix(static_cast<int>(quality)),
|
||||
itemID, name);
|
||||
return n > 0 && static_cast<size_t>(n) < outSize;
|
||||
}
|
||||
|
||||
// Shared path for types 6/7 (action bar / mail drag) and type 9
|
||||
// (equipment slot). All three write the itemID at
|
||||
// `VAR_CURSOR_GENERIC_SLOT` — see the type-table notes in
|
||||
// [[src/Offsets.h]]. The cursor doesn't preserve a GUID for these,
|
||||
// so the live CGItem isn't directly addressable here. To recover
|
||||
// enchant / random-suffix decoration we walk the player's
|
||||
// equipment + bags looking for the first CGItem with a matching
|
||||
// itemID — same trick the action-bar tooltip uses to display
|
||||
// enchanted bracers correctly when they're on an action bar. Falls
|
||||
// back to a basic ID-only link when no instance is found (item was
|
||||
// deleted, or it's a vendor-roster reference with no local copy).
|
||||
int PushGenericItemCursor(void *L) {
|
||||
const uint32_t itemID = ReadVar(Offsets::VAR_CURSOR_GENERIC_SLOT);
|
||||
if (itemID == 0)
|
||||
return 0;
|
||||
Game::Lua::PushString(L, "item");
|
||||
Game::Lua::PushNumber(L, static_cast<double>(itemID));
|
||||
|
||||
Item::Location::ByGUIDResult found;
|
||||
if (Item::Location::FindByItemID(L, static_cast<int>(itemID), &found)) {
|
||||
const char *link = Item::Link::FromCGItem(found.item);
|
||||
if (link != nullptr && *link != '\0') {
|
||||
Game::Lua::PushString(L, link);
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
char linkBuf[256];
|
||||
if (BuildBasicItemLink(itemID, linkBuf, sizeof(linkBuf))) {
|
||||
Game::Lua::PushString(L, linkBuf);
|
||||
return 3;
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
|
||||
int PushMoneyCursor(void *L) {
|
||||
const uint32_t copper = ReadVar(Offsets::VAR_CURSOR_MONEY_COPPER);
|
||||
Game::Lua::PushString(L, "money");
|
||||
Game::Lua::PushNumber(L, static_cast<double>(copper));
|
||||
return 2;
|
||||
}
|
||||
|
||||
int PushSpellCursor(void *L) {
|
||||
const uint32_t spellID = ReadVar(Offsets::VAR_CURSOR_SPELL_ID);
|
||||
if (spellID == 0)
|
||||
return 0;
|
||||
int bookType = 0;
|
||||
const int slot = Spell::Lookup::FindSpellbookSlot(
|
||||
static_cast<int>(spellID), &bookType);
|
||||
Game::Lua::PushString(L, "spell");
|
||||
// Slot can be 0 (rare — talent-passive auras the player has but
|
||||
// that have no spellbook entry). Push it anyway; the modern API's
|
||||
// contract is "slot may be 0 when there's no book row".
|
||||
Game::Lua::PushNumber(L, static_cast<double>(slot));
|
||||
Game::Lua::PushString(L, bookType == 1 ? "pet" : "spell");
|
||||
Game::Lua::PushNumber(L, static_cast<double>(spellID));
|
||||
return 4;
|
||||
}
|
||||
|
||||
int PushMacroCursor(void *L) {
|
||||
const uint32_t index = ReadVar(Offsets::VAR_CURSOR_MACRO_INDEX);
|
||||
if (index == 0)
|
||||
return 0;
|
||||
Game::Lua::PushString(L, "macro");
|
||||
Game::Lua::PushNumber(L, static_cast<double>(index));
|
||||
return 2;
|
||||
}
|
||||
|
||||
int PushMerchantCursor(void *L) {
|
||||
// Engine stores the 0-based merchant slot at
|
||||
// `VAR_CURSOR_GENERIC_SOURCE`. Convert to 1-based for the modern
|
||||
// Lua-API contract (`MerchantItemButton`s and friends are
|
||||
// 1-indexed).
|
||||
const uint32_t slot0 = ReadVar(Offsets::VAR_CURSOR_GENERIC_SOURCE);
|
||||
Game::Lua::PushString(L, "merchant");
|
||||
Game::Lua::PushNumber(L, static_cast<double>(slot0 + 1));
|
||||
return 2;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetCursorInfo(void *L) {
|
||||
const uint32_t type = ReadVar(Offsets::VAR_CURSOR_TYPE);
|
||||
if (type >= TYPE_GENERIC_ITEM_LO && type <= TYPE_GENERIC_ITEM_HI)
|
||||
return PushGenericItemCursor(L);
|
||||
switch (type) {
|
||||
case TYPE_BAG_ITEM: return PushBagItemCursor(L);
|
||||
case TYPE_MERCHANT: return PushMerchantCursor(L);
|
||||
case TYPE_INVENTORY_ITEM: return PushGenericItemCursor(L);
|
||||
case TYPE_MONEY: return PushMoneyCursor(L);
|
||||
case TYPE_SPELL: return PushSpellCursor(L);
|
||||
case TYPE_MACRO: return PushMacroCursor(L);
|
||||
case TYPE_EMPTY:
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("GetCursorInfo", &Script_GetCursorInfo);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Cursor::Info
|
||||
+151
-16
@@ -35,6 +35,7 @@
|
||||
#include "Offsets.h"
|
||||
#include "Set.h"
|
||||
#include "event/Custom.h"
|
||||
#include "item/Location.h"
|
||||
#include "item/Swap.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -330,6 +331,19 @@ int __fastcall Script_ClearIgnoredSlotsForSave(void *L) {
|
||||
// would race with). Reads the per-CGItem instance flag at
|
||||
// `OFF_ITEM_CLIENT_LOCK` bit 0 — same bit `C_Item.IsLocked` checks
|
||||
// and the engine's own pickup/unlock paths manipulate.
|
||||
//
|
||||
// Gates on `Locations::FindGUID` BEFORE consulting the lock flag:
|
||||
// for items the player has deleted/mailed/traded away, the engine's
|
||||
// CGItem cache often retains a stale entry that `ResolveByGUID`
|
||||
// still hands back, but the inventory walk no longer finds the GUID
|
||||
// anywhere. Reading the lock flag off the stale CGItem reflects
|
||||
// whatever bits were last set on it (frequently the "locked"
|
||||
// in-transaction state from when it was being deleted/swapped),
|
||||
// which would falsely report the set as "contains locked items"
|
||||
// and prevent `UseEquipmentSet` from running through addon
|
||||
// wrappers that gate on this check. The inventory presence check
|
||||
// matches modern semantics: a missing item is *missing*, not
|
||||
// *locked*.
|
||||
int __fastcall Script_EquipmentSetContainsLockedItems(void *L) {
|
||||
const uint32_t setID = ArgSetID(L, 1);
|
||||
const Set *s = Data::FindByID(setID);
|
||||
@@ -340,6 +354,8 @@ int __fastcall Script_EquipmentSetContainsLockedItems(void *L) {
|
||||
const uint64_t g = s->items[i];
|
||||
if (g == GUID_EMPTY || g == GUID_IGNORED)
|
||||
continue;
|
||||
if (Locations::FindGUID(g) == 0)
|
||||
continue; // not in inventory — treat as missing, not locked
|
||||
const uint8_t *item = Locations::ResolveItemByGUID(g);
|
||||
if (item == nullptr)
|
||||
continue;
|
||||
@@ -401,39 +417,158 @@ int __fastcall Script_UseEquipmentSet(void *L) {
|
||||
uint64_t guids[SLOT_COUNT];
|
||||
std::memcpy(guids, s->items, sizeof(guids));
|
||||
|
||||
// Snapshot empty bag slots up front. The CGItem state doesn't
|
||||
// update between packet sends (server has to acknowledge each
|
||||
// swap), so iterating the bag walk fresh per unequip would
|
||||
// repeatedly pick the same "first empty" slot — second packet
|
||||
// collides with what packet 1 just put there, and the server
|
||||
// tries to swap that item back onto the wrong paperdoll slot
|
||||
// → "This item cannot be equipped".
|
||||
struct FreeSlot {
|
||||
int bag;
|
||||
int slot;
|
||||
};
|
||||
FreeSlot freeSlots[64];
|
||||
int freeSlotCount = 0;
|
||||
for (int bag = 0; bag <= 4 && freeSlotCount < 64; ++bag) {
|
||||
const int slots = Item::Location::GetBagSlotCount(bag);
|
||||
for (int slot = 1; slot <= slots && freeSlotCount < 64; ++slot) {
|
||||
if (Item::Location::ResolveBag(L, bag, slot) == nullptr)
|
||||
freeSlots[freeSlotCount++] = {bag, slot};
|
||||
}
|
||||
}
|
||||
int nextFreeSlot = 0;
|
||||
|
||||
// Pre-snapshot each set item's location against the ORIGINAL
|
||||
// state, before any swap packets fly. This mirrors how 4.3.4's
|
||||
// `Script_UseEquipmentSet` (`0x009A15D0`) builds its action list:
|
||||
// resolve all 19 source locations up front, then execute. The
|
||||
// engine's CGItem / InvMgr state lags behind our packet sends
|
||||
// (waits for server ACK), so any `FindGUID` mid-execution would
|
||||
// return stale answers and we'd re-resolve items into the wrong
|
||||
// place.
|
||||
int sourceLoc[SLOT_COUNT];
|
||||
for (int i = 0; i < SLOT_COUNT; ++i) {
|
||||
const uint64_t g = guids[i];
|
||||
if (g == GUID_EMPTY || g == GUID_IGNORED) {
|
||||
sourceLoc[i] = 0;
|
||||
continue;
|
||||
}
|
||||
sourceLoc[i] = Locations::FindGUID(g);
|
||||
}
|
||||
|
||||
// Virtual paperdoll occupant model — seeded from the engine's
|
||||
// initial paperdoll GUID array, then updated in-place after each
|
||||
// swap so phase 1 can chain rotations and phase 3 can spot
|
||||
// already-empty slots.
|
||||
uint64_t virtualPaperdoll[SLOT_COUNT];
|
||||
Locations::SnapshotPaperdoll(virtualPaperdoll);
|
||||
|
||||
// Locates the current virtual slot of a GUID on the paperdoll,
|
||||
// or 0 if it's not currently there. Used during phase 1 so
|
||||
// chained paperdoll-to-paperdoll moves operate on the *current*
|
||||
// virtual position, not the stale pre-snapshot.
|
||||
auto currentPaperdollSlot = [&](uint64_t g) -> int {
|
||||
if (g == 0)
|
||||
return 0;
|
||||
for (int slot = 1; slot <= SLOT_COUNT; ++slot) {
|
||||
if (virtualPaperdoll[slot - 1] == g)
|
||||
return slot;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
// ── Phase 1: paperdoll → paperdoll ─────────────────────────────
|
||||
// Move items already on the paperdoll into their target slots.
|
||||
// Source and destination are both paperdoll, so atomic swaps
|
||||
// never touch bag state — phase 2's bag lookups stay accurate.
|
||||
for (int i = 0; i < SLOT_COUNT; ++i) {
|
||||
const int targetSlot = i + 1;
|
||||
const uint64_t g = guids[i];
|
||||
if (g == GUID_EMPTY || g == GUID_IGNORED)
|
||||
continue;
|
||||
|
||||
const int loc = Locations::FindGUID(g);
|
||||
const int loc = sourceLoc[i];
|
||||
if (loc == 0)
|
||||
continue; // missing — nothing to do
|
||||
|
||||
// Already in its target slot? Skip.
|
||||
if ((loc & LOC_BAGS) == 0 && (loc & LOC_BANK) == 0 &&
|
||||
(loc & LOC_SLOT_MASK) == targetSlot)
|
||||
continue;
|
||||
|
||||
// In bank (main or bank-bag) — server rejects equip-from-bank.
|
||||
if (loc & LOC_BANK)
|
||||
continue;
|
||||
continue; // server rejects equip-from-bank
|
||||
if (loc & LOC_BAGS)
|
||||
continue; // phase 2
|
||||
|
||||
const int srcSlot = currentPaperdollSlot(g);
|
||||
if (srcSlot == 0 || srcSlot == targetSlot)
|
||||
continue; // missing or already there
|
||||
|
||||
const uint8_t *item = Locations::ResolveItemByGUID(g);
|
||||
if (item == nullptr)
|
||||
continue;
|
||||
|
||||
if (loc & LOC_BAGS) {
|
||||
const int srcBag = (loc >> LOC_BAG_SHIFT) & 0xFF;
|
||||
const int srcSlot = loc & LOC_SLOT_MASK;
|
||||
Item::Swap::FromBag(item, srcBag, srcSlot, targetSlot);
|
||||
} else {
|
||||
const int srcSlot = loc & LOC_SLOT_MASK;
|
||||
Item::Swap::FromPaperdoll(item, srcSlot, targetSlot);
|
||||
const uint64_t displaced = virtualPaperdoll[targetSlot - 1];
|
||||
if (Item::Swap::FromPaperdoll(item, srcSlot, targetSlot)) {
|
||||
virtualPaperdoll[targetSlot - 1] = g;
|
||||
virtualPaperdoll[srcSlot - 1] = displaced;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2: bag → paperdoll ───────────────────────────────────
|
||||
// Move items from bag slots onto the paperdoll. Each atomic swap
|
||||
// displaces the current paperdoll occupant into the bag slot the
|
||||
// incoming item vacated. Anything we still wanted on the paperdoll
|
||||
// was already placed by phase 1, so phase 2 displacements can't
|
||||
// strand a future set item in a bag.
|
||||
for (int i = 0; i < SLOT_COUNT; ++i) {
|
||||
const int targetSlot = i + 1;
|
||||
const uint64_t g = guids[i];
|
||||
if (g == GUID_EMPTY || g == GUID_IGNORED)
|
||||
continue;
|
||||
if (virtualPaperdoll[targetSlot - 1] == g)
|
||||
continue; // already at target (probably by phase 1)
|
||||
|
||||
const int loc = sourceLoc[i];
|
||||
if (loc == 0)
|
||||
continue;
|
||||
if (loc & LOC_BANK)
|
||||
continue;
|
||||
if ((loc & LOC_BAGS) == 0)
|
||||
continue; // not from a bag — phase 1 handled, or item missing
|
||||
|
||||
const uint8_t *item = Locations::ResolveItemByGUID(g);
|
||||
if (item == nullptr)
|
||||
continue;
|
||||
|
||||
const int srcBag = (loc >> LOC_BAG_SHIFT) & 0xFF;
|
||||
const int srcSlot = loc & LOC_SLOT_MASK;
|
||||
if (Item::Swap::FromBag(item, srcBag, srcSlot, targetSlot)) {
|
||||
virtualPaperdoll[targetSlot - 1] = g;
|
||||
// The displaced item lands in (srcBag, srcSlot). We
|
||||
// don't track bag-side virtual state — by phase 3 we
|
||||
// only care about the paperdoll occupant.
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 3: unequip slots the set wants empty ─────────────────
|
||||
// For each `GUID_EMPTY` slot, send the current paperdoll occupant
|
||||
// (if any) to a pre-snapshotted free bag slot. Runs last so any
|
||||
// displaced items from phases 1/2 that landed in the slot get
|
||||
// unequipped here too.
|
||||
for (int i = 0; i < SLOT_COUNT; ++i) {
|
||||
const int targetSlot = i + 1;
|
||||
if (guids[i] != GUID_EMPTY)
|
||||
continue;
|
||||
const uint64_t occupant = virtualPaperdoll[targetSlot - 1];
|
||||
if (occupant == 0)
|
||||
continue;
|
||||
if (nextFreeSlot >= freeSlotCount)
|
||||
continue;
|
||||
const uint8_t *equipped = Locations::ResolveItemByGUID(occupant);
|
||||
if (equipped == nullptr)
|
||||
continue;
|
||||
const FreeSlot &fs = freeSlots[nextFreeSlot++];
|
||||
if (Item::Swap::ToBag(equipped, targetSlot, fs.bag, fs.slot))
|
||||
virtualPaperdoll[targetSlot - 1] = 0;
|
||||
}
|
||||
|
||||
const int evt = Event::Custom::Lookup(kSwapFinishedEvent);
|
||||
if (evt >= 0)
|
||||
Event::Custom::Fire(evt, "%d%d", 1, static_cast<int>(setID));
|
||||
|
||||
@@ -140,8 +140,6 @@ uint32_t Create(const char *name, const char *icon) {
|
||||
EnsureLoaded();
|
||||
if (g_path.empty() || name == nullptr || name[0] == '\0')
|
||||
return 0;
|
||||
if (g_sets.size() >= MAX_SETS)
|
||||
return 0;
|
||||
if (FindByName(name) != nullptr)
|
||||
return 0;
|
||||
Set s;
|
||||
|
||||
@@ -177,4 +177,15 @@ const uint8_t *ResolveItemByGUID(uint64_t guid) {
|
||||
return Item::Location::ResolveByGUID(guid);
|
||||
}
|
||||
|
||||
void SnapshotPaperdoll(uint64_t *out) {
|
||||
for (int i = 0; i < SLOT_COUNT; ++i)
|
||||
out[i] = 0;
|
||||
auto *invMgr = ResolvePlayerInvMgr();
|
||||
auto *guids = InvMgrGuidArray(invMgr);
|
||||
if (guids == nullptr)
|
||||
return;
|
||||
for (int i = 0; i < SLOT_COUNT; ++i)
|
||||
out[i] = guids[i];
|
||||
}
|
||||
|
||||
} // namespace EquipmentSet::Locations
|
||||
|
||||
@@ -42,4 +42,16 @@ int FindGUID(uint64_t targetGuid);
|
||||
// isn't known to the client. Side-effect free.
|
||||
const uint8_t *ResolveItemByGUID(uint64_t guid);
|
||||
|
||||
// Copies the current paperdoll GUIDs into `out[0..SLOT_COUNT-1]`,
|
||||
// where `out[i]` is the GUID at 1-based equipment slot `i+1`.
|
||||
// Empty / unequipped slots write 0. Reads from the player invMgr's
|
||||
// flat GUID array directly — no Lua stack, no engine state changes.
|
||||
//
|
||||
// Used by `UseEquipmentSet` to seed a "virtual paperdoll" model that
|
||||
// stays current across in-flight swaps. The engine's CGItem state
|
||||
// lags behind packet sends (server has to ACK each swap), so any
|
||||
// subsequent `FindGUID` after the first packet would return stale
|
||||
// pre-swap locations. Tracking moves locally bypasses that lag.
|
||||
void SnapshotPaperdoll(uint64_t *out);
|
||||
|
||||
} // namespace EquipmentSet::Locations
|
||||
|
||||
@@ -56,11 +56,6 @@ inline const char *DefaultIcon() { return "INV_Misc_QuestionMark"; }
|
||||
// (1.15.x is also 32). 4.3.4 used the same buffer size.
|
||||
constexpr int MAX_NAME_LEN = 31;
|
||||
|
||||
// Hard cap matching 4.3.4 behavior. Modern Classic Era (1.15) raised
|
||||
// this to 30; we follow the 4.3.4 number so legacy addons that show
|
||||
// "X / 10" don't surprise users.
|
||||
constexpr int MAX_SETS = 10;
|
||||
|
||||
// `GetItemLocations` encoding — matches Blizzard FrameXML's
|
||||
// `EquipmentManager_UnpackLocation` (constants are in
|
||||
// `Constants.lua`: `ITEM_INVENTORY_LOCATION_*` and
|
||||
|
||||
@@ -105,6 +105,23 @@ int Lookup(const char *name) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int LookupByName(const char *name) {
|
||||
if (name == nullptr)
|
||||
return -1;
|
||||
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
if (base == nullptr || count <= 0)
|
||||
return -1;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const char *entryName = *reinterpret_cast<const char *const *>(
|
||||
base + i * Offsets::EVENT_ENTRY_STRIDE +
|
||||
Offsets::OFF_EVENT_ENTRY_NAME);
|
||||
if (entryName != nullptr && std::strcmp(entryName, name) == 0)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void RetryClaims() {
|
||||
for (int i = 0; i < g_reservedCount; ++i) {
|
||||
if (g_reserved[i].slot < 0)
|
||||
|
||||
@@ -47,8 +47,22 @@ struct AutoReserve {
|
||||
// claimed (e.g. before the first Lua-side `RegisterEvent` has
|
||||
// triggered `RetryClaims`). Slot indices may change across `/reload`,
|
||||
// so call this at fire time rather than caching the value.
|
||||
//
|
||||
// Only consults our reservation cache — for engine-defined events
|
||||
// (UNIT_FACTION, BAG_UPDATE, etc.) use `LookupByName`.
|
||||
int Lookup(const char *name);
|
||||
|
||||
// Walks the live engine event table at `[VAR_EVENT_TABLE_BASE_PTR]`
|
||||
// looking for an entry whose name strcmps equal to `name`. Returns
|
||||
// the slot index, or -1 if no entry matches.
|
||||
//
|
||||
// Works for both engine-defined events AND our `AutoReserve`-claimed
|
||||
// custom events (both populate the same table). Slightly slower than
|
||||
// `Lookup` (O(table size) vs O(reservations)) — prefer `Lookup` for
|
||||
// names we've reserved. Use this for engine-defined events we want to
|
||||
// fire ourselves (e.g., polyfilling missing event dispatches).
|
||||
int LookupByName(const char *name);
|
||||
|
||||
// Dispatches a custom event via the engine's printf-style event
|
||||
// dispatcher at `FUN_FIRE_EVENT` (`0x00703F50`). `format` is a
|
||||
// concatenation of `%d` (int), `%u` (uint), `%f` (double), `%s`
|
||||
@@ -74,6 +88,22 @@ inline void Fire(int eventID, const char *format, Args... args) {
|
||||
fn(eventID, format, args...);
|
||||
}
|
||||
|
||||
// Convenience for the modern WoW `(id: number, success: bool)` event
|
||||
// shape (ITEM_DATA_LOAD_RESULT, GET_ITEM_INFO_RECEIVED,
|
||||
// QUEST_DATA_LOAD_RESULT, …). The engine's printf-style dispatcher has
|
||||
// no `%b`, so a naive `%d%d` with `0` for failure surfaces `arg2 = 0` —
|
||||
// truthy in Lua, breaking the canonical `if success then` branch. We
|
||||
// push `1` for success and `nil` for failure instead, leaning on the
|
||||
// engine's `lua_pushstring(L, NULL) → lua_pushnil` tail-jump so the
|
||||
// dispatcher itself emits the nil without any pre-staging. Lua handlers
|
||||
// can then use the natural idiom: nil is falsy, `1` is truthy.
|
||||
inline void FireIdSuccess(int eventID, int id, bool success) {
|
||||
if (success)
|
||||
Fire(eventID, "%d%d", id, 1);
|
||||
else
|
||||
Fire(eventID, "%d%s", id, static_cast<const char *>(nullptr));
|
||||
}
|
||||
|
||||
// Internal: try to claim a slot for every reservation that's still
|
||||
// unclaimed (`slot < 0`). Called from the `Frame::RegisterEvent` hook
|
||||
// in DllMain — every time Lua calls `frame:RegisterEvent(...)`, we
|
||||
|
||||
+2
-28
@@ -12,36 +12,10 @@
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include "event/Custom.h"
|
||||
|
||||
namespace Event::Util {
|
||||
|
||||
// Walks the same `EventEntry` array that `Frame::RegisterEvent` strcmps
|
||||
// against (base at `[VAR_EVENT_TABLE_BASE_PTR]`, count at
|
||||
// `[VAR_EVENT_TABLE_COUNT]`, stride 0x10, name field at +0x00). This is
|
||||
// the source of truth for "events the engine will accept registration
|
||||
// for", so it picks up anything we've injected via `Event::Custom::Register`
|
||||
// in addition to all built-in events.
|
||||
static bool IsKnownEventName(const char *needle) {
|
||||
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
if (base == nullptr || count <= 0)
|
||||
return false;
|
||||
for (int i = 0; i < count; i++) {
|
||||
const uint8_t *entry = base + i * Offsets::EVENT_ENTRY_STRIDE;
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
entry + Offsets::OFF_EVENT_ENTRY_NAME);
|
||||
if (name == nullptr)
|
||||
continue;
|
||||
if (std::strcmp(name, needle) == 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static int __fastcall Script_IsEventValid(void *L) {
|
||||
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_STRING) {
|
||||
Game::Lua::Error(L, "Usage: C_EventUtils.IsEventValid(eventName)");
|
||||
@@ -52,7 +26,7 @@ static int __fastcall Script_IsEventValid(void *L) {
|
||||
Game::Lua::PushBool(L, 0);
|
||||
return 1;
|
||||
}
|
||||
Game::Lua::PushBoolean(L, IsKnownEventName(needle));
|
||||
Game::Lua::PushBoolean(L, Event::Custom::LookupByName(needle) >= 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
// 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/>.
|
||||
|
||||
// Restores `UNIT_FACTION("player")` event semantics that vanilla 1.12.1
|
||||
// silently dropped from four engine paths. In 3.3.5 each of these calls
|
||||
// `FUN_0071F8F0(player, 0) → FUN_0060BF10(playerGUID, UNIT_FACTION_id)`
|
||||
// which broadcasts the event for every unit token referencing the
|
||||
// local player; vanilla just returns. We hook each path and fire
|
||||
// `UNIT_FACTION("player")` post-original via the engine's printf-style
|
||||
// dispatcher.
|
||||
//
|
||||
// Why one file and not four: every hook does the same shape work
|
||||
// (snapshot rep-slot state, run original, compare, fire). The shared
|
||||
// `LookupEngineEvent` + `FireUnitFactionPlayer` helpers are the value;
|
||||
// splitting four 30-line hooks into four files would just spread that
|
||||
// helper around.
|
||||
//
|
||||
// Why hook the inner setters (vs. `Script_*` wrappers): same coverage,
|
||||
// and any future C++ caller of the setter inherits the polyfill.
|
||||
//
|
||||
// Coverage map:
|
||||
//
|
||||
// 1. `FUN_FACTION_SET_AT_WAR` — `FactionToggleAtWar` toggles
|
||||
// AT_WAR. Hook fires UNIT_FACTION if the flag byte changed.
|
||||
//
|
||||
// 2. `FUN_FACTION_SET_INACTIVE` — `SetFactionInactive` /
|
||||
// `SetFactionActive` toggle INACTIVE. Same change-detection
|
||||
// shape as at-war.
|
||||
//
|
||||
// 3. `FUN_SMSG_SET_FACTION_STANDINGS` — server rep update.
|
||||
// The handler mutates AT_WAR / VISIBLE bits as standing crosses
|
||||
// thresholds (recovery from below-Hated clears the force-set
|
||||
// AT_WAR). Snapshots the 64-entry flag-byte array before/after
|
||||
// and fires once if any byte changed.
|
||||
//
|
||||
// 4. `FUN_SMSG_INITIALIZE_FACTIONS` — server initial sync at login.
|
||||
// Fires unconditionally after the engine populates the rep table.
|
||||
// Lets addons that observe UNIT_FACTION (rather than
|
||||
// PLAYER_LOGIN / VARIABLES_LOADED) see the initial state.
|
||||
//
|
||||
// 5. `FUN_SMSG_SET_FACTION_ATWAR` — server force-change of AT_WAR
|
||||
// (e.g., faction reset, server-side state change). Fires
|
||||
// unconditionally after the engine writes the slot byte, matching
|
||||
// 3.3.5's behavior — server-pushed packets are infrequent and
|
||||
// always indicate a state change worth signaling.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "event/Custom.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace Faction::UnitFactionPolyfill {
|
||||
|
||||
namespace {
|
||||
|
||||
// Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
// `UNIT_FACTION`'s event-table slot. The engine's `RebuildEventTable`
|
||||
// preserves slot order across reloads and other DLLs claim only NULL
|
||||
// gaps, so this is stable once resolved. Lazy-init on first fire.
|
||||
int s_unitFactionSlot = -1;
|
||||
|
||||
void FireUnitFactionPlayer() {
|
||||
if (s_unitFactionSlot < 0)
|
||||
s_unitFactionSlot = Event::Custom::LookupByName("UNIT_FACTION");
|
||||
if (s_unitFactionSlot < 0)
|
||||
return;
|
||||
using FireEventFn_t = void(__cdecl *)(int eventID, const char *format, ...);
|
||||
auto fire = reinterpret_cast<FireEventFn_t>(Offsets::FUN_FIRE_EVENT);
|
||||
fire(s_unitFactionSlot, "%s", "player");
|
||||
}
|
||||
|
||||
uint8_t ReadRepSlotFlags(int repListID) {
|
||||
return *reinterpret_cast<uint8_t *>(
|
||||
Offsets::VAR_PLAYER_REP_SLOTS +
|
||||
repListID * Offsets::REP_SLOT_STRIDE +
|
||||
Offsets::OFF_REP_SLOT_FLAGS);
|
||||
}
|
||||
|
||||
// Resolves `factionID → repListID` (0..63 rep-slot index in
|
||||
// `VAR_PLAYER_REP_SLOTS`) by reading the `+0x04` field on the
|
||||
// `Faction.dbc` record directly. Same value the engine's helper at
|
||||
// `0x004D5600` returns — it just dereferences the same DBC field after
|
||||
// its own bounds check. Returns -1 for factionIDs that don't resolve
|
||||
// to a record (out of range / empty slot) or are header categories
|
||||
// (record stores -1 in the field).
|
||||
int FactionIDToRepListID(int factionID) {
|
||||
if (factionID <= 0)
|
||||
return -1;
|
||||
const auto *record = DBC::Record(Offsets::VAR_FACTION_DBC_RECORDS,
|
||||
Offsets::VAR_FACTION_DBC_COUNT,
|
||||
static_cast<uint32_t>(factionID));
|
||||
if (record == nullptr)
|
||||
return -1;
|
||||
return *reinterpret_cast<const int32_t *>(
|
||||
record + Offsets::OFF_FACTION_REP_LIST_INDEX);
|
||||
}
|
||||
|
||||
// Hook 1 — at-war setter ──────────────────────────────────────────────
|
||||
|
||||
using SetAtWar_t = void(__fastcall *)(int factionID, int newState);
|
||||
SetAtWar_t s_setAtWar_o = nullptr;
|
||||
|
||||
void __fastcall SetAtWar_h(int factionID, int newState) {
|
||||
const int repListID = FactionIDToRepListID(factionID);
|
||||
const bool slotValid = repListID >= 0 && repListID < Offsets::MAX_REP_SLOTS;
|
||||
const uint8_t beforeFlags = slotValid ? ReadRepSlotFlags(repListID) : 0;
|
||||
|
||||
s_setAtWar_o(factionID, newState);
|
||||
|
||||
if (!slotValid)
|
||||
return;
|
||||
if (ReadRepSlotFlags(repListID) == beforeFlags)
|
||||
return; // engine bailed (looting / PEACE_FORCED no-op / standing block)
|
||||
|
||||
FireUnitFactionPlayer();
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookAtWar{
|
||||
Offsets::FUN_FACTION_SET_AT_WAR,
|
||||
reinterpret_cast<void *>(&SetAtWar_h),
|
||||
reinterpret_cast<void **>(&s_setAtWar_o)};
|
||||
|
||||
// Hook 2 — inactive setter ────────────────────────────────────────────
|
||||
|
||||
using SetInactive_t = void(__fastcall *)(int factionID, int newState);
|
||||
SetInactive_t s_setInactive_o = nullptr;
|
||||
|
||||
void __fastcall SetInactive_h(int factionID, int newState) {
|
||||
const int repListID = FactionIDToRepListID(factionID);
|
||||
const bool slotValid = repListID >= 0 && repListID < Offsets::MAX_REP_SLOTS;
|
||||
const uint8_t beforeFlags = slotValid ? ReadRepSlotFlags(repListID) : 0;
|
||||
|
||||
s_setInactive_o(factionID, newState);
|
||||
|
||||
if (!slotValid)
|
||||
return;
|
||||
if (ReadRepSlotFlags(repListID) == beforeFlags)
|
||||
return; // no-op (already in requested state)
|
||||
|
||||
FireUnitFactionPlayer();
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookInactive{
|
||||
Offsets::FUN_FACTION_SET_INACTIVE,
|
||||
reinterpret_cast<void *>(&SetInactive_h),
|
||||
reinterpret_cast<void **>(&s_setInactive_o)};
|
||||
|
||||
// Hook 3 — SMSG_SET_FACTION_STANDINGS ─────────────────────────────────
|
||||
|
||||
// Snapshot all 64 rep-slot flag bytes into a packed buffer. The engine's
|
||||
// per-rep-change handler mutates AT_WAR / VISIBLE bits as standing
|
||||
// crosses thresholds, so we need a delta-detect across the whole table
|
||||
// (single rep change may flip flags on an unrelated slot — and the SMSG
|
||||
// can carry multiple rep updates in one packet).
|
||||
struct RepFlagsSnapshot {
|
||||
uint8_t flags[Offsets::MAX_REP_SLOTS];
|
||||
};
|
||||
|
||||
void TakeSnapshot(RepFlagsSnapshot *out) {
|
||||
for (int i = 0; i < Offsets::MAX_REP_SLOTS; ++i)
|
||||
out->flags[i] = ReadRepSlotFlags(i);
|
||||
}
|
||||
|
||||
bool SnapshotsDiffer(const RepFlagsSnapshot *a, const RepFlagsSnapshot *b) {
|
||||
return std::memcmp(a->flags, b->flags, sizeof(a->flags)) != 0;
|
||||
}
|
||||
|
||||
using SmsgHandler_t = int(__stdcall *)(uint32_t opcode, void *packet);
|
||||
|
||||
SmsgHandler_t s_smsgSetStandings_o = nullptr;
|
||||
|
||||
int __stdcall SmsgSetStandings_h(uint32_t opcode, void *packet) {
|
||||
RepFlagsSnapshot before;
|
||||
TakeSnapshot(&before);
|
||||
|
||||
const int result = s_smsgSetStandings_o(opcode, packet);
|
||||
|
||||
RepFlagsSnapshot after;
|
||||
TakeSnapshot(&after);
|
||||
if (SnapshotsDiffer(&before, &after))
|
||||
FireUnitFactionPlayer();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookSetStandings{
|
||||
Offsets::FUN_SMSG_SET_FACTION_STANDINGS,
|
||||
reinterpret_cast<void *>(&SmsgSetStandings_h),
|
||||
reinterpret_cast<void **>(&s_smsgSetStandings_o)};
|
||||
|
||||
// Hook 4 — SMSG_INITIALIZE_FACTIONS ──────────────────────────────────
|
||||
|
||||
SmsgHandler_t s_smsgInitFactions_o = nullptr;
|
||||
|
||||
int __stdcall SmsgInitFactions_h(uint32_t opcode, void *packet) {
|
||||
const int result = s_smsgInitFactions_o(opcode, packet);
|
||||
// Initial sync — engine has just populated all 64 rep slots from
|
||||
// server state. Always fire so addons listening for UNIT_FACTION
|
||||
// (rather than PLAYER_LOGIN / VARIABLES_LOADED) see the initial
|
||||
// load. Modern WoW's behavior.
|
||||
FireUnitFactionPlayer();
|
||||
return result;
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookInitFactions{
|
||||
Offsets::FUN_SMSG_INITIALIZE_FACTIONS,
|
||||
reinterpret_cast<void *>(&SmsgInitFactions_h),
|
||||
reinterpret_cast<void **>(&s_smsgInitFactions_o)};
|
||||
|
||||
// Hook 5 — SMSG_SET_FACTION_ATWAR ─────────────────────────────────────
|
||||
|
||||
SmsgHandler_t s_smsgSetAtWar_o = nullptr;
|
||||
|
||||
int __stdcall SmsgSetAtWar_h(uint32_t opcode, void *packet) {
|
||||
const int result = s_smsgSetAtWar_o(opcode, packet);
|
||||
// Server-pushed AT_WAR change — packet is rare and always carries
|
||||
// intent to update. Match 3.3.5's behavior: fire unconditionally
|
||||
// after the byte write, without delta-detecting against the slot
|
||||
// (the engine writes the byte regardless of whether it differs, so
|
||||
// a delta check would conflate "value didn't change" with "server
|
||||
// had nothing to say" — but the server only sends this when
|
||||
// something did).
|
||||
FireUnitFactionPlayer();
|
||||
return result;
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookSetAtWar{
|
||||
Offsets::FUN_SMSG_SET_FACTION_ATWAR,
|
||||
reinterpret_cast<void *>(&SmsgSetAtWar_h),
|
||||
reinterpret_cast<void **>(&s_smsgSetAtWar_o)};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Faction::UnitFactionPolyfill
|
||||
@@ -18,6 +18,12 @@
|
||||
// per-frame `OnMouseDown` scripts — these fire even when the click
|
||||
// misses any UI frame.
|
||||
//
|
||||
// `IsMouseButtonDown([button])` — query the current held state of a
|
||||
// specific button by name or 1-based ID, or "any button held" when
|
||||
// called with no args. State is maintained from the same hook stream
|
||||
// as the events (single source of truth — events firing and the
|
||||
// state being readable happen at the same point in the pipeline).
|
||||
//
|
||||
// Same `WH_GETMESSAGE` thread-message hook pattern as
|
||||
// [Input::Modifier](Modifier.cpp): we intercept the WM_*BUTTON*
|
||||
// messages on the engine's main thread *before* dispatch. The hook
|
||||
@@ -28,6 +34,9 @@
|
||||
#include "Game.h"
|
||||
#include "event/Custom.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
namespace Input::GlobalMouse {
|
||||
@@ -37,6 +46,19 @@ namespace {
|
||||
constexpr const char *kDownEvent = "GLOBAL_MOUSE_DOWN";
|
||||
constexpr const char *kUpEvent = "GLOBAL_MOUSE_UP";
|
||||
|
||||
// Held-state bitmap — bit `i` set means the button with 1-based
|
||||
// `IsMouseButtonDown` ID `i+1` is currently held. Modern API IDs:
|
||||
// 1 = LeftButton, 2 = RightButton, 3 = MiddleButton,
|
||||
// 4 = Button4 (XBUTTON1), 5 = Button5 (XBUTTON2).
|
||||
constexpr int BIT_LEFT_BUTTON = 0;
|
||||
constexpr int BIT_RIGHT_BUTTON = 1;
|
||||
constexpr int BIT_MIDDLE_BUTTON = 2;
|
||||
constexpr int BIT_BUTTON4 = 3;
|
||||
constexpr int BIT_BUTTON5 = 4;
|
||||
constexpr uint32_t MASK_ANY_BUTTON = 0x1F;
|
||||
|
||||
uint32_t g_mouseButtonMask = 0;
|
||||
|
||||
HHOOK g_msgHook = nullptr;
|
||||
|
||||
// Maps WM_*BUTTON{DOWN,UP} → ("LeftButton"|..., isDown). Returns
|
||||
@@ -60,16 +82,60 @@ const char *DecodeButton(UINT msg, WPARAM wParam, bool *outDown) {
|
||||
}
|
||||
}
|
||||
|
||||
// Maps a "LeftButton"/.../"Button5" name to its bit index. -1 for
|
||||
// any other string.
|
||||
int NameToBitIdx(const char *name) {
|
||||
if (name == nullptr)
|
||||
return -1;
|
||||
if (std::strcmp(name, "LeftButton") == 0) return BIT_LEFT_BUTTON;
|
||||
if (std::strcmp(name, "RightButton") == 0) return BIT_RIGHT_BUTTON;
|
||||
if (std::strcmp(name, "MiddleButton") == 0) return BIT_MIDDLE_BUTTON;
|
||||
if (std::strcmp(name, "Button4") == 0) return BIT_BUTTON4;
|
||||
if (std::strcmp(name, "Button5") == 0) return BIT_BUTTON5;
|
||||
return -1;
|
||||
}
|
||||
|
||||
void ProcessMouseMessage(UINT msg, WPARAM wParam) {
|
||||
bool down = false;
|
||||
const char *name = DecodeButton(msg, wParam, &down);
|
||||
if (name == nullptr)
|
||||
return;
|
||||
|
||||
const int bitIdx = NameToBitIdx(name);
|
||||
if (bitIdx >= 0) {
|
||||
const uint32_t bit = 1u << bitIdx;
|
||||
if (down) g_mouseButtonMask |= bit;
|
||||
else g_mouseButtonMask &= ~bit;
|
||||
}
|
||||
|
||||
const int slot = Event::Custom::Lookup(down ? kDownEvent : kUpEvent);
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "%s", name);
|
||||
}
|
||||
|
||||
int __fastcall Script_IsMouseButtonDown(void *L) {
|
||||
bool down;
|
||||
if (Game::Lua::GetTop(L) == 0) {
|
||||
// No-arg form: any button held.
|
||||
down = (g_mouseButtonMask & MASK_ANY_BUTTON) != 0;
|
||||
} else {
|
||||
int bitIdx = -1;
|
||||
if (Game::Lua::IsNumber(L, 1)) {
|
||||
const int id = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (id >= 1 && id <= 5)
|
||||
bitIdx = id - 1;
|
||||
} else if (Game::Lua::IsString(L, 1)) {
|
||||
bitIdx = NameToBitIdx(Game::Lua::ToString(L, 1));
|
||||
}
|
||||
// Unrecognized button (bad id, unknown name) → false, matching
|
||||
// the modern API's "did this specific button happen to be held"
|
||||
// semantic. Bad input doesn't promote to the any-button check.
|
||||
down = (bitIdx >= 0) && (g_mouseButtonMask & (1u << bitIdx)) != 0;
|
||||
}
|
||||
Game::Lua::PushBool(L, down);
|
||||
return 1;
|
||||
}
|
||||
|
||||
LRESULT CALLBACK GetMsgHook(int code, WPARAM wParam, LPARAM lParam) {
|
||||
// Only process when the message is being removed from the queue
|
||||
// (`PM_REMOVE` / `GetMessage`); `PM_NOREMOVE` peeks would deliver
|
||||
@@ -88,7 +154,11 @@ void InstallHook() {
|
||||
GetCurrentThreadId());
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() { InstallHook(); }
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("IsMouseButtonDown",
|
||||
&Script_IsMouseButtonDown);
|
||||
InstallHook();
|
||||
}
|
||||
|
||||
const Event::Custom::AutoReserve _reserveDown{kDownEvent};
|
||||
const Event::Custom::AutoReserve _reserveUp{kUpEvent};
|
||||
|
||||
+153
-29
@@ -17,6 +17,7 @@
|
||||
#include "item/Arg.h"
|
||||
#include "item/Data.h"
|
||||
#include "item/Location.h"
|
||||
#include "tick/WorldTick.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
@@ -52,16 +53,14 @@ static constexpr const char *kGetItemInfoReceived = "GET_ITEM_INFO_RECEIVED";
|
||||
static const Event::Custom::AutoReserve _reserveItemDataLoadResult{kItemDataLoadResult};
|
||||
static const Event::Custom::AutoReserve _reserveGetItemInfoReceived{kGetItemInfoReceived};
|
||||
|
||||
static void FireGetItemInfoReceived(int itemID, int success) {
|
||||
const int slot = Event::Custom::Lookup(kGetItemInfoReceived);
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "%d%d", itemID, success);
|
||||
static void FireGetItemInfoReceived(int itemID, bool success) {
|
||||
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kGetItemInfoReceived),
|
||||
itemID, success);
|
||||
}
|
||||
|
||||
static void FireItemDataLoadResult(int itemID, int success) {
|
||||
const int slot = Event::Custom::Lookup(kItemDataLoadResult);
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "%d%d", itemID, success);
|
||||
static void FireItemDataLoadResult(int itemID, bool success) {
|
||||
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kItemDataLoadResult),
|
||||
itemID, success);
|
||||
}
|
||||
|
||||
// Engine callback for **implicit** cache fills (transparent warmup
|
||||
@@ -76,16 +75,7 @@ static void FireItemDataLoadResult(int itemID, int success) {
|
||||
// callback would crash the engine, so stdcall is verified by induction.
|
||||
static void __stdcall ItemLoadCallback_Implicit(void *userData, int success) {
|
||||
const auto itemID = static_cast<int>(reinterpret_cast<uintptr_t>(userData));
|
||||
FireGetItemInfoReceived(itemID, static_cast<int>(success != 0));
|
||||
}
|
||||
|
||||
// Engine callback for **explicit** cache fills (player-side
|
||||
// `C_Item.RequestLoadItemData(ByID)` calls). Fires ITEM_DATA_LOAD_RESULT
|
||||
// only — the implicit GET_ITEM_INFO_RECEIVED path is for ambient cache
|
||||
// fills, not for callers who explicitly asked.
|
||||
static void __stdcall ItemLoadCallback_Explicit(void *userData, int success) {
|
||||
const auto itemID = static_cast<int>(reinterpret_cast<uintptr_t>(userData));
|
||||
FireItemDataLoadResult(itemID, static_cast<int>(success != 0));
|
||||
FireGetItemInfoReceived(itemID, success != 0);
|
||||
}
|
||||
|
||||
using ItemLoadCallback_t = void(__stdcall *)(void *userData, int success);
|
||||
@@ -103,6 +93,15 @@ using ItemLoadCallback_t = void(__stdcall *)(void *userData, int success);
|
||||
// VanillaHelpers documented as `requestIfMissing` — that slot is reused
|
||||
// as a local immediately after entry, never read. The trigger for the
|
||||
// request path is `callback != NULL`.
|
||||
//
|
||||
// The 6th arg IS used elsewhere though: when non-zero, the engine
|
||||
// walks the entry's existing pending-callback list and skips the
|
||||
// append if a node with matching `(callback, userData)` is already
|
||||
// queued. We always pass `1` so that bursts of `WarmCache` calls for
|
||||
// the same itemID (e.g. Bagnon enumerating the same slot repeatedly
|
||||
// while the SMSG_ITEM_QUERY_SINGLE_RESPONSE is in flight) collapse to
|
||||
// a single callback registration — otherwise the response fires our
|
||||
// `GET_ITEM_INFO_RECEIVED` once per call.
|
||||
static const uint8_t *CacheFetch(uint32_t itemID, ItemLoadCallback_t callback) {
|
||||
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
|
||||
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
|
||||
@@ -110,7 +109,7 @@ static const uint8_t *CacheFetch(uint32_t itemID, ItemLoadCallback_t callback) {
|
||||
void *cb = reinterpret_cast<void *>(callback);
|
||||
void *userData =
|
||||
callback ? reinterpret_cast<void *>(static_cast<uintptr_t>(itemID)) : nullptr;
|
||||
return fn(cache, itemID, &zeroGuid, cb, userData, 0);
|
||||
return fn(cache, itemID, &zeroGuid, cb, userData, 1);
|
||||
}
|
||||
|
||||
// Resolves an item-location table at stack idx to the itemID by walking
|
||||
@@ -148,18 +147,143 @@ static int __fastcall Script_IsItemDataCached(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Explicit-request path: kicks off the cache fill via the explicit
|
||||
// callback (which fires ITEM_DATA_LOAD_RESULT). If the item is already
|
||||
// cached, the engine won't invoke our callback — synthesize the event
|
||||
// so addons get the same notification regardless of cache state,
|
||||
// matching modern `C_Item.RequestLoadItemData(ByID)` semantics.
|
||||
static void RequestAndMaybeNotify(uint32_t itemID) {
|
||||
const bool wasCached = (CacheFetch(itemID, nullptr) != nullptr);
|
||||
CacheFetch(itemID, &ItemLoadCallback_Explicit);
|
||||
if (wasCached)
|
||||
FireItemDataLoadResult(static_cast<int>(itemID), 1);
|
||||
// Pending explicit-request tracking. We deliberately do NOT eagerly
|
||||
// create cache entries when an addon calls `C_Item.RequestLoadItemData`
|
||||
// at addon-load / PLAYER_ENTERING_WORLD time — empirically (pfQuest),
|
||||
// pre-creating the entry blocks the engine's natural prefetch path
|
||||
// from issuing a fresh `SMSG_ITEM_QUERY_SINGLE`. The entry stays in a
|
||||
// permanently-pending state because nothing ever sends the query.
|
||||
//
|
||||
// Instead we hook the engine's item-cache response handler at
|
||||
// `FUN_ITEMSTATS_CACHE_RESPONSE` (the function that fills cache
|
||||
// entries on `SMSG_ITEM_QUERY_SINGLE_RESPONSE`). After the engine
|
||||
// finishes filling any entries and walking its own callback lists,
|
||||
// we sweep our pending set and fire `ITEM_DATA_LOAD_RESULT(itemID, 1)`
|
||||
// for any tracked items that now resolve. The engine's natural
|
||||
// prefetch (for player-inventory items) takes care of the actual
|
||||
// `SMSG_ITEM_QUERY_SINGLE` for us.
|
||||
//
|
||||
// For items the engine *doesn't* naturally prefetch (e.g. quest
|
||||
// reward IDs the player has never owned), we escalate after
|
||||
// `kEscalateTicks` by issuing our own `CacheFetch` with a no-op
|
||||
// callback — that creates the entry and queues the query, and the
|
||||
// response handler hook still fires our event when the data lands.
|
||||
// `kMaxWaitTicks` is the hard timeout that gives up and fires
|
||||
// `ITEM_DATA_LOAD_RESULT(itemID, nil)`.
|
||||
struct Pending {
|
||||
uint32_t itemID;
|
||||
int ticksWaiting;
|
||||
bool requestIssued;
|
||||
};
|
||||
|
||||
constexpr int kMaxPending = 64;
|
||||
constexpr int kEscalateTicks = 60;
|
||||
constexpr int kMaxWaitTicks = 1200;
|
||||
|
||||
static Pending g_pending[kMaxPending];
|
||||
static int g_pendingCount = 0;
|
||||
|
||||
static int FindPending(uint32_t itemID) {
|
||||
for (int i = 0; i < g_pendingCount; ++i) {
|
||||
if (g_pending[i].itemID == itemID)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
static void RemovePendingAt(int idx) {
|
||||
--g_pendingCount;
|
||||
if (idx != g_pendingCount)
|
||||
g_pending[idx] = g_pending[g_pendingCount];
|
||||
}
|
||||
|
||||
// No-op callback for the escalation path — we just need a non-null
|
||||
// callback pointer so the engine's `_GetRecord` queues the query.
|
||||
// The response handler hook will detect the fill and fire the event;
|
||||
// this callback doesn't need to do anything.
|
||||
static void __stdcall ItemLoadCallback_NoOp(void * /*userData*/, int /*success*/) {}
|
||||
|
||||
static void RequestAndMaybeNotify(uint32_t itemID) {
|
||||
// Already loaded — fire success synchronously.
|
||||
if (CacheFetch(itemID, nullptr) != nullptr) {
|
||||
FireItemDataLoadResult(static_cast<int>(itemID), true);
|
||||
return;
|
||||
}
|
||||
// Already tracking — second request just dedups.
|
||||
if (FindPending(itemID) >= 0)
|
||||
return;
|
||||
if (g_pendingCount >= kMaxPending)
|
||||
return;
|
||||
g_pending[g_pendingCount].itemID = itemID;
|
||||
g_pending[g_pendingCount].ticksWaiting = 0;
|
||||
g_pending[g_pendingCount].requestIssued = false;
|
||||
++g_pendingCount;
|
||||
}
|
||||
|
||||
// Response handler hook. After the engine fills cache entries from
|
||||
// `SMSG_ITEM_QUERY_SINGLE_RESPONSE` and walks the per-entry callback
|
||||
// lists, we sweep `g_pending` for anything now-loaded and fire the
|
||||
// event. This is the engine-aligned alternative to per-tick polling:
|
||||
// we observe fills exactly when they happen, with zero added latency
|
||||
// and no risk of racing the engine's own callback walk.
|
||||
//
|
||||
// `__thiscall` is mimicked as `__fastcall(ecx, edx_unused, ...)` per
|
||||
// the convention used elsewhere in this codebase (see
|
||||
// `KeyringDescChange_h`, `FrameRegisterEvent_h`).
|
||||
using ItemStatsCacheResponse_t = void(__thiscall *)(void *cache, void *packetReader, int flag);
|
||||
static ItemStatsCacheResponse_t ItemStatsCacheResponse_o = nullptr;
|
||||
|
||||
static void __fastcall ItemStatsCacheResponse_h(void *cache, void * /*edx_unused*/,
|
||||
void *packetReader, int flag) {
|
||||
using Trampoline_t = void(__fastcall *)(void *cache, void *edx, void *packetReader, int flag);
|
||||
reinterpret_cast<Trampoline_t>(ItemStatsCacheResponse_o)(cache, nullptr, packetReader, flag);
|
||||
// Sweep pending — the engine just finished filling any matching
|
||||
// entries. Peek (no callback) returns non-null iff the entry's
|
||||
// loaded flag is set.
|
||||
for (int i = 0; i < g_pendingCount;) {
|
||||
const uint32_t itemID = g_pending[i].itemID;
|
||||
if (CacheFetch(itemID, nullptr) != nullptr) {
|
||||
RemovePendingAt(i);
|
||||
FireItemDataLoadResult(static_cast<int>(itemID), true);
|
||||
continue;
|
||||
}
|
||||
++i;
|
||||
}
|
||||
}
|
||||
|
||||
static const Game::HookAutoRegister _hookCacheResponse{
|
||||
Offsets::FUN_ITEMSTATS_CACHE_RESPONSE,
|
||||
reinterpret_cast<void *>(&ItemStatsCacheResponse_h),
|
||||
reinterpret_cast<void **>(&ItemStatsCacheResponse_o)};
|
||||
|
||||
// Escalation + timeout. The hook covers the happy path (engine fills
|
||||
// cache → we fire). This tick is only responsible for:
|
||||
// 1. Issuing a query for items the engine doesn't naturally prefetch
|
||||
// (e.g. quest rewards) — wait `kEscalateTicks` so we don't race
|
||||
// the engine's startup-time state machine, then call CacheFetch
|
||||
// with a no-op callback. The engine sends the SMSG, the response
|
||||
// handler hook fires the event when the response lands.
|
||||
// 2. Giving up after `kMaxWaitTicks` for items that never resolve
|
||||
// (bad itemID, server doesn't respond, etc.) — fire failure.
|
||||
static void OnWorldTick() {
|
||||
for (int i = 0; i < g_pendingCount;) {
|
||||
if (!g_pending[i].requestIssued && g_pending[i].ticksWaiting >= kEscalateTicks) {
|
||||
CacheFetch(g_pending[i].itemID, &ItemLoadCallback_NoOp);
|
||||
g_pending[i].requestIssued = true;
|
||||
}
|
||||
if (g_pending[i].ticksWaiting >= kMaxWaitTicks) {
|
||||
const uint32_t itemID = g_pending[i].itemID;
|
||||
RemovePendingAt(i);
|
||||
FireItemDataLoadResult(static_cast<int>(itemID), false);
|
||||
continue;
|
||||
}
|
||||
++g_pending[i].ticksWaiting;
|
||||
++i;
|
||||
}
|
||||
}
|
||||
|
||||
static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
|
||||
|
||||
// Public C++ API — see Item/Data.h. Implicit (transparent) warmup
|
||||
// path used by the `Script_GetItemInfo` hook and `SetItemByID`. Fires
|
||||
// the cache request only if the item isn't already cached; uses the
|
||||
|
||||
+5
-49
@@ -166,49 +166,6 @@ int __fastcall Script_C_Item_IsEquippedItem(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Walks bags 0..4 looking for an item matching `arg`. On hit, sets
|
||||
// `*outBag` / `*outSlot` / `*outItem` and returns true.
|
||||
//
|
||||
// itemID match is direct; name match peeks the item-cache record's
|
||||
// `m_name[0]` and compares case-insensitively. Stomps the Lua stack
|
||||
// per the helpers it calls.
|
||||
bool FindItemInBags(void *L, const Item::Arg::Resolved &arg, int *outBag, int *outSlot,
|
||||
const uint8_t **outItem) {
|
||||
for (int bag = 0; bag <= 4; ++bag) {
|
||||
const int slotCount = Item::Location::GetBagSlotCount(bag);
|
||||
for (int slot = 1; slot <= slotCount; ++slot) {
|
||||
const uint8_t *item = Item::Location::ResolveBag(L, bag, slot);
|
||||
if (item == nullptr) continue;
|
||||
|
||||
const int id = Item::ID::FromCGItem(item);
|
||||
if (id == 0) continue;
|
||||
|
||||
if (arg.itemID > 0) {
|
||||
if (id == arg.itemID) {
|
||||
*outBag = bag;
|
||||
*outSlot = slot;
|
||||
*outItem = item;
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
auto *record = PeekItemRecord(static_cast<uint32_t>(id));
|
||||
if (record == nullptr) continue;
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSTATS_NAME);
|
||||
if (name == nullptr) continue;
|
||||
if (_stricmp(name, arg.name) == 0) {
|
||||
*outBag = bag;
|
||||
*outSlot = slot;
|
||||
*outItem = item;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// `C_Item.EquipItemByName(itemInfo [, dstSlot])` — finds the first
|
||||
// item in the player's bags matching `itemInfo` (itemID, link, or
|
||||
// name) and equips it.
|
||||
@@ -255,14 +212,13 @@ int __fastcall Script_C_Item_EquipItemByName(void *L) {
|
||||
reinterpret_cast<int(__fastcall *)(void *)>(
|
||||
Offsets::FUN_SCRIPT_CLEAR_CURSOR)(L);
|
||||
|
||||
int bag = -1, slot = -1;
|
||||
const uint8_t *cgItem = nullptr;
|
||||
if (!FindItemInBags(L, arg, &bag, &slot, &cgItem)) {
|
||||
Item::Location::ByGUIDResult found;
|
||||
if (!Item::Location::FindByArgInBags(L, arg, &found)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (hasDstSlot) {
|
||||
Item::Swap::FromBag(cgItem, bag, slot, dstSlot);
|
||||
Item::Swap::FromBag(found.item, found.bagID, found.slotIndex, dstSlot);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -273,8 +229,8 @@ int __fastcall Script_C_Item_EquipItemByName(void *L) {
|
||||
// — `Script_AutoEquipCursorItem` is a thin wrapper that just
|
||||
// resolves the local player and calls this with flag=0.
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(bag));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(slot));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(found.bagID));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(found.slotIndex));
|
||||
auto pickup = reinterpret_cast<ScriptFn_t>(Offsets::FUN_SCRIPT_PICKUP_CONTAINER_ITEM);
|
||||
pickup(L);
|
||||
|
||||
|
||||
+22
-33
@@ -14,28 +14,12 @@
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "item/Arg.h"
|
||||
#include "item/Data.h"
|
||||
#include "item/Location.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Item::Exists {
|
||||
|
||||
namespace {
|
||||
|
||||
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
|
||||
const uint64_t *guid, void *callback,
|
||||
void *userData, int unused);
|
||||
|
||||
const uint8_t *PeekItemRecord(uint32_t itemID) {
|
||||
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
|
||||
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
|
||||
const uint64_t zeroGuid = 0;
|
||||
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// `C_Item.DoesItemExist(itemLocation)` — true iff the location resolves
|
||||
// to a populated inventory slot on the active player. Equipment-slot
|
||||
// locations check the character pane; bag-slot locations walk the
|
||||
@@ -51,27 +35,32 @@ static int __fastcall Script_C_Item_DoesItemExist(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Item.DoesItemExistByID(itemInfo)` — true iff the cache currently
|
||||
// has data for this itemID (i.e. `GetItemInfo` would return non-nil
|
||||
// right now). Same semantics as the modern function and the existing
|
||||
// Lua shim: cache miss → false, but we kick off the network query
|
||||
// so a subsequent call after `GET_ITEM_INFO_RECEIVED` will succeed.
|
||||
// `C_Item.DoesItemExistByID(itemInfo)` — true if `itemID` looks like a
|
||||
// valid item identifier. Modern WoW answers from the client-side item
|
||||
// database (Item-sparse.db2) and is independent of whether the network
|
||||
// cache has been warmed yet. 1.12 has no client-side DB, so we
|
||||
// optimistically accept any positive itemID; callers needing the
|
||||
// "is the cache populated?" check should use `IsItemDataCachedByID`.
|
||||
//
|
||||
// This split matters for `ItemMixin:IsItemEmpty` (FrameXML ItemUtil),
|
||||
// which uses `DoesItemExistByID` to gate `ContinueOnItemLoad` — the
|
||||
// whole point of `ContinueOnItemLoad` is to wait for an uncached item
|
||||
// to load, so the existence check must succeed for items that aren't
|
||||
// in the cache yet.
|
||||
//
|
||||
// We deliberately do NOT auto-warm the cache here. Existence is a pure
|
||||
// query about the ID, not a load request — and during early-login
|
||||
// (cold WDB, pfQuest's PLAYER_ENTERING_WORLD path) a spurious cache
|
||||
// touch can race the engine's natural inventory prefetch and leave the
|
||||
// item stuck. Callers wanting to actually load data should use
|
||||
// `RequestLoadItemDataByID`; passive readers (`GetItemInfo`,
|
||||
// `GetItemNameByID`) already warm on their own miss path.
|
||||
//
|
||||
// Accepts numeric itemID or `"item:NNN..."` string (including full
|
||||
// chat links). Returns false for any input we can't resolve to an
|
||||
// itemID; never raises.
|
||||
// chat links); never raises.
|
||||
static int __fastcall Script_C_Item_DoesItemExistByID(void *L) {
|
||||
const int itemID = Item::Arg::ResolveItemID(L, 1);
|
||||
if (itemID <= 0) {
|
||||
Game::Lua::PushBoolean(L, 0);
|
||||
return 1;
|
||||
}
|
||||
if (PeekItemRecord(static_cast<uint32_t>(itemID)) == nullptr) {
|
||||
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
|
||||
Game::Lua::PushBoolean(L, 0);
|
||||
return 1;
|
||||
}
|
||||
Game::Lua::PushBoolean(L, 1);
|
||||
Game::Lua::PushBoolean(L, itemID > 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
// 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.GetItemDataByID(itemInfo)` / `C_Item.GetItemData(itemLocation)`
|
||||
// — return a single table containing every field we can extract from
|
||||
// the cached `ItemStats_C` record. Convenience for addons that want
|
||||
// the kitchen sink without making 15 separate `C_Item.*` calls.
|
||||
//
|
||||
// Returns `nil` if the item isn't cached (caller should
|
||||
// `RequestLoadItemData` and listen for `ITEM_DATA_LOAD_RESULT`).
|
||||
// Does not warm the cache itself — this is a passive reader, same as
|
||||
// `GetItemInfoInstant`'s contract.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "item/Arg.h"
|
||||
#include "item/ID.h"
|
||||
#include "item/Location.h"
|
||||
#include "item/Spell.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
|
||||
namespace Item::GetData {
|
||||
|
||||
namespace {
|
||||
|
||||
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
|
||||
const uint64_t *guid, void *callback,
|
||||
void *userData, bool requestIfMissing);
|
||||
|
||||
const uint8_t *FetchItemRecord(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, false);
|
||||
}
|
||||
|
||||
int CurrentLocaleIndex() {
|
||||
return *reinterpret_cast<int *>(Offsets::VAR_LOCALE_INDEX);
|
||||
}
|
||||
|
||||
// Mirrors the lookup in `Item::Info`. ItemClass.dbc record `+0x0C` is
|
||||
// the 9-locale name array; null/empty slots fall back to "".
|
||||
const char *LookupClassName(uint32_t classID) {
|
||||
const char *s = DBC::LocalizedField(
|
||||
Offsets::VAR_ITEMCLASS_RECORDS, Offsets::VAR_ITEMCLASS_COUNT,
|
||||
classID, Offsets::OFF_ITEMCLASS_NAMES);
|
||||
return (s != nullptr && s[0] != '\0') ? s : "";
|
||||
}
|
||||
|
||||
// ItemSubClass.dbc walker — same fallback chain as Script_GetItemInfo
|
||||
// (verbose name first, short name on miss).
|
||||
const char *LookupSubClassName(uint32_t classID, uint32_t subClassID) {
|
||||
const int count = *reinterpret_cast<int *>(Offsets::VAR_ITEMSUBCLASS_COUNT);
|
||||
if (count <= 0)
|
||||
return "";
|
||||
auto *records = *reinterpret_cast<const uint8_t *const *>(Offsets::VAR_ITEMSUBCLASS_RECORDS);
|
||||
if (records == nullptr)
|
||||
return "";
|
||||
const int locale = CurrentLocaleIndex();
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const uint8_t *record = records + i * Offsets::OFF_ITEMSUBCLASS_RECORD_STRIDE;
|
||||
if (*reinterpret_cast<const uint32_t *>(record + 0x00) != classID)
|
||||
continue;
|
||||
if (*reinterpret_cast<const uint32_t *>(record + 0x04) != subClassID)
|
||||
continue;
|
||||
const char *verbose = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSUBCLASS_DISPLAY_NAME + locale * 4);
|
||||
if (verbose != nullptr && verbose[0] != '\0')
|
||||
return verbose;
|
||||
const char *shortName = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSUBCLASS_NAME + locale * 4);
|
||||
return (shortName != nullptr && shortName[0] != '\0') ? shortName : "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const char *LookupInvType(uint32_t invType) {
|
||||
if (invType > Offsets::INVTYPE_TABLE_MAX_INDEX)
|
||||
return "";
|
||||
auto **table = reinterpret_cast<const char **>(Offsets::VAR_INVTYPE_STRING_TABLE);
|
||||
const char *s = table[invType];
|
||||
return (s != nullptr && s[0] != '\0') ? s : "";
|
||||
}
|
||||
|
||||
bool BuildIconPath(uint32_t displayInfoID, char *out, size_t outSize) {
|
||||
if (out == nullptr || outSize == 0)
|
||||
return false;
|
||||
out[0] = '\0';
|
||||
const char *iconName = DBC::StringField(
|
||||
Offsets::VAR_ITEMDISPLAYINFO_RECORDS, Offsets::VAR_ITEMDISPLAYINFO_COUNT,
|
||||
displayInfoID, Offsets::OFF_ITEMDISPLAYINFO_ICON);
|
||||
if (iconName == nullptr || iconName[0] == '\0')
|
||||
return false;
|
||||
std::snprintf(out, outSize, "Interface\\Icons\\%s", iconName);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wrath flipped the bagFamily encoding from raw 1-based IDs to a
|
||||
// bitmask. We surface the modern shape so backported addons compile
|
||||
// cleanly. See `Item::Info::BagFamilyIdToBitmask` for the full story.
|
||||
uint32_t BagFamilyIdToBitmask(uint32_t rawId) {
|
||||
return (rawId == 0) ? 0 : (1u << (rawId - 1));
|
||||
}
|
||||
|
||||
// Shorthand: read a u32 field from the record at `offset`, push it on
|
||||
// the stack as a Lua number, and set `t[key] = value` on the table at
|
||||
// stack[-3]. `SetFieldNumber` already handles the push/set; this is
|
||||
// just the field-fetch convenience.
|
||||
void SetU32(void *L, const uint8_t *record, const char *key, int offset) {
|
||||
const uint32_t v = *reinterpret_cast<const uint32_t *>(record + offset);
|
||||
Game::Lua::SetFieldNumber(L, key, static_cast<double>(v));
|
||||
}
|
||||
|
||||
void SetI32(void *L, const uint8_t *record, const char *key, int offset) {
|
||||
const int32_t v = *reinterpret_cast<const int32_t *>(record + offset);
|
||||
Game::Lua::SetFieldNumber(L, key, static_cast<double>(v));
|
||||
}
|
||||
|
||||
void SetF32(void *L, const uint8_t *record, const char *key, int offset) {
|
||||
const float v = *reinterpret_cast<const float *>(record + offset);
|
||||
Game::Lua::SetFieldNumber(L, key, static_cast<double>(v));
|
||||
}
|
||||
|
||||
// Sets `t[key]` to a sub-array of N consecutive u32 values from the
|
||||
// record. Pushes 1-indexed (Lua array convention).
|
||||
void SetU32Array(void *L, const uint8_t *record, const char *key,
|
||||
int offset, int count) {
|
||||
Game::Lua::PushString(L, key);
|
||||
Game::Lua::NewTable(L);
|
||||
auto *arr = reinterpret_cast<const uint32_t *>(record + offset);
|
||||
for (int i = 0; i < count; ++i) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(arr[i]));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
|
||||
void SetI32Array(void *L, const uint8_t *record, const char *key,
|
||||
int offset, int count) {
|
||||
Game::Lua::PushString(L, key);
|
||||
Game::Lua::NewTable(L);
|
||||
auto *arr = reinterpret_cast<const int32_t *>(record + offset);
|
||||
for (int i = 0; i < count; ++i) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(arr[i]));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
|
||||
void SetF32Array(void *L, const uint8_t *record, const char *key,
|
||||
int offset, int count) {
|
||||
Game::Lua::PushString(L, key);
|
||||
Game::Lua::NewTable(L);
|
||||
auto *arr = reinterpret_cast<const float *>(record + offset);
|
||||
for (int i = 0; i < count; ++i) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(arr[i]));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
|
||||
// Sparse stats: walks the 10 (statType, statValue) slots and pushes
|
||||
// only the populated ones into a map keyed by statType. Modern addons
|
||||
// expect `stats[STAT_AGI] = 5`-style access; matching that here avoids
|
||||
// callers having to walk the fixed-10 array themselves.
|
||||
void SetStatsMap(void *L, const uint8_t *record) {
|
||||
Game::Lua::PushString(L, "stats");
|
||||
Game::Lua::NewTable(L);
|
||||
auto *types = reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_STAT_TYPE);
|
||||
auto *values = reinterpret_cast<const int32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_STAT_VALUE);
|
||||
for (int i = 0; i < Offsets::ITEMSTATS_STAT_SLOT_COUNT; ++i) {
|
||||
if (types[i] == 0 && values[i] == 0)
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(types[i]));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(values[i]));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
|
||||
// Spell slots: walks all 5 slots, pushes only entries with a non-zero
|
||||
// spellID as `{ id, trigger, charges, cooldown, category,
|
||||
// categoryCooldown }` records. Sparse but 1-indexed in declaration
|
||||
// order so the slot index is preserved.
|
||||
void SetSpellsArray(void *L, const uint8_t *record) {
|
||||
Game::Lua::PushString(L, "spells");
|
||||
Game::Lua::NewTable(L);
|
||||
auto *ids = reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SPELL_ID);
|
||||
auto *triggers = reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SPELL_TRIGGER);
|
||||
auto *charges = reinterpret_cast<const int32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SPELL_CHARGES);
|
||||
auto *cooldowns = reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SPELL_COOLDOWN);
|
||||
auto *categories = reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SPELL_CATEGORY);
|
||||
auto *catCooldowns = reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SPELL_CATEGORY_CD);
|
||||
int written = 0;
|
||||
for (int i = 0; i < Offsets::ITEMSTATS_SPELL_SLOT_COUNT; ++i) {
|
||||
if (ids[i] == 0)
|
||||
continue;
|
||||
++written;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(written));
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "id", static_cast<double>(ids[i]));
|
||||
Game::Lua::SetFieldNumber(L, "trigger", static_cast<double>(triggers[i]));
|
||||
Game::Lua::SetFieldNumber(L, "charges", static_cast<double>(charges[i]));
|
||||
Game::Lua::SetFieldNumber(L, "cooldown", static_cast<double>(cooldowns[i]));
|
||||
Game::Lua::SetFieldNumber(L, "category", static_cast<double>(categories[i]));
|
||||
Game::Lua::SetFieldNumber(L, "categoryCooldown",
|
||||
static_cast<double>(catCooldowns[i]));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
|
||||
// Pushes a single Lua table for `record` onto the stack, populated
|
||||
// with every field we can read out of `ItemStats_C`. Caller is
|
||||
// responsible for verifying `record` is non-null and pushing the
|
||||
// itemID as a setup step.
|
||||
void PushItemDataTable(void *L, uint32_t itemID, const uint8_t *record) {
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
Game::Lua::SetFieldNumber(L, "itemID", static_cast<double>(itemID));
|
||||
|
||||
// Identity strings
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSTATS_NAME);
|
||||
Game::Lua::SetFieldString(L, "name", name);
|
||||
const char *description = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSTATS_DESCRIPTION);
|
||||
if (description != nullptr && description[0] != '\0')
|
||||
Game::Lua::SetFieldString(L, "description", description);
|
||||
|
||||
// Display
|
||||
const uint32_t displayInfoID = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID);
|
||||
Game::Lua::SetFieldNumber(L, "displayInfoID", static_cast<double>(displayInfoID));
|
||||
char iconPath[260];
|
||||
if (BuildIconPath(displayInfoID, iconPath, sizeof(iconPath)))
|
||||
Game::Lua::SetFieldString(L, "icon", iconPath);
|
||||
|
||||
// Quality / classification
|
||||
const uint32_t quality = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_QUALITY);
|
||||
Game::Lua::SetFieldNumber(L, "quality", static_cast<double>(quality));
|
||||
|
||||
const uint32_t classID = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_CLASS);
|
||||
const uint32_t subClassID = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SUBCLASS);
|
||||
Game::Lua::SetFieldNumber(L, "classID", static_cast<double>(classID));
|
||||
Game::Lua::SetFieldNumber(L, "subclassID", static_cast<double>(subClassID));
|
||||
Game::Lua::SetFieldString(L, "className", LookupClassName(classID));
|
||||
Game::Lua::SetFieldString(L, "subclassName", LookupSubClassName(classID, subClassID));
|
||||
|
||||
const uint32_t invType = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_INVENTORY_TYPE);
|
||||
Game::Lua::SetFieldNumber(L, "inventoryType", static_cast<double>(invType));
|
||||
Game::Lua::SetFieldString(L, "equipLoc", LookupInvType(invType));
|
||||
|
||||
// Bind
|
||||
SetU32(L, record, "bindType", Offsets::OFF_ITEMSTATS_BONDING);
|
||||
|
||||
// Flags: raw + decoded bits.
|
||||
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_FLAGS);
|
||||
Game::Lua::SetFieldNumber(L, "flags", static_cast<double>(flags));
|
||||
Game::Lua::SetFieldBool(L, "isConjured",
|
||||
(flags & Offsets::ITEM_FLAG_CONJURED) != 0);
|
||||
Game::Lua::SetFieldBool(L, "isOpenable",
|
||||
(flags & Offsets::ITEMSTATS_FLAG_OPENABLE) != 0);
|
||||
Game::Lua::SetFieldBool(L, "isLootable",
|
||||
(flags & Offsets::ITEM_FLAG_LOOTABLE) != 0);
|
||||
Game::Lua::SetFieldBool(L, "isWrapper",
|
||||
(flags & Offsets::ITEM_FLAG_WRAPPER) != 0);
|
||||
|
||||
// Stacking / inventory
|
||||
SetI32(L, record, "maxStackSize", Offsets::OFF_ITEMSTATS_STACKABLE);
|
||||
SetI32(L, record, "maxCount", Offsets::OFF_ITEMSTATS_MAX_COUNT);
|
||||
SetU32(L, record, "containerSlots", Offsets::OFF_ITEMSTATS_CONTAINER_SLOTS);
|
||||
const uint32_t rawBagFamily = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_BAG_FAMILY);
|
||||
Game::Lua::SetFieldNumber(L, "bagFamily",
|
||||
static_cast<double>(BagFamilyIdToBitmask(rawBagFamily)));
|
||||
|
||||
// Economy
|
||||
SetU32(L, record, "buyPrice", Offsets::OFF_ITEMSTATS_BUY_PRICE);
|
||||
SetU32(L, record, "sellPrice", Offsets::OFF_ITEMSTATS_SELL_PRICE);
|
||||
|
||||
// Level / requirements
|
||||
SetU32(L, record, "itemLevel", Offsets::OFF_ITEMSTATS_ITEM_LEVEL);
|
||||
SetU32(L, record, "requiredLevel", Offsets::OFF_ITEMSTATS_REQUIRED_LEVEL);
|
||||
SetU32(L, record, "requiredSkill", Offsets::OFF_ITEMSTATS_REQUIRED_SKILL);
|
||||
SetU32(L, record, "requiredSkillRank", Offsets::OFF_ITEMSTATS_REQUIRED_SKILL_RANK);
|
||||
SetU32(L, record, "requiredSpell", Offsets::OFF_ITEMSTATS_REQUIRED_SPELL);
|
||||
SetU32(L, record, "requiredHonorRank", Offsets::OFF_ITEMSTATS_REQUIRED_HONOR_RANK);
|
||||
SetU32(L, record, "requiredCityRank", Offsets::OFF_ITEMSTATS_REQUIRED_CITY_RANK);
|
||||
SetU32(L, record, "requiredFaction", Offsets::OFF_ITEMSTATS_REQUIRED_FACTION);
|
||||
SetU32(L, record, "requiredFactionRank", Offsets::OFF_ITEMSTATS_REQUIRED_FACTION_RANK);
|
||||
SetU32(L, record, "allowableClass", Offsets::OFF_ITEMSTATS_ALLOWABLE_CLASS);
|
||||
SetU32(L, record, "allowableRace", Offsets::OFF_ITEMSTATS_ALLOWABLE_RACE);
|
||||
|
||||
// Equipment defenses
|
||||
SetU32(L, record, "armor", Offsets::OFF_ITEMSTATS_ARMOR);
|
||||
SetU32(L, record, "block", Offsets::OFF_ITEMSTATS_BLOCK);
|
||||
SetU32(L, record, "maxDurability", Offsets::OFF_ITEMSTATS_MAX_DURABILITY);
|
||||
|
||||
// Sparse stats map (statType -> statValue, only populated entries)
|
||||
SetStatsMap(L, record);
|
||||
|
||||
// Resistances
|
||||
SetU32(L, record, "resistanceHoly", Offsets::OFF_ITEMSTATS_RES_HOLY);
|
||||
SetU32(L, record, "resistanceFire", Offsets::OFF_ITEMSTATS_RES_FIRE);
|
||||
SetU32(L, record, "resistanceNature", Offsets::OFF_ITEMSTATS_RES_NATURE);
|
||||
SetU32(L, record, "resistanceFrost", Offsets::OFF_ITEMSTATS_RES_FROST);
|
||||
SetU32(L, record, "resistanceShadow", Offsets::OFF_ITEMSTATS_RES_SHADOW);
|
||||
SetU32(L, record, "resistanceArcane", Offsets::OFF_ITEMSTATS_RES_ARCANE);
|
||||
|
||||
// Weapon
|
||||
SetF32Array(L, record, "damageMin", Offsets::OFF_ITEMSTATS_DAMAGE_MIN,
|
||||
Offsets::ITEMSTATS_DAMAGE_SLOT_COUNT);
|
||||
SetF32Array(L, record, "damageMax", Offsets::OFF_ITEMSTATS_DAMAGE_MAX,
|
||||
Offsets::ITEMSTATS_DAMAGE_SLOT_COUNT);
|
||||
SetU32Array(L, record, "damageType", Offsets::OFF_ITEMSTATS_DAMAGE_TYPE,
|
||||
Offsets::ITEMSTATS_DAMAGE_SLOT_COUNT);
|
||||
SetU32(L, record, "delay", Offsets::OFF_ITEMSTATS_DELAY);
|
||||
SetU32(L, record, "ammoType", Offsets::OFF_ITEMSTATS_AMMO_TYPE);
|
||||
SetF32(L, record, "rangedModRange", Offsets::OFF_ITEMSTATS_RANGED_MOD_RANGE);
|
||||
|
||||
// Spell slots (sparse array of records) + ON_USE convenience
|
||||
SetSpellsArray(L, record);
|
||||
const int useSpellID = Item::Spell::OnUseSpellIDForItemID(itemID);
|
||||
if (useSpellID > 0)
|
||||
Game::Lua::SetFieldNumber(L, "useSpellID", static_cast<double>(useSpellID));
|
||||
|
||||
// Lock / item set
|
||||
SetU32(L, record, "lockID", Offsets::OFF_ITEMSTATS_LOCK_ID);
|
||||
SetU32(L, record, "itemSet", Offsets::OFF_ITEMSTATS_ITEM_SET);
|
||||
|
||||
// Books / quest / binding
|
||||
SetU32(L, record, "pageText", Offsets::OFF_ITEMSTATS_PAGE_TEXT);
|
||||
SetU32(L, record, "pageMaterial", Offsets::OFF_ITEMSTATS_PAGE_MATERIAL);
|
||||
SetU32(L, record, "languageID", Offsets::OFF_ITEMSTATS_LANGUAGE_ID);
|
||||
SetU32(L, record, "startQuest", Offsets::OFF_ITEMSTATS_START_QUEST);
|
||||
SetI32(L, record, "material", Offsets::OFF_ITEMSTATS_MATERIAL);
|
||||
SetU32(L, record, "sheath", Offsets::OFF_ITEMSTATS_SHEATH);
|
||||
SetI32(L, record, "randomProperty", Offsets::OFF_ITEMSTATS_RANDOM_PROPERTY);
|
||||
SetU32(L, record, "area", Offsets::OFF_ITEMSTATS_AREA);
|
||||
SetU32(L, record, "map", Offsets::OFF_ITEMSTATS_MAP);
|
||||
}
|
||||
|
||||
// Common path for both entry points: take an itemID, peek the cache,
|
||||
// build & push the data table or push nil for uncached.
|
||||
int PushDataForItemID(void *L, int itemID) {
|
||||
if (itemID <= 0) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
const uint8_t *record = FetchItemRecord(static_cast<uint32_t>(itemID));
|
||||
if (record == nullptr) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
PushItemDataTable(L, static_cast<uint32_t>(itemID), record);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Item.GetItemDataByID(itemInfo)` — accepts numeric itemID,
|
||||
// `"item:NNN..."` string, or full chat link. Returns the data table
|
||||
// or nil for invalid input / uncached items.
|
||||
int __fastcall Script_GetItemDataByID(void *L) {
|
||||
return PushDataForItemID(L, Item::Arg::ResolveItemID(L, 1));
|
||||
}
|
||||
|
||||
// `C_Item.GetItemData(itemLocation)` — resolves the equipment-slot /
|
||||
// bag-slot location to its CGItem, extracts the itemID, then peeks
|
||||
// the cache. Returns nil for empty slots, invalid locations, or
|
||||
// uncached items.
|
||||
int __fastcall Script_GetItemData(void *L) {
|
||||
if (!Item::Location::IsLocationArg(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: C_Item.GetItemData(itemLocation)");
|
||||
return 0;
|
||||
}
|
||||
return PushDataForItemID(L, Item::ID::FromCGItem(Item::Location::Resolve(L, 1)));
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Item", "GetItemDataByID", &Script_GetItemDataByID);
|
||||
Game::Lua::RegisterTableFunction("C_Item", "GetItemData", &Script_GetItemData);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Item::GetData
|
||||
+20
-3
@@ -93,13 +93,31 @@ static bool BuildIconPath(uint32_t displayInfoID, char *out, size_t outSize) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// `GetItemInfoInstant` is the modern "synchronous, never blocks" item
|
||||
// info call. In modern WoW the trailing 6 fields come from client-side
|
||||
// `Item-sparse.db2` and so the call always returns the full 7-tuple.
|
||||
// 1.12 has no client-side item DB — those fields live only in the
|
||||
// network-fed `DBCache_ItemStats`. We mirror the contract by:
|
||||
// - always returning the itemID (echoed from input) so callers using
|
||||
// `GetItemInfoInstant(link_or_id)` as an ID extractor never see nil
|
||||
// - returning the remaining 6 fields from the cache when present,
|
||||
// nil when not (preserving the 7-tuple shape)
|
||||
// - not warming the cache; "Instant" implies no side effects. Callers
|
||||
// that need the full info should use `GetItemInfo`, which already
|
||||
// triggers the warmup via the `Script_GetItemInfo` hook.
|
||||
static int __fastcall Script_GetItemInfoInstant(void *L) {
|
||||
const int itemID = Item::Arg::ResolveItemID(L, 1);
|
||||
if (itemID <= 0)
|
||||
return 0;
|
||||
|
||||
Game::Lua::PushNumber(L, static_cast<double>(itemID));
|
||||
|
||||
const uint8_t *record = FetchItemRecord(static_cast<uint32_t>(itemID));
|
||||
if (record == nullptr)
|
||||
return 0;
|
||||
if (record == nullptr) {
|
||||
for (int i = 0; i < 6; ++i)
|
||||
Game::Lua::PushNil(L);
|
||||
return 7;
|
||||
}
|
||||
|
||||
const uint32_t classID =
|
||||
*reinterpret_cast<const uint32_t *>(record + Offsets::OFF_ITEMSTATS_CLASS);
|
||||
@@ -114,7 +132,6 @@ static int __fastcall Script_GetItemInfoInstant(void *L) {
|
||||
if (!BuildIconPath(displayInfoID, iconPath, sizeof(iconPath)))
|
||||
iconPath[0] = 0;
|
||||
|
||||
Game::Lua::PushNumber(L, static_cast<double>(itemID));
|
||||
Game::Lua::PushString(L, LookupItemClassName(classID));
|
||||
Game::Lua::PushString(L, LookupItemSubClassName(classID, subClassID));
|
||||
Game::Lua::PushString(L, LookupInvType(invType));
|
||||
|
||||
@@ -105,6 +105,47 @@ bool FitsSlot(int slot1Based, uint32_t invType) {
|
||||
return (kSlotMaskByInvType[invType] & (1u << (slot1Based - 1))) != 0;
|
||||
}
|
||||
|
||||
// ── Proficiency gate ──────────────────────────────────────────────
|
||||
// `FitsSlot` only checks invType→slot shape compatibility; without
|
||||
// also gating on the player's proficiencies this would return (e.g.)
|
||||
// mail helms for a warlock since both are `invType=HEAD`. Modern
|
||||
// WoW's equipment-set picker filters by what the player can actually
|
||||
// equip; we mirror that.
|
||||
//
|
||||
// Source of truth: the engine's per-itemClass proficiency bitmap at
|
||||
// `[VAR_PROFICIENCY_TABLE]` (17 u32 entries indexed by `m_class`).
|
||||
// SMSG_SET_PROFICIENCY's handler at `0x005E7B70` writes the bitmask
|
||||
// for each class verbatim from the wire. Bit N of `table[class]` =
|
||||
// "player can equip subclass N of this class". Same data the engine
|
||||
// itself reads for the tooltip "Mail/Leather/Plate" red-text and the
|
||||
// cursor-drop-on-paperdoll-slot check — covers both armor and weapon
|
||||
// proficiencies in one lookup.
|
||||
//
|
||||
// 4.3.4's `Script_GetInventoryItemsForSlot` does the equivalent check
|
||||
// via `FUN_00553AD0(itemClass) & (1 << subclass)`, reading from
|
||||
// `DAT_00DD4D38` (its own copy of the same packet-fed table).
|
||||
|
||||
bool PlayerHasProficiency(uint32_t itemClass, uint32_t subclass) {
|
||||
if (itemClass > 16)
|
||||
return true; // table only covers 0..16; be permissive past that
|
||||
const uint32_t mask = *reinterpret_cast<const uint32_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_PROFICIENCY_TABLE) + itemClass * 4);
|
||||
if (mask == 0)
|
||||
return true; // table not populated yet (pre-SMSG_SET_PROFICIENCY)
|
||||
// — fail open so empty-mask doesn't blank the list
|
||||
return (mask & (1u << subclass)) != 0;
|
||||
}
|
||||
|
||||
bool PlayerCanEquip(const uint8_t *record) {
|
||||
if (record == nullptr)
|
||||
return true; // missing record — be permissive, don't hide the item
|
||||
const uint32_t classCode = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_CLASS);
|
||||
const uint32_t subclass = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SUBCLASS);
|
||||
return PlayerHasProficiency(classCode, subclass);
|
||||
}
|
||||
|
||||
const uint8_t *PeekItemRecord(uint32_t itemID) {
|
||||
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
|
||||
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
|
||||
@@ -112,11 +153,14 @@ const uint8_t *PeekItemRecord(uint32_t itemID) {
|
||||
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
|
||||
}
|
||||
|
||||
uint32_t ReadInventoryType(const uint8_t *cgItem) {
|
||||
const uint8_t *ItemRecordForCGItem(const uint8_t *cgItem) {
|
||||
const int itemID = Item::ID::FromCGItem(cgItem);
|
||||
if (itemID <= 0)
|
||||
return 0;
|
||||
auto *record = PeekItemRecord(static_cast<uint32_t>(itemID));
|
||||
return nullptr;
|
||||
return PeekItemRecord(static_cast<uint32_t>(itemID));
|
||||
}
|
||||
|
||||
uint32_t ReadInventoryType(const uint8_t *record) {
|
||||
if (record == nullptr)
|
||||
return 0;
|
||||
return *reinterpret_cast<const uint32_t *>(
|
||||
@@ -132,7 +176,10 @@ void CollectIfFits(int slot, const uint8_t *cgItem, int location,
|
||||
std::vector<Hit> &out) {
|
||||
if (cgItem == nullptr)
|
||||
return;
|
||||
if (!FitsSlot(slot, ReadInventoryType(cgItem)))
|
||||
const uint8_t *record = ItemRecordForCGItem(cgItem);
|
||||
if (!FitsSlot(slot, ReadInventoryType(record)))
|
||||
return;
|
||||
if (!PlayerCanEquip(record))
|
||||
return;
|
||||
out.push_back({location, cgItem});
|
||||
}
|
||||
|
||||
@@ -16,8 +16,11 @@
|
||||
#include "../Game.h"
|
||||
#include "../Offsets.h"
|
||||
#include "../guid/Guid.h"
|
||||
#include "Arg.h"
|
||||
#include "ID.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace Item::Location {
|
||||
|
||||
namespace {
|
||||
@@ -146,6 +149,81 @@ const uint8_t *ResolveByGUID(uint64_t guid) {
|
||||
0x172));
|
||||
}
|
||||
|
||||
bool FindByItemID(void *L, int itemID, ByGUIDResult *out) {
|
||||
if (itemID <= 0)
|
||||
return false;
|
||||
|
||||
for (int slot = Offsets::EQUIPMENT_SLOT_FIRST;
|
||||
slot <= Offsets::EQUIPMENT_SLOT_LAST; ++slot) {
|
||||
auto *item = ResolveEquipmentSlot(slot);
|
||||
if (item == nullptr)
|
||||
continue;
|
||||
if (Item::ID::FromCGItem(item) == itemID) {
|
||||
out->equipmentSlotIndex = slot;
|
||||
out->bagID = 0;
|
||||
out->slotIndex = 0;
|
||||
out->item = item;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (int bagID = 0; bagID <= 4; ++bagID) {
|
||||
const int slotCount = GetBagSlotCount(bagID);
|
||||
for (int slotIndex = 1; slotIndex <= slotCount; ++slotIndex) {
|
||||
auto *item = ResolveBag(L, bagID, slotIndex);
|
||||
if (item == nullptr)
|
||||
continue;
|
||||
if (Item::ID::FromCGItem(item) == itemID) {
|
||||
out->equipmentSlotIndex = 0;
|
||||
out->bagID = bagID;
|
||||
out->slotIndex = slotIndex;
|
||||
out->item = item;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out) {
|
||||
if (arg.itemID <= 0 && arg.name == nullptr)
|
||||
return false;
|
||||
|
||||
for (int bagID = 0; bagID <= 4; ++bagID) {
|
||||
const int slotCount = GetBagSlotCount(bagID);
|
||||
for (int slotIndex = 1; slotIndex <= slotCount; ++slotIndex) {
|
||||
auto *item = ResolveBag(L, bagID, slotIndex);
|
||||
if (item == nullptr)
|
||||
continue;
|
||||
|
||||
const int id = Item::ID::FromCGItem(item);
|
||||
if (id == 0)
|
||||
continue;
|
||||
|
||||
if (arg.itemID > 0) {
|
||||
if (id != arg.itemID)
|
||||
continue;
|
||||
} else {
|
||||
auto *record = PeekItemRecord(static_cast<uint32_t>(id));
|
||||
if (record == nullptr)
|
||||
continue;
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSTATS_NAME);
|
||||
if (name == nullptr || _stricmp(name, arg.name) != 0)
|
||||
continue;
|
||||
}
|
||||
|
||||
out->equipmentSlotIndex = 0;
|
||||
out->bagID = bagID;
|
||||
out->slotIndex = slotIndex;
|
||||
out->item = item;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool FindByGUID(void *L, uint64_t guid, ByGUIDResult *out) {
|
||||
if (guid == 0)
|
||||
return false;
|
||||
|
||||
@@ -86,6 +86,36 @@ struct ByGUIDResult {
|
||||
// Lua stack — only safe from inside a Lua callback.
|
||||
bool FindByGUID(void *L, uint64_t guid, ByGUIDResult *out);
|
||||
|
||||
// Same equipment-first, bag-second walk as `FindByGUID`, but matches
|
||||
// on itemID instead. Returns the FIRST instance found — equipment
|
||||
// slots are checked before bags, so an equipped enchanted copy is
|
||||
// preferred over an unenchanted duplicate in the backpack. Used
|
||||
// when we only have the itemID for a slot (e.g. cursor-held items
|
||||
// picked up from the action bar, which the engine stores as a
|
||||
// bare itemID with no GUID) and want to recover the live CGItem
|
||||
// for link-decoration purposes. Stomps the Lua stack.
|
||||
bool FindByItemID(void *L, int itemID, ByGUIDResult *out);
|
||||
|
||||
} // namespace Item::Location
|
||||
|
||||
// Forward-declare for `FindByArgInBags` below.
|
||||
namespace Item::Arg { struct Resolved; }
|
||||
|
||||
namespace Item::Location {
|
||||
|
||||
// Walks the player's bags (0..4 only — no equipment) looking for the
|
||||
// first item matching `arg`. `arg.itemID > 0` ⇒ direct ID compare;
|
||||
// otherwise `arg.name` is matched case-insensitively against the
|
||||
// cached `ItemStats_C.m_name[0]` of each candidate. On hit, populates
|
||||
// `out->bagID` / `out->slotIndex` / `out->item` and sets
|
||||
// `equipmentSlotIndex = 0`. Stomps the Lua stack.
|
||||
//
|
||||
// Used by the by-name action APIs (`C_Item.EquipItemByName`,
|
||||
// `C_Item.UseItemByName`, `C_Item.UseAtCursor`) — none of them want
|
||||
// equipment in the candidate pool (the engine doesn't equip-from-
|
||||
// equipped or use-from-equipped; the item has to be in bags first).
|
||||
bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out);
|
||||
|
||||
// Parses the `"0xHHHHHHHHLLLLLLLL"` GUID string format
|
||||
// `C_Item.GetItemGUID` returns. Strict: requires exactly `0x` prefix
|
||||
// + 16 case-insensitive hex digits. Returns false (and leaves `*out`
|
||||
|
||||
@@ -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
|
||||
@@ -199,6 +199,38 @@ bool FromPaperdoll(const void *cgItem, int srcPaperdollSlot, int dstPaperdollSlo
|
||||
dstPaperdollSlot);
|
||||
}
|
||||
|
||||
bool ToBag(const void *cgItem, int srcPaperdollSlot, int dstBagID, int dstSlotInBag) {
|
||||
if (srcPaperdollSlot < 1 || srcPaperdollSlot > 19)
|
||||
return false;
|
||||
if (cgItem == nullptr)
|
||||
return false;
|
||||
|
||||
void *player = ResolvePlayer();
|
||||
if (player == nullptr)
|
||||
return false;
|
||||
uint32_t playerLo = 0, playerHi = 0;
|
||||
if (!ReadGuid(player, &playerLo, &playerHi))
|
||||
return false;
|
||||
|
||||
uint32_t dstContainerLo = 0, dstContainerHi = 0, dstLinear = 0;
|
||||
if (!EncodeBagSlot(dstBagID, dstSlotInBag,
|
||||
&dstContainerLo, &dstContainerHi, &dstLinear))
|
||||
return false;
|
||||
|
||||
uint32_t itemLo = 0, itemHi = 0;
|
||||
if (!ReadGuid(cgItem, &itemLo, &itemHi))
|
||||
return false;
|
||||
|
||||
auto fn = reinterpret_cast<SwapFn_t>(Offsets::FUN_INVENTORY_SWAP);
|
||||
fn(player,
|
||||
itemLo, itemHi,
|
||||
playerLo, playerHi,
|
||||
static_cast<uint32_t>(srcPaperdollSlot - 1),
|
||||
dstContainerLo, dstContainerHi, dstLinear,
|
||||
0);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MoveCount(void *L, int srcBag, int srcSlot, int dstBag, int dstSlot, int count) {
|
||||
// Vanilla protocol writes count as a single byte. Clamp at 255;
|
||||
// anything larger would either truncate silently (255 sent) or
|
||||
@@ -222,6 +254,24 @@ bool MoveCount(void *L, int srcBag, int srcSlot, int dstBag, int dstSlot, int co
|
||||
if (srcItem == nullptr)
|
||||
return false;
|
||||
|
||||
// Vanilla server rejects `CMSG_SPLIT_ITEM` when count == srcStack
|
||||
// (`EQUIP_ERR_COULDNT_SPLIT_ITEMS`) — conceptually "split N off,
|
||||
// leave residual", and splitting the entire stack would leave
|
||||
// source empty. The drag-and-drop UI's "drop whole stack on
|
||||
// matching stack" goes through CMSG_SWAP_ITEM instead, which the
|
||||
// server treats as a merge for same-item destinations (up to
|
||||
// maxStack). Route there for count == srcStack so callers don't
|
||||
// have to special-case "move everything".
|
||||
auto *srcDescriptor = *reinterpret_cast<const uint8_t *const *>(
|
||||
srcItem + Offsets::OFF_ITEM_DESCRIPTOR);
|
||||
const int srcStack = srcDescriptor == nullptr ? 0 :
|
||||
static_cast<int>(*reinterpret_cast<const uint32_t *>(
|
||||
srcDescriptor + Offsets::OFF_DESCRIPTOR_STACK_COUNT));
|
||||
if (count > srcStack)
|
||||
return false; // server would reject anyway; fail fast and locally
|
||||
if (count == srcStack)
|
||||
return Containers(L, srcBag, srcSlot, dstBag, dstSlot);
|
||||
|
||||
void *player = ResolvePlayer();
|
||||
if (player == nullptr)
|
||||
return false;
|
||||
|
||||
+17
-3
@@ -32,6 +32,12 @@ namespace Item::Swap {
|
||||
// paperdoll slot `srcSlot` (1-based). Used for paperdoll-to-
|
||||
// paperdoll swaps like rings 11/12, trinkets 13/14.
|
||||
//
|
||||
// - `ToBag(item, srcPaperdollSlot, dstBag, dstSlot)` — item currently
|
||||
// in paperdoll slot, moving to a bag slot. The "unequip" direction
|
||||
// `UseEquipmentSet` needs for slots the set wants empty. Uses the
|
||||
// same atomic swap packet — server-side accept rules are identical
|
||||
// (bagID 0..4, dst slot must be valid for that bag).
|
||||
//
|
||||
// All slot args are 1-based Lua-facing; helpers do the
|
||||
// linear-slot conversion. Returns `false` on bad args (slot out of
|
||||
// range, no player object, missing instance block on the item or
|
||||
@@ -44,6 +50,8 @@ bool FromBag(const void *cgItem, int bagID, int slotInBag, int dstPaperdollSlot)
|
||||
|
||||
bool FromPaperdoll(const void *cgItem, int srcPaperdollSlot, int dstPaperdollSlot);
|
||||
|
||||
bool ToBag(const void *cgItem, int srcPaperdollSlot, int dstBagID, int dstSlotInBag);
|
||||
|
||||
// General bag-to-bag swap — neither side is constrained to the
|
||||
// paperdoll. Same engine helper (`FUN_INVENTORY_SWAP`), which routes
|
||||
// to opcode 0x10D (same-container slot swap, e.g. backpack ↔ backpack)
|
||||
@@ -74,9 +82,15 @@ bool Containers(void *L, int srcBag, int srcSlot, int dstBag, int dstSlot);
|
||||
// - `dst` same item & fits maxStack → merges `count` into dst
|
||||
// - `dst` different item / overflow → server rejects (no partial)
|
||||
//
|
||||
// Source must be occupied with at least `count` items; server rejects
|
||||
// if not. Returns false locally only on bad args or unresolvable bags.
|
||||
// All other validation is server-side — same as `Containers` above.
|
||||
// Source must be occupied with at least `count` items. Locally
|
||||
// validates `count <= srcStack` (server would reject otherwise with
|
||||
// `EQUIP_ERR_COULDNT_SPLIT_ITEMS` — fail fast). When `count ==
|
||||
// srcStack` ("move the whole stack"), routes through `Containers`
|
||||
// instead — vanilla's `CMSG_SPLIT_ITEM` rejects splits that would
|
||||
// leave the source empty, but `CMSG_SWAP_ITEM` handles the case
|
||||
// (merges into a matching destination stack, swaps otherwise).
|
||||
// Returns false locally on bad args, unresolvable bags, or
|
||||
// `count > srcStack`; all other validation is server-side.
|
||||
//
|
||||
// Requires Lua state for the same reason as `Containers`: the source
|
||||
// CGItem lookup goes through `Item::Location::ResolveBag`. Stomps the
|
||||
|
||||
+79
-21
@@ -17,9 +17,11 @@
|
||||
#include "item/ID.h"
|
||||
#include "item/Link.h"
|
||||
#include "item/Location.h"
|
||||
#include "item/QualityColor.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
namespace Item::Tooltip {
|
||||
|
||||
@@ -134,25 +136,6 @@ static int __fastcall Script_GameTooltipSetInventoryItemByID(void *L) {
|
||||
return fn(L);
|
||||
}
|
||||
|
||||
// Modern `Enum.ItemQuality` color codes for the SetItemByID fallback
|
||||
// path (no CGItem → no engine link helper). For the CGItem-resolved
|
||||
// path we delegate to the engine's own builder, which uses the same
|
||||
// palette internally.
|
||||
static const char *QualityHex(int quality) {
|
||||
static const char *const kHex[] = {
|
||||
"9d9d9d", // POOR
|
||||
"ffffff", // COMMON
|
||||
"1eff00", // UNCOMMON
|
||||
"0070dd", // RARE
|
||||
"a335ee", // EPIC
|
||||
"ff8000", // LEGENDARY
|
||||
"e6cc80", // ARTIFACT (TBC+)
|
||||
"00ccff", // HEIRLOOM (WotLK+)
|
||||
};
|
||||
if (quality < 0 || quality > 7)
|
||||
return kHex[1];
|
||||
return kHex[quality];
|
||||
}
|
||||
|
||||
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
|
||||
const uint64_t *guid, void *callback,
|
||||
@@ -277,8 +260,9 @@ static int __fastcall Script_GameTooltipGetItem(void *L) {
|
||||
|
||||
char link[256];
|
||||
std::snprintf(link, sizeof(link),
|
||||
"|cff%s|Hitem:%d:0:0:0:0:0:0:0|h[%s]|h|r",
|
||||
QualityHex(static_cast<int>(quality)), itemID, name);
|
||||
"%s|Hitem:%d:0:0:0:0:0:0:0|h[%s]|h|r",
|
||||
Item::QualityColor::Prefix(static_cast<int>(quality)),
|
||||
itemID, name);
|
||||
|
||||
Game::Lua::PushString(L, name);
|
||||
Game::Lua::PushString(L, link);
|
||||
@@ -307,8 +291,82 @@ static int __fastcall Script_GameTooltipHasItem(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `GameTooltip:SetItemByGUID(guidString)` — modern method that renders
|
||||
// the tooltip for the specific item instance identified by GUID
|
||||
// (`"0xHHHHHHHHLLLLLLLL"` per `C_Item.GetItemGUID`). Distinct from
|
||||
// `SetItemByID`, which shows the base `ItemSparse` data with no
|
||||
// per-instance state: this path uses the live CGItem so the tooltip
|
||||
// includes enchant lines, random-suffix-decorated name + bonuses,
|
||||
// locked/broken state, etc.
|
||||
//
|
||||
// Implementation: parse the GUID string, resolve via the engine's
|
||||
// `FUN_OBJECT_RESOLVE_BY_GUID` (same path `C_Item.GetItemLocation`
|
||||
// and `GameTooltip:GetItem` use), build the fully-decorated
|
||||
// hyperlink via `Item::Link::FromCGItem` (engine helper at
|
||||
// `FUN_GAMETOOLTIP_BUILD_ITEM_LINK`), then dispatch to the engine's
|
||||
// existing `Script_GameTooltip_SetHyperlink`. SetHyperlink parses
|
||||
// the link's `item:N:enchant:gem:gem:gem:gem:suffix:unique` payload
|
||||
// back into instance-specific tooltip data — closing the loop
|
||||
// without us needing a separate engine entry point for "tooltip
|
||||
// from CGItem*".
|
||||
//
|
||||
// Silent no-op when the GUID is malformed, not loaded in the
|
||||
// client, or doesn't resolve to an item (creature/object GUIDs go
|
||||
// to a different resolver).
|
||||
static int __fastcall Script_GameTooltipSetItemByGUID(void *L) {
|
||||
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
|
||||
Game::Lua::Error(L, "Usage: GameTooltip:SetItemByGUID(itemGUID)");
|
||||
return 0;
|
||||
}
|
||||
if (Game::Lua::Type(L, 2) != Game::Lua::TYPE_STRING) {
|
||||
Game::Lua::Error(L, "Usage: GameTooltip:SetItemByGUID(itemGUID)");
|
||||
return 0;
|
||||
}
|
||||
const char *guidStr = Game::Lua::ToString(L, 2);
|
||||
uint64_t guid = 0;
|
||||
if (!Item::Location::ParseGUIDString(guidStr, &guid))
|
||||
return 0;
|
||||
const uint8_t *cgItem = Item::Location::ResolveByGUID(guid);
|
||||
if (cgItem == nullptr)
|
||||
return 0;
|
||||
const char *link = Item::Link::FromCGItem(cgItem);
|
||||
if (link == nullptr || *link == '\0')
|
||||
return 0;
|
||||
|
||||
// `Item::Link::FromCGItem` returns the full
|
||||
// `|cffXXXXXXXX|Hitem:N:E:S:S:S:S:R:U|h[Name]|h|r` decorated link
|
||||
// (the public-link form addons paste into chat). `SetHyperlink`
|
||||
// wants just the bare `item:N:E:S:S:S:S:R:U` payload — passing the
|
||||
// decorated form trips its "Unknown link type" Lua error because
|
||||
// the parser sees `|c` first and gives up. Extract the payload by
|
||||
// finding `|Hitem:` and copying through to the closing `|h`.
|
||||
const char *open = std::strstr(link, "|Hitem:");
|
||||
if (open == nullptr)
|
||||
return 0;
|
||||
open += 2; // skip past `|H` so we start at `item:`
|
||||
const char *close = std::strstr(open, "|h");
|
||||
if (close == nullptr)
|
||||
return 0;
|
||||
const size_t payloadLen = static_cast<size_t>(close - open);
|
||||
char payload[128];
|
||||
if (payloadLen + 1 > sizeof(payload))
|
||||
return 0;
|
||||
std::memcpy(payload, open, payloadLen);
|
||||
payload[payloadLen] = '\0';
|
||||
|
||||
// Replace stack[2] (the GUID) with the bare item link and
|
||||
// dispatch to SetHyperlink with (self, "item:...").
|
||||
Game::Lua::SetTop(L, 1);
|
||||
Game::Lua::PushString(L, payload);
|
||||
|
||||
using Script_t = int(__fastcall *)(void *L);
|
||||
auto fn = reinterpret_cast<Script_t>(Offsets::FUN_SCRIPT_GAMETOOLTIP_SET_HYPERLINK);
|
||||
return fn(L);
|
||||
}
|
||||
|
||||
static const Game::Lua::FrameMethodEntry g_methods[] = {
|
||||
{"SetItemByID", &Script_GameTooltipSetItemByID},
|
||||
{"SetItemByGUID", &Script_GameTooltipSetItemByGUID},
|
||||
{"SetInventoryItemByID", &Script_GameTooltipSetInventoryItemByID},
|
||||
{"GetItem", &Script_GameTooltipGetItem},
|
||||
{"HasItem", &Script_GameTooltipHasItem},
|
||||
|
||||
+62
-45
@@ -11,18 +11,31 @@
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Item.GetItemUniqueness(itemLocation)` — uniqueness cap. Reads
|
||||
// `ItemStats_C.m_maxCount` from the item's cached record; that's the
|
||||
// only uniqueness signal vanilla's client carries. `1` = unique
|
||||
// ("Held In Off-hand" trinkets, special quest items, etc.); `0` =
|
||||
// unlimited; higher values cap the inventory count.
|
||||
// `C_Item.GetItemUniqueness(itemLocation)` and the `*ByID` variant
|
||||
// — uniqueness cap. Both read `ItemStats_C.m_maxCount` from the
|
||||
// item's cached record; that's the only uniqueness signal vanilla
|
||||
// carries. `1` = unique-equipped (one at a time); higher values
|
||||
// cap the inventory count; `0` = unlimited.
|
||||
//
|
||||
// Modern WoW returns a 3-tuple `(maxEquipped, category, equippedCount)`
|
||||
// keyed off the post-TBC category system (e.g. "Unique-Equipped:
|
||||
// Brewfest Mug"). Vanilla has no category system, so we surface
|
||||
// `m_maxCount` as the first return and leave the category nil and
|
||||
// equipped count `0` — matching modern's "field present but inapplicable"
|
||||
// convention.
|
||||
// Modern WoW (Classic Era 1.15.x) gives the two functions **different
|
||||
// signatures**, verified against `_classic_` BlizzardInterfaceCode's
|
||||
// `ItemDocumentation.lua`:
|
||||
//
|
||||
// GetItemUniqueness(itemLocation) →
|
||||
// limitCategory (number, non-nilable),
|
||||
// limitMax (number, non-nilable)
|
||||
//
|
||||
// GetItemUniquenessByID(itemInfo) →
|
||||
// isUnique (bool, non-nilable),
|
||||
// limitCategoryName (string, nilable),
|
||||
// limitCategoryCount (number, nilable),
|
||||
// limitCategoryID (number, nilable)
|
||||
//
|
||||
// Vanilla has no `LimitCategory.dbc` system (TBC+ addition for
|
||||
// "Unique-Equipped: Brewfest Mug"-style cross-item category caps),
|
||||
// so the category fields are always nil/0. `isUnique` collapses to
|
||||
// `m_maxCount > 0`; `limitMax` and `limitCategoryCount` both surface
|
||||
// the raw `m_maxCount`.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
@@ -50,54 +63,58 @@ const uint8_t *PeekItemRecord(uint32_t itemID) {
|
||||
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// Pushes the 3-tuple `(maxAllowed, nil, 0)` derived from the
|
||||
// ItemStats_C record, or nil + early-return on cache miss.
|
||||
int PushFromItemID(void *L, uint32_t itemID) {
|
||||
// Returns the item's `m_maxCount` or -1 if the record isn't cached.
|
||||
// `-1` is a "warm-and-bail" sentinel; caller pushes nothing on -1
|
||||
// so the Lua side sees the modern "MayReturnNothing" behaviour.
|
||||
int ReadMaxCount(uint32_t itemID) {
|
||||
const uint8_t *record = PeekItemRecord(itemID);
|
||||
if (record == nullptr) {
|
||||
Item::Data::WarmCache(itemID);
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
return -1;
|
||||
}
|
||||
const int maxCount = *reinterpret_cast<const int *>(
|
||||
return *reinterpret_cast<const int *>(
|
||||
record + Offsets::OFF_ITEMSTATS_MAX_COUNT);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(maxCount));
|
||||
Game::Lua::PushNil(L); // category (vanilla has none)
|
||||
Game::Lua::PushNumber(L, 0.0); // equippedCount (not tracked)
|
||||
return 3;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// `GetItemUniqueness(itemLocation)` shape: `(limitCategory, limitMax)`.
|
||||
static int __fastcall Script_GetItemUniqueness(void *L) {
|
||||
if (!Item::Location::IsLocationArg(L, 1)) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
if (!Item::Location::IsLocationArg(L, 1))
|
||||
return 0;
|
||||
auto *item = Item::Location::Resolve(L, 1);
|
||||
if (item == nullptr) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
if (item == nullptr)
|
||||
return 0;
|
||||
const int itemID = Item::ID::FromCGItem(item);
|
||||
if (itemID <= 0) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
return PushFromItemID(L, static_cast<uint32_t>(itemID));
|
||||
if (itemID <= 0)
|
||||
return 0;
|
||||
const int maxCount = ReadMaxCount(static_cast<uint32_t>(itemID));
|
||||
if (maxCount < 0)
|
||||
return 0; // cache miss — modern API documents "MayReturnNothing"
|
||||
Game::Lua::PushNumber(L, 0.0); // limitCategory (vanilla has no LimitCategory.dbc)
|
||||
Game::Lua::PushNumber(L, static_cast<double>(maxCount));
|
||||
return 2;
|
||||
}
|
||||
|
||||
// `GetItemUniquenessByID(itemID)` shape:
|
||||
// `(isUnique, limitCategoryName, limitCategoryCount, limitCategoryID)`.
|
||||
static int __fastcall Script_GetItemUniquenessByID(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
if (!Game::Lua::IsNumber(L, 1))
|
||||
return 0;
|
||||
const int itemID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (itemID <= 0) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
return PushFromItemID(L, static_cast<uint32_t>(itemID));
|
||||
if (itemID <= 0)
|
||||
return 0;
|
||||
const int maxCount = ReadMaxCount(static_cast<uint32_t>(itemID));
|
||||
if (maxCount < 0)
|
||||
return 0;
|
||||
Game::Lua::PushBool(L, maxCount > 0);
|
||||
Game::Lua::PushNil(L); // limitCategoryName (no categories in vanilla)
|
||||
if (maxCount > 0)
|
||||
Game::Lua::PushNumber(L, static_cast<double>(maxCount));
|
||||
else
|
||||
Game::Lua::PushNil(L); // limitCategoryCount — nil when not limited
|
||||
Game::Lua::PushNil(L); // limitCategoryID
|
||||
return 4;
|
||||
}
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
|
||||
+82
-54
@@ -11,9 +11,12 @@
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Item.UseItemByName(itemInfo)` — finds the first item in the
|
||||
// player's bags matching `itemInfo` (itemID, link, or name) and uses
|
||||
// it via the engine's `CGItem::UseItem` primitive.
|
||||
// `C_Item.UseItemByName(itemInfo [, unit])` — finds the first item in
|
||||
// the player's bags matching `itemInfo` (itemID, link, or name) and
|
||||
// uses it via the engine's `CGItem::UseItem` primitive. Optional
|
||||
// `unit` is a unit token (`"player"`, `"target"`, `"focus"`, …)
|
||||
// passed to spell-cast items (scrolls, traps, on-use targeted
|
||||
// effects) as the cast target.
|
||||
//
|
||||
// Same structure 3.3.5's `Script_UseItemByName` uses: locate the item
|
||||
// directly, then hand it to the engine's by-item-pointer use call.
|
||||
@@ -25,91 +28,116 @@
|
||||
// food / potion / hearthstone / scroll / quiver all route through
|
||||
// the same entry.
|
||||
//
|
||||
// Target arg: we pass nullptr / a zero GUID. The engine substitutes
|
||||
// the player itself for self-use items (hearthstone, food, potion);
|
||||
// for target-required items (scrolls, traps) the engine reads
|
||||
// cursor-target state at call time, which a Lua-side caller can
|
||||
// pre-set with `TargetUnit("...")`.
|
||||
// Target arg: `FUN_ITEM_USE`'s `param_1` is read as a `uint64_t *`
|
||||
// pointing at the target's GUID. The engine writes it through to the
|
||||
// "use item" event (`DAT_00c4c1d0`/`DAT_00c4c1d4` + event 0x122) for
|
||||
// spell-cast items, and into the cast-targeting helper at
|
||||
// `FUN_006e5a90`. For self-use items (hearthstone, food, potion) the
|
||||
// engine overwrites with the item's own GUID before dispatch, so a
|
||||
// stray target is harmless. We default to a zero GUID when the caller
|
||||
// omits `unit` — matches existing call sites.
|
||||
//
|
||||
// Returns nothing. Silently no-ops on bad input, item not found in
|
||||
// bags, item locked, on cooldown, etc.
|
||||
// bags, item locked, on cooldown, etc. An unrecognized `unit` string
|
||||
// is treated as "no target" rather than raising, matching arg-1's
|
||||
// "silently no-ops on bad input" contract.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "item/Arg.h"
|
||||
#include "item/ID.h"
|
||||
#include "item/Location.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string.h>
|
||||
#include <cstring>
|
||||
|
||||
namespace Item::Use {
|
||||
|
||||
namespace {
|
||||
|
||||
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
|
||||
const uint64_t *guid, void *callback,
|
||||
void *userData, int unused);
|
||||
|
||||
using UseItem_t = unsigned(__thiscall *)(const void *item, const uint64_t *targetGuid,
|
||||
int flag);
|
||||
|
||||
const uint8_t *PeekItemRecord(uint32_t itemID) {
|
||||
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
|
||||
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
|
||||
const uint64_t zeroGuid = 0;
|
||||
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
|
||||
using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
|
||||
|
||||
bool HasPrefix(const char *s, const char *prefix) {
|
||||
return std::strncmp(s, prefix, std::strlen(prefix)) == 0;
|
||||
}
|
||||
|
||||
// Returns the matching CGItem* in `*outItem` on hit; mirrors the
|
||||
// `Item::Equipment::FindItemInBags` walk but skips bag/slot output
|
||||
// since `FUN_ITEM_USE` operates directly on the CGItem pointer.
|
||||
bool FindItemInBags(void *L, const Item::Arg::Resolved &arg, const uint8_t **outItem) {
|
||||
for (int bag = 0; bag <= 4; ++bag) {
|
||||
const int slotCount = Item::Location::GetBagSlotCount(bag);
|
||||
for (int slot = 1; slot <= slotCount; ++slot) {
|
||||
const uint8_t *item = Item::Location::ResolveBag(L, bag, slot);
|
||||
if (item == nullptr) continue;
|
||||
// Walk past any "target" suffixes (`targettarget`, `focustargettarget`,
|
||||
// …); returns a pointer at the first non-"target" character.
|
||||
const char *SkipTargetSuffixes(const char *s) {
|
||||
while (HasPrefix(s, "target"))
|
||||
s += 6;
|
||||
return s;
|
||||
}
|
||||
|
||||
const int id = Item::ID::FromCGItem(item);
|
||||
if (id == 0) continue;
|
||||
|
||||
if (arg.itemID > 0) {
|
||||
if (id == arg.itemID) {
|
||||
*outItem = item;
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
auto *record = PeekItemRecord(static_cast<uint32_t>(id));
|
||||
if (record == nullptr) continue;
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSTATS_NAME);
|
||||
if (name == nullptr) continue;
|
||||
if (_stricmp(name, arg.name) == 0) {
|
||||
*outItem = item;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Predicate mirror of the engine's token grammar inside
|
||||
// `FUN_TOKEN_TO_GUID` (`0x00515970`) plus the families our own
|
||||
// `Unit::TokenExtensions` hook adds (`focus`, `nameplateN`). The
|
||||
// engine raises a Lua error on unknown input, so we gate before
|
||||
// dispatching to keep our "silently no-ops on bad input" contract.
|
||||
//
|
||||
// Every base token may be followed by zero or more `target` suffixes
|
||||
// (`playertarget`, `party1targettarget`, …) which the engine's
|
||||
// resolver walks via `LAB_005159d3..0x00515A2C`.
|
||||
bool MatchBase(const char *s, const char *base, bool requireDigits) {
|
||||
if (!HasPrefix(s, base))
|
||||
return false;
|
||||
const char *rest = s + std::strlen(base);
|
||||
if (requireDigits) {
|
||||
const char *p = rest;
|
||||
while (*p >= '0' && *p <= '9')
|
||||
++p;
|
||||
if (p == rest)
|
||||
return false;
|
||||
rest = p;
|
||||
}
|
||||
return *SkipTargetSuffixes(rest) == '\0';
|
||||
}
|
||||
|
||||
bool LooksLikeToken(const char *s) {
|
||||
if (s == nullptr || *s == '\0')
|
||||
return false;
|
||||
// Order matters for shared roots: longer prefix first
|
||||
// (`partypet`/`raidpet` before `party`/`raid`).
|
||||
if (MatchBase(s, "partypet", true)) return true;
|
||||
if (MatchBase(s, "raidpet", true)) return true;
|
||||
if (MatchBase(s, "party", true)) return true;
|
||||
if (MatchBase(s, "raid", true)) return true;
|
||||
if (MatchBase(s, "nameplate", true)) return true;
|
||||
if (MatchBase(s, "mouseover", false)) return true;
|
||||
if (MatchBase(s, "player", false)) return true;
|
||||
if (MatchBase(s, "target", false)) return true;
|
||||
if (MatchBase(s, "focus", false)) return true;
|
||||
if (MatchBase(s, "npc", false)) return true;
|
||||
if (MatchBase(s, "pet", false)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
uint64_t ResolveUnitGuid(void *L, int idx) {
|
||||
if (!Game::Lua::IsString(L, idx))
|
||||
return 0;
|
||||
const char *token = Game::Lua::ToString(L, idx);
|
||||
if (!LooksLikeToken(token))
|
||||
return 0;
|
||||
auto fn = reinterpret_cast<TokenToGUID_t>(Offsets::FUN_TOKEN_TO_GUID);
|
||||
return fn(token);
|
||||
}
|
||||
|
||||
int __fastcall Script_C_Item_UseItemByName(void *L) {
|
||||
const auto arg = Item::Arg::Resolve(L, 1);
|
||||
if (arg.itemID <= 0 && arg.name == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const uint8_t *cgItem = nullptr;
|
||||
if (!FindItemInBags(L, arg, &cgItem)) {
|
||||
Item::Location::ByGUIDResult found;
|
||||
if (!Item::Location::FindByArgInBags(L, arg, &found)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const uint64_t zeroTarget = 0;
|
||||
const uint64_t targetGuid = ResolveUnitGuid(L, 2);
|
||||
auto useItem = reinterpret_cast<UseItem_t>(Offsets::FUN_ITEM_USE);
|
||||
useItem(cgItem, &zeroTarget, 0);
|
||||
useItem(found.item, &targetGuid, 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Item.UseAtCursor(itemInfo)` — use the named item at the player's
|
||||
// current cursor world position. For items whose on-use spell is
|
||||
// ground-target (Iron Grenade, demolition charges, etc.), bypasses
|
||||
// the manual click on the AoE reticle that the engine would
|
||||
// otherwise require.
|
||||
//
|
||||
// Mirrors `C_Item.UseItemByName`'s bag-walk + `FUN_ITEM_USE` dispatch
|
||||
// — the engine's item-use path eventually hands the on-use spell to
|
||||
// the same cast dispatcher that ground-target spells go through, so
|
||||
// placement-mode activation works identically. After firing the
|
||||
// item, `Spell::AtCursor::Resolve()` commits the placement at cursor.
|
||||
//
|
||||
// Returns `true` when the cursor-placement leg landed; `false` for
|
||||
// items that aren't ground-target (the item still fires, just with
|
||||
// no implicit target), unparseable input, item-not-in-bags, etc.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "item/Arg.h"
|
||||
#include "item/Location.h"
|
||||
#include "spell/AtCursor.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Item::UseAtCursor {
|
||||
|
||||
namespace {
|
||||
|
||||
using UseItem_t = unsigned(__thiscall *)(const void *item, const uint64_t *targetGuid,
|
||||
int flag);
|
||||
|
||||
int __fastcall Script_C_Item_UseAtCursor(void *L) {
|
||||
const auto arg = Item::Arg::Resolve(L, 1);
|
||||
if (arg.itemID <= 0 && arg.name == nullptr) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
Item::Location::ByGUIDResult found;
|
||||
if (!Item::Location::FindByArgInBags(L, arg, &found)) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const uint64_t zeroTarget = 0;
|
||||
auto useItem = reinterpret_cast<UseItem_t>(Offsets::FUN_ITEM_USE);
|
||||
useItem(found.item, &zeroTarget, 0);
|
||||
|
||||
const bool placed = Spell::AtCursor::Resolve();
|
||||
Game::Lua::PushBool(L, placed);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Item", "UseAtCursor",
|
||||
&Script_C_Item_UseAtCursor);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Item::UseAtCursor
|
||||
@@ -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,74 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `IsLoggedIn()` — exposed on both Lua states (glue and in-game).
|
||||
// Returns `true` once a character has been loaded *and identified*
|
||||
// (GUID populated), `false` on glue, pre-load, and the brief window
|
||||
// between struct allocation and character-data unpack.
|
||||
//
|
||||
// Check is two-step:
|
||||
// 1. `[VAR_LOCAL_PLAYER_PTR]` non-NULL — the player aggregator
|
||||
// struct has been allocated
|
||||
// 2. `*(uint64*)(player + OFF_LOCAL_PLAYER_GUID)` non-zero — the
|
||||
// character's GUID has been populated into the struct
|
||||
//
|
||||
// The pointer alone is too lax: the engine allocates the struct
|
||||
// shortly before the character data lands, so there's a window where
|
||||
// the pointer is non-NULL but the GUID is still zero. Most addons
|
||||
// that check `IsLoggedIn` go straight to `UnitGUID("player")` next,
|
||||
// and we don't want them to see GUID==nil right after IsLoggedIn
|
||||
// reported true.
|
||||
//
|
||||
// 3.3.5 uses a single byte flag at `[0x00BD0793]` cleared on world
|
||||
// entry — we couldn't pin down the analogous 1.12 flag (the engine
|
||||
// path that signals event 253 wires through CVar-init machinery and
|
||||
// doesn't map cleanly across the binary). The pointer+GUID check is
|
||||
// the same effective signal: both flip together at the moment the
|
||||
// player object is ready, and stay stable across zone transitions.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Player::LoginStatus {
|
||||
|
||||
namespace {
|
||||
|
||||
int __fastcall Script_IsLoggedIn(void *L) {
|
||||
auto *player = *reinterpret_cast<const uint8_t *const *>(
|
||||
Offsets::VAR_LOCAL_PLAYER_PTR);
|
||||
if (player == nullptr) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
const uint64_t guid = *reinterpret_cast<const uint64_t *>(
|
||||
player + Offsets::OFF_LOCAL_PLAYER_GUID);
|
||||
Game::Lua::PushBool(L, guid != 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterInGame() {
|
||||
Game::Lua::RegisterGlobalFunction("IsLoggedIn", &Script_IsLoggedIn);
|
||||
}
|
||||
|
||||
void RegisterGlue() {
|
||||
Game::Lua::RegisterGlueFunction("IsLoggedIn", &Script_IsLoggedIn);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
|
||||
static const Game::GlueModuleAutoRegister _gluereg{&RegisterGlue};
|
||||
|
||||
} // namespace Player::LoginStatus
|
||||
+155
-1
@@ -40,15 +40,156 @@ inline const uint8_t *Peek(uint32_t questID) {
|
||||
}
|
||||
|
||||
// Field offsets within the data block returned by `Lookup`.
|
||||
constexpr int OFF_TITLE = 0x9C;
|
||||
//
|
||||
// Derived from the engine's own `Script_GetQuestLog*` accessors at
|
||||
// `0x004DF930..0x004E12B0`. Each accessor calls `_GetRecord` then
|
||||
// reads from one of these offsets; the existing comment block on each
|
||||
// constant identifies the source function. See CLAUDE.md's
|
||||
// "Quest static-info cache" section for the full trace.
|
||||
|
||||
// Numeric metadata fields, in wire order from `SMSG_QUEST_QUERY_RESPONSE`.
|
||||
//
|
||||
// The cache layout mirrors the packet's field order — the deserializer
|
||||
// at the SMSG handler unpacks 15 uint32 fields at +0x00..+0x38 before
|
||||
// the reward/choice item arrays. Names and semantics cross-referenced
|
||||
// from the in-binary serializer (`FUN_006DBCEC`) plus the canonical
|
||||
// vanilla 1.12 packet structure documented by vmangos/cmangos emulator
|
||||
// projects. Fields without a direct binary-side accessor (so just
|
||||
// known from wire-format alignment) are marked **(hypothesis)** —
|
||||
// they should be verified empirically before being relied on for
|
||||
// gameplay logic.
|
||||
//
|
||||
// QuestId — uint32, echo of the questID this entry caches.
|
||||
constexpr int OFF_QUEST_ID = 0x00;
|
||||
// QuestMethod — uint32. Server-side classification (typically `2` for
|
||||
// active-and-available, `0` for unavailable). Vanilla doesn't use
|
||||
// this for gameplay; it's stored but no engine accessor reads it.
|
||||
// **(hypothesis)**
|
||||
constexpr int OFF_QUEST_METHOD = 0x04;
|
||||
// Quest level — int32. Source: `FUN_004df340` (called by
|
||||
// `Script_GetQuestLogTitle`) does `mov eax, [block + 0x08]` and
|
||||
// pushes it as `level`.
|
||||
constexpr int OFF_LEVEL = 0x08;
|
||||
// ZoneOrSort — int32. The packet's `ZoneOrSort` field, used by the
|
||||
// quest log UI to group entries under headers:
|
||||
// - Positive = `AreaTable.dbc` zone areaID (e.g. 132 = Coldridge
|
||||
// Valley, 12 = Elwynn Forest)
|
||||
// - Negative = `-QuestSort.dbc` row (e.g. -263 = "Druid" sort)
|
||||
// `FUN_004dee60` reads this directly and matches it against
|
||||
// `VAR_QUEST_LOG_CATEGORY_TABLE` to find the collapsible header index.
|
||||
constexpr int OFF_ZONE_OR_SORT = 0x0C;
|
||||
// QuestInfo.dbc row — uint32 row index into the small DBC at
|
||||
// `0x00C0D9CC` (records) / `0x00C0D9D0` (count). Each record is
|
||||
// `{id@+0, char *name[locale]@+4}`; the localized name is the
|
||||
// quest-type tag string ("Dungeon", "Raid", "Group", "PvP", "Elite"
|
||||
// — modern WoW's `frequency`/`questTag`). Source: `FUN_004df2a0`.
|
||||
constexpr int OFF_QUEST_INFO_ROW = 0x10;
|
||||
// +0x14, +0x18, +0x1C, +0x20, +0x24 — five uint32 cache slots that
|
||||
// SMSG_QUEST_QUERY_RESPONSE in vanilla 1.12 doesn't populate. The
|
||||
// struct has them (the serializer at `0x006DBCEC` writes them as part
|
||||
// of the WDB save / network shape), but the response handler leaves
|
||||
// them zero-filled. Verified empirically across three diverse quests
|
||||
// (race-restricted starter, dungeon, authored-timer delivery) — all
|
||||
// five slots zero in every case.
|
||||
//
|
||||
// Wire-format docs (vmangos / cmangos emulator quest_template) name
|
||||
// them SuggestedPlayers, LimitTime, RequiredRaces, RequiredSkill,
|
||||
// RequiredSkillValue respectively, but the vanilla server *enforces*
|
||||
// these constraints server-side and filters quests before broadcasting
|
||||
// them, so the client never needs the data. Addons that want race /
|
||||
// class / skill / timer info must source from an external scraped DB
|
||||
// (pfQuest-style). Constants intentionally omitted — no use case.
|
||||
//
|
||||
// Signed money delta — int32. Positive = reward copper, negative =
|
||||
// `-required` copper. The engine has two accessors that share this
|
||||
// field with complementary sign:
|
||||
// - `Script_GetQuestLogRewardMoney` returns max(0, +money)
|
||||
// - `Script_GetQuestLogRequiredMoney` returns max(0, -money)
|
||||
constexpr int OFF_MONEY_DELTA = 0x28;
|
||||
// Level-cap reward money bonus — int32. Added to the reward when
|
||||
// the player's level is ≥ 60. Source: `Script_GetQuestLogRewardMoney`
|
||||
// does `cmp [player.unit + 0x70], 0x3C` (level >= 60) then
|
||||
// `add edx, [block + 0x2C]`.
|
||||
constexpr int OFF_LEVEL_CAP_BONUS_MONEY = 0x2C;
|
||||
// Reward spellID — int32. 0 = no spell reward. Source:
|
||||
// `Script_GetQuestLogRewardSpell` does `mov eax, [block + 0x30]` then
|
||||
// resolves the SpellIcon path and locale-applied name via `Spell.dbc`.
|
||||
// This is a spell *learned by the player* on completion (recipes,
|
||||
// taught spells); not to be confused with `OFF_SRC_ITEM_ID` which
|
||||
// is the item the questgiver hands over at accept-time.
|
||||
constexpr int OFF_REWARD_SPELL_ID = 0x30;
|
||||
// SrcItemId — itemID given to the player when the quest is accepted.
|
||||
// 0 = no source item. The questgiver hands this item over so the
|
||||
// player can complete the quest objective (e.g. "Read the Verdant
|
||||
// Sigil" gives item 9580; player uses it, then turns the same item
|
||||
// back in).
|
||||
//
|
||||
// Empirically derived (the offset was originally interpreted as
|
||||
// `RewardSpellCast` from one emulator wire-order variant, but a
|
||||
// real-world dump of quest 3120 stored `9580` here while the quest's
|
||||
// own item objective also references item 9580 — consistent with
|
||||
// SrcItemId semantics, not with a spell-cast-at-turn-in).
|
||||
constexpr int OFF_SRC_ITEM_ID = 0x34;
|
||||
// Quest flags — uint32. Bit definitions match modern WoW's
|
||||
// `QUEST_FLAGS_*` enum, of which only bit 3 (`0x08` SHARABLE) is
|
||||
// confirmed-tested by the engine (`Script_GetQuestLogPushable`'s
|
||||
// `test byte ptr [block + 0x38], 0x08`). Other bits are presumed
|
||||
// stored but unverified.
|
||||
constexpr int OFF_QUEST_FLAGS = 0x38;
|
||||
// Reward and choice item arrays — up to 4 reward and 6 choice
|
||||
// itemIDs stored inline (4 bytes each). 0 in any slot means
|
||||
// "no item in this slot". Used by `GetQuestLogItemLink` /
|
||||
// `GetQuestLogItemID`.
|
||||
//
|
||||
// Each itemID array is paired with a parallel uint32 count array
|
||||
// (the "how many of this item" reward). Layout in cache memory:
|
||||
// +0x3C RewardItems[4]
|
||||
// +0x4C RewardItemsCount[4]
|
||||
// +0x5C ChoiceItems[6]
|
||||
// +0x74 ChoiceItemsCount[6]
|
||||
// Verified from `Script_GetQuestLogRewardInfo` (`fild [block + slot*4 + 0x4C]`
|
||||
// for numItems) and `Script_GetQuestLogChoiceInfo` (`fild [block + slot*4 + 0x74]`).
|
||||
constexpr int OFF_REWARD_ITEMS = 0x3C;
|
||||
constexpr int OFF_REWARD_ITEMS_COUNT = 0x4C;
|
||||
constexpr int OFF_CHOICE_ITEMS = 0x5C;
|
||||
constexpr int OFF_CHOICE_ITEMS_COUNT = 0x74;
|
||||
constexpr int REWARD_COUNT = 4;
|
||||
constexpr int CHOICE_COUNT = 6;
|
||||
// Point-of-interest marker — server-supplied map coordinate hint
|
||||
// used by the questgiver objective panel to display "go here" arrows
|
||||
// in modern WoW. Set on quests that have a designated location;
|
||||
// 0/0/0 = no POI. Read sequentially by the serializer at
|
||||
// `0x006DBE5B..0x006DBE8E` using mixed integer + float wire methods.
|
||||
// **(hypothesis)**
|
||||
constexpr int OFF_POI_MAP_ID = 0x8C; // uint32
|
||||
constexpr int OFF_POI_X = 0x90; // float
|
||||
constexpr int OFF_POI_Y = 0x94; // float
|
||||
constexpr int OFF_POI_OPT = 0x98; // uint32 — display option, or doubles as title length
|
||||
constexpr int OFF_TITLE = 0x9C;
|
||||
// Inline narrative text — both locale-applied and printf-style
|
||||
// format-expanded by the engine's variable substituter at
|
||||
// `0x00506F70` (handles tokens like `$N` for player name, `$C` for
|
||||
// class, etc.). Source: `Script_GetQuestLogQuestText` reads both,
|
||||
// pushes `description` first then `objectives`.
|
||||
//
|
||||
// We deliberately expose the *raw* inline bytes, not the format-
|
||||
// expanded version, because the substituter wants a 1024-byte
|
||||
// scratch buffer and the substitutions (player name, class) belong
|
||||
// in the addon layer anyway. Addons can run `string.gsub` if they
|
||||
// need the runtime values, otherwise raw text is more useful for
|
||||
// guides / databases.
|
||||
constexpr int OFF_OBJECTIVES = 0x29C;
|
||||
constexpr int OFF_DESCRIPTION = 0xA9C;
|
||||
// Completion text — the "talk to me now" body shown on the
|
||||
// quest-reward UI panel after objectives are met. 0x200 (512) bytes
|
||||
// inline. Modern API exposes this as `GetQuestLogCompletionText()`.
|
||||
// Confirmed by the cache copy at `0x00562E1F..0x00562E36` (a 0x200-byte
|
||||
// memcpy from src+0x129C to dst+0x129C) and by the serializer at
|
||||
// `0x006DBEBE` (`lea eax, [edi+0x129C]` followed by the string-writer
|
||||
// call). Vanilla LeaderBoard reads from this offset for some override
|
||||
// cases but the engine never directly exposes the text to Lua —
|
||||
// `Script_GetQuestLogQuestText` returns description + objectives only.
|
||||
constexpr int OFF_COMPLETION_TEXT = 0x129C;
|
||||
// Objective arrays — four uint32[4] parallel slabs carrying
|
||||
// SMSG_QUEST_QUERY_RESPONSE's RequiredNPCOrGo / Count / Item / Count
|
||||
// fields verbatim. NPC-or-GO is **signed**: positive = creature
|
||||
@@ -66,5 +207,18 @@ constexpr int OFF_REQUIRED_NPC_OR_GO_COUNT = 0x14AC; // uint32[4]
|
||||
constexpr int OFF_REQUIRED_ITEM = 0x14BC; // uint32[4]
|
||||
constexpr int OFF_REQUIRED_ITEM_COUNT = 0x14CC; // uint32[4]
|
||||
constexpr int OBJECTIVE_COUNT = 4;
|
||||
// Per-objective override text — array of 4 inline char buffers,
|
||||
// 256 (0x100) bytes per entry, indexed in objective declaration
|
||||
// order (NPC/GO slots fill first, then item slots — same order the
|
||||
// LeaderBoard accessor enumerates). Empty string = no override
|
||||
// (engine auto-formats as `"<NPC name>: X/Y"`); otherwise the
|
||||
// engine displays the buffer verbatim. Confirmed by the
|
||||
// `shl ecx, 8; mov dl, [block + ecx + 0x14DC]` access pattern in
|
||||
// `Script_GetQuestLogLeaderBoard` and by the four sequential reads
|
||||
// in the packet serializer at `0x006DBF17..0x006DBF41` (0x14DC,
|
||||
// 0x15DC, 0x16DC, 0x17DC).
|
||||
constexpr int OFF_OBJECTIVE_TEXT = 0x14DC;
|
||||
constexpr int OBJECTIVE_TEXT_STRIDE = 0x100;
|
||||
constexpr int OBJECTIVE_TEXT_COUNT = 4;
|
||||
|
||||
} // namespace Quest::Cache
|
||||
|
||||
+4
-7
@@ -31,12 +31,10 @@ static const Event::Custom::AutoReserve _reserveQuestDataLoadResult{kQuestDataLo
|
||||
// `ret 8` cleans both args. We encode the questID into `userData` at
|
||||
// request time so the callback knows which quest completed.
|
||||
static void __stdcall QuestLoadCallback(void *userData, int success) {
|
||||
const int slot = Event::Custom::Lookup(kQuestDataLoadResult);
|
||||
if (slot < 0)
|
||||
return;
|
||||
const auto questID =
|
||||
static_cast<int>(reinterpret_cast<uintptr_t>(userData));
|
||||
Event::Custom::Fire(slot, "%d%d", questID, static_cast<int>(success != 0));
|
||||
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kQuestDataLoadResult),
|
||||
questID, success != 0);
|
||||
}
|
||||
|
||||
static void RequestAndMaybeNotify(uint32_t questID) {
|
||||
@@ -47,9 +45,8 @@ static void RequestAndMaybeNotify(uint32_t questID) {
|
||||
// invoking our callback. Fire the event ourselves so addons get the
|
||||
// notification regardless of cache state, matching modern semantics.
|
||||
if (wasCached) {
|
||||
const int slot = Event::Custom::Lookup(kQuestDataLoadResult);
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "%d%d", static_cast<int>(questID), 1);
|
||||
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kQuestDataLoadResult),
|
||||
static_cast<int>(questID), true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_QuestLog.GetQuestDetails(questID)` — single accessor that returns
|
||||
// everything we know how to read from the quest static-info cache as a
|
||||
// Lua table. Powers quest-helper-style addons that want one round-trip
|
||||
// instead of N separate `C_QuestLog.GetTitleForQuestID` / etc. calls.
|
||||
//
|
||||
// Returns `nil` if the quest isn't cached. Caller should pair with
|
||||
// `C_QuestLog.RequestLoadQuestByID` + listen for `QUEST_DATA_LOAD_RESULT`.
|
||||
// All offsets read here come from the engine's own `Script_GetQuestLog*`
|
||||
// accessors — see `Cache.h` for the per-field derivation.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "quest/Cache.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Quest::Details {
|
||||
|
||||
namespace {
|
||||
|
||||
// QuestInfo.dbc — 12-row table holding the localized quest-type tags
|
||||
// ("Dungeon" / "Raid" / "Group" / "PvP" / "Elite"). Address pair from
|
||||
// `docs/DBCs.md`.
|
||||
constexpr uintptr_t VAR_QUESTINFO_DBC_RECORDS = 0x00C0D9CC;
|
||||
constexpr uintptr_t VAR_QUESTINFO_DBC_COUNT = 0x00C0D9D0;
|
||||
|
||||
const char *LookupQuestType(uint32_t row) {
|
||||
if (row == 0)
|
||||
return nullptr;
|
||||
const int count = *reinterpret_cast<const int *>(VAR_QUESTINFO_DBC_COUNT);
|
||||
if (count <= 0 || row > static_cast<uint32_t>(count))
|
||||
return nullptr;
|
||||
const uint8_t *const *records =
|
||||
*reinterpret_cast<const uint8_t *const *const *>(
|
||||
VAR_QUESTINFO_DBC_RECORDS);
|
||||
if (!records)
|
||||
return nullptr;
|
||||
const uint8_t *record = records[row];
|
||||
if (!record)
|
||||
return nullptr;
|
||||
const int localeIdx =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_LOCALE_INDEX);
|
||||
if (localeIdx < 0 || localeIdx > 8)
|
||||
return nullptr;
|
||||
auto *names = reinterpret_cast<const char *const *>(record + 4);
|
||||
return names[localeIdx];
|
||||
}
|
||||
|
||||
// Pushes an array of `{id=itemID, count=N}` tables for each non-zero
|
||||
// reward slot. `ids[count]` and `counts[count]` are parallel arrays
|
||||
// in the cache block. Leaves exactly one table on top of the stack.
|
||||
void PushItemArray(void *L, const uint32_t *ids, const uint32_t *counts,
|
||||
int count) {
|
||||
Game::Lua::NewTable(L);
|
||||
int next = 1;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
if (ids[i] == 0)
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(next++));
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "id", static_cast<double>(ids[i]));
|
||||
Game::Lua::SetFieldNumber(L, "count", static_cast<double>(counts[i]));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
}
|
||||
|
||||
// Per-objective override text — the 4-slot array at `+0x14DC` with
|
||||
// 256-byte stride. Index in objective-declaration order (NPC/GO first,
|
||||
// then item, same enumeration the LeaderBoard accessor walks).
|
||||
// Returns empty string for slots past the cache's 4-entry capacity
|
||||
// or for slots with an empty buffer (engine auto-formats those).
|
||||
const char *OverrideTextFor(const uint8_t *data, int objectiveSlot) {
|
||||
if (objectiveSlot < 0 || objectiveSlot >= Quest::Cache::OBJECTIVE_TEXT_COUNT)
|
||||
return "";
|
||||
return reinterpret_cast<const char *>(
|
||||
data + Quest::Cache::OFF_OBJECTIVE_TEXT +
|
||||
objectiveSlot * Quest::Cache::OBJECTIVE_TEXT_STRIDE);
|
||||
}
|
||||
|
||||
void PushRequirements(void *L, const uint8_t *data) {
|
||||
Game::Lua::NewTable(L);
|
||||
int next = 1;
|
||||
int overrideSlot = 0;
|
||||
|
||||
auto *npcOrGo = reinterpret_cast<const int32_t *>(
|
||||
data + Quest::Cache::OFF_REQUIRED_NPC_OR_GO);
|
||||
auto *npcOrGoCount = reinterpret_cast<const uint32_t *>(
|
||||
data + Quest::Cache::OFF_REQUIRED_NPC_OR_GO_COUNT);
|
||||
for (int i = 0; i < Quest::Cache::OBJECTIVE_COUNT; ++i) {
|
||||
const int32_t v = npcOrGo[i];
|
||||
if (v == 0)
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(next++));
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldString(L, "kind", v > 0 ? "monster" : "object");
|
||||
Game::Lua::SetFieldNumber(L, "id",
|
||||
static_cast<double>(v > 0 ? v : -v));
|
||||
Game::Lua::SetFieldNumber(L, "count",
|
||||
static_cast<double>(npcOrGoCount[i]));
|
||||
Game::Lua::SetFieldString(L, "text",
|
||||
OverrideTextFor(data, overrideSlot++));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
|
||||
auto *items = reinterpret_cast<const uint32_t *>(
|
||||
data + Quest::Cache::OFF_REQUIRED_ITEM);
|
||||
auto *itemCounts = reinterpret_cast<const uint32_t *>(
|
||||
data + Quest::Cache::OFF_REQUIRED_ITEM_COUNT);
|
||||
for (int i = 0; i < Quest::Cache::OBJECTIVE_COUNT; ++i) {
|
||||
if (items[i] == 0)
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(next++));
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldString(L, "kind", "item");
|
||||
Game::Lua::SetFieldNumber(L, "id", static_cast<double>(items[i]));
|
||||
Game::Lua::SetFieldNumber(L, "count",
|
||||
static_cast<double>(itemCounts[i]));
|
||||
Game::Lua::SetFieldString(L, "text",
|
||||
OverrideTextFor(data, overrideSlot++));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
}
|
||||
|
||||
int __fastcall Script_GetQuestDetails(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: C_QuestLog.GetQuestDetails(questID)");
|
||||
return 0;
|
||||
}
|
||||
const int questID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (questID <= 0)
|
||||
return 0;
|
||||
|
||||
const uint8_t *data = Quest::Cache::Peek(static_cast<uint32_t>(questID));
|
||||
if (data == nullptr)
|
||||
return 0;
|
||||
|
||||
const auto u32 = [data](int off) {
|
||||
return *reinterpret_cast<const uint32_t *>(data + off);
|
||||
};
|
||||
const auto i32 = [data](int off) {
|
||||
return *reinterpret_cast<const int32_t *>(data + off);
|
||||
};
|
||||
const auto str = [data](int off) {
|
||||
return reinterpret_cast<const char *>(data + off);
|
||||
};
|
||||
|
||||
const auto f32 = [data](int off) {
|
||||
return *reinterpret_cast<const float *>(data + off);
|
||||
};
|
||||
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
Game::Lua::SetFieldNumber(L, "questID", static_cast<double>(questID));
|
||||
Game::Lua::SetFieldString(L, "title", str(Quest::Cache::OFF_TITLE));
|
||||
Game::Lua::SetFieldNumber(L, "level",
|
||||
static_cast<double>(i32(Quest::Cache::OFF_LEVEL)));
|
||||
|
||||
if (const char *tag =
|
||||
LookupQuestType(u32(Quest::Cache::OFF_QUEST_INFO_ROW))) {
|
||||
if (*tag != '\0')
|
||||
Game::Lua::SetFieldString(L, "questType", tag);
|
||||
}
|
||||
|
||||
// Race / class / skill / timer / suggested-players gates aren't
|
||||
// here intentionally — vanilla 1.12's quest-query response leaves
|
||||
// those cache slots (+0x14..+0x24) zero-filled; the server enforces
|
||||
// those constraints and only ships quests to clients who could
|
||||
// accept them. Addons that need that data must source it from an
|
||||
// external scraped DB (pfQuest-style).
|
||||
const int32_t money = i32(Quest::Cache::OFF_MONEY_DELTA);
|
||||
Game::Lua::SetFieldNumber(L, "rewardMoney",
|
||||
money > 0 ? static_cast<double>(money) : 0.0);
|
||||
Game::Lua::SetFieldNumber(L, "requiredMoney",
|
||||
money < 0 ? static_cast<double>(-money) : 0.0);
|
||||
Game::Lua::SetFieldNumber(
|
||||
L, "rewardMoneyAtMaxLevel",
|
||||
static_cast<double>(i32(Quest::Cache::OFF_LEVEL_CAP_BONUS_MONEY)));
|
||||
Game::Lua::SetFieldNumber(
|
||||
L, "rewardSpellID",
|
||||
static_cast<double>(i32(Quest::Cache::OFF_REWARD_SPELL_ID)));
|
||||
Game::Lua::SetFieldNumber(
|
||||
L, "srcItemID",
|
||||
static_cast<double>(u32(Quest::Cache::OFF_SRC_ITEM_ID)));
|
||||
|
||||
const uint32_t flags = u32(Quest::Cache::OFF_QUEST_FLAGS);
|
||||
Game::Lua::SetFieldNumber(L, "questFlags", static_cast<double>(flags));
|
||||
Game::Lua::SetFieldBool(L, "isSharable", (flags & 0x08) != 0);
|
||||
|
||||
Game::Lua::SetFieldString(L, "description",
|
||||
str(Quest::Cache::OFF_DESCRIPTION));
|
||||
Game::Lua::SetFieldString(L, "objectives",
|
||||
str(Quest::Cache::OFF_OBJECTIVES));
|
||||
Game::Lua::SetFieldString(L, "completionText",
|
||||
str(Quest::Cache::OFF_COMPLETION_TEXT));
|
||||
|
||||
if (const uint32_t mapID = u32(Quest::Cache::OFF_POI_MAP_ID)) {
|
||||
Game::Lua::PushString(L, "poi");
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "mapID", static_cast<double>(mapID));
|
||||
Game::Lua::SetFieldNumber(L, "x", static_cast<double>(f32(Quest::Cache::OFF_POI_X)));
|
||||
Game::Lua::SetFieldNumber(L, "y", static_cast<double>(f32(Quest::Cache::OFF_POI_Y)));
|
||||
Game::Lua::SetFieldNumber(L, "opt",
|
||||
static_cast<double>(u32(Quest::Cache::OFF_POI_OPT)));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
|
||||
Game::Lua::PushString(L, "rewardItems");
|
||||
PushItemArray(L,
|
||||
reinterpret_cast<const uint32_t *>(
|
||||
data + Quest::Cache::OFF_REWARD_ITEMS),
|
||||
reinterpret_cast<const uint32_t *>(
|
||||
data + Quest::Cache::OFF_REWARD_ITEMS_COUNT),
|
||||
Quest::Cache::REWARD_COUNT);
|
||||
Game::Lua::SetTable(L, -3);
|
||||
|
||||
Game::Lua::PushString(L, "choiceItems");
|
||||
PushItemArray(L,
|
||||
reinterpret_cast<const uint32_t *>(
|
||||
data + Quest::Cache::OFF_CHOICE_ITEMS),
|
||||
reinterpret_cast<const uint32_t *>(
|
||||
data + Quest::Cache::OFF_CHOICE_ITEMS_COUNT),
|
||||
Quest::Cache::CHOICE_COUNT);
|
||||
Game::Lua::SetTable(L, -3);
|
||||
|
||||
Game::Lua::PushString(L, "requirements");
|
||||
PushRequirements(L, data);
|
||||
Game::Lua::SetTable(L, -3);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_QuestLog", "GetQuestDetails",
|
||||
&Script_GetQuestDetails);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace Quest::Details
|
||||
@@ -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,59 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Suppress vanilla 1.12.1's "Your game interface files are corrupt"
|
||||
// fatal error so the engine accepts the in-memory file modifications
|
||||
// ClassicAPI makes via the file-read hook:
|
||||
//
|
||||
// - `src/bindings/Inject.cpp` splices into FrameXML's `Bindings.xml`
|
||||
// - `src/addons/Embedded.cpp` surfaces the embedded `!!!ClassicAPI`
|
||||
// addon
|
||||
//
|
||||
// Both diverge from what's on disk, so the cumulative hashes the
|
||||
// engine computes against `Interface\FrameXML.sig` (global) and each
|
||||
// addon entry's stored signature (per-addon) no longer match. The
|
||||
// engine calls `FUN_00401560(10)` and tears down the process.
|
||||
//
|
||||
// We hook `FUN_00401560` (the fatal-error dispatcher) and drop calls
|
||||
// with code `10`. Other codes pass through to the original handler
|
||||
// unmolested — we don't want to mask legitimate fatal conditions
|
||||
// (login failures, connection drops, etc., which use other codes).
|
||||
//
|
||||
// Modded clients (Octo + TLS-bootloader, SuperWoW-style) usually
|
||||
// neutralize the integrity checks at startup; the engine never
|
||||
// reaches `FUN_00401560(10)` there and our hook is a harmless no-op.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
namespace Security::FrameXMLBypass {
|
||||
|
||||
namespace {
|
||||
|
||||
using FatalError_t = void(__fastcall *)(unsigned code);
|
||||
FatalError_t FatalError_o = nullptr;
|
||||
|
||||
void __fastcall FatalError_h(unsigned code) {
|
||||
if (code == 10)
|
||||
return;
|
||||
FatalError_o(code);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookreg{
|
||||
Offsets::FUN_FATAL_ERROR,
|
||||
reinterpret_cast<void *>(&FatalError_h),
|
||||
reinterpret_cast<void **>(&FatalError_o)};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Security::FrameXMLBypass
|
||||
@@ -0,0 +1,199 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Spell.CastAtCursor(spellID)` — cast a ground-target spell at
|
||||
// the player's current cursor world position, bypassing the manual
|
||||
// click on the AoE reticle.
|
||||
//
|
||||
// Implementation chains the engine's existing primitives:
|
||||
//
|
||||
// 1. Initiate the cast the same way `Script_CastSpellByName` does:
|
||||
// resolve `spellID` (via our `Spell::CastByID` numeric-name hook)
|
||||
// to a spellbook slot through `FUN_RESOLVE_SPELL_NAME_TO_SLOT`,
|
||||
// then dispatch through `FUN_SPELL_CAST_DISPATCH`. For a ground-
|
||||
// target spell, the engine enters placement mode at this point
|
||||
// (sets `VAR_SPELL_PLACEMENT_STATE`); for a normal cast, it
|
||||
// fires immediately.
|
||||
//
|
||||
// 2. `Spell::AtCursor::Resolve()` then refreshes the cursor's world
|
||||
// raycast via `FUN_REFRESH_CURSOR_RAYCAST` and commits the
|
||||
// placement at the resulting world coords via
|
||||
// `FUN_COMMIT_PLACEMENT_COORDS`. Self-cancelling when the spell
|
||||
// isn't ground-target (unit-target placement → cancel) or when
|
||||
// the cursor isn't on terrain (over UI / off-screen → cancel).
|
||||
//
|
||||
// Returns `true` from `Resolve()` when we successfully placed at
|
||||
// cursor; `false` otherwise (no placement was needed, or cursor was
|
||||
// invalid). The cast itself proceeds either way — `Resolve()`'s
|
||||
// return tells the caller whether the cursor-placement leg landed.
|
||||
|
||||
#include "AtCursor.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "spell/MacroPrimarySpell.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
namespace Spell::AtCursor {
|
||||
|
||||
namespace {
|
||||
|
||||
using RefreshCursorRaycast_t = void(__fastcall *)(void *clickInfo);
|
||||
using CommitPlacementCoords_t = unsigned(__fastcall *)(const float *xyz);
|
||||
using StopPlacement_t = void(__fastcall *)();
|
||||
|
||||
bool InWorld() {
|
||||
// Player resolves only when in-world. Same gate other modules
|
||||
// (e.g. `Item::Location::ResolveBag`) use to avoid touching
|
||||
// engine state during loading screens or glue.
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *);
|
||||
auto fn = reinterpret_cast<ResolveUnitToken_t>(
|
||||
Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
return fn("player") != nullptr;
|
||||
}
|
||||
|
||||
void CancelPlacement() {
|
||||
auto fn = reinterpret_cast<StopPlacement_t>(Offsets::FUN_STOP_PLACEMENT);
|
||||
fn();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool Resolve() {
|
||||
const uint32_t state = *reinterpret_cast<const uint32_t *>(
|
||||
Offsets::VAR_SPELL_PLACEMENT_STATE);
|
||||
if (state == 0)
|
||||
return false; // no placement active — nothing to commit
|
||||
|
||||
if ((state & Offsets::SPELL_PLACEMENT_GROUND_MASK) == 0) {
|
||||
// Spell wants a unit / item / corpse / etc. — not ground.
|
||||
// Cancel so the cursor recovers; caller learns via the false
|
||||
// return that no cursor-placement happened.
|
||||
CancelPlacement();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!InWorld())
|
||||
return false;
|
||||
|
||||
void *clickInfo = *reinterpret_cast<void *const *>(
|
||||
Offsets::VAR_WORLDFRAME_CLICK_INFO);
|
||||
if (clickInfo == nullptr)
|
||||
return false;
|
||||
|
||||
// Re-raycast at the current cursor screen position. Writes the
|
||||
// new click type to `[clickInfo + OFF_CLICK_INFO_TYPE]` and the
|
||||
// hit world coords to `[clickInfo + OFF_CLICK_INFO_COORDS]`.
|
||||
auto refresh = reinterpret_cast<RefreshCursorRaycast_t>(
|
||||
Offsets::FUN_REFRESH_CURSOR_RAYCAST);
|
||||
refresh(clickInfo);
|
||||
|
||||
const uint32_t hitType = *reinterpret_cast<const uint32_t *>(
|
||||
static_cast<const uint8_t *>(clickInfo) + Offsets::OFF_CLICK_INFO_TYPE);
|
||||
if (hitType != 1) {
|
||||
// Cursor wasn't on terrain (over UI, off-screen, on a unit
|
||||
// we didn't ask for). Modern `[@cursor]` would error; we
|
||||
// chose silent cancel-and-return-false.
|
||||
CancelPlacement();
|
||||
return false;
|
||||
}
|
||||
|
||||
const float *coords = reinterpret_cast<const float *>(
|
||||
static_cast<const uint8_t *>(clickInfo) + Offsets::OFF_CLICK_INFO_COORDS);
|
||||
|
||||
auto commit = reinterpret_cast<CommitPlacementCoords_t>(
|
||||
Offsets::FUN_COMMIT_PLACEMENT_COORDS);
|
||||
commit(coords);
|
||||
return true;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
using NameToSlot_t = int(__fastcall *)(const char *name, void *out);
|
||||
using CastDispatch_t = void(__fastcall *)(unsigned slot, int bookType,
|
||||
unsigned targetGuidLo, float targetGuidHi);
|
||||
|
||||
int __fastcall Script_C_Spell_CastAtCursor(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
const int spellID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (spellID <= 0) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Hand the spellID off as a numeric string — our `Spell::CastByID`
|
||||
// hook on `FUN_RESOLVE_SPELL_NAME_TO_SLOT` translates pure-numeric
|
||||
// input into the locale-resolved spell name internally, so the
|
||||
// engine treats it just like `CastSpellByName("Blizzard")`.
|
||||
char numBuf[16];
|
||||
std::snprintf(numBuf, sizeof(numBuf), "%d", spellID);
|
||||
|
||||
auto nameToSlot = reinterpret_cast<NameToSlot_t>(
|
||||
Offsets::FUN_RESOLVE_SPELL_NAME_TO_SLOT);
|
||||
int bookType = 0;
|
||||
const int slot = nameToSlot(numBuf, &bookType);
|
||||
if (slot < 0) {
|
||||
// Spell not in the player's spellbook — can't cast it from
|
||||
// here either way. Match `CastSpellByName`'s silent failure.
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto dispatch = reinterpret_cast<CastDispatch_t>(
|
||||
Offsets::FUN_SPELL_CAST_DISPATCH);
|
||||
dispatch(static_cast<unsigned>(slot), bookType,
|
||||
0, 0.0f); // no implicit target unit; engine handles placement
|
||||
|
||||
const bool placed = Resolve();
|
||||
Game::Lua::PushBool(L, placed);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Spell", "CastAtCursor",
|
||||
&Script_C_Spell_CastAtCursor);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
// Macro-parser pattern: tag macros that call
|
||||
// `C_Spell.CastAtCursor(<spellID>)` with the spellID, so action-bar
|
||||
// UIs highlight the slot the same way they do for `/cast SpellName`.
|
||||
// The Lua call only accepts a numeric spellID, so the extractor just
|
||||
// reads a numeric literal — no name resolution needed.
|
||||
int ExtractCastAtCursorArg(const char *p, const char *end) {
|
||||
while (p < end && (*p == ' ' || *p == '\t'))
|
||||
++p;
|
||||
int value = 0;
|
||||
const char *digitStart = p;
|
||||
while (p < end && *p >= '0' && *p <= '9') {
|
||||
if (value > 100000000) // sanity cap before overflow
|
||||
return 0;
|
||||
value = value * 10 + (*p - '0');
|
||||
++p;
|
||||
}
|
||||
return (p > digitStart && value > 0) ? value : 0;
|
||||
}
|
||||
|
||||
const Spell::MacroPrimarySpell::PatternAutoRegister _patreg{
|
||||
"C_Spell.CastAtCursor(", &ExtractCastAtCursorArg};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Spell::AtCursor
|
||||
@@ -0,0 +1,33 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace Spell::AtCursor {
|
||||
|
||||
// Resolves the engine's in-flight ground-target placement at the
|
||||
// player's current cursor world position. Intended to be called
|
||||
// immediately after initiating a ground-target cast (spell or item)
|
||||
// to skip the manual click. Decision tree:
|
||||
//
|
||||
// 1. No placement active → return false (nothing to do)
|
||||
// 2. Placement is unit-target, not ground → cancel placement, return false
|
||||
// 3. Cursor not pointed at terrain → cancel placement, return false
|
||||
// 4. Cursor on terrain → commit at those world coords, return true
|
||||
//
|
||||
// Shared by `C_Spell.CastAtCursor` and `C_Item.UseAtCursor`. Safe to
|
||||
// call from any in-world context — internally gates on a non-NULL
|
||||
// click-info struct pointer.
|
||||
bool Resolve();
|
||||
|
||||
} // namespace Spell::AtCursor
|
||||
+27
-130
@@ -42,6 +42,7 @@
|
||||
#include "Offsets.h"
|
||||
#include "spell/Arg.h"
|
||||
#include "spell/Lookup.h"
|
||||
#include "spell/MacroPrimarySpell.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
@@ -208,141 +209,37 @@ void RegisterLuaFunctions() {
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Macro parser hook
|
||||
// Macro parser pattern
|
||||
//
|
||||
// Without this hook, a macro whose only cast statement is
|
||||
// `CastSpellNoToggle("Shoot")` doesn't get tagged in the spell-state
|
||||
// cache with the resolved spellID, because the engine's parser
|
||||
// (`FUN_004EFE00`) only knows to look for `/cast` slash-aliases and
|
||||
// `CastSpellByName("...")`. `IsAutoRepeatAction(macroSlot)` then has
|
||||
// nothing to match against the active auto-repeat spell, so the slot
|
||||
// fails to highlight in action-bar UIs (pfUI etc.).
|
||||
//
|
||||
// We hook the parser, let the original run, and if it didn't recognize
|
||||
// any pattern (cache field stays 0), scan the body ourselves for our
|
||||
// function name and use the engine's own name→spellID resolver to
|
||||
// fill in the field.
|
||||
// Tags macros containing `CastSpellNoToggle("Name")` with the
|
||||
// resolved spellID, so `IsAutoRepeatAction(macroSlot)` reports
|
||||
// correctly and action-bar UIs (pfUI etc.) highlight the slot for
|
||||
// auto-repeat spells. `Spell::MacroPrimarySpell` owns the parser
|
||||
// hook and the line walk; we just declare our `(prefix, extractor)`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
using MacroParse_t = void(__fastcall *)(int macroEntry);
|
||||
MacroParse_t MacroParse_o = nullptr;
|
||||
|
||||
|
||||
// Scan a macro body for `CastSpellNoToggle("<name>")` and return the
|
||||
// resolved spellID, or 0 if no recognized invocation is present.
|
||||
// Mirrors the engine parser's "first hit wins, line-at-a-time" walk
|
||||
// order so behavior is consistent with how `/cast` and
|
||||
// `CastSpellByName` are matched.
|
||||
int ScanMacroBodyForOurPattern(const char *body) {
|
||||
static constexpr char kPattern[] = "CastSpellNoToggle(";
|
||||
static constexpr size_t kPatternLen = sizeof(kPattern) - 1;
|
||||
|
||||
const char *line = body;
|
||||
while (*line != '\0') {
|
||||
const char *eol = line;
|
||||
while (*eol != '\0' && *eol != '\n')
|
||||
++eol;
|
||||
|
||||
const char *p = line;
|
||||
const char *end = eol;
|
||||
while (p + kPatternLen <= end) {
|
||||
if (std::memcmp(p, kPattern, kPatternLen) == 0) {
|
||||
p += kPatternLen;
|
||||
while (p < end && (*p == ' ' || *p == '\t'))
|
||||
++p;
|
||||
if (p < end && *p == '"') {
|
||||
++p;
|
||||
const char *q = p;
|
||||
while (q < end && *q != '"')
|
||||
++q;
|
||||
if (q < end) {
|
||||
const size_t n = static_cast<size_t>(q - p);
|
||||
if (n > 0 && n < 128) {
|
||||
char name[128];
|
||||
std::memcpy(name, p, n);
|
||||
name[n] = '\0';
|
||||
const int spellID = Spell::Arg::NameToSpellID(name);
|
||||
if (spellID > 0)
|
||||
return spellID;
|
||||
}
|
||||
}
|
||||
}
|
||||
break; // matched our prefix on this line; don't double-match
|
||||
}
|
||||
++p;
|
||||
}
|
||||
|
||||
line = (*eol == '\n') ? eol + 1 : eol;
|
||||
}
|
||||
return 0;
|
||||
int ExtractCastSpellNoToggleArg(const char *p, const char *end) {
|
||||
while (p < end && (*p == ' ' || *p == '\t'))
|
||||
++p;
|
||||
if (p >= end || *p != '"')
|
||||
return 0;
|
||||
++p;
|
||||
const char *q = p;
|
||||
while (q < end && *q != '"')
|
||||
++q;
|
||||
if (q >= end)
|
||||
return 0;
|
||||
const size_t n = static_cast<size_t>(q - p);
|
||||
if (n == 0 || n >= 128)
|
||||
return 0;
|
||||
char name[128];
|
||||
std::memcpy(name, p, n);
|
||||
name[n] = '\0';
|
||||
return Spell::Arg::NameToSpellID(name);
|
||||
}
|
||||
|
||||
// The vanilla parser's per-line stack buffer is 256 bytes. When a
|
||||
// macro line exceeds that, the parser's local at `[EBP-4]` (the saved
|
||||
// entry pointer) and the saved return address get clobbered with body
|
||||
// bytes — observed in a crash with `entry@[EBP-4]` reading as ASCII
|
||||
// `"onTi"` (fragment of `"expirationTime"` from a long `/run` line).
|
||||
// The bound-check on the line-read function reads correctly in
|
||||
// Ghidra, so the overflow path is somewhere we couldn't isolate from
|
||||
// static analysis — we settle for a conservative pre-check that
|
||||
// skips the original entirely when any line is long enough to risk
|
||||
// the bug.
|
||||
//
|
||||
// 240 (not 256) leaves a safety margin: the parser writes a NUL
|
||||
// terminator and may walk one or two bytes past in its loop logic,
|
||||
// so we keep clear of the boundary. Macros that hit this branch
|
||||
// can't have a `/cast` line anyway (`/cast SpellName(Rank N)` is
|
||||
// well under 100 bytes), so losing the engine's `/cast` /
|
||||
// `CastSpellByName` tagging is no loss. We still run our own
|
||||
// `CastSpellNoToggle` pattern scan since it bound-checks its own
|
||||
// extraction.
|
||||
bool BodyHasLongLine(const char *body) {
|
||||
if (body == nullptr)
|
||||
return false;
|
||||
constexpr int kMaxSafeLineBytes = 240;
|
||||
int lineLen = 0;
|
||||
for (; *body != '\0'; ++body) {
|
||||
if (*body == '\n') {
|
||||
lineLen = 0;
|
||||
} else if (++lineLen > kMaxSafeLineBytes) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void __fastcall MacroParse_h(int macroEntry) {
|
||||
const char *body = reinterpret_cast<const char *>(
|
||||
macroEntry + Offsets::OFF_MACRO_BODY);
|
||||
int *cacheField = reinterpret_cast<int *>(
|
||||
macroEntry + Offsets::OFF_MACRO_PRIMARY_SPELL);
|
||||
|
||||
if (BodyHasLongLine(body)) {
|
||||
// Skip the engine parser entirely — it overflows on lines
|
||||
// longer than its 256-byte buffer. Mark the cache as "no
|
||||
// recognized cast" (0); our own scan below may still tag it
|
||||
// if it's a `CastSpellNoToggle("...")` macro.
|
||||
*cacheField = 0;
|
||||
const int spellID = ScanMacroBodyForOurPattern(body);
|
||||
if (spellID > 0)
|
||||
*cacheField = spellID;
|
||||
return;
|
||||
}
|
||||
|
||||
MacroParse_o(macroEntry);
|
||||
|
||||
if (*cacheField != 0)
|
||||
return;
|
||||
|
||||
const int spellID = ScanMacroBodyForOurPattern(body);
|
||||
if (spellID > 0)
|
||||
*cacheField = spellID;
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookreg{
|
||||
Offsets::FUN_MACRO_PARSE_PRIMARY_SPELL,
|
||||
reinterpret_cast<void *>(&MacroParse_h),
|
||||
reinterpret_cast<void **>(&MacroParse_o)};
|
||||
const Spell::MacroPrimarySpell::PatternAutoRegister _patreg{
|
||||
"CastSpellNoToggle(", &ExtractCastSpellNoToggleArg};
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Owns the hook on `FUN_MACRO_PARSE_PRIMARY_SPELL` and dispatches to
|
||||
// feature-module-registered `(prefix, extractor)` pairs. The engine's
|
||||
// parser only recognizes `/cast SpellName` and `CastSpellByName(...)`
|
||||
// — addons that define new cast wrappers (`CastSpellNoToggle`,
|
||||
// `C_Spell.CastAtCursor`, etc.) need their macro slots tagged with
|
||||
// the resolved spellID too, otherwise action-bar UIs lose
|
||||
// auto-repeat highlight and cooldown overlay.
|
||||
//
|
||||
// Each feature module owns an extractor that knows how to read the
|
||||
// arg shape for its specific function (quoted string, numeric
|
||||
// literal, etc.). Patterns auto-register at static-init via
|
||||
// `PatternAutoRegister`. The hook walks each macro line ONCE,
|
||||
// checking every registered prefix at every position; on a prefix
|
||||
// match it calls the extractor to resolve a spellID.
|
||||
//
|
||||
// Long-line overflow guard: the vanilla parser has a 256-byte
|
||||
// per-line stack buffer that overflows on `/run` lines longer than
|
||||
// that. We pre-check and skip the original entirely when any line
|
||||
// exceeds the safe threshold — the original's tagging is lost in
|
||||
// that case (no `/cast` on a 240+ byte line anyway) but our
|
||||
// patterns still run since they bound-check their own extraction.
|
||||
|
||||
#include "MacroPrimarySpell.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace Spell::MacroPrimarySpell {
|
||||
|
||||
namespace {
|
||||
|
||||
PatternAutoRegister *g_patternHead = nullptr;
|
||||
|
||||
using MacroParse_t = void(__fastcall *)(int macroEntry);
|
||||
MacroParse_t MacroParse_o = nullptr;
|
||||
|
||||
// Vanilla's parser uses a 256-byte stack buffer per line; bytes past
|
||||
// the buffer clobber the saved entry pointer at `[EBP-4]` and the
|
||||
// return address. 240 leaves a safety margin.
|
||||
constexpr int kMaxSafeLineBytes = 240;
|
||||
|
||||
bool BodyHasLongLine(const char *body) {
|
||||
if (body == nullptr)
|
||||
return false;
|
||||
int lineLen = 0;
|
||||
for (; *body != '\0'; ++body) {
|
||||
if (*body == '\n') {
|
||||
lineLen = 0;
|
||||
} else if (++lineLen > kMaxSafeLineBytes) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Walks each line once. At every byte position, checks every
|
||||
// registered prefix; on first match, invokes the extractor. First
|
||||
// extractor that returns a positive spellID wins for the whole macro
|
||||
// — matches the engine parser's "first hit, line-at-a-time" walk.
|
||||
int ScanBody(const char *body) {
|
||||
if (body == nullptr)
|
||||
return 0;
|
||||
const char *line = body;
|
||||
while (*line != '\0') {
|
||||
const char *eol = line;
|
||||
while (*eol != '\0' && *eol != '\n')
|
||||
++eol;
|
||||
|
||||
for (const char *p = line; p < eol; ++p) {
|
||||
for (auto *node = g_patternHead; node != nullptr; node = node->next) {
|
||||
if (p + node->prefixLen > eol)
|
||||
continue;
|
||||
if (std::memcmp(p, node->prefix, node->prefixLen) != 0)
|
||||
continue;
|
||||
const int spellID = node->extract(p + node->prefixLen, eol);
|
||||
if (spellID > 0)
|
||||
return spellID;
|
||||
// Prefix matched but extraction failed (malformed
|
||||
// args). Don't keep checking other prefixes at this
|
||||
// position — they couldn't possibly also match here.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
line = (*eol == '\n') ? eol + 1 : eol;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void __fastcall MacroParse_h(int macroEntry) {
|
||||
const char *body = reinterpret_cast<const char *>(
|
||||
macroEntry + Offsets::OFF_MACRO_BODY);
|
||||
int *cacheField = reinterpret_cast<int *>(
|
||||
macroEntry + Offsets::OFF_MACRO_PRIMARY_SPELL);
|
||||
|
||||
if (BodyHasLongLine(body)) {
|
||||
*cacheField = 0;
|
||||
const int spellID = ScanBody(body);
|
||||
if (spellID > 0)
|
||||
*cacheField = spellID;
|
||||
return;
|
||||
}
|
||||
|
||||
MacroParse_o(macroEntry);
|
||||
|
||||
if (*cacheField != 0)
|
||||
return;
|
||||
|
||||
const int spellID = ScanBody(body);
|
||||
if (spellID > 0)
|
||||
*cacheField = spellID;
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookreg{
|
||||
Offsets::FUN_MACRO_PARSE_PRIMARY_SPELL,
|
||||
reinterpret_cast<void *>(&MacroParse_h),
|
||||
reinterpret_cast<void **>(&MacroParse_o)};
|
||||
|
||||
} // namespace
|
||||
|
||||
PatternAutoRegister::PatternAutoRegister(const char *p, ArgExtractor e)
|
||||
: prefix(p),
|
||||
prefixLen(static_cast<unsigned>(std::strlen(p))),
|
||||
extract(e),
|
||||
next(g_patternHead) {
|
||||
g_patternHead = this;
|
||||
}
|
||||
|
||||
} // namespace Spell::MacroPrimarySpell
|
||||
@@ -0,0 +1,44 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace Spell::MacroPrimarySpell {
|
||||
|
||||
// Parses the argument(s) immediately after a known function-call
|
||||
// prefix. `p` points to the first byte past the closing `(` of the
|
||||
// prefix (so for `CastSpellNoToggle("Shoot")`, `p` would be at the
|
||||
// `"`). `end` is end-of-line. Returns the resolved spellID, or 0 if
|
||||
// extraction failed (malformed args, unknown spell, etc.).
|
||||
using ArgExtractor = int(*)(const char *p, const char *end);
|
||||
|
||||
// File-scope static auto-registers a `(prefix, extractor)` pair. Each
|
||||
// feature module that wants its Lua wrapper to tag macros declares:
|
||||
//
|
||||
// static const Spell::MacroPrimarySpell::PatternAutoRegister _r{
|
||||
// "CastSpellNoToggle(", &MyExtractor};
|
||||
//
|
||||
// The macro-parser hook walks every line once, checks each registered
|
||||
// prefix at each position, and on match invokes the extractor to
|
||||
// resolve a spellID. The resolved ID is written to the macro's
|
||||
// primary-spell cache field — drives auto-repeat highlight, cooldown
|
||||
// overlay, etc. in action-bar UIs (pfUI).
|
||||
struct PatternAutoRegister {
|
||||
PatternAutoRegister(const char *prefix, ArgExtractor extract);
|
||||
const char *prefix;
|
||||
unsigned prefixLen;
|
||||
ArgExtractor extract;
|
||||
PatternAutoRegister *next;
|
||||
};
|
||||
|
||||
} // namespace Spell::MacroPrimarySpell
|
||||
Reference in New Issue
Block a user