Compare commits

...
16 Commits
Author SHA1 Message Date
Brues a7ffe1c704 max stack size is the 7th return 2026-06-08 11:25:50 -05:00
Brues 9f75737882 GetSendMailItemLink / GetInboxItemLink 2026-06-07 23:45:14 -05:00
Brues 10d5790975 always load enUS strings as fallback 2026-06-07 19:09:10 -05:00
Brues b571483c5f AoE loot testing
commit 24ab88fac3c9da1d83e173aefacad92ad3b0ab2c
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Sun Jun 7 15:11:27 2026 -0500

    C_Loot docs

commit aca61c0cb7b90cb84c2d782490de38a2db099004
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Thu Jun 4 21:47:33 2026 -0500

    loot scan

commit bf44cb9acc07504336c54323074126738ed623a0
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Thu Jun 4 21:30:29 2026 -0500

    aoe loot test

commit 648cb053a9232ea9eef17e48ff45424c9eed5bbb
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Thu Jun 4 19:06:18 2026 -0500

    LootUnit/GetNearbyLootableUnits
2026-06-07 15:18:56 -05:00
Brues 0a79016b0f CI: split configure into tag/branch steps so branch builds don't pass branch name as tag
CMakeLists rejects CLASSICAPI_TAG values that aren't empty or
vMAJOR.MINOR.PATCH. Branch pushes were passing `github.ref_name`
(= the branch name, e.g. "master") and failing the regex.
2026-06-07 14:57:37 -05:00
Brues 3c4f24192a CI: build every push, release only on tag
Was tag-only. Now builds on every push to any branch, uploading the
DLL as a workflow artifact (ClassicAPI-<sha>, ~90-day retention) so
pre-release DLLs are downloadable from the run page without cutting
a tag. Tag pushes still build + stamp the embedded TOC version +
publish the GitHub release.

Renamed release.yml -> build.yml to match the broader role.
2026-06-07 14:54:10 -05:00
Brues 93b4dc5db8 PlayerBuffExpirationSeconds: read tick value as uint32, not signed int
The engine stores expiration timestamps as GetTickCount-derived uint32
milliseconds. We were reading as signed int, then bailing on `<= 0` to
catch the "no timing" sentinel of 0. Once system uptime crossed 2^31 ms
(~24.86 days) the high bit flipped, signed reads went negative, and
every active buff started reporting expirationTime = 0 while
GetTime() kept working through a different code path.

Switch to uint32_t with `== 0` sentinel check. The unsigned read flows
correctly through GetTickCount's 2^32-ms (~49.7-day) wrap because
addon math (`expirationTime - GetTime()`) operates on values that wrap
together — same as the engine's own `expirationTable[slot] - currentMs`
subtraction.
2026-06-07 00:52:59 -05:00
Brues d786c607b8 C_Container.MoveItem: route count == srcStack through swap path
Vanilla's CMSG_SPLIT_ITEM rejects splits where count == srcStack
(EQUIP_ERR_COULDNT_SPLIT_ITEMS — would leave source empty). The
drag-and-drop UI's "drop whole stack on matching stack" goes through
CMSG_SWAP_ITEM, which the server merges for same-item destinations.

In MoveCount, read ITEM_FIELD_STACK_COUNT off the resolved source
CGItem and route count == srcStack to Item::Swap::Containers. Also
return false locally for count > srcStack (server would reject anyway,
fail fast). count < srcStack keeps the existing SPLIT path.
2026-06-06 09:41:48 -05:00
Brues 598c66894e Polyfill UNIT_FACTION fire-coverage on 5 paths the 1.12 engine skips
3.3.5+ fires UNIT_FACTION("player") on rep-state changes that vanilla
silently drops. Hook each path's inner setter / SMSG handler and fire
the event post-original via the engine's printf-style dispatcher:

- FactionToggleAtWar — fire if rep slot's flag byte changed
- SetFactionInactive / SetFactionActive — same shape
- SMSG_SET_FACTION_STANDINGS — fire if any of 64 flag bytes changed
  (catches AT_WAR side-effect flips on standing threshold crossings)
- SMSG_SET_FACTION_ATWAR — unconditional after byte write
- SMSG_INITIALIZE_FACTIONS — fire after initial sync at login

Hoist the event-table strcmp walk into Event::Custom::LookupByName
(sibling to Lookup, which only consults our reservation cache). Replaces
duplicated walks in event/Util.cpp::IsKnownEventName and the polyfill.
2026-06-05 20:50:51 -05:00
Brues 75b4ea3863 Update README.md 2026-06-04 17:38:23 -05:00
Brues 5f66e381d2 Restore Lua 5.0 coroutine.* library
Re-registers the standard five entries (create / resume / yield /
status / wrap) on the global `coroutine` table. The C-level
machinery (lua_resume, lua_yield, lua_newthread, threads as tag 8)
was always linked into the engine; only the script-side library
was stripped from the registration tables at build time.

Two WoW-specific quirks discovered along the way and worked
around:

- `lua_resume` returns 1 for BOTH yield and caught runtime error.
  Disambiguated by checking `co->ci > co->base_ci` — a yielded
  coroutine still has its body frame on the call stack; an
  errored one has been unwound to base_ci.

- The byte at L+0x31 is `allowhook` (saved/restored around
  protected calls), not `L->status` as it initially looked.
  Status reading is replaced by the ci comparison above.
2026-06-04 16:23:55 -05:00
Brues 4dd892297d removed unused GlobalStrings.lua 2026-06-04 15:43:19 -05:00
Brues 51cb7a5674 Add C_Item.GetItemData / GetItemDataByID
Single-call kitchen-sink reader for the cached `ItemStats_C` record.
Returns a table with ~50 fields covering everything the cache holds:
identity (name, description, icon, class/subclass/equipLoc strings),
quality + bind type + decoded flag bits (isConjured, isOpenable,
isLootable, isWrapper), stacking, economy, requirements, equipment
stats (sparse map keyed by statType), resistances, weapon damage
arrays, spell slots, and the misc fields (lockID, itemSet, pageText,
startQuest, area/map, etc.).

Returns nil for uncached / invalid input — passive reader, no cache
warming. Caller wanting to ensure load should `RequestLoadItemData`
and listen for `ITEM_DATA_LOAD_RESULT`.

Implements via existing offsets where available; adds the remaining
`ItemStats_C` fields to `Offsets.h` based on VanillaHelpers's full
struct layout, anchored against the offsets we'd already verified
(`m_stackable@+0x60`, `m_lockID@+0x1AC`, `m_bagFamily@+0x1D0`, etc.).
2026-06-04 14:39:01 -05:00
Brues cc0bf769c4 Dedup CacheFetch callbacks via _GetRecord's built-in walk
Bursts of `WarmCache` calls for the same uncached itemID (e.g. Bagnon
re-enumerating a slot while the SMSG_ITEM_QUERY_SINGLE_RESPONSE is in
flight) were each appending a fresh `Implicit` callback to the
entry's pending list. When the response landed the engine walked the
list and fired our callback N times, surfacing as duplicate
`GET_ITEM_INFO_RECEIVED` events for the same itemID.

`_GetRecord`'s 6th arg (`param_5` in the decompilation, previously
documented as the unused `requestIfMissing` bool) controls whether
the engine dedups appends. With `0` it always appends; with non-zero
it walks the existing list and skips the append on a matching
`(callback, userData)` node. We always have the same callback ptr and
encode the itemID in userData, so flipping the bit collapses repeat
warmups for the same item to a single registration.
2026-06-04 13:34:14 -05:00
Brues ad13440743 Item cache: hook response handler instead of racing the engine
`Item:CreateFromItemID(X):ContinueOnItemLoad(cb)` was leaving items
permanently uncached when pfQuest called it at addon-load /
PLAYER_ENTERING_WORLD against an empty WDB. Three intertwined bugs:

1. `GetItemInfoInstant` returned no values for uncached items, so
   `ItemMixin:GetItemID()` returned nil and `AddCallback(nil, cb)`
   crashed with "table index is nil".
2. `DoesItemExistByID` returned false for uncached items, which made
   `IsItemEmpty()` return true and erroneously gated `ContinueOnItemLoad`
   on cache state — modern semantic is "is this a valid ID", independent
   of cache.
3. Our `RequestLoadItemData` path eagerly called `CacheFetch` with our
   own callback at addon-load. That created a cache entry before the
   engine's natural inventory prefetch had a chance to run; the engine
   then saw the entry already existed and skipped its own SMSG, leaving
   the entry permanently pending. `GetItemInfo` retries didn't help —
   subsequent `_GetRecord` calls just appended callbacks to the dead
   list, no new query was sent.

Fixes:

- `GetItemInfoInstant` always pushes the itemID (echoed from input) and
  the remaining 6 fields when cached / nils when not. No cache touch.
- `DoesItemExistByID` returns `itemID > 0`. No cache touch.
- `RequestLoadItemData` tracks itemIDs in a pending set without touching
  the cache. We hook `FUN_ITEMSTATS_CACHE_RESPONSE` (the engine's
  `SMSG_ITEM_QUERY_SINGLE_RESPONSE` handler at `0x0055BDB0`) and sweep
  pending after the engine fills entries — observing the natural
  prefetch instead of racing it. `WorldTick` only handles escalation
  (after ~60 ticks, kick a query for items the engine doesn't prefetch
  itself, e.g. quest rewards) and the hard timeout.

Also fix the truthiness bug in modern (id, success) events:
`Event::Custom::Fire("%d%d", id, success)` was pushing `success` as a
Lua number — both 0 and 1 are truthy, so `if success then` fired the
success branch on failed loads. New `Event::Custom::FireIdSuccess`
pushes `1` for success and `nil` for failure (via the engine's
`lua_pushstring(NULL) → lua_pushnil` tail-jump). `ITEM_DATA_LOAD_RESULT`,
`GET_ITEM_INFO_RECEIVED`, and `QUEST_DATA_LOAD_RESULT` now all use this.
2026-06-04 13:15:59 -05:00
Brues 999a5846c4 Macro tagging: extract dispatch hub, add C_Spell.CastAtCursor pattern
Hoists the macro-parser hook + long-line guard + per-line walk out
of Spell::CastNoToggle into a new Spell::MacroPrimarySpell module
that dispatches to (prefix, extractor) pairs registered by feature
modules. The body walk now visits each line once and checks every
registered prefix at each position; extractors only parse args
after a matched prefix.

Registers a pattern for C_Spell.CastAtCursor(<spellID>) so macros
like `/run C_Spell.CastAtCursor(10)` get tagged with the spellID
the same way `/cast Blizzard` does, driving action-bar cooldown
overlays and auto-repeat highlight in pfUI etc.
2026-06-04 02:29:58 -05:00
38 changed files with 4054 additions and 278 deletions
@@ -1,9 +1,9 @@
name: Release
name: Build
on:
push:
tags:
- 'v*'
branches: ['**']
tags: ['v*']
permissions:
contents: write
@@ -16,12 +16,8 @@ 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
if: startsWith(github.ref, 'refs/tags/v')
shell: pwsh
run: |
$version = '${{ github.ref_name }}' -replace '^v',''
@@ -29,13 +25,26 @@ jobs:
(Get-Content $toc) -replace '^## Version:.*', "## Version: $version" |
Set-Content $toc
- name: Configure
- name: Configure (tag)
if: startsWith(github.ref, 'refs/tags/v')
run: cmake -B build -A Win32 "-DCLASSICAPI_TAG=${{ github.ref_name }}"
- name: Configure (branch)
if: "!startsWith(github.ref, 'refs/tags/v')"
run: cmake -B build -A Win32
- name: Build
run: cmake --build build --config Release
- name: Upload artifact
if: "!startsWith(github.ref, 'refs/tags/v')"
uses: actions/upload-artifact@v4
with:
name: ClassicAPI-${{ github.sha }}
path: build/Release/ClassicAPI.dll
- name: Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: build/Release/ClassicAPI.dll
-10
View File
@@ -1,10 +0,0 @@
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";
-2
View File
@@ -1,5 +1,3 @@
if GetLocale() ~= "enUS" then return end
BINDING_NAME_FOCUSTARGET = "Focus Target";
BINDING_NAME_TARGETFOCUS = "Target Focus";
FOCUSTARGET = "Focus Target";
+2 -2
View File
@@ -1,6 +1,6 @@
<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="enUS.lua"/>
<Script file="deDE.lua"/>
<Script file="esES.lua"/>
<Script file="frFR.lua"/>
<Script file="ruRU.lua"/>
+217
View File
@@ -0,0 +1,217 @@
-- AoE Loot Test
--
-- /aoeloot opens a picker window listing every item in every lootable
-- corpse within click range. Clicking a row hands the (guid, itemID)
-- pair to C_Loot.LootUnitItem and re-scans so the picker refreshes
-- with whatever's left.
--
-- Known visual: each scan opens / closes loot windows in rapid
-- succession, so the Blizzard LootFrame flickers during the scan. This
-- addon intentionally doesn't suppress that — the goal is to exercise
-- the C++ surface, not to ship polished UI.
local MAX_ROWS = 24
local RESCAN_DELAY = 0.3 -- seconds; lets LootUnitItem complete before re-scan
-- ---------- UI scaffolding ----------
-- Frame geometry. Height is recomputed dynamically per scan so the
-- window only takes up as much screen real estate as the result count
-- needs — see `ResizeForRows`.
local FRAME_WIDTH = 320
local HEADER_HEIGHT = 64 -- title + status + insets; minimum frame height
local ROW_HEIGHT = 22
local frame = CreateFrame("Frame", "AoELootTestFrame", UIParent)
frame:SetWidth(FRAME_WIDTH)
frame:SetHeight(HEADER_HEIGHT)
frame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
frame:SetBackdrop({
bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
tile = true, tileSize = 32, edgeSize = 32,
insets = { left = 11, right = 12, top = 12, bottom = 11 },
})
frame:SetMovable(true)
frame:EnableMouse(true)
frame:RegisterForDrag("LeftButton")
-- Vanilla 1.12 fires script handlers with the frame set as the global
-- `this`, not as a `self` arg to the function. Passing `frame.StartMoving`
-- directly (the modern shortcut) breaks because the C method has no
-- `self` to operate on. Wrap in closures so `this:method()` resolves
-- correctly inside the handler.
frame:SetScript("OnDragStart", function() this:StartMoving() end)
frame:SetScript("OnDragStop", function() this:StopMovingOrSizing() end)
frame:Hide()
local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
title:SetPoint("TOP", frame, "TOP", 0, -16)
title:SetText("AoE Loot")
local status = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
status:SetPoint("TOPLEFT", frame, "TOPLEFT", 18, -36)
status:SetText("")
local closeBtn = CreateFrame("Button", nil, frame, "UIPanelCloseButton")
closeBtn:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -6, -6)
-- ---------- Row pool ----------
local rows = {}
local function CreateRow(parent, idx)
local row = CreateFrame("Button", nil, parent)
row:SetWidth(290)
row:SetHeight(20)
row:SetPoint("TOPLEFT", parent, "TOPLEFT", 15,
-54 - (idx - 1) * ROW_HEIGHT)
local icon = row:CreateTexture(nil, "ARTWORK")
icon:SetWidth(18); icon:SetHeight(18)
icon:SetPoint("LEFT", row, "LEFT", 0, 0)
row.icon = icon
local text = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
text:SetPoint("LEFT", icon, "RIGHT", 6, 0)
text:SetPoint("RIGHT", row, "RIGHT", 0, 0)
text:SetJustifyH("LEFT")
row.text = text
local hl = row:CreateTexture(nil, "HIGHLIGHT")
hl:SetTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight")
hl:SetBlendMode("ADD")
hl:SetAllPoints(row)
-- Tooltip on hover. The engine's native `SetHyperlink` does a
-- literal `SStrCmpI` of the input's first 5 chars against the
-- string `"item:"` at `.rdata` VA `0x00842D60` — anything that
-- starts with `|c...` (full link with color) or `|Hitem` (full
-- link without color) fails the prefix check with "Unknown link
-- type". Only the bare payload form survives.
--
-- We extract the payload from the engine's full link rather than
-- composing `item:ID:0:0:0` ourselves, so per-instance fields
-- (enchant / random-suffix / unique) survive round-trip and
-- "Stringy Wolf Meat of the Bear" tooltips correctly instead of
-- collapsing to the un-suffixed itemID.
row:SetScript("OnEnter", function()
if this.payload then
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetHyperlink(this.payload)
GameTooltip:Show()
end
end)
row:SetScript("OnLeave", function() GameTooltip:Hide() end)
row:Hide()
return row
end
for i = 1, MAX_ROWS do
rows[i] = CreateRow(frame, i)
end
-- ---------- Render ----------
-- Tail of a GUID string — last 4 hex chars of the low dword. Lets the
-- user tell apart two rows of "Stringy Wolf Meat x1" coming from
-- different corpses, without occupying a whole column with the full
-- 16-digit GUID.
local function ShortGuidSuffix(guid)
return "[.." .. string.sub(guid, -4) .. "]"
end
-- Extract the `item:ID:enchant:suffix:unique` payload out of the
-- engine's full link string (`|cff...|Hitem:...|h[Name]|h|r`).
-- Vanilla `SetHyperlink` requires the input to start literally with
-- `"item:"` — so we can't pass the full link, but we also can't drop
-- the per-instance fields (random suffix is what distinguishes "of
-- the Bear" from "of the Eagle" on otherwise identical itemIDs).
-- Returns the payload, or nil if extraction fails.
local function PayloadFromLink(link)
if not link then return nil end
return string.match(link, "|H(item:[^|]+)|h")
end
local function ClearRows()
for i = 1, MAX_ROWS do
rows[i]:Hide()
rows[i]:SetScript("OnClick", nil)
rows[i].payload = nil
end
end
local function PopulateRows(results)
local idx = 0
for _, corpse in ipairs(results) do
for _, item in ipairs(corpse.items) do
idx = idx + 1
if idx > MAX_ROWS then break end
local row = rows[idx]
local name, _, _, _, _, _, _, _, icon = GetItemInfo(item.itemID)
row.icon:SetTexture(icon or "Interface\\Icons\\INV_Misc_QuestionMark")
local label = name or ("Item #" .. item.itemID)
if item.count and item.count > 1 then
label = label .. " x" .. item.count
end
label = label .. " " .. ShortGuidSuffix(corpse.guid)
row.text:SetText(label)
row.payload = PayloadFromLink(item.link) -- for OnEnter tooltip
local guid, itemID = corpse.guid, item.itemID
row:SetScript("OnClick", function()
C_Loot.LootUnitItem(guid, itemID)
C_Timer.After(RESCAN_DELAY, function()
if frame:IsShown() then C_Loot.ScanNearbyLoot() end
end)
end)
row:Show()
end
if idx > MAX_ROWS then break end
end
for i = idx + 1, MAX_ROWS do rows[i]:Hide() end
local shown
if idx == 0 then
status:SetText("Nothing lootable in range.")
shown = 0
elseif idx > MAX_ROWS then
status:SetText(MAX_ROWS .. "+ items (showing first " .. MAX_ROWS .. ")")
shown = MAX_ROWS
else
status:SetText(idx .. " item(s) available")
shown = idx
end
frame:SetHeight(HEADER_HEIGHT + shown * ROW_HEIGHT)
end
-- ---------- Scan trigger ----------
local function StartScan()
status:SetText("Scanning...")
ClearRows()
frame:SetHeight(HEADER_HEIGHT) -- collapse while we wait for results
if not C_Loot.ScanNearbyLoot() then
status:SetText("Scan busy or not in world.")
end
end
-- ---------- Event wiring ----------
local evt = CreateFrame("Frame")
evt:RegisterEvent("LOOT_SCAN_COMPLETED")
evt:SetScript("OnEvent", function()
-- Race guard: a delayed scan may complete after the user closed
-- the picker. Just drop the result rather than re-showing.
if not frame:IsShown() then return end
PopulateRows(C_Loot.GetLastScanResults())
end)
-- ---------- Slash command ----------
RegisterNewSlashCommand(function()
if frame:IsShown() then
StartScan() -- already open → just refresh
else
frame:Show()
StartScan()
end
end, "aoeloot", "aoe")
+7
View File
@@ -0,0 +1,7 @@
## Interface: 11200
## Title: AoE Loot Test
## Notes: Test bed for ClassicAPI's C_Loot.ScanNearbyLoot / LootUnitItem. /aoeloot
## Dependencies: !!!ClassicAPI
## Version: DEV
AoELootTest.lua
+5 -1
View File
@@ -25,8 +25,9 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [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` |
@@ -38,7 +39,9 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [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.UseAtCursor`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
| [Macros](docs/API.md#macros) | `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
| [Mail](docs/API.md#mail) | `GetInboxItemLink`, `GetSendMailItemLink` |
| [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` |
@@ -98,6 +101,7 @@ functions, just behavior the stock 1.12 engine didn't have. See the
| `GLOBAL_MOUSE_UP` | `button` |
| `HEARTHSTONE_BOUND` | *(none)* |
| `ITEM_DATA_LOAD_RESULT` | `itemID, success` |
| `LOOT_SCAN_COMPLETED` | *(none)* |
| `MODIFIER_STATE_CHANGED` | `keyName, down` |
| `NAME_PLATE_CREATED` | `nameplateFrame` |
| `NAME_PLATE_UNIT_ADDED` | `unitToken` ("nameplateN") |
+681 -11
View File
@@ -46,6 +46,14 @@ build instructions.
- [`C_Container.SwapItems(srcBag, srcSlot, dstBag, dstSlot)`](#c_containerswapitemssrcbag-srcslot-dstbag-dstslot)
- [`C_Container.MoveItem(srcBag, srcSlot, dstBag, dstSlot, count)`](#c_containermoveitemsrcbag-srcslot-dstbag-dstslot-count)
- [Coroutine](#coroutine)
- [`coroutine.create(fn)`](#coroutinecreatefn)
- [`coroutine.resume(co, ...)`](#coroutineresumeco-)
- [`coroutine.yield(...)`](#coroutineyield)
- [`coroutine.status(co)`](#coroutinestatusco)
- [`coroutine.wrap(fn)`](#coroutinewrapfn)
- [Async pattern (RunAsync + C_Timer.After)](#async-pattern-runasync--c_timerafter)
- [CVar](#cvar)
- [`C_CVar.GetCVarBool(cvar)`](#c_cvargetcvarboolcvar)
@@ -80,11 +88,13 @@ build instructions.
- [`EQUIPMENT_SWAP_PENDING` event](#equipment_swap_pending-event)
- [`EQUIPMENT_SWAP_FINISHED` event](#equipment_swap_finished-event)
- [`FACTION_STANDING_CHANGED` event](#faction_standing_changed-event)
- [`LOOT_SCAN_COMPLETED` event](#loot_scan_completed-event)
- [`MODIFIER_STATE_CHANGED` event](#modifier_state_changed-event)
- [`NAME_PLATE_CREATED` / `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` events](#name_plate_created--name_plate_unit_added--name_plate_unit_removed-events)
- [`PLAYER_FOCUS_CHANGED` event](#player_focus_changed-event)
- [`QUEST_ACCEPTED` event](#quest_accepted-event)
- [`QUEST_TURNED_IN` event](#quest_turned_in-event)
- [`UNIT_FACTION` event (fire-coverage fix)](#unit_faction-event-fire-coverage-fix)
- [`UPDATE_SHAPESHIFT_FORM` event](#update_shapeshift_form-event)
- [Expansion](#expansion)
@@ -162,6 +172,7 @@ build instructions.
- [`C_Item.EquipItemByName(itemInfo [, dstSlot])`](#c_itemequipitembynameiteminfo--dstslot)
- [`C_Item.GetCurrentItemLevel(itemLocation)` / `C_Item.GetDetailedItemLevelInfo(item)`](#c_itemgetcurrentitemlevelitemlocation--c_itemgetdetaileditemlevelinfoitem)
- [`C_Item.GetItemCount(itemInfo, [includeBank], [includeUses])`](#c_itemgetitemcountiteminfo-includebank-includeuses)
- [`C_Item.GetItemData(itemLocation)` / `C_Item.GetItemDataByID(item)`](#c_itemgetitemdataitemlocation--c_itemgetitemdatabyiditem)
- [`C_Item.GetItemFamily(item)`](#c_itemgetitemfamilyitem)
- [`C_Item.GetItemGUID(itemLocation)`](#c_itemgetitemguiditemlocation)
- [`C_Item.GetItemID(itemLocation)`](#c_itemgetitemiditemlocation)
@@ -200,12 +211,24 @@ build instructions.
- [`GetItemIcon(itemID)` / `C_Item.GetItemIcon(itemLocation)` / `C_Item.GetItemIconByID(item)`](#getitemiconitemid--c_itemgetitemiconitemlocation--c_itemgetitemiconbyiditem)
- [`OffhandHasWeapon()`](#offhandhasweapon)
- [Loot](#loot)
- [`C_Loot.GetNearbyLootableUnits()`](#c_lootgetnearbylootableunits)
- [`C_Loot.LootUnit(guid)`](#c_lootlootunitguid)
- [`C_Loot.LootUnitItem(guid, itemID)`](#c_lootlootunititemguid-itemid)
- [`C_Loot.ScanNearbyLoot()`](#c_lootscannearbyloot)
- [`C_Loot.IsScanInProgress()`](#c_lootisscaninprogress)
- [`C_Loot.GetLastScanResults()`](#c_lootgetlastscanresults)
- [Macros](#macros)
- [Numeric spellIDs in `/cast` and `CastSpellByName`](#numeric-spellids-in-cast-and-castspellbyname)
- [`CastSpellNoToggle` as a macro cast line](#castspellnotoggle-as-a-macro-cast-line)
- [`GetMacroSpell(macroSlot)`](#getmacrospellmacroslot)
- [`GetMacroIcons` / `GetMacroItemIcons` / `GetLooseMacroIcons` / `GetLooseMacroItemIcons`](#getmacroicons--getmacroitemicons--getloosemacroicons--getloosemacroitemicons)
- [Mail](#mail)
- [`GetSendMailItemLink([attachmentIndex])`](#getsendmailitemlinkattachmentindex)
- [`GetInboxItemLink(messageIndex[, attachmentIndex])`](#getinboxitemlinkmessageindex-attachmentindex)
- [Map](#map)
- [`C_Map.GetBestMapForUnit(unitToken)`](#c_mapgetbestmapforunitunittoken)
@@ -1212,6 +1235,146 @@ constraint as `SwapItems`.
Send is fire-and-forget (same as `SwapItems`).
## Coroutine
Restores Lua 5.0's stripped `coroutine.*` library. The C-level
coroutine machinery (`lua_resume`, `lua_yield`, `lua_newthread`,
and the `thread` type at tag 8) is linked into the engine, but
the script-facing library was never registered as a global table.
ClassicAPI rewires the standard five entries on `coroutine`,
matching Lua 5.0 semantics with two WoW-specific quirks called
out below.
### `coroutine.create(fn)`
Creates a new coroutine with `fn` as its body and returns the
resulting `thread`. `fn` must be a **Lua** function — passing a
C function (any engine global like `GetTime` or `UnitName`)
raises `bad argument #1 to 'create' (Lua function expected)`.
The new coroutine starts suspended; nothing runs until
`coroutine.resume` is called on it. The args passed to the first
resume become the body's arguments.
### `coroutine.resume(co, ...)`
Resumes a suspended coroutine, passing the trailing args to either
the body (first resume) or back as the return values of the
`coroutine.yield` that paused the coroutine.
Returns:
- `true, ...values` on success, where `values` are whatever the
coroutine yielded (mid-execution) or returned (on completion).
- `false, errMsg` on error. Two error sources:
- Coroutine isn't suspended — e.g. resuming a dead one;
`errMsg` is a fixed string like `"cannot resume dead coroutine"`.
- The body hit a Lua VM error; `errMsg` is the formatted error
string with file:line prefix.
**Caveat — `error()` doesn't propagate.** WoW replaces Lua's
`error()` global with a soft handler that calls
`geterrorhandler()(msg)` and returns normally instead of
longjmp'ing. So `error("msg")` inside a coroutine body looks like
a clean return to `resume` — the message gets printed to chat as
a side effect and `resume` returns `true` with no values. Real VM
errors (calling `nil`, indexing `nil`, etc.) still propagate
through the standard `false, msg` path.
### `coroutine.yield(...)`
Suspends the current coroutine. Whatever's passed to `yield`
becomes the return values of the matching `coroutine.resume`
call. When the coroutine is next resumed, the args passed to
`resume` become the return values of `yield`.
Raises an error if called outside a coroutine
(`attempt to yield across metamethod/C-call boundary`) or from
a C function (`cannot yield a C function`).
### `coroutine.status(co)`
Returns the coroutine's state as a string:
| Value | Meaning |
|-------|---------|
| `"running"` | `co` is the currently-running coroutine (i.e. the same thread that called `status`). |
| `"suspended"` | `co` is paused — either initial (never resumed) or mid-yield. |
| `"dead"` | `co`'s body has returned or errored. |
The standard Lua `"normal"` state — a coroutine that resumed a
deeper coroutine and is now an ancestor on the call chain — is
collapsed into `"suspended"`. Detecting it cleanly requires
walking the global thread list, and the case is near-useless for
addon-level logic.
### `coroutine.wrap(fn)`
Equivalent to `coroutine.create(fn)` followed by returning a
closure that calls `coroutine.resume(co, ...)` on each invocation
— but propagates errors as raised exceptions (via `lua_error`)
instead of returning `false, msg`. Lets you use a coroutine as
if it were an ordinary function:
```lua
local gen = coroutine.wrap(function()
for i = 1, 5 do coroutine.yield(i) end
end)
print(gen()) -- 1
print(gen()) -- 2
print(gen()) -- 3
```
Calling the wrapped function after the body has completed raises
`cannot resume dead coroutine`. The canonical "iterator that
terminates cleanly through `for ... in`" idiom is to yield an
explicit `nil` at the end so the for-loop's nil sentinel fires:
```lua
local function range(from, to)
return coroutine.wrap(function()
for i = from, to do coroutine.yield(i) end
coroutine.yield(nil)
end)
end
for n in range(1, 5) do print(n) end
```
### Async pattern (`RunAsync` + `C_Timer.After`)
Coroutines pair naturally with `C_Timer.After` for chunked
"do some work, wait a frame, do more" loops. The canonical
scheduler:
```lua
local function RunAsync(fn)
local co = coroutine.create(fn)
local function step()
local ok, delay = coroutine.resume(co)
if not ok or coroutine.status(co) == "dead" then return end
C_Timer.After(delay or 0, step)
end
step()
end
RunAsync(function()
for i = 1, 100 do
DoExpensiveWork(i)
coroutine.yield(0) -- next frame
end
coroutine.yield(0.5) -- pause half a second
DoFinalWork()
end)
```
`coroutine.yield(n)` returns `n` to the scheduler, which schedules
the next `step` after that many seconds. `yield(0)` is the
"resume on the next frame" form (since `C_Timer.After(0, ...)`
fires on the next OnUpdate tick). `RunNextFrame(cb)` in
[`AddOns/!!!ClassicAPI/Util/FunctionUtil.lua`](../AddOns/!!!ClassicAPI/Util/FunctionUtil.lua)
wraps the same primitive for the non-coroutine case.
## CVar
### `C_CVar.GetCVarBool(cvar)`
@@ -1821,6 +1984,29 @@ forwarding, which [`C_Reputation.GetLastStandingChange`](#c_reputationgetlaststa
exposes — so addons can read the structured payload from inside the
chat event without re-parsing the localized string.
### `LOOT_SCAN_COMPLETED` event
Fires (no payload) once a [`C_Loot.ScanNearbyLoot()`](#c_lootscannearbyloot)
call has finished walking every reachable corpse. Handler should call
[`C_Loot.GetLastScanResults()`](#c_lootgetlastscanresults) to read the
collected `{ guid, coin, items }` array.
```lua
local f = CreateFrame("Frame")
f:RegisterEvent("LOOT_SCAN_COMPLETED")
f:SetScript("OnEvent", function()
local results = C_Loot.GetLastScanResults()
-- ... process results
end)
C_Loot.ScanNearbyLoot()
```
Fires exactly once per `ScanNearbyLoot()` call regardless of
outcome — even if every corpse timed out and the results table is
empty. Doesn't fire spuriously for scans triggered by other addons
(only one scan runs at a time; concurrent attempts return `false`
from `ScanNearbyLoot`). See the Loot section for the full scan flow.
### `MODIFIER_STATE_CHANGED` event
Fires on every modifier-key press and release with `(key, down)`:
@@ -2022,6 +2208,75 @@ quest invalidated, etc.). Only fires on a real successful turn-in.
> done, quest removed from log). Hooking the packet handler gives
> us the same signal modern WoW's event uses.
### `UNIT_FACTION` event (fire-coverage fix)
`UNIT_FACTION` already exists in the vanilla event table — addons can
register for it — but the 1.12 engine **never fires it** on several
paths where modern WoW (3.3.5+) does. We restore the modern firing
semantics so addons that observe `UNIT_FACTION` stop missing state
changes.
Payload is unchanged from modern: `arg1` is the unit token string,
always `"player"` for these paths (you can only toggle / sync rep on
the local player).
```lua
local f = CreateFrame("Frame")
f:RegisterEvent("UNIT_FACTION")
f:SetScript("OnEvent", function()
if event == "UNIT_FACTION" and arg1 == "player" then
RefreshFactionUI()
end
end)
```
**Paths that now fire `UNIT_FACTION("player")`:**
| Path | Trigger | Detection |
|------|---------|-----------|
| `FactionToggleAtWar` | Lua call to toggle AT_WAR | Slot's flag byte changed (skips PEACE_FORCED no-ops, looting-blocked attempts, standing-blocked attempts) |
| `SetFactionInactive` / `SetFactionActive` | Lua call to toggle INACTIVE | Slot's flag byte changed |
| `SMSG_SET_FACTION_STANDINGS` | Server rep-change push (kills, quest turn-ins, etc.) | Any byte in the 64-entry flag array changed — catches AT_WAR side-effect flips when standing crosses the -3000 threshold |
| `SMSG_SET_FACTION_ATWAR` | Server force-change of AT_WAR (faction reset, server-side state push) | Unconditional after byte write |
| `SMSG_INITIALIZE_FACTIONS` | Server initial faction sync at login | Unconditional after table populated |
The last one fixes the case where addons relying on `UNIT_FACTION`
(rather than `PLAYER_LOGIN` / `VARIABLES_LOADED`) miss the initial
load entirely on vanilla.
**Not yet covered:**
- Unit-faction-template changes for *non-player* units (mind control,
charm, server-side faction scripts). Modern WoW fires
`UNIT_FACTION` on the affected unit token; the vanilla broad helper
isn't hooked here yet. Mostly affects nameplate / threat addons in
rare encounter mechanics.
**Implementation notes**
`FactionToggleAtWar` / `SetFactionInactive` / `SetFactionActive`'s
inner setters (`FUN_004D5FD0`, `FUN_004D60F0`) write to the rep slot's
flag byte and send the corresponding CMSG, but never call the
engine's "fire UNIT_FACTION on this unit" dispatcher. 3.3.5 added a
`FUN_0071F8F0(player, 0)` call to both paths whose inner
`FUN_0060BF10(playerGUID, UNIT_FACTION_id)` broadcasts the event for
every unit token referencing the local player.
Since `FactionToggleAtWar` only ever runs for the player and the only
unit token resolving to the player is `"player"`, we fire
`UNIT_FACTION("player")` directly via the engine's printf-style
dispatcher (`FUN_FIRE_EVENT`) — same observable result without
re-deriving the vanilla broad helper. We resolve `UNIT_FACTION`'s
event-table slot lazily by name (`Event::Custom::LookupByName`) so we
stay correct against any DLL combination that reshuffles the table.
For the two SMSG-bound flag-flip paths (`SetFactionStandings`,
`SetFactionInactive`-but-from-server), we snapshot the 64-entry rep
slot flag-byte array before the original handler runs and compare
after — single fire if any byte changed. The bulk snapshot is 64
byte reads (cheap) and naturally handles the case where one packet
carries multiple rep updates whose threshold crossings ripple AT_WAR
state across unrelated slots.
### `UPDATE_SHAPESHIFT_FORM` event
Fires whenever the local player's shapeshift form changes — entering
@@ -3568,6 +3823,143 @@ directly off the CGItem's m_objectFields descriptor at +0x20
Equivalent to the legacy global `GetItemCount` (since 3.0) and the
modern `C_Item.GetItemCount` introduced in 10.x.
### `C_Item.GetItemData(itemLocation)` / `C_Item.GetItemDataByID(item)`
ClassicAPI-only kitchen-sink reader: returns a single table with every
field we can extract from the cached `ItemStats_C` record, so addons
that need more than a couple of fields don't have to chain a dozen
`C_Item.*` calls. Saves both stack churn and the cost of redoing the
cache lookup for every accessor.
```
data = C_Item.GetItemDataByID(item) -- numeric id / "item:NNN" / chat link
data = C_Item.GetItemData(itemLocation) -- {bagID,slotIndex} or {equipmentSlotIndex}
```
Returns `nil` if the input doesn't resolve to a valid itemID or the
item isn't cached yet. Does **not** warm the cache — same passive-
reader contract as
[`C_Item.GetItemInfoInstant`](#c_itemgetiteminfoinstantitem).
Callers that need to wait for cache fill should call
[`C_Item.RequestLoadItemDataByID`](#c_itemrequestloaditemdatabyiditem--c_itemrequestloaditemdataitemlocation)
and listen for `ITEM_DATA_LOAD_RESULT`.
Returned table:
| Key | Type | Source / notes |
|-----|------|---------------|
| `itemID` | number | Echoed from input. |
| `name` | string | `m_name[0]` — localized display name. |
| `description` | string \| nil | `m_description`. Omitted when empty. |
| `icon` | string \| nil | `"Interface\\Icons\\<name>"`. Omitted when no icon. |
| `displayInfoID` | number | `m_displayInfoID` → `ItemDisplayInfo.dbc` row. |
| `quality` | number | 0..5 — see `LE_ITEM_QUALITY_*` enum. |
| `classID` | number | `m_class` — `ItemClass.dbc` row. |
| `subclassID` | number | `m_subClass` — `ItemSubClass.dbc` row. |
| `className` | string | Localized class name (e.g. `"Weapon"`). |
| `subclassName` | string | Localized subclass name (e.g. `"One-Handed Swords"`). |
| `inventoryType` | number | `m_inventoryType` — raw integer. |
| `equipLoc` | string | `"INVTYPE_*"` constant or `""` for non-equippable. |
| `bindType` | number | `m_bonding` — 0=none, 1=BoP, 2=BoE, 3=BoU, 4=Quest. |
| `flags` | number | Raw `m_flags` u32. |
| `isConjured` | bool | Flag bit `0x2`. |
| `isOpenable` | bool | Flag bit `0x4`. Same bit `C_Item.IsItemOpenable` reads. |
| `isLootable` | bool | Flag bit `0x10` — has loot generators (right-click loot). |
| `isWrapper` | bool | Flag bit `0x200` — gift-wrappable. |
| `maxStackSize` | number | `m_stackable`. |
| `maxCount` | number | `m_maxCount` — 0=unlimited, 1=unique, otherwise per-character cap. |
| `containerSlots` | number | `m_containerSlots` — bag slot count, 0 for non-bags. |
| `bagFamily` | number | `m_bagFamily` converted to modern bitmask (`1 << (id-1)`). |
| `buyPrice` | number | Vendor buy price in copper. |
| `sellPrice` | number | Vendor sell price in copper. |
| `itemLevel` | number | Base ilvl from `ItemSparse`. |
| `requiredLevel` | number | Minimum character level. |
| `requiredSkill` | number | `SkillLine.dbc` row, 0=none. |
| `requiredSkillRank` | number | Skill rank required. |
| `requiredSpell` | number | `Spell.dbc` row required to learn / use, 0=none. |
| `requiredHonorRank` | number | PvP honor rank required, 0=none. |
| `requiredCityRank` | number | Reserved; 0 for vanilla. |
| `requiredFaction` | number | `Faction.dbc` row, 0=none. |
| `requiredFactionRank` | number | Reputation tier (Friendly/Honored/…). |
| `allowableClass` | number | Class bitmask, `-1` = all classes. |
| `allowableRace` | number | Race bitmask, `-1` = all races. |
| `armor` | number | Armor value. |
| `block` | number | Shield block value. |
| `maxDurability` | number | 0 for items without durability. |
| `stats` | table | Sparse `{ [statType] = statValue, … }`. Empty for items without stat allocations. |
| `resistanceHoly` | number | Holy resistance. |
| `resistanceFire` | number | Fire resistance. |
| `resistanceNature` | number | Nature resistance. |
| `resistanceFrost` | number | Frost resistance. |
| `resistanceShadow` | number | Shadow resistance. |
| `resistanceArcane` | number | Arcane resistance. |
| `damageMin` | table | 5-element float array — weapon min damage per damage slot. |
| `damageMax` | table | 5-element float array. |
| `damageType` | table | 5-element integer array — damage school per slot. |
| `delay` | number | Weapon swing time in ms. |
| `ammoType` | number | Ammo subclass for ranged weapons. |
| `rangedModRange` | number | Range modifier for bows/guns. |
| `spells` | table | Sparse array of `{id, trigger, charges, cooldown, category, categoryCooldown}` records — one per non-empty spell slot (up to 5). |
| `useSpellID` | number \| nil | Convenience — the spellID of the first slot with trigger=ON_USE (0). Omitted when the item has none. Same value [`C_Item.GetItemSpell`](#c_itemgetitemspellitem) returns. |
| `lockID` | number | `Lock.dbc` row for the item's lock, 0=none. |
| `itemSet` | number | `ItemSet.dbc` row, 0=not part of a set. |
| `pageText` | number | `PageText.dbc` row for readable items. |
| `pageMaterial` | number | Book page material. |
| `languageID` | number | Language for in-game-readable books. |
| `startQuest` | number | `Quest` row started by right-clicking this item. |
| `material` | number | Material type (cloth/leather/metal etc. for hit sounds). |
| `sheath` | number | Weapon sheath style. |
| `randomProperty` | number | Random property template id. |
| `area` | number | `AreaTable.dbc` — bound area for area-locked items, 0=none. |
| `map` | number | `Map.dbc` — bound map for map-locked items, 0=none. |
```lua
local data = C_Item.GetItemDataByID(6948) -- Hearthstone
-- data.name == "Hearthstone"
-- data.quality == 1, data.classID == 15, data.subclassID == 0
-- data.bindType == 1 (BoP)
-- data.useSpellID == 8690 (the recall spell)
-- data.maxStackSize == 1, data.maxCount == 1
local sword = C_Item.GetItemDataByID(2092) -- Worn Shortsword
-- sword.equipLoc == "INVTYPE_WEAPONMAINHAND"
-- sword.damageMin[1] == 1.0, sword.damageMax[1] == 2.0, sword.delay == 1900
-- sword.stats == {} (no stat allocations on the starter sword)
```
**Cache state.** The function reads the same `DBCache_ItemStats`
network cache every other `C_Item.*` reader consults. Items already in
the player's inventory are typically populated by the engine's natural
prefetch before `PLAYER_ENTERING_WORLD` returns; for other items
(quest rewards, AH items, link hovers, etc.) the data lands once a
`SMSG_ITEM_QUERY_SINGLE_RESPONSE` arrives. Use
`C_Item.RequestLoadItemDataByID` if you specifically need to trigger
the load.
**Stats encoding.** The `stats` table is keyed by the **ItemModType**
enum (vanilla's encoding):
| Key | Meaning |
|-----|---------|
| 0 | Mana |
| 1 | Health |
| 3 | Agility |
| 4 | Strength |
| 5 | Intellect |
| 6 | Spirit |
| 7 | Stamina |
Higher-numbered slots (consumable per-second-regen, defense rating,
hit/crit/dodge/parry chance, weapon/spell-power) only appear on items
the server actually carries — vanilla 1.12 mostly uses 0..7. Empty
slots (both type and value zero) are omitted, so iterating `stats`
yields only the stats actually allocated.
No equivalent in modern WoW — `GetItemInfo` returns the 14-tuple
piecemeal and `C_Item.GetItemInfo` adds a few more. ClassicAPI's
`GetItemData` is a single-call superset useful for backporting tooltip
addons and item-data caches.
### `C_Item.GetItemFamily(item)`
Returns the BagFamily bitmask for an item — i.e., what kind of
@@ -3678,8 +4070,8 @@ end
Modern-style accessor for the always-available subset of item info — the
fields that depend only on classification, not on player-specific state.
Returns immediately from the client-side item cache; no server round-trip,
no async polling. Returns `nil` if the item isn't in cache.
Synchronous, side-effect-free: peeks the client-side item cache and
returns whatever it has without warming or queueing.
Accepts a numeric `itemID` or a string containing `"item:NNN"` (matches both
the bare `"item:1234"` shorthand and full chat links like
@@ -3693,6 +4085,13 @@ Returns seven values:
itemID, itemType, itemSubType, itemEquipLoc, icon, classID, subClassID
```
The `itemID` return is always populated for any input that resolves to
a positive integer (so it's safe to use the function as an ID extractor
on a link without first warming the cache). The remaining six fields
come from the cache record when present — when the item isn't cached
yet they're all `nil`, but the call still returns a 7-tuple so the
positional shape is preserved.
- `itemType` / `itemSubType` are the localized class / subclass names
(e.g. `"Weapon"` / `"One-Handed Swords"`), read from `ItemClass.dbc` and
`ItemSubClass.dbc`.
@@ -3706,15 +4105,27 @@ itemID, itemType, itemSubType, itemEquipLoc, icon, classID, subClassID
```lua
local id, type, subtype, equipLoc, icon, classID, subClassID
= C_Item.GetItemInfoInstant(6948) -- Hearthstone
= C_Item.GetItemInfoInstant(6948) -- Hearthstone, cached
-- type="Miscellaneous", subtype="Junk", equipLoc="",
-- icon="Interface\\Icons\\INV_Misc_Rune_01", classID=15, subClassID=0
local id = C_Item.GetItemInfoInstant("|cff...|Hitem:6948:0:0:0|h[Hearthstone]|h|r")
-- id == 6948 even when the cache is cold — the call parses the link
-- to extract the ID without consulting the cache.
```
The actual class/subclass values reflect 1.12.1's data, which differs from
modern WoW. For example, vanilla had no Cloth subclass under Trade Goods —
Silk Cloth lives at `(7, 0)` in this client, not the modern `(7, 5)`.
> **No auto-warmup.** Unlike `GetItemInfo` or `C_Item.GetItemNameByID`,
> `GetItemInfoInstant` does not trigger a network query on a cache
> miss. The "Instant" name is contractual — callers that need the
> cache populated should use
> [`C_Item.RequestLoadItemDataByID`](#c_itemrequestloaditemdatabyiditem--c_itemrequestloaditemdataitemlocation)
> explicitly, or just call `GetItemInfo` (which warms via the
> `Script_GetItemInfo` hook).
### `C_Item.GetItemInventoryType(itemLocation)` / `C_Item.GetItemInventoryTypeByID(item)`
Returns the numeric `Enum.InventoryType` straight off the cache
@@ -3802,7 +4213,7 @@ return nil; hits never need a fallback.
### `C_Item.GetItemMaxStackSize(itemLocation)` / `C_Item.GetItemMaxStackSizeByID(item)`
Returns the item type's max stack size — what you'd find as the 8th
Returns the item type's max stack size — what you'd find as the 7th
return of `GetItemInfo(item)`. `1` for non-stackable items; `5`, `20`,
`200`, etc. for stackables. Different from `C_Item.GetStackCount` /
the engine's `GetContainerItemInfo`, which return the **current**
@@ -4241,16 +4652,19 @@ was already cached (so polling code paths still work), asynchronously
fired when the engine's SMSG response handler completes a network
fetch.
> **Vanilla quirk:** the engine has no native bool format code — `success`
> arrives as `1`/`0` (number), not `true`/`false`. Idiomatic check is
> `if success == 1 then ...`.
> **`success` is `1` or `nil`.** The engine's printf-style event
> dispatcher has no `%b` token, so we encode the boolean as `1` for
> success / `nil` for failure (leaning on `lua_pushstring(NULL)` →
> `lua_pushnil`). `if success then …` distinguishes them correctly —
> `nil` is falsy, `1` is truthy. Same encoding as `GET_ITEM_INFO_RECEIVED`
> and `QUEST_DATA_LOAD_RESULT`.
```lua
local f = CreateFrame("Frame")
f:RegisterEvent("ITEM_DATA_LOAD_RESULT")
f:SetScript("OnEvent", function()
-- vanilla 1.12: event payload is in `arg1`, `arg2`, ... globals
if event == "ITEM_DATA_LOAD_RESULT" and arg2 == 1 then
if event == "ITEM_DATA_LOAD_RESULT" and arg2 then
local _, type = C_Item.GetItemInfoInstant(arg1)
-- ...
end
@@ -4258,6 +4672,21 @@ end)
C_Item.RequestLoadItemDataByID(2589)
```
> **Engine-aligned cache strategy.** `RequestLoadItemData` does NOT
> eagerly create a cache entry. Empirically (e.g. pfQuest calling it
> at addon-load / PLAYER_ENTERING_WORLD against a cleared WDB), pre-
> creating the entry races the engine's natural inventory prefetch
> and can leave the item permanently pending — the engine sees the
> entry exists and skips its own `SMSG_ITEM_QUERY_SINGLE`, while our
> early query gets dropped. Instead we track the itemID and hook the
> engine's `SMSG_ITEM_QUERY_SINGLE_RESPONSE` handler at `0x0055BDB0`:
> when the engine fills any entry, we sweep tracked items and fire
> `ITEM_DATA_LOAD_RESULT` for matches. For items the engine doesn't
> prefetch (quest rewards, AH browse targets, etc.) we escalate after
> ~60 ticks by calling `CacheFetch` ourselves — by then engine state
> has settled and our query goes through normally. Hard timeout at
> ~1200 ticks (~20–40s) fires `ITEM_DATA_LOAD_RESULT(itemID, nil)`.
### `C_Item.UnlockAllItems()`
Sweep clear of every CGItem the engine knows about — same primitive
@@ -4717,6 +5146,177 @@ if OffhandHasWeapon() then
end
```
## Loot
Programmatic loot operations that vanilla 1.12's Lua surface doesn't
expose: enumerate nearby lootable corpses, open a loot session against
one by GUID, take a specific item out of one without going through the
visible `LootFrame`, and pre-scan every reachable corpse to build a
picker table before any cursor work. Foundation for AoE-loot,
auto-loot-by-filter, and similar addons that 1.12's stock
`LootSlot` / `LootSlotInfo` flow can't drive.
All `guid` args are hex-string GUIDs in the same form `UnitGUID`
returns (`"0xF130..."`). All `itemID` args are integers.
### `C_Loot.GetNearbyLootableUnits()`
Returns a 1-indexed array of `{ guid = "0x..." }` subtables, one per
visible unit that the server has flagged lootable AND that's within
the engine's right-click-loot interact range. Empty table pre-login,
when no lootable units are in range, or when the local player has no
loot rights on visible corpses (the server only sets the lootable
flag for clients with rights).
```lua
for _, unit in ipairs(C_Loot.GetNearbyLootableUnits()) do
C_Loot.LootUnit(unit.guid)
end
```
The interact-range check matches what the engine uses for the
hover-to-loot cursor — slightly tighter than melee range, looser than
"on top of." Server still enforces real range on the resulting
`CMSG_LOOT`; in-client OOR requests just silently get no response.
### `C_Loot.LootUnit(guid)`
Opens a loot session against the given corpse. The loot window
appears asynchronously via the normal `LOOT_OPENED` event once
`SMSG_LOOT_RESPONSE` arrives — same flow as right-clicking the
corpse, just dispatched programmatically.
No return value. Silent no-op when:
- The object manager is NULL (glue / character-select).
- The GUID doesn't resolve to a visible unit (out of render range,
despawned, wrong typemask).
- The local player pointer can't be resolved.
Errors only on bad Lua usage (missing arg, unparseable GUID).
```lua
-- Loot everything in range, one corpse at a time. With autoloot on,
-- the engine drains each window automatically; without it, you
-- still get the visible loot frame per corpse.
for _, unit in ipairs(C_Loot.GetNearbyLootableUnits()) do
C_Loot.LootUnit(unit.guid)
end
```
`useDistanceCheck` is hardcoded off — the engine won't try to
auto-walk the player toward a corpse just out of range. That'd be a
surprise side effect for an AoE-loot loop.
### `C_Loot.LootUnitItem(guid, itemID)`
Takes a specific item from a specific corpse without using the
visible loot frame's slot list. Dispatches `CMSG_LOOT_ITEM` directly.
Two paths, picked automatically:
1. **Fast path** — the corpse's loot window is already open for the
requested target. Synchronously finds the slot, sends the take
packet, returns `true` if the slot was found / `false` if not.
2. **Async path** — opens the corpse's window first (same wire as
`C_Loot.LootUnit`), waits for the response, then sends the take
packet automatically. Returns `true` once the request is queued.
No "did it succeed" event; observe `BAG_UPDATE` / `ITEM_PUSH` to
confirm.
`false` immediately when:
- Args missing or wrong types (raises an error, not returns false).
- Another `LootUnitItem` is mid-flight (one at a time).
- The world isn't loaded.
- The target GUID doesn't resolve to a visible unit.
- (sync path only) No slot in the open window holds the itemID.
The send bypasses vanilla's BoP-confirmation dialog. Server still
enforces all real permissions (loot rights, distance, master-loot
rules); the BoP prompt is purely a client-side courtesy that callers
of a programmatic API have already opted out of.
```lua
-- Scan, then take just one specific item out of one specific corpse.
C_Loot.ScanNearbyLoot() -- LOOT_SCAN_COMPLETED → results below
local results = C_Loot.GetLastScanResults()
local guid = results[1].guid
local itemID = results[1].items[1].itemID
C_Loot.LootUnitItem(guid, itemID)
```
### `C_Loot.ScanNearbyLoot()`
Initiates a pre-scan: opens each lootable corpse in range one at a
time, scrapes its slot contents (itemID, count, item link), closes
the window, advances. The `LOOT_OPENED` / `LOOT_CLOSED` events are
suppressed for the duration of the scan so `LootFrame` and other
listeners don't react — the scan is invisible to addons that aren't
opted in.
Returns `true` if the scan was queued, `false` if another scan is
already in progress or the world isn't loaded. Completion is signaled
via the [`LOOT_SCAN_COMPLETED`](#loot_scan_completed-event) event;
results are read via [`C_Loot.GetLastScanResults()`](#c_lootgetlastscanresults).
```lua
local f = CreateFrame("Frame")
f:RegisterEvent("LOOT_SCAN_COMPLETED")
f:SetScript("OnEvent", function()
for _, corpse in ipairs(C_Loot.GetLastScanResults()) do
for _, item in ipairs(corpse.items) do
ChatFrame1:AddMessage(item.link or ("item:"..item.itemID))
end
end
end)
C_Loot.ScanNearbyLoot()
```
Per-corpse step timeout is ~3-6 seconds (180 WorldTick frames) so a
single dropped server response doesn't hang the whole scan. Failed
corpses simply don't appear in the results.
### `C_Loot.IsScanInProgress()`
Returns `true` while a `ScanNearbyLoot` call is mid-flight, `false`
otherwise. Use to gate re-scan attempts so addons don't spam-call
during the engine's per-corpse round-trip.
### `C_Loot.GetLastScanResults()`
Returns the most-recent completed-scan results as a 1-indexed array:
```lua
{
[1] = {
guid = "0x...", -- corpse GUID
coin = 1234, -- copper amount; 0 if no coin
items = {
[1] = {
itemID = 2589,
count = 4,
link = "|cff...|Hitem:2589:0:0:0|h[Linen Cloth]|h|r",
-- omitted if cache entry wasn't loaded
},
...
},
},
...
}
```
Empty table before the first scan completes. Replaced wholesale by
each new scan — re-read after `LOOT_SCAN_COMPLETED` rather than
caching the table.
The `link` field is the engine's full hyperlink (color + payload +
display name + reset). Random-suffix items like "Stringy Wolf Meat
of the Bear" survive round-trip — the link encodes the per-instance
enchant / suffix / unique fields, not just the base itemID. For
tooltip display, extract the payload form via
`string.match(link, "|H(item:[^|]+)|h")` — vanilla's `SetHyperlink`
requires the literal `"item:"` prefix and rejects full `|cff...|Hitem...|h`
input.
## Macros
Engine-level extensions to how macros are parsed and dispatched. These
@@ -4889,6 +5489,74 @@ MPQ will appear in both the loose and mpq lists).
The vanilla legacy globals `GetNumMacroIcons` / `GetMacroIconInfo`
remain available unchanged.
## Mail
Backports of modern WoW's mail-attachment link getters. Both follow
the modern signature including the optional `attachmentIndex` arg
(added with TBC's multi-attachment mail), even though vanilla 1.12
only supports one attachment per message — pass-through compatibility
for addons originally written against later API versions. Any
`attachmentIndex` other than `1` is treated as no-op.
Both functions return `(link, itemID)` — modern returns just `link`,
but `itemID` is cheap to surface at the same lookup site and saves
callers a `string.match` on the link.
### `GetSendMailItemLink([attachmentIndex])`
Returns the **fully-decorated per-instance** hyperlink for the item
currently attached to the outgoing-mail slot, plus that item's
`itemID`. Same enchant / random-suffix / unique-ID decoration as
`GetContainerItemLink` would return for the same item back in the
player's bag — the attached item still exists as a live CGItem until
the mail is sent, so the link builder has full instance state to
draw from.
Returns nothing when:
- No item is attached.
- `attachmentIndex` is anything other than `nil` or `1`.
- The attachment's CGItem can't be resolved (shouldn't normally
happen — engine writes the GUID on attach and clears it on detach).
```lua
local link, itemID = GetSendMailItemLink()
if link then
ChatFrame1:AddMessage("Attached: " .. link .. " (id " .. itemID .. ")")
end
```
### `GetInboxItemLink(messageIndex[, attachmentIndex])`
Returns the **basic itemID-only** hyperlink (`|cff…|Hitem:N:0:0:0|h[Name]|h|r`)
for the item attached to inbox message `messageIndex` (1-based),
plus the `itemID`.
**Why basic-link instead of per-instance:** vanilla inbox entries do
store per-instance modifiers inline (enchant at `+0x12C`, suffix
factor at `+0x134`, random property at `+0x138` — the same fields
`GameTooltip:SetInboxItem` copies onto the tooltip to render a fully-
decorated preview). We intentionally ignore them in the link to match
modern 3.3.5's behavior — its `GetInboxItemLink` calls the
itemID-only link builder (`FUN_0061E290(itemID)`) and ignores the
per-instance fields. Per-instance data only fully manifests on the
client side once the player takes the item out of the mail and the
engine spawns a real CGItem; the inbox-entry fields are display-only
hints used by the tooltip builder.
Errors on missing or non-numeric `messageIndex`. Returns nothing when:
- `messageIndex` is out of range (`< 1` or `> GetInboxNumItems()`).
- The message slot is empty / has no attached item.
- `attachmentIndex` is anything other than `nil` or `1`.
```lua
for i = 1, GetInboxNumItems() do
local link, itemID = GetInboxItemLink(i)
if link then
ChatFrame1:AddMessage(i .. ": " .. link .. " (id " .. itemID .. ")")
end
end
```
## Map
### `C_Map.GetBestMapForUnit(unitToken)`
@@ -5626,9 +6294,11 @@ cache. Synchronously fired when the data was already cached
`SMSG_QUEST_QUERY_RESPONSE` lands when the engine had to round-trip to the
server.
> **Vanilla quirk:** the engine has no native bool format code — `success`
> arrives as `1`/`0` (number), not `true`/`false`, just like
> `ITEM_DATA_LOAD_RESULT`. Idiomatic check is `if success == 1 then ...`.
> **`success` is `1` or `nil`.** The engine's printf-style event
> dispatcher has no `%b` token, so we encode the boolean as `1` for
> success / `nil` for failure. `if success then ...` works as expected
> (`nil` falsy, `1` truthy). Same encoding as `ITEM_DATA_LOAD_RESULT`
> and `GET_ITEM_INFO_RECEIVED`.
> **Vanilla limitation:** for *invalid* questIDs (ones the server doesn't
> have), the 1.12 server silently drops the query — it doesn't send a
+9 -2
View File
@@ -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
+9 -1
View File
@@ -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);
+31 -1
View File
@@ -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.
+408 -1
View File
@@ -286,7 +286,10 @@ enum Offsets {
// holds the local player's 64-bit GUID, and the visible-object
// iterator at FUN_CLNT_OBJ_MGR_ENUM_VISIBLE_OBJECTS walks its
// +0xAC list. Useful for "is this GUID me?" checks without
// round-tripping through Lua.
// round-tripping through Lua. Also a NULL-check proxy for "is
// the object manager initialised" — engine iterators deref this
// unconditionally on entry, so callers must skip on the glue /
// character-select screen.
VAR_LOCAL_PLAYER_PTR = 0x00B41414,
OFF_LOCAL_PLAYER_GUID = 0xC0,
// `__fastcall(ecx = const char *token) → uint64_t GUID` — the
@@ -1068,6 +1071,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,
@@ -1182,6 +1205,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,
@@ -1193,6 +1220,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.
@@ -1703,6 +1787,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
@@ -1722,6 +1810,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
@@ -2365,6 +2528,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).
@@ -3142,6 +3354,174 @@ enum Offsets {
VAR_LOOT_LOOTABLE = 0x00B71BA0,
LOOT_SLOT_STRIDE = 0x1C,
LOOT_MAX_SLOTS = 0x10,
// Per-slot field offsets within a `VAR_LOOT_SLOTS + slot * 0x1C`
// entry. Decoded from `Script_GetLootSlotInfo` and the populator
// at `FUN_004C1CB0`:
// +0x00 itemID (Script_GetLootSlotInfo's first push)
// +0x04 displayInfoID (icon lookup via DBC index)
// +0x08 count (FUN_004C22E0 reads `[base+0x8]`)
// +0x0C ?
// +0x10 ?
// +0x14 wire-slot byte (the server-side slot index — what
// `CMSG_LOOT_ITEM` expects; differs from the local
// densely-packed index when the response had gaps)
OFF_LOOT_SLOT_COUNT = 0x08,
OFF_LOOT_SLOT_WIRE_INDEX = 0x14,
// `__fastcall(uint userFacingSlot, char *outBuf, int bufSize) →
// const char *outBuf` — builds the loot-slot hyperlink string
// (e.g. `|cff1eff00|Hitem:12345:0:0:0|h[Item Name]|h|r`) and
// copies it into `outBuf`. Returns the buffer pointer on success,
// `NULL` if no loot window is open, the slot is empty, or the
// item's cache record isn't loaded yet. Used by
// `Script_GetLootSlotLink` at `0x004C2D20`.
//
// `userFacingSlot` is the post-`__ftol`-decrement slot index — the
// same shape Lua's `LootSlot(slot)` produces internally after
// `dec eax`:
// - When coin is present (`VAR_LOOT_LOOTABLE != 0`), input `0`
// is the coin slot itself and returns NULL; subsequent
// internal items live at `userFacingSlot = internal_slot + 1`
// (the function dec's again inside).
// - When no coin, `userFacingSlot = internal_slot` directly.
// The internal `_GetRecord` call inside uses `callback = NULL`, so
// the link is a pure cache hit — no network request gets queued
// when the cache is cold, just a NULL return.
FUN_LOOT_SLOT_LINK_BUILDER = 0x004C2670,
// `__stdcall(uint8_t slotByte)` — sends `CMSG_LOOT_ITEM` (opcode
// `0x108`) carrying the wire-protocol slot byte for the currently-
// open loot window. Built directly from the engine's packet
// primitives — bypasses the `Script_LootSlot` Lua wrapper at
// `FUN_004C2790`, which routes through a `param_2 != 0`
// BoP-confirm-after-dialog branch that's of no use to us. The
// server cheerfully takes whatever slot you send for the current
// loot session; no client-side BoP confirmation gate.
//
// **Calling convention matters.** The callee reads its arg from
// `[ebp+8]` (stack) and ends with `ret 4`. Mis-typedef'ing as
// `__fastcall` produces a stack-corrupting caller — symptoms are
// delayed crashes on unrelated vtable reads.
FUN_CMSG_LOOT_ITEM = 0x005E1AD0,
// UNIT_DYNAMIC_FLAGS within `m_objectFields`. Standard
// server-driven dynamic-state bitmask. Empirically verified by
// cross-referencing three `Script_*` accessors that all read
// bytes at `[m_objectFields + 0x224]`:
// - `Script_UnitIsTapped` (`0x00519C90`): `test [eax+0x224], 0x04`
// - `Script_UnitIsTappedByPlayer` (`0x00519D00`): `test [eax+0x224], 0x08`
// - `Script_UnitIsDead` (`0x00517AC0`): `[eax+0x224] >> 5 & 1`
// Bit values match CMaNGOS conventions: 0x01 LOOTABLE,
// 0x02 TRACK_UNIT, 0x04 TAPPED, 0x08 TAPPED_BY_PLAYER,
// 0x10 SPECIALINFO, 0x20 DEAD, 0x40 REFER_A_FRIEND_LINKED,
// 0x80 ZONE_OUT. The +0x224 offset is significantly later than
// CMaNGOS-doc'd vanilla (which puts DYNAMIC_FLAGS at field 0x46
// = +0x118) — yet another non-uniform shift in 1.12.1's actual
// UNIT_FIELD layout. Direct empirical read is the authority.
OFF_UNIT_FIELD_DYNAMIC_FLAGS = 0x224,
UNIT_DYNFLAG_LOOTABLE = 0x01,
UNIT_DYNFLAG_DEAD = 0x20,
// UNIT_FIELD_BOUNDINGRADIUS within m_objectFields — single
// float, the unit's interact-radius in world yards. Both
// `FUN_005EC110` (interact-state check) and `FUN_005DF130`
// (CMSG_LOOT sender) compute the interaction-range threshold
// as `target_radius + player_radius + INTERACT_REACH`, clamped
// to a floor of `MIN_INTERACT_RANGE`. Verified at `0x005EC1A4`
// where the function reads
// `[(target_m_objectFields) + 0x1F0]`.
OFF_UNIT_FIELD_BOUNDING_RADIUS = 0x1F0,
// `__thiscall(player, target, useDistanceCheck)` — engine's
// CMSG_LOOT sender for **dead mobs** (CGCreature_C with
// DYNFLAG_LOOTABLE set). Pre-checks: `FUN_005EC110` interact
// validity + `FUN_006003A0` lootability (target dead + bit 0 of
// `m_objectFields[+0x224]` set) + local player not in a state
// that forbids interaction. Releases any open loot window before
// sending the new request.
//
// `useDistanceCheck` is the auto-walk gate:
// - `1` (engine default): if target out of range, start
// walking toward it and *don't* send CMSG_LOOT. If in range,
// send normally.
// - `0`: skip the range check; send immediately. The server
// enforces server-side range, so OOR sends silently fail
// rather than walking the player toward distant corpses —
// the right pick for AoE-loot-style callers.
//
// Engine callsite at `FUN_0060FA20` (right-click world handler)
// resolves the target via
// `ClntObjMgrObjectPtr(TYPEMASK_UNIT, ..., guid)` first, same as
// we do.
//
// Distinct from the sibling sender at `0x005DF130`, which gates
// on `FUN_CGCORPSE_IS_LOOTABLE` (player corpses only) instead of
// the mob path. The CGCorpse helper is documented immediately
// below.
FUN_CMSG_LOOT_UNIT = 0x005DF2A0,
// `__fastcall(int loot_response_struct, undefined4 coin, int unk)`
// — master loot-window state controller. Called by the
// `SMSG_LOOT_RESPONSE` handler tail (via `FUN_0048F3A0`) and by
// `CloseLoot`-inner (via `FUN_0048F200(0, 0, 0)`). Two phases:
// - **Close prior**: if `VAR_LOOT_GUID_LO/HI != 0`, clears
// them, releases item-cache callbacks for the prior slots,
// fires `LOOT_CLOSED` (event id `0x10D`).
// - **Open new**: if `param_1 != 0`, copies the GUID from
// `[param_1 + 8]` into `VAR_LOOT_GUID_LO/HI`, zeroes
// `VAR_LOOT_SLOTS`, walks 16 slots populating itemID via
// `FUN_005EBC90(slot)` and sibling helpers, sets
// `VAR_LOOT_LOOTABLE = param_2` (coin), then fires
// `LOOT_OPENED` (event id `0x10B`).
// Both event fires go through `FUN_FIRE_EVENT_NO_ARGS` —
// intercepting that function lets a caller swallow the events
// without touching this one.
FUN_LOOT_CONTROLLER = 0x004C1CB0,
// `__fastcall(int eventID)` — engine's no-args event dispatcher,
// the lighter-weight sibling of `FUN_FIRE_EVENT` (variadic).
// Called from ~100 sites across the engine, including the
// `LOOT_OPENED` / `LOOT_CLOSED` fires inside
// `FUN_LOOT_CONTROLLER`. Hooking it lets us conditionally swallow
// specific event ids while leaving the rest untouched — the
// mechanism we use to do silent scan loops without flickering
// the `LootFrame`. Fan-in is wide but call frequency is
// event-driven (state changes only), not per-frame, so per-call
// overhead from a hooked predicate is negligible.
FUN_FIRE_EVENT_NO_ARGS = 0x00703E50,
// `__fastcall(int sendRelease, char earlyReturnOnGameObject,
// char showError)` — engine's "close the current loot window"
// inner. Behavior keyed off args:
// - `sendRelease` (ECX): if non-zero, builds and sends
// `CMSG_LOOT_RELEASE` (opcode `0x15F`) for the current loot
// target before clearing client state.
// - `earlyReturnOnGameObject` (EDX): if non-zero, bails early
// when the loot target is a game object that's been
// interacted with in a particular way (fishing-style?).
// - `showError` (stack): if non-zero, calls `FUN_00496720(0x87)`
// to display a "cannot close loot" error.
// Side effects: clears `[player + 0x1D28/+0x1D2C]` (the player's
// prior-interact GUID slots), then calls `FUN_004C1CB0(0, 0, 0)`
// which clears `VAR_LOOT_GUID_LO/HI`, zeroes `VAR_LOOT_SLOTS`,
// and fires the `LOOT_CLOSED` event (`0x10D`). Use
// `(1, 0, 0)` for a clean release + clear.
FUN_CLOSE_LOOT_INNER = 0x0048F200,
// `CGCorpse::IsLootable(this)` — `__fastcall(corpse) → uint`,
// tests bit 0 of `[corpse_m_objectFields + 0x78]`. Specific to
// `TYPEMASK_CORPSE` (player corpses) — `+0x78` of a CGCorpse_C's
// m_objectFields holds dynamic flags with bit 0 = LOOTABLE. The
// same offset in CGUnit_C / CGPlayer_C's m_objectFields holds
// BYTES_0 (race / class / gender / power type), so this helper
// returns nonsense for live units (e.g. it returns 1 for Human
// players because race=1 has bit 0 = 1). Used by the engine's
// right-click-on-corpse handler at `FUN_005D6BF0` to gate
// CMSG_LOOT for player corpses, and from `FUN_005DF130` (one of
// two CMSG_LOOT senders) for the same corpse path. Don't reuse
// for live-unit lootability — use `OFF_UNIT_FIELD_DYNAMIC_FLAGS`
// bit 0 against a CGUnit instead.
FUN_CGCORPSE_IS_LOOTABLE = 0x005D6E20,
// Merchant inventory: flat array at `0xBDD118`, stride 0x1C,
// itemID at `entry+4`. The "flag" globals are actually the
@@ -3242,10 +3622,37 @@ enum Offsets {
// `+0x120`. (The engine's `Script_GetInboxItem` passes a per-
// entry completion callback at `0x004AF0A0` to GetRecord; for
// ID-only retrieval we skip the callback.)
//
// Entries also carry per-instance modifiers at `+0x12C`
// (enchantID), `+0x134` (suffix factor), `+0x138` (random
// property ID), etc. — `Script_GameTooltip_SetInboxItem` at
// `0x005354C0` copies them onto the tooltip object to render a
// fully-decorated tooltip. We don't expose them in
// `GetInboxItemLink` because 3.3.5's same function only emits
// basic itemID-only links (`FUN_0061E290(itemID)`); per-instance
// data only fully manifests when the player takes the item and
// the engine spawns a real CGItem.
VAR_INBOX_ENTRIES = 0x00B6EF54,
VAR_INBOX_COUNT = 0x00B6EFC0,
OFF_INBOX_ENTRY_ITEM_ID = 0x120,
// SendMail attached item — the engine stores the attached item's
// 64-bit GUID at these globals when the player drops an item onto
// the SendMail slot (`SetSendMailItem` / `ClickSendMailItemButton`).
// Cleared to zero by `ClearSendMail` and on `MAIL_CLOSED`. Vanilla
// allows exactly one attachment per outgoing mail (modern WoW lifted
// this to 12+; we ignore the modern `attachmentIndex` arg since only
// index 1 is meaningful).
//
// The attached item still exists as a live CGItem in the player's
// inventory until the mail is actually sent, so the GUID resolves
// via `FUN_GET_OBJECT_BY_GUID(OBJECT_TYPE_ITEM, ...)`. That lets
// `GetSendMailItemLink` return the full per-instance link (enchant
// / random suffix preserved) by feeding the resolved CGItem to
// `Item::Link::FromCGItem`.
VAR_SEND_MAIL_ITEM_GUID_LO = 0x00B6EF90,
VAR_SEND_MAIL_ITEM_GUID_HI = 0x00B6EF94,
// Craft window (engineering, alchemy, etc. before the "trade
// skill" overhaul). Same slot-indirection shape as inbox: the
// address holds a pointer to the heap-allocated entry-pointer
+1 -1
View File
@@ -40,7 +40,7 @@
// Same pattern WeirdUtils.dll uses (we reverse-engineered it from
// `0x10015A74` while figuring this out). The addon load pass then
// reads each `.lua` file through the same hooked read path —
// `GlobalStrings.lua`, `Util\Print.lua`, etc. all surface from
// `Compat.lua`, `Util\Print.lua`, etc. all surface from
// embedded buffers.
#include "embedded_classicapi.h"
+11 -4
View File
@@ -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 →
+216
View File
@@ -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
+17
View File
@@ -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)
+30
View File
@@ -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
View File
@@ -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;
}
+248
View File
@@ -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
+153 -29
View File
@@ -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
+22 -33
View File
@@ -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;
}
+417
View File
@@ -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
View File
@@ -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));
+37
View File
@@ -16,13 +16,31 @@
#include "Game.h"
#include "Offsets.h"
#include "item/Location.h"
#include "item/QualityColor.h"
#include <cstdint>
#include <cstdio>
namespace Item::Link {
namespace {
using BuildItemLink_t = const char *(__fastcall *)(const void *cgItem);
// Cached ItemStats record lookup. Same `_GetRecord` pattern the rest of
// the codebase uses; passes a noop NULL callback so no SMSG fires for
// uncached items — caller treats "not cached" as a clean failure.
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int dedupFlag);
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
const char *FromCGItem(const uint8_t *cgItem) {
@@ -32,6 +50,25 @@ const char *FromCGItem(const uint8_t *cgItem) {
return fn(cgItem);
}
bool BasicFromItemID(uint32_t itemID, char *out, size_t outSize) {
if (out == nullptr || outSize == 0)
return false;
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;
}
namespace {
// `C_Item.GetItemLink(itemLocation)` — returns the fully-decorated
+12
View File
@@ -13,6 +13,7 @@
#pragma once
#include <cstddef>
#include <cstdint>
namespace Item::Link {
@@ -28,4 +29,15 @@ namespace Item::Link {
// produce for the same slot.
const char *FromCGItem(const uint8_t *cgItem);
// Builds a basic itemID-only hyperlink (`"|cffRRGGBB|Hitem:N:0:0:0|h[Name]|h|r"`)
// into `out` from the cached ItemStats record for `itemID`. No
// enchant / random-suffix decoration — use this when the caller has
// only an itemID and no live CGItem (mail inbox attachments, cursor
// drag-source items, vendor roster previews). Returns true on
// success, false if the item isn't cached or the buffer is too small.
//
// `outSize` should be at least 256 to comfortably fit the longest
// possible link (`item:` payload + colored prefix + bracketed name).
bool BasicFromItemID(uint32_t itemID, char *out, size_t outSize);
} // namespace Item::Link
+18
View File
@@ -254,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;
+9 -3
View File
@@ -82,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
+227
View File
@@ -0,0 +1,227 @@
// 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 "../Game.h"
#include "../Offsets.h"
#include "../guid/Guid.h"
#include <cstdint>
namespace Loot::Nearby {
namespace {
// Engine interact-range constants, read out of `.rdata` once and
// pinned here as `constexpr`. Both are referenced by `FUN_005EC110`
// (the master interact-state checker) and propagated through
// `FUN_005DF130` / `FUN_005DF2A0` (CMSG_LOOT senders). Yards units;
// values verified at runtime addresses `DAT_0080B058` and
// `DAT_0080A1E8`.
//
// INTERACT_REACH (1.333…) is added to `target_radius + player_radius`
// for the unclamped range; modeled as the "reach beyond your touch"
// constant.
// MIN_INTERACT_RANGE (5.0) clamps the result so even tiny
// targets with near-zero bounding radius still get the standard
// 5-yard right-click reach.
constexpr float INTERACT_REACH = 1.3333333f;
constexpr float MIN_INTERACT_RANGE = 5.0f;
// `ClntObjMgrEnumVisibleObjects(callback, context)` — walks the engine's
// visible-object table. The callback shape's `void *unusedEdx` slot
// absorbs the second `__fastcall` register so MSVC lays out the trailing
// `uint64_t guid` on the stack exactly where the engine puts it. The
// callback returns non-zero to keep iterating; zero stops the walk early.
using ClntObjMgrEnumVisibleObjectsCallback_t = int(__fastcall *)(void *ctx,
void *unusedEdx,
uint64_t guid);
using ClntObjMgrEnumVisibleObjects_t =
int(__fastcall *)(ClntObjMgrEnumVisibleObjectsCallback_t cb, void *ctx);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// `__thiscall(self, outBuf) → const C3Vector *` at vtable slot 5
// (byte offset `0x14`). Both CGUnit_C and CGPlayer_C populate the
// caller-provided 12-byte buffer with the unit's world position and
// return it. Same call shape `FUN_005EC110` uses to read the
// player / target positions when deciding interact range.
struct C3Vector {
float x, y, z;
};
using GetPosition_t = const C3Vector *(__thiscall *)(const void *self,
C3Vector *outBuf);
constexpr int VTBL_GET_POSITION_OFFSET = 0x14;
// `ClntObjMgrObjectPtr(typeMask, debugMessage, guid_lo, guid_hi, debugCode)`
// — resolves a 64-bit GUID to the loaded `CGObject_C *` it currently
// refers to, filtered by `typeMask`. Returns NULL when no live object
// matches the GUID *and* the typeMask. Splitting the GUID into lo/hi
// DWORDs on the stack is equivalent to the engine's
// `__fastcall(uint32_t, const char*, uint64_t, int)` signature once the
// `uint64_t` argument is placed in the same stack slots.
using ClntObjMgrObjectPtr_t = void *(__fastcall *)(uint32_t typeMask,
const char *debugMsg,
uint32_t guidLo,
uint32_t guidHi,
int debugCode);
// Reads `(unit.m_objectFields[+0x224] & UNIT_DYNFLAG_LOOTABLE) != 0`
// — the server-driven bit it sets when the local player has loot
// rights on this unit. Works uniformly for live and dead CGUnit
// subclasses; the bit is simply 0 for non-lootable targets.
//
// Don't call `FUN_CGCORPSE_IS_LOOTABLE` here even though the engine
// itself uses it in `FUN_005D6BF0`: that helper reads `+0x78` of
// m_objectFields, which is the CORPSE dynamic-flags slot for
// CGCorpse_C but the BYTES_0 (race / class / gender / power type)
// slot for CGUnit_C. Routing live units through it returns the low
// bit of *race* — yields false positives for any Human / Dwarf /
// Undead / Gnome player and false negatives for dead mobs.
bool IsLootable(const void *unit) {
auto *fields = *reinterpret_cast<const uint8_t *const *>(
static_cast<const uint8_t *>(unit) + Offsets::OFF_UNIT_DESCRIPTOR);
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
fields + Offsets::OFF_UNIT_FIELD_DYNAMIC_FLAGS);
return (flags & Offsets::UNIT_DYNFLAG_LOOTABLE) != 0;
}
// Reads the bounding-radius float from a unit's m_objectFields. The
// engine uses this as the contribution from both sides (player + target)
// in its interact-range computation.
float BoundingRadius(const void *unit) {
auto *fields = *reinterpret_cast<const uint8_t *const *>(
static_cast<const uint8_t *>(unit) + Offsets::OFF_UNIT_DESCRIPTOR);
return *reinterpret_cast<const float *>(
fields + Offsets::OFF_UNIT_FIELD_BOUNDING_RADIUS);
}
// Invokes the unit's virtual `GetPosition` (vtable slot at offset
// `0x14`). The engine's signature is `__thiscall(this, outBuf) →
// outBuf`; we pass our own 12-byte stack buffer and read x/y/z out
// of the returned pointer.
const C3Vector *GetPosition(const void *unit, C3Vector *outBuf) {
auto vtable = *reinterpret_cast<void *const *const *>(unit);
auto fn = reinterpret_cast<GetPosition_t>(
*reinterpret_cast<const void *const *>(
reinterpret_cast<const uint8_t *>(vtable) +
VTBL_GET_POSITION_OFFSET));
return fn(unit, outBuf);
}
// Mirrors the range portion of `FUN_005EC110`. We skip the engine's
// player-state checks (mounted / casting / prior-interaction GUID
// gate) because they fire intermittently during normal play and would
// make the enumerator return an empty list whenever the player has
// already clicked something — surprising for a "what's lootable
// nearby" reader. The pure distance test is what controls cursor
// highlight, which is the contract the user asked for.
bool InInteractRange(const void *player, const void *target) {
C3Vector pBuf{};
C3Vector tBuf{};
const C3Vector *pPos = GetPosition(player, &pBuf);
const C3Vector *tPos = GetPosition(target, &tBuf);
if (pPos == nullptr || tPos == nullptr)
return false;
const float dx = pPos->x - tPos->x;
const float dy = pPos->y - tPos->y;
const float dz = pPos->z - tPos->z;
const float distSq = dx * dx + dy * dy + dz * dz;
float range = BoundingRadius(player) + BoundingRadius(target) + INTERACT_REACH;
if (range < MIN_INTERACT_RANGE)
range = MIN_INTERACT_RANGE;
return distSq <= range * range;
}
// Only `TYPEMASK_UNIT` carries lootable-mob state in 1.12 — dead
// creatures stay as CGCreature_C objects with the lootable bit flipped
// on, rather than transitioning to a separate corpse object. Dead
// players become CGCorpse_C (`TYPEMASK_CORPSE`) but those aren't
// lootable by other players in vanilla — no reason to walk them here.
struct ScanCtx {
void *L;
const void *player; // resolved once outside the iterator
int idx; // 1-based slot of the next entry to append
};
int __fastcall ScanCallback(ScanCtx *ctx, void * /*unusedEdx*/, uint64_t guid) {
auto ObjectPtr = reinterpret_cast<ClntObjMgrObjectPtr_t>(
Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
void *obj = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0);
if (obj == nullptr)
return 1; // GUID isn't a unit; continue
if (!IsLootable(obj))
return 1; // unit isn't lootable to us; continue
if (!InInteractRange(ctx->player, obj))
return 1; // out of click-loot range; continue
char guidBuf[Guid::STRING_SIZE];
Game::Lua::PushNumber(ctx->L, static_cast<double>(ctx->idx));
Game::Lua::NewTable(ctx->L);
Game::Lua::SetFieldString(ctx->L, "guid",
Guid::FormatAsString(guid, guidBuf, sizeof guidBuf));
Game::Lua::SetTable(ctx->L, -3);
++ctx->idx;
return 1;
}
// `C_Loot.GetNearbyLootableUnits()` — returns a 1-indexed array of
// `{ guid = "0x..." }` subtables, one per visible unit that is (a)
// flagged DYNFLAG_LOOTABLE by the server and (b) inside the engine's
// own interact range (the distance threshold that lights up the
// right-click-loot cursor). Empty table pre-login, when no lootable
// units are in range, or when we don't have loot rights on visible
// corpses (the server only sets the bit for clients with rights).
int __fastcall Script_GetNearbyLootableUnits(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
// The engine derefs the object manager unconditionally on iterator
// entry. NULL during glue / character-select; bail with an empty
// table rather than crashing.
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) == nullptr)
return 1;
// Resolve the local player once — the canonical CGPlayer_C the
// engine's own interact-range checks use, distinct from the
// `VAR_LOCAL_PLAYER_PTR` global. Bail if unavailable.
auto ResolveUnitToken = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
const void *player = ResolveUnitToken("player");
if (player == nullptr)
return 1;
auto Enum = reinterpret_cast<ClntObjMgrEnumVisibleObjects_t>(
Offsets::FUN_CLNT_OBJ_MGR_ENUM_VISIBLE_OBJECTS);
ScanCtx ctx{L, player, 1};
Enum(reinterpret_cast<ClntObjMgrEnumVisibleObjectsCallback_t>(&ScanCallback),
&ctx);
return 1;
}
void Register() {
Game::Lua::RegisterTableFunction("C_Loot", "GetNearbyLootableUnits",
&Script_GetNearbyLootableUnits);
}
} // namespace
static const Game::ModuleAutoRegister _autoreg{&Register};
} // namespace Loot::Nearby
+479
View File
@@ -0,0 +1,479 @@
// 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.
//
// Hook-driven loot scanner. Walks the nearby-lootable-units set, opens
// each corpse's loot session via `FUN_CMSG_LOOT_UNIT`, then *intercepts
// the engine's loot-window response* before any visual updates happen.
// The intercept (`FUN_LOOT_CONTROLLER`) lets the original function run
// so `VAR_LOOT_SLOTS` populates and the link builder works — but a
// paired hook on `FUN_FIRE_EVENT_NO_ARGS` swallows the `LOOT_OPENED` /
// `LOOT_CLOSED` event fires while the scan is in progress. Net result:
// the engine's state machine cycles silently for each corpse and
// `LootFrame` never reacts. Other Lua addons listening to those events
// also see nothing during the scan; that's deliberate.
//
// Compared to the older polling-driven version this also collapses the
// state machine — we no longer need `WaitingOpen` / `WaitingClose` /
// per-tick `VAR_LOOT_GUID` reads, because the controller hook *is* the
// state transition.
#include "../Game.h"
#include "../Offsets.h"
#include "../event/Custom.h"
#include "../guid/Guid.h"
#include "../tick/WorldTick.h"
#include <cstdint>
#include <string>
#include <vector>
namespace Loot::Scan {
namespace {
constexpr const char *kEventCompleted = "LOOT_SCAN_COMPLETED";
const Event::Custom::AutoReserve _reserve{kEventCompleted};
// Per-step timeout for the controller hook to fire on a queued
// CMSG_LOOT. WorldTick fires at the client frame rate (~30-60 Hz),
// so 180 ticks ≈ 3-6s wall clock. Long enough for high-latency
// servers, short enough that a single dropped response doesn't hang
// the whole scan.
constexpr int kStepTimeoutTicks = 180;
// Event IDs the engine fires through `FUN_FIRE_EVENT_NO_ARGS` for
// loot-window transitions. The hook swallows these only when the
// scanner has the suppression flag raised — normal play sees them
// fire unmodified.
constexpr int EVENT_LOOT_OPENED = 0x10B;
constexpr int EVENT_LOOT_CLOSED = 0x10D;
enum class State {
Idle,
WaitingForResponse, // CMSG_LOOT sent; awaiting our controller hook
};
struct SlotItem {
uint32_t itemID;
uint32_t count;
std::string link; // empty if the cache record wasn't loaded
};
struct LootEntry {
uint64_t guid;
uint32_t coin; // copper amount; 0 if no coin
std::vector<SlotItem> items;
};
struct ScanCtx {
State state = State::Idle;
void *player = nullptr;
uint64_t currentGuid = 0;
int ticksSinceTransition = 0;
bool suppressEvents = false; // read by the FireEvent hook
std::vector<uint64_t> queue;
std::vector<LootEntry> results;
};
ScanCtx s_scan;
// === Typedefs for engine helpers ===
using ClntObjMgrEnumVisibleObjectsCallback_t = int(__fastcall *)(void *ctx,
void *unusedEdx,
uint64_t guid);
using ClntObjMgrEnumVisibleObjects_t =
int(__fastcall *)(ClntObjMgrEnumVisibleObjectsCallback_t cb, void *ctx);
using ClntObjMgrObjectPtr_t = void *(__fastcall *)(uint32_t typeMask,
const char *debugMsg,
uint32_t guidLo,
uint32_t guidHi,
int debugCode);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using LootUnit_t = void(__thiscall *)(void *player, void *target,
char useDistanceCheck);
using CloseLootInner_t = void(__fastcall *)(int sendRelease,
char earlyReturnOnGameObject,
char showError);
using LootSlotLinkBuilder_t = const char *(__fastcall *)(uint32_t userFacingSlot,
char *outBuf,
int bufSize);
// `FUN_LOOT_CONTROLLER` is `__fastcall(int, undefined4, int)` — three
// args: lootStructPtr in ECX, coin in EDX, an extra unk on stack[0].
// Mis-declaring as 4-arg __fastcall would push one phantom stack slot
// the engine doesn't pop, drifting ESP by 4 bytes per call until a
// later return reads `0x00000001` as a corrupt frame pointer and the
// process crashes. We do see / forward the `coin` value because the
// engine reads it as `DAT_00b71ba0` later.
using LootController_t = void(__fastcall *)(int lootStructPtr,
int coin,
int unk);
using FireEventNoArgs_t = void(__fastcall *)(int eventID,
int /*edx unused*/);
struct C3Vector {
float x, y, z;
};
using GetPosition_t = const C3Vector *(__thiscall *)(const void *self,
C3Vector *outBuf);
constexpr int VTBL_GET_POSITION_OFFSET = 0x14;
constexpr float INTERACT_REACH = 1.3333333f;
constexpr float MIN_INTERACT_RANGE = 5.0f;
// === Range / lootability helpers (identical to Loot::Nearby) ===
bool IsLootableUnit(const void *unit) {
auto *fields = *reinterpret_cast<const uint8_t *const *>(
static_cast<const uint8_t *>(unit) + Offsets::OFF_UNIT_DESCRIPTOR);
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
fields + Offsets::OFF_UNIT_FIELD_DYNAMIC_FLAGS);
return (flags & Offsets::UNIT_DYNFLAG_LOOTABLE) != 0;
}
float BoundingRadius(const void *unit) {
auto *fields = *reinterpret_cast<const uint8_t *const *>(
static_cast<const uint8_t *>(unit) + Offsets::OFF_UNIT_DESCRIPTOR);
return *reinterpret_cast<const float *>(
fields + Offsets::OFF_UNIT_FIELD_BOUNDING_RADIUS);
}
const C3Vector *GetPosition(const void *unit, C3Vector *outBuf) {
auto vtable = *reinterpret_cast<void *const *const *>(unit);
auto fn = reinterpret_cast<GetPosition_t>(
*reinterpret_cast<const void *const *>(
reinterpret_cast<const uint8_t *>(vtable) +
VTBL_GET_POSITION_OFFSET));
return fn(unit, outBuf);
}
bool InInteractRange(const void *player, const void *target) {
C3Vector pBuf{};
C3Vector tBuf{};
const C3Vector *pPos = GetPosition(player, &pBuf);
const C3Vector *tPos = GetPosition(target, &tBuf);
if (pPos == nullptr || tPos == nullptr)
return false;
const float dx = pPos->x - tPos->x;
const float dy = pPos->y - tPos->y;
const float dz = pPos->z - tPos->z;
const float distSq = dx * dx + dy * dy + dz * dz;
float range = BoundingRadius(player) + BoundingRadius(target) + INTERACT_REACH;
if (range < MIN_INTERACT_RANGE)
range = MIN_INTERACT_RANGE;
return distSq <= range * range;
}
// === Scrape ===
// Reads the post-population `VAR_LOOT_SLOTS` table into a LootEntry.
// The controller hook calls this *after* letting the original
// `FUN_LOOT_CONTROLLER` run, so the slot table is fully filled in
// and the link builder works against it.
void ScrapeCurrentLoot(LootEntry *out) {
const uint32_t coinRaw = *reinterpret_cast<const uint32_t *>(
Offsets::VAR_LOOT_LOOTABLE);
out->coin = (coinRaw == 0xFFFFFFFFu) ? 0u : coinRaw;
const bool hasCoin = coinRaw != 0;
auto LinkBuilder = reinterpret_cast<LootSlotLinkBuilder_t>(
Offsets::FUN_LOOT_SLOT_LINK_BUILDER);
for (int slot = 0; slot < Offsets::LOOT_MAX_SLOTS; ++slot) {
auto *entry = reinterpret_cast<const uint8_t *>(
Offsets::VAR_LOOT_SLOTS + slot * Offsets::LOOT_SLOT_STRIDE);
const uint32_t itemID = *reinterpret_cast<const uint32_t *>(entry);
if (itemID == 0)
continue;
const uint32_t count = *reinterpret_cast<const uint32_t *>(entry + 8);
SlotItem item;
item.itemID = itemID;
item.count = count;
char linkBuf[0x400];
const uint32_t slotArg = hasCoin ? static_cast<uint32_t>(slot + 1)
: static_cast<uint32_t>(slot);
const char *link = LinkBuilder(slotArg, linkBuf, sizeof linkBuf);
if (link != nullptr)
item.link = link;
out->items.push_back(std::move(item));
}
}
// === State-machine transitions ===
void StartLoot(uint64_t guid);
void TryStartNext();
void SendCloseLoot() {
auto Close = reinterpret_cast<CloseLootInner_t>(Offsets::FUN_CLOSE_LOOT_INNER);
Close(1, '\0', '\0');
}
void Complete() {
s_scan.state = State::Idle;
s_scan.currentGuid = 0;
s_scan.suppressEvents = false;
Event::Custom::Fire(Event::Custom::Lookup(kEventCompleted), "");
}
void TryStartNext() {
if (s_scan.queue.empty()) {
Complete();
return;
}
uint64_t guid = s_scan.queue.back();
s_scan.queue.pop_back();
StartLoot(guid);
}
void StartLoot(uint64_t guid) {
auto ObjectPtr = reinterpret_cast<ClntObjMgrObjectPtr_t>(
Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
void *target = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0);
if (target == nullptr) {
TryStartNext();
return;
}
auto LootUnit = reinterpret_cast<LootUnit_t>(Offsets::FUN_CMSG_LOOT_UNIT);
LootUnit(s_scan.player, target, /*useDistanceCheck=*/0);
s_scan.currentGuid = guid;
s_scan.state = State::WaitingForResponse;
s_scan.ticksSinceTransition = 0;
}
// === Hooks ===
LootController_t s_lootController_o = nullptr;
FireEventNoArgs_t s_fireEventNoArgs_o = nullptr;
// `LOOT_OPENED` / `LOOT_CLOSED` get suppressed only while a scan is
// actively interleaving open/close cycles, signaled by
// `s_scan.suppressEvents`. The flag is raised by the controller hook
// just around the original-loot-controller call and the
// `SendCloseLoot` that follows, then dropped before
// `TryStartNext`-initiated CMSG_LOOTs go out. Every other engine
// event fire — and normal play's loot events — pass through.
void __fastcall FireEventNoArgs_h(int eventID, int /*unusedEdx*/) {
if (s_scan.suppressEvents &&
(eventID == EVENT_LOOT_OPENED || eventID == EVENT_LOOT_CLOSED)) {
return;
}
s_fireEventNoArgs_o(eventID, 0);
}
// Intercepts the engine's loot-window state controller. Two paths:
//
// 1. **Scan-driven open** — the incoming `lootStructPtr` carries a
// GUID matching our expected one. Let the original run (so the
// slot table populates), then scrape, send a release (which
// will recursively re-enter via `FUN_0048F200` → close-path
// `FUN_LOOT_CONTROLLER(NULL, …)`), and advance the scan. Both
// the inner open and the recursive close fire their respective
// events through our `FireEventNoArgs_h`, which swallows them
// while `suppressEvents` is on.
//
// 2. **Anything else** — pass through unmodified. Normal loot
// interactions outside a scan are unaffected; the inner-close
// call generated by our scan's `SendCloseLoot` re-enters here
// with `arg1 == 0` and is also a passthrough (its
// suppression-flag check happens at the FireEvent hook layer).
void __fastcall LootController_h(int lootStructPtr, int coin, int unk) {
const bool scanOpenCall =
s_scan.state == State::WaitingForResponse &&
lootStructPtr != 0 &&
[&] {
// Read the GUID off the response struct (`+8` deref to a
// `{uint32 lo; uint32 hi}` block — same shape the original
// copies into `VAR_LOOT_GUID_LO/HI`).
auto *guidPtr = *reinterpret_cast<const uint32_t *const *>(
reinterpret_cast<const uint8_t *>(
static_cast<intptr_t>(lootStructPtr)) + 8);
if (guidPtr == nullptr) return false;
const uint64_t guid =
static_cast<uint64_t>(guidPtr[0]) |
(static_cast<uint64_t>(guidPtr[1]) << 32);
return guid == s_scan.currentGuid;
}();
if (scanOpenCall) {
s_scan.suppressEvents = true;
s_lootController_o(lootStructPtr, coin, unk);
LootEntry entry;
entry.guid = s_scan.currentGuid;
ScrapeCurrentLoot(&entry);
s_scan.results.push_back(std::move(entry));
// Sends `CMSG_LOOT_RELEASE` and recursively calls this hook
// with `arg1 == 0`. That recursive call falls into the
// passthrough branch but still sees `suppressEvents == true`
// at the FireEvent layer, so the `LOOT_CLOSED` fire inside
// gets swallowed.
SendCloseLoot();
s_scan.suppressEvents = false;
TryStartNext();
return;
}
s_lootController_o(lootStructPtr, coin, unk);
}
const Game::HookAutoRegister _hookController{
Offsets::FUN_LOOT_CONTROLLER,
reinterpret_cast<void *>(&LootController_h),
reinterpret_cast<void **>(&s_lootController_o)};
const Game::HookAutoRegister _hookFireEvent{
Offsets::FUN_FIRE_EVENT_NO_ARGS,
reinterpret_cast<void *>(&FireEventNoArgs_h),
reinterpret_cast<void **>(&s_fireEventNoArgs_o)};
// === Tick (timeout only) ===
void OnTick() {
if (s_scan.state == State::Idle)
return;
++s_scan.ticksSinceTransition;
if (s_scan.ticksSinceTransition >= kStepTimeoutTicks) {
// Controller hook never fired — engine guards rejected the
// send (player state / OOR), or the server dropped the
// packet. Skip this corpse and advance.
s_scan.suppressEvents = false;
TryStartNext();
}
}
const Tick::WorldTick::AutoSubscribe _tick{&OnTick};
// === Enumeration ===
struct EnumCtx {
const void *player;
std::vector<uint64_t> *out;
};
int __fastcall EnumCallback(EnumCtx *ctx, void * /*unusedEdx*/, uint64_t guid) {
auto ObjectPtr = reinterpret_cast<ClntObjMgrObjectPtr_t>(
Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
void *obj = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0);
if (obj == nullptr)
return 1;
if (!IsLootableUnit(obj))
return 1;
if (!InInteractRange(ctx->player, obj))
return 1;
ctx->out->push_back(guid);
return 1;
}
void BuildQueue(void *player) {
s_scan.queue.clear();
auto Enum = reinterpret_cast<ClntObjMgrEnumVisibleObjects_t>(
Offsets::FUN_CLNT_OBJ_MGR_ENUM_VISIBLE_OBJECTS);
EnumCtx ctx{player, &s_scan.queue};
Enum(reinterpret_cast<ClntObjMgrEnumVisibleObjectsCallback_t>(&EnumCallback),
&ctx);
}
// === Lua bindings ===
int __fastcall Script_ScanNearbyLoot(void *L) {
if (s_scan.state != State::Idle) {
Game::Lua::PushBool(L, false);
return 1;
}
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
auto Resolve = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
void *player = Resolve("player");
if (player == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
s_scan.results.clear();
s_scan.player = player;
BuildQueue(player);
if (s_scan.queue.empty()) {
Complete();
Game::Lua::PushBool(L, true);
return 1;
}
TryStartNext();
Game::Lua::PushBool(L, true);
return 1;
}
int __fastcall Script_IsScanInProgress(void *L) {
Game::Lua::PushBool(L, s_scan.state != State::Idle);
return 1;
}
int __fastcall Script_GetLastScanResults(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
int outIdx = 1;
for (const auto &entry : s_scan.results) {
Game::Lua::PushNumber(L, static_cast<double>(outIdx++));
Game::Lua::NewTable(L);
char guidBuf[Guid::STRING_SIZE];
Game::Lua::SetFieldString(L, "guid",
Guid::FormatAsString(entry.guid, guidBuf,
sizeof guidBuf));
Game::Lua::SetFieldNumber(L, "coin", static_cast<double>(entry.coin));
Game::Lua::PushString(L, "items");
Game::Lua::NewTable(L);
int itemIdx = 1;
for (const auto &item : entry.items) {
Game::Lua::PushNumber(L, static_cast<double>(itemIdx++));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "itemID", static_cast<double>(item.itemID));
Game::Lua::SetFieldNumber(L, "count", static_cast<double>(item.count));
if (!item.link.empty())
Game::Lua::SetFieldString(L, "link", item.link.c_str());
Game::Lua::SetTable(L, -3);
}
Game::Lua::SetTable(L, -3);
Game::Lua::SetTable(L, -3);
}
return 1;
}
void Register() {
Game::Lua::RegisterTableFunction("C_Loot", "ScanNearbyLoot",
&Script_ScanNearbyLoot);
Game::Lua::RegisterTableFunction("C_Loot", "IsScanInProgress",
&Script_IsScanInProgress);
Game::Lua::RegisterTableFunction("C_Loot", "GetLastScanResults",
&Script_GetLastScanResults);
}
} // namespace
static const Game::ModuleAutoRegister _autoreg{&Register};
} // namespace Loot::Scan
+104
View File
@@ -0,0 +1,104 @@
// 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 "../Game.h"
#include "../Offsets.h"
#include "../guid/Guid.h"
#include <cstdint>
namespace Loot::Unit {
namespace {
using ClntObjMgrObjectPtr_t = void *(__fastcall *)(uint32_t typeMask,
const char *debugMsg,
uint32_t guidLo,
uint32_t guidHi,
int debugCode);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// `FUN_CMSG_LOOT_UNIT` is `__thiscall(player, target, useDistanceCheck)`.
// MSVC `__thiscall` puts `player` in ECX and the rest on the stack —
// matching the engine's own callsite at `FUN_0060FA20`. The trailing
// `char` argument is padded to 4 stack bytes by the ABI.
using LootUnit_t = void(__thiscall *)(void *player, void *target,
char useDistanceCheck);
// `C_Loot.LootUnit(guid)` — initiates a loot session against the unit
// identified by `guid` (a hex-string as returned by `UnitGUID` /
// `C_Loot.GetNearbyLootableUnits`). The engine handles release-prior-
// loot, packet construction, and dispatch; the loot window appears
// asynchronously via the regular `LOOT_OPENED` event once the server's
// `SMSG_LOOT_RESPONSE` lands.
//
// `useDistanceCheck` is hardcoded to `0` so the engine doesn't auto-
// walk the player toward a corpse that happens to be just out of
// range — which would be a surprise side-effect for an AoE-loot
// caller looping over `GetNearbyLootableUnits`. The server still
// enforces real range; OOR requests just don't get a response.
//
// Returns nothing. Silent no-op when:
// - The object manager is NULL (glue / character-select).
// - The GUID doesn't resolve to a visible `TYPEMASK_UNIT` object
// (target despawned, out of render range, wrong typemask).
// - The local player pointer can't be resolved.
// Errors only on Lua usage problems (missing arg, unparseable GUID).
int __fastcall Script_LootUnit(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: C_Loot.LootUnit(guid)");
return 0;
}
const char *guidStr = Game::Lua::ToString(L, 1);
uint64_t guid = 0;
if (!Guid::Parse(guidStr, &guid) || guid == 0) {
Game::Lua::Error(L, "Usage: C_Loot.LootUnit(guid) — unparseable GUID");
return 0;
}
// Object manager NULL pre-login; bail silently rather than
// crashing inside the resolver.
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) == nullptr)
return 0;
auto ObjectPtr = reinterpret_cast<ClntObjMgrObjectPtr_t>(
Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
void *target = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0);
if (target == nullptr)
return 0;
// `ResolveUnitToken("player")` gives the canonical CGPlayer_C the
// engine uses for `__thiscall` calls — distinct from the global
// at `VAR_LOCAL_PLAYER_PTR` (see the comment on that offset).
auto ResolveUnitToken = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
void *player = ResolveUnitToken("player");
if (player == nullptr)
return 0;
auto LootUnit = reinterpret_cast<LootUnit_t>(Offsets::FUN_CMSG_LOOT_UNIT);
LootUnit(player, target, /*useDistanceCheck=*/0);
return 0;
}
void Register() {
Game::Lua::RegisterTableFunction("C_Loot", "LootUnit", &Script_LootUnit);
}
} // namespace
static const Game::ModuleAutoRegister _autoreg{&Register};
} // namespace Loot::Unit
+209
View File
@@ -0,0 +1,209 @@
// 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.
//
// Targeted single-item looting. Given a corpse GUID and a desired
// itemID, opens that corpse's loot window via the engine's standard
// `FUN_005DF2A0` path, finds the slot containing that itemID once the
// window populates, and dispatches `CMSG_LOOT_ITEM` directly through
// `FUN_005E1AD0`. The Lua-side `LootSlot` wrapper isn't used — see
// the `FUN_CMSG_LOOT_ITEM` comment in Offsets.h for why.
//
// Fast path when the window is already open for the requested target:
// skip the open round-trip and dispatch immediately. This makes
// "take items A, B, C from corpse X" a series of cheap calls — the
// first opens the window, the rest land in fast-path.
#include "../Game.h"
#include "../Offsets.h"
#include "../guid/Guid.h"
#include "../tick/WorldTick.h"
#include <cstdint>
namespace Loot::UnitItem {
namespace {
constexpr int kStepTimeoutTicks = 180;
enum class State {
Idle,
WaitingOpen,
};
using ClntObjMgrObjectPtr_t = void *(__fastcall *)(uint32_t typeMask,
const char *debugMsg,
uint32_t guidLo,
uint32_t guidHi,
int debugCode);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using LootUnit_t = void(__thiscall *)(void *player, void *target,
char useDistanceCheck);
// `FUN_005E1AD0` is `__stdcall(uint8_t)`, not `__fastcall` — the
// callee reads its arg at `[ebp+8]` (stack) and ends with `ret 4`.
// Mis-declaring as `__fastcall` makes MSVC skip the push but the
// callee still `ret 4`'s, drifting the caller's ESP by 4 bytes per
// call and corrupting the surrounding frame. The first symptom is a
// crash several frames later on an unrelated vtable lookup —
// exactly the EIP=0x00000A70-style faults we saw before this fix.
using SendLootItem_t = void(__stdcall *)(uint8_t slotByte);
State s_state = State::Idle;
uint64_t s_targetGuid = 0;
uint32_t s_desiredItemID = 0;
int s_ticksSinceTransition = 0;
uint64_t ReadLootTargetGuid() {
const uint32_t lo = *reinterpret_cast<const uint32_t *>(Offsets::VAR_LOOT_GUID_LO);
const uint32_t hi = *reinterpret_cast<const uint32_t *>(Offsets::VAR_LOOT_GUID_HI);
return static_cast<uint64_t>(hi) << 32 | lo;
}
// Walks the currently-open loot window's slots looking for `itemID`.
// Returns the wire-protocol slot byte the server expects in
// `CMSG_LOOT_ITEM`, or `-1` if no slot matches. First match wins; if
// the same itemID appears twice, only the first is reachable through
// this function (caller can issue a second call after the first
// completes).
int FindWireSlotForItemID(uint32_t itemID) {
for (int slot = 0; slot < Offsets::LOOT_MAX_SLOTS; ++slot) {
auto *entry = reinterpret_cast<const uint8_t *>(
Offsets::VAR_LOOT_SLOTS + slot * Offsets::LOOT_SLOT_STRIDE);
const uint32_t id = *reinterpret_cast<const uint32_t *>(entry);
if (id == 0)
continue;
if (id == itemID)
return static_cast<int>(*(entry + Offsets::OFF_LOOT_SLOT_WIRE_INDEX));
}
return -1;
}
bool TakeItem(uint32_t itemID) {
const int slotByte = FindWireSlotForItemID(itemID);
if (slotByte < 0)
return false;
auto Send = reinterpret_cast<SendLootItem_t>(Offsets::FUN_CMSG_LOOT_ITEM);
Send(static_cast<uint8_t>(slotByte));
return true;
}
void Reset() {
s_state = State::Idle;
s_targetGuid = 0;
s_desiredItemID = 0;
}
void OnTick() {
if (s_state == State::Idle)
return;
++s_ticksSinceTransition;
if (s_state == State::WaitingOpen) {
if (ReadLootTargetGuid() == s_targetGuid) {
TakeItem(s_desiredItemID);
Reset();
return;
}
if (s_ticksSinceTransition >= kStepTimeoutTicks)
Reset();
}
}
const Tick::WorldTick::AutoSubscribe _tick{&OnTick};
// `C_Loot.LootUnitItem(guid, itemID)` — takes a specific item from a
// specific corpse. If the corpse's loot window is already open, the
// item is dispatched synchronously and the function returns `true` if
// the slot was found, `false` if not. Otherwise the corpse's window is
// opened asynchronously, and once it populates the item is sent
// automatically. The async path returns `true` once the request is
// queued; there's no "did it succeed" event — observe the player's
// inventory or `LOOT_OPENED`/`ITEM_PUSH` events to confirm.
//
// Returns `false` immediately if:
// - args are missing or invalid types,
// - another `LootUnitItem` is mid-flight,
// - the world isn't loaded,
// - the target GUID doesn't resolve to a visible unit,
// - (sync path only) no slot in the currently-open window holds
// the requested itemID.
//
// The CMSG_LOOT_ITEM send bypasses the BoP-confirmation dialog the
// vanilla UI normally shows. Server still enforces all real
// permissions (loot rights, distance, master-loot rules); the
// client-side BoP-prompt is purely a courtesy that callers of this
// programmatic API have already opted out of by virtue of asking.
int __fastcall Script_LootUnitItem(void *L) {
if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: C_Loot.LootUnitItem(guid, itemID)");
return 0;
}
uint64_t guid = 0;
if (!Guid::Parse(Game::Lua::ToString(L, 1), &guid) || guid == 0) {
Game::Lua::Error(L, "Usage: C_Loot.LootUnitItem(guid, itemID) — invalid guid");
return 0;
}
const uint32_t itemID = static_cast<uint32_t>(Game::Lua::ToNumber(L, 2));
if (s_state != State::Idle) {
Game::Lua::PushBool(L, false);
return 1;
}
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
// Fast path: window already open for the requested target — dispatch
// CMSG_LOOT_ITEM synchronously. No waiting on `WorldTick`, no state
// transition needed.
if (ReadLootTargetGuid() == guid) {
Game::Lua::PushBool(L, TakeItem(itemID));
return 1;
}
auto Resolve = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
void *player = Resolve("player");
if (player == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
auto ObjectPtr = reinterpret_cast<ClntObjMgrObjectPtr_t>(
Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
void *target = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0);
if (target == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
auto LootUnit = reinterpret_cast<LootUnit_t>(Offsets::FUN_CMSG_LOOT_UNIT);
LootUnit(player, target, /*useDistanceCheck=*/0);
s_targetGuid = guid;
s_desiredItemID = itemID;
s_state = State::WaitingOpen;
s_ticksSinceTransition = 0;
Game::Lua::PushBool(L, true);
return 1;
}
void Register() {
Game::Lua::RegisterTableFunction("C_Loot", "LootUnitItem",
&Script_LootUnitItem);
}
} // namespace
static const Game::ModuleAutoRegister _autoreg{&Register};
} // namespace Loot::UnitItem
+101
View File
@@ -0,0 +1,101 @@
// 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/>.
// `GetInboxItemLink(messageIndex[, attachmentIndex])` — returns
// `(link, itemID)` for the item attached to inbox message
// `messageIndex` (1-based). `link` is a basic itemID-only hyperlink
// (see note below). Vanilla supports one attachment per message; the
// optional 1-based `attachmentIndex` exists for modern-signature
// parity and is no-op for any value other than 1.
//
// `itemID` as a second return is a ClassicAPI extension on top of
// modern's link-only signature — saves callers a `string.match` to
// parse it back out of the link.
//
// **Basic link only** (`|cff...|Hitem:N:0:0:0|h[Name]|h|r`). Vanilla
// inbox entries DO store per-instance modifiers inline at `+0x12C..`
// (enchant, suffix factor, random property — same fields
// `Script_GameTooltip_SetInboxItem` copies onto the tooltip), but
// 3.3.5's `GetInboxItemLink` ignores them and returns the itemID-only
// link too (`FUN_0061E290(itemID)`). We match modern behavior — per-
// instance data only fully manifests when the player takes the item
// and the engine spawns a real CGItem; until then the link is
// definitionally itemID-bound.
//
// Returns nothing on out-of-range messageIndex, empty message slot,
// no attachment on the message, or attachmentIndex ≠ 1.
#include "Game.h"
#include "Offsets.h"
#include "item/Link.h"
#include <cstdint>
namespace Mail::InboxItemLink {
namespace {
int __fastcall Script_GetInboxItemLink(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L,
"Usage: GetInboxItemLink(messageIndex[, attachmentIndex])");
return 0;
}
const int oneBasedMsg = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (oneBasedMsg < 1)
return 0;
if (Game::Lua::IsNumber(L, 2)) {
const int attach = static_cast<int>(Game::Lua::ToNumber(L, 2));
if (attach != 1)
return 0;
}
const int msgIdx = oneBasedMsg - 1;
const int count = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_INBOX_COUNT));
if (msgIdx >= count)
return 0;
// `VAR_INBOX_ENTRIES` is a pointer slot, not the array itself —
// one extra deref to reach the entry-pointer array.
auto **entries = *reinterpret_cast<const uint8_t ***>(
Offsets::VAR_INBOX_ENTRIES);
if (entries == nullptr)
return 0;
const uint8_t *entry = entries[msgIdx];
if (entry == nullptr)
return 0;
const uint32_t itemID = *reinterpret_cast<const uint32_t *>(
entry + Offsets::OFF_INBOX_ENTRY_ITEM_ID);
if (itemID == 0)
return 0;
char buf[256];
if (!Item::Link::BasicFromItemID(itemID, buf, sizeof buf))
return 0;
Game::Lua::PushString(L, buf);
Game::Lua::PushNumber(L, static_cast<double>(itemID));
return 2;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetInboxItemLink",
&Script_GetInboxItemLink);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Mail::InboxItemLink
+90
View File
@@ -0,0 +1,90 @@
// 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/>.
// `GetSendMailItemLink([attachmentIndex])` — returns
// `(link, itemID)` for the item currently attached to the outgoing-
// mail slot. `link` is the fully-decorated per-instance hyperlink
// (enchant / random suffix preserved). Modern WoW expanded mail to up
// to 12 attachments and added the index arg; vanilla 1.12 allows
// exactly one, so we accept the arg for signature parity but treat
// only index 1 as meaningful (any other index returns nothing).
//
// `itemID` as a second return is a ClassicAPI extension on top of
// modern's link-only signature — saves callers a `string.match` to
// parse it back out of the link.
//
// The attached item still exists as a live CGItem in the player's
// inventory until the mail is actually sent — the engine stores its
// GUID at `VAR_SEND_MAIL_ITEM_GUID_LO/HI`. We resolve the GUID via
// `FUN_GET_OBJECT_BY_GUID(OBJECT_TYPE_ITEM, ...)` and feed the
// resulting CGItem to the standard `Item::Link::FromCGItem` builder,
// so enchant / random-suffix / unique-ID decoration matches what
// `GetContainerItemLink` would return for the same item back in
// the player's bag.
#include "Game.h"
#include "Offsets.h"
#include "item/ID.h"
#include "item/Link.h"
#include <cstdint>
namespace Mail::SendMailItemLink {
namespace {
using GetObjectByGuid_t = void *(__fastcall *)(uint32_t typeMask, const char *debugMsg,
uint32_t guidLo, uint32_t guidHi,
int debugCode);
int __fastcall Script_GetSendMailItemLink(void *L) {
// Optional 1-based attachment index. Anything other than 1 is a
// no-op (vanilla is single-slot). Missing arg = treat as index 1.
if (Game::Lua::IsNumber(L, 1)) {
const int idx = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (idx != 1)
return 0;
}
const uint32_t guidLo = *reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_SEND_MAIL_ITEM_GUID_LO));
const uint32_t guidHi = *reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_SEND_MAIL_ITEM_GUID_HI));
if (guidLo == 0 && guidHi == 0)
return 0;
auto fn = reinterpret_cast<GetObjectByGuid_t>(Offsets::FUN_GET_OBJECT_BY_GUID);
auto *cgItem = static_cast<const uint8_t *>(
fn(Offsets::OBJECT_TYPE_ITEM, nullptr, guidLo, guidHi, 0));
if (cgItem == nullptr)
return 0;
const char *link = Item::Link::FromCGItem(cgItem);
if (link == nullptr || *link == '\0')
return 0;
const int itemID = Item::ID::FromCGItem(cgItem);
Game::Lua::PushString(L, link);
Game::Lua::PushNumber(L, static_cast<double>(itemID));
return 2;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetSendMailItemLink",
&Script_GetSendMailItemLink);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Mail::SendMailItemLink
+4 -7
View File
@@ -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);
}
}
+24
View File
@@ -41,9 +41,11 @@
#include "Game.h"
#include "Offsets.h"
#include "spell/MacroPrimarySpell.h"
#include <cstdint>
#include <cstdio>
#include <cstring>
namespace Spell::AtCursor {
@@ -170,6 +172,28 @@ void RegisterLuaFunctions() {
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
+27 -130
View File
@@ -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
+145
View File
@@ -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
+44
View File
@@ -0,0 +1,44 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
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