mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-12 12:57:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0332e49ac2 | ||
|
|
b1b564a533 | ||
|
|
444875ae38 | ||
|
|
a7ffe1c704 | ||
|
|
9f75737882 | ||
|
|
10d5790975 | ||
|
|
b571483c5f | ||
|
|
0a79016b0f | ||
|
|
3c4f24192a |
@@ -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
|
||||
@@ -1,5 +1,3 @@
|
||||
if GetLocale() ~= "enUS" then return end
|
||||
|
||||
BINDING_NAME_FOCUSTARGET = "Focus Target";
|
||||
BINDING_NAME_TARGETFOCUS = "Target Focus";
|
||||
FOCUSTARGET = "Focus Target";
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -39,11 +39,13 @@ 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` |
|
||||
| [NameCache](docs/API.md#namecache) | `C_CreatureInfo.GetCreatureID`, `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `GetPlayerInfoByGUID` |
|
||||
| [NameCache](docs/API.md#namecache) | `C_CreatureInfo.GetCreatureID`, `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `GetPlayerInfoByGUID`, `UnitNameFromGUID` |
|
||||
| [Quest](docs/API.md#quest) | `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
|
||||
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellName`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
|
||||
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
|
||||
@@ -52,7 +54,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
|
||||
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime` |
|
||||
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` (backports `GlobalColor.dbc`; companion addon wraps each row with `ColorMixin` and assigns `_G[baseTag]` + `_G[baseTag.."_CODE"]`) |
|
||||
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitGUID`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitTokenFromGUID` |
|
||||
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitGUID`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
|
||||
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras` |
|
||||
|
||||
### GlueXML calls
|
||||
@@ -99,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") |
|
||||
|
||||
+334
-1
@@ -88,6 +88,7 @@ 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)
|
||||
@@ -210,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)
|
||||
|
||||
@@ -236,6 +249,7 @@ build instructions.
|
||||
|
||||
- [NameCache](#namecache)
|
||||
- [`GetPlayerInfoByGUID(guid)`](#getplayerinfobyguidguid)
|
||||
- [`UnitNameFromGUID(guid)`](#unitnamefromguidguid)
|
||||
- [`C_PlayerCache.GetPlayerInfoByName(name)`](#c_playercachegetplayerinfobynamename)
|
||||
- [`C_PlayerInfo.GUIDIsPlayer(guid)` / `GUIDIsCreature` / `GUIDIsPet` / `GUIDIsGameObject`](#c_playerinfoguidisplayerguid--guidiscreature--guidispet--guidisgameobject)
|
||||
- [`C_CreatureInfo.GetCreatureID(guid)`](#c_creatureinfogetcreatureidguid)
|
||||
@@ -333,6 +347,7 @@ build instructions.
|
||||
- [Unit](#unit)
|
||||
- [`UnitGUID(unit)`](#unitguidunit)
|
||||
- [`UnitTokenFromGUID(guid)`](#unittokenfromguidguid)
|
||||
- [`UnitSubName(unit)`](#unitsubnameunit)
|
||||
- [`GetUnitSpeed(unit)`](#getunitspeedunit)
|
||||
- [`UnitClassBase(unit)`](#unitclassbaseunit)
|
||||
- [`UnitRaceBase(unit)`](#unitracebaseunit)
|
||||
@@ -1971,6 +1986,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)`:
|
||||
@@ -4177,7 +4215,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**
|
||||
@@ -5110,6 +5148,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
|
||||
@@ -5282,6 +5491,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)`
|
||||
@@ -5728,6 +6005,35 @@ instance lives at `0x00C0E228`; entry layout (name, realm, race,
|
||||
sex, class) was reverse-engineered from the
|
||||
`SMSG_NAME_QUERY_RESPONSE` write path at `0x0055F310`.
|
||||
|
||||
### `UnitNameFromGUID(guid)`
|
||||
|
||||
Returns `name, realm` for the player identified by `guid`, or `nil`
|
||||
if no player with that GUID has been encountered yet. Same lookup
|
||||
chain as [`GetPlayerInfoByGUID`](#getplayerinfobyguidguid) (engine
|
||||
NameCache, persistent on-disk fallback when enabled) — just narrower
|
||||
return shape for callers that only need the name.
|
||||
|
||||
```lua
|
||||
local name, realm = UnitNameFromGUID(UnitGUID("target"))
|
||||
if name then
|
||||
ChatFrame1:AddMessage("Target: " .. name)
|
||||
end
|
||||
|
||||
-- Resolving a chat link
|
||||
local name, realm = UnitNameFromGUID("0x0000000000000777")
|
||||
```
|
||||
|
||||
`realm` is always `""` in vanilla — the engine doesn't populate
|
||||
per-player realm names and 1.12 has no cross-realm interaction.
|
||||
We push the empty string rather than `nil` to match the convention
|
||||
of the other player-info accessors; addons can gate on `realm == ""`.
|
||||
|
||||
Doesn't trigger a network query on miss. To populate an unknown
|
||||
GUID, either let the engine receive a `SMSG_NAME_QUERY_RESPONSE`
|
||||
through normal chat/target interaction, or call
|
||||
[`C_PlayerCache.RememberPlayer`](#c_playercacherememberplayerguid-name-classtoken)
|
||||
explicitly.
|
||||
|
||||
### `C_PlayerCache.GetPlayerInfoByName(name)`
|
||||
|
||||
Returns
|
||||
@@ -7976,6 +8282,33 @@ end
|
||||
> ex-targets, ex-mouseover units, distant players seen in the chat
|
||||
> log, etc. The engine simply doesn't address those by token.
|
||||
|
||||
### `UnitSubName(unit)`
|
||||
|
||||
Returns the NPC's subtitle / title string (the small italic text
|
||||
under their name in the tooltip): `"Innkeeper"`, `"Stable Master"`,
|
||||
`"<Master Engineer>"`, etc. Returns `nil` for players, unsynced
|
||||
NPCs, and NPCs with no subtitle.
|
||||
|
||||
```lua
|
||||
/dump UnitSubName("target")
|
||||
-- "Innkeeper" (Stormwind innkeep)
|
||||
-- "<Auction House>" (auctioneer)
|
||||
-- nil (a player, or a wolf in the woods)
|
||||
```
|
||||
|
||||
Modern WoW returns the subtitle as `UnitName`'s second return;
|
||||
vanilla 1.12's `UnitName` returns only one value, leaving no
|
||||
direct route to the subtitle. Addons that wanted it had to scrape
|
||||
`GameTooltip` text, which is fragile and required the unit to be
|
||||
hovered. `UnitSubName` reads it straight off the engine's creature
|
||||
cache (`[unit + 0xB30] → [+0x10]`), no tooltip round-trip.
|
||||
|
||||
Coverage caveat: the creature cache row is populated when an NPC
|
||||
becomes visible to the client AND the server has sent its
|
||||
`CMSG_CREATURE_QUERY_RESPONSE`. The first time a fresh NPC appears
|
||||
the row may be briefly NULL — usually only one or two frames.
|
||||
Subsequent calls succeed once the response lands.
|
||||
|
||||
### `GetUnitSpeed(unit)`
|
||||
|
||||
Returns `currentSpeed, runSpeed, flightSpeed, swimSpeed` — all four
|
||||
|
||||
+216
-1
@@ -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
|
||||
@@ -402,6 +405,23 @@ enum Offsets {
|
||||
// descriptor at +0x114 — these are sibling classes under CGObject
|
||||
// with class-specific descriptor offsets.
|
||||
OFF_UNIT_DESCRIPTOR = 0x110,
|
||||
|
||||
// CGCreature client-side creature-data cache row. Populated by the
|
||||
// engine when an NPC GUID becomes visible (the same row the
|
||||
// `CreatureCache` SMSG fills with the templated NPC's name,
|
||||
// subname, etc.). NULL for unsynced units or for player CGUnits
|
||||
// (players don't have a row in this cache — their name comes from
|
||||
// the SMSG_NAME_QUERY_RESPONSE-fed name cache at `0x00C0E228`).
|
||||
//
|
||||
// Layout of the cache row:
|
||||
// +0x00 ?
|
||||
// +0x0C name C-string ptr (alias of UNIT_NAME for creatures)
|
||||
// +0x10 subName C-string ptr ("Innkeeper", "<Master Trainer>", etc.)
|
||||
//
|
||||
// Used by `Script_UnitName`'s creature path and by VanillaMinimapTracking's
|
||||
// subname-based blip filter (`src/Blips.cpp::ExtractUnitSubName`).
|
||||
OFF_UNIT_CREATURE_CACHE_ROW = 0xB30,
|
||||
OFF_CREATURE_CACHE_SUB_NAME = 0x10,
|
||||
// Pointer to the CGUnit's 8-byte GUID at `*(CGUnit + 0x08)`. Verified
|
||||
// in `Script_GetInventoryItemLink` at `0x004C8CB0`-`0x004C8CB5`:
|
||||
// `mov eax, [esi+8]; mov edi, [eax]; mov ecx, [eax+4]` reads the
|
||||
@@ -3351,6 +3371,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
|
||||
@@ -3451,10 +3639,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -41,6 +41,7 @@
|
||||
#include "guid/Guid.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace Player::Info {
|
||||
|
||||
@@ -182,6 +183,92 @@ int PushFromNameCacheEntry(void *L, const char *name,
|
||||
return 7;
|
||||
}
|
||||
|
||||
// `UnitNameFromGUID(guid)` → `(name, realm)`. Modern WoW backport;
|
||||
// vanilla 1.12 doesn't expose any GUID → name accessor at the Lua
|
||||
// surface, so addons that need to resolve a chat link or combat-log
|
||||
// GUID can't get a name without round-tripping through a unit token.
|
||||
//
|
||||
// Resolution order (matches vanilla's own `Script_UnitName` at
|
||||
// `0x00517020`):
|
||||
// 1. Resolve the GUID through the object manager. Any currently-
|
||||
// synced unit — player you can see, NPC in interact range,
|
||||
// target / mouseover / party / raid member — gets a `CGObject *`
|
||||
// back. Call the engine's canonical polymorphic name getter
|
||||
// `FUN_OBJECT_GET_NAME` on it; this routes through the player
|
||||
// NameCache for players AND the creature cache for NPCs, so it
|
||||
// works for both with a single call.
|
||||
// 2. If the unit isn't currently synced (ex-target who logged off,
|
||||
// player encountered earlier in chat but no longer visible),
|
||||
// fall back to the persistent NameCache (when enabled).
|
||||
//
|
||||
// `realm` is always `""` in vanilla — the engine doesn't populate
|
||||
// per-player realm names and 1.12 has no cross-realm interaction.
|
||||
// We push the empty string rather than nil so callers don't have to
|
||||
// special-case the second return; modern WoW returns nil, but
|
||||
// `realm == ""` is just as easy to gate on.
|
||||
//
|
||||
// Returns nothing on:
|
||||
// - Missing or non-string arg (raises an error rather than return).
|
||||
// - Unparseable GUID string.
|
||||
// - Zero / NULL GUID.
|
||||
// - GUID not resolvable in the object manager AND not in persistent
|
||||
// NameCache (i.e., we've never seen this unit).
|
||||
//
|
||||
// Doesn't trigger a network query on miss — passive read, same as
|
||||
// `GetPlayerInfoByGUID`. Use `C_PlayerCache.RememberPlayer` or let
|
||||
// the engine populate via normal interaction.
|
||||
int __fastcall Script_UnitNameFromGUID(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: UnitNameFromGUID(\"0x...\")");
|
||||
return 0;
|
||||
}
|
||||
const char *guidStr = Game::Lua::ToString(L, 1);
|
||||
uint32_t hi, lo;
|
||||
if (!ParseGUID(guidStr, hi, lo))
|
||||
return 0;
|
||||
if (hi == 0 && lo == 0)
|
||||
return 0;
|
||||
|
||||
// Object-manager path — TYPEMASK_OBJECT (0x01) is permissive; the
|
||||
// name getter at `FUN_OBJECT_GET_NAME` does its own type check and
|
||||
// safely returns the "UNKNOWNOBJECT" sentinel for non-unit
|
||||
// objects (gameobjects etc.), so we just gate on the sentinel
|
||||
// rather than pre-filtering by typemask.
|
||||
using ObjectPtr_t = void *(__fastcall *)(uint32_t typeMask,
|
||||
const char *debugMsg,
|
||||
uint32_t guidLo,
|
||||
uint32_t guidHi,
|
||||
int debugCode);
|
||||
using GetName_t = const char *(__thiscall *)(void *obj, int *outFlags);
|
||||
|
||||
auto objectPtr = reinterpret_cast<ObjectPtr_t>(
|
||||
Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
|
||||
void *obj = objectPtr(Offsets::TYPEMASK_OBJECT, nullptr, lo, hi, 0);
|
||||
if (obj != nullptr) {
|
||||
auto getName = reinterpret_cast<GetName_t>(Offsets::FUN_OBJECT_GET_NAME);
|
||||
const char *name = getName(obj, nullptr);
|
||||
if (name != nullptr && *name != '\0' &&
|
||||
std::strcmp(name, "UNKNOWNOBJECT") != 0 &&
|
||||
std::strcmp(name, "Unknown Being") != 0) {
|
||||
Game::Lua::PushString(L, name);
|
||||
Game::Lua::PushString(L, "");
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Object-manager miss / sentinel — fall back to the persistent
|
||||
// NameCache (when enabled). Covers ex-units no longer in the
|
||||
// engine's sync window.
|
||||
const std::string *cachedName = nullptr;
|
||||
const NameCache::Entry *cached = NameCache::Lookup(
|
||||
(static_cast<uint64_t>(hi) << 32) | lo, &cachedName);
|
||||
if (cached == nullptr || cachedName == nullptr || cachedName->empty())
|
||||
return 0;
|
||||
Game::Lua::PushString(L, cachedName->c_str());
|
||||
Game::Lua::PushString(L, "");
|
||||
return 2;
|
||||
}
|
||||
|
||||
// `C_PlayerCache.GetPlayerInfoByName(name)` — name-keyed lookup over
|
||||
// the persistent NameCache. Returns `(localizedClass, englishClass,
|
||||
// localizedRace, englishRace, sex, name, realm, guid)` or nothing if
|
||||
@@ -212,6 +299,8 @@ int __fastcall Script_C_PlayerCache_GetPlayerInfoByName(void *L) {
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("GetPlayerInfoByGUID",
|
||||
&Script_GetPlayerInfoByGUID);
|
||||
Game::Lua::RegisterGlobalFunction("UnitNameFromGUID",
|
||||
&Script_UnitNameFromGUID);
|
||||
Game::Lua::RegisterTableFunction("C_PlayerCache", "GetPlayerInfoByName",
|
||||
&Script_C_PlayerCache_GetPlayerInfoByName);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// 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/>.
|
||||
|
||||
// `UnitSubName(unit)` — returns the NPC's subname / title (the small
|
||||
// italic text under their name in the tooltip): "Innkeeper",
|
||||
// "Stable Master", "<Master Engineer>", etc. Modern WoW exposes this
|
||||
// via `UnitName`'s second return; vanilla 1.12's `UnitName` returns
|
||||
// only one value, so addons that want the subtitle have to roll their
|
||||
// own — until now.
|
||||
//
|
||||
// Returns `nil` for:
|
||||
// - Unresolvable unit token (`target` with nothing targeted, empty
|
||||
// `partyN` slot, etc.).
|
||||
// - Player units — they don't have a row in the creature cache.
|
||||
// - NPCs whose creature data hasn't synced yet (rare; happens when
|
||||
// the unit comes into view but the SMSG_CREATURE_QUERY_RESPONSE
|
||||
// hasn't arrived).
|
||||
// - NPCs with no subname (most non-vendor / non-trainer creatures).
|
||||
//
|
||||
// Raises a Lua error on missing / non-string `unit` argument — same
|
||||
// semantics as the engine's `UnitName`.
|
||||
//
|
||||
// Implementation: walks the creature cache row at `[unit + 0xB30]` →
|
||||
// `[+0x10]` (subname C string). Verified path; same one
|
||||
// VanillaMinimapTracking's blip subname filter uses.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Unit::SubName {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
|
||||
int __fastcall Script_UnitSubName(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: UnitSubName(\"unit\")");
|
||||
return 0;
|
||||
}
|
||||
const char *token = Game::Lua::ToString(L, 1);
|
||||
if (token == nullptr)
|
||||
return 0;
|
||||
|
||||
auto resolve = reinterpret_cast<ResolveUnitToken_t>(
|
||||
Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
auto *unit = static_cast<const uint8_t *>(resolve(token));
|
||||
if (unit == nullptr)
|
||||
return 0;
|
||||
|
||||
auto *cacheRow = *reinterpret_cast<const uint8_t *const *>(
|
||||
unit + Offsets::OFF_UNIT_CREATURE_CACHE_ROW);
|
||||
if (cacheRow == nullptr)
|
||||
return 0;
|
||||
|
||||
auto *subName = *reinterpret_cast<const char *const *>(
|
||||
cacheRow + Offsets::OFF_CREATURE_CACHE_SUB_NAME);
|
||||
if (subName == nullptr || *subName == '\0')
|
||||
return 0;
|
||||
|
||||
Game::Lua::PushString(L, subName);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("UnitSubName", &Script_UnitSubName);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Unit::SubName
|
||||
Reference in New Issue
Block a user