mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-10 20:07:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bd33288c9 | ||
|
|
bf7e476c10 | ||
|
|
8f3ed96d51 | ||
|
|
e1ffaab6b4 | ||
|
|
c13b1c8eb7 | ||
|
|
bedc5d6740 | ||
|
|
bdef428ff7 | ||
|
|
25b1a1a95c | ||
|
|
5e23332880 | ||
|
|
eb36b3e09e | ||
|
|
16452519a6 | ||
|
|
33c5f3041d | ||
|
|
e8a03d584b | ||
|
|
adba73a6d8 | ||
|
|
d09137cae0 | ||
|
|
5721fb65a7 | ||
|
|
3afd403eae | ||
|
|
e1030213d3 | ||
|
|
c1cf437bf8 | ||
|
|
07a91feff0 | ||
|
|
88ea4356a0 | ||
|
|
50909ce440 | ||
|
|
d48d003828 | ||
|
|
d51f5f72a9 | ||
|
|
04efd4caf1 | ||
|
|
329d6b75ba | ||
|
|
6a677ce447 | ||
|
|
f6f88ab4ee | ||
|
|
17c928028e | ||
|
|
a47f38417f | ||
|
|
1282e3f6db | ||
|
|
4e4fad9196 | ||
|
|
67063876da | ||
|
|
b34368ef14 | ||
|
|
4983c9fd83 | ||
|
|
9e5114141e | ||
|
|
6be25d78e0 | ||
|
|
06e68d04a8 | ||
|
|
8bfe0b008c | ||
|
|
e9c2e7416a | ||
|
|
6641e5ac13 | ||
|
|
db1f00e0a3 | ||
|
|
82062309a9 | ||
|
|
2a218a0a76 | ||
|
|
e8a9003bf0 | ||
|
|
aa0279a600 | ||
|
|
a754d1ba22 | ||
|
|
d55877649f | ||
|
|
f55779f87e | ||
|
|
91e88d768a | ||
|
|
d358bd9515 | ||
|
|
4ba82e59e4 | ||
|
|
b05c15ffa7 | ||
|
|
4daf6b9364 | ||
|
|
4c6c9deae6 | ||
|
|
983cc3bdff | ||
|
|
18ac033381 | ||
|
|
81434f7014 | ||
|
|
22605dad64 | ||
|
|
7378e0b128 | ||
|
|
94b0545631 | ||
|
|
243ab37836 | ||
|
|
a12fff6704 | ||
|
|
d747c699f5 | ||
|
|
88d76c04ad | ||
|
|
47d4b9c660 | ||
|
|
8e9e30567c | ||
|
|
8be78ad946 | ||
|
|
0dea869ab1 | ||
|
|
b4dffb75c0 | ||
|
|
9445fd6333 | ||
|
|
c8effdd6ec | ||
|
|
b3ac1c07f7 | ||
|
|
cfbc4f4e9c | ||
|
|
bb9579c398 | ||
|
|
5642644833 |
@@ -12,7 +12,6 @@ Util\Print.lua
|
||||
Util\Mixin.lua
|
||||
Util\Color.lua
|
||||
Util\Constants.lua
|
||||
Util\String.lua
|
||||
Util\TableUtil.lua
|
||||
Util\MathUtil.lua
|
||||
Util\PixelUtil.lua
|
||||
@@ -23,6 +22,7 @@ Util\CallbackRegistry.lua
|
||||
Util\GlobalCallbackRegistry.lua
|
||||
Util\ColorUtil.lua
|
||||
Util\ItemLocation.lua
|
||||
Util\PlayerLocation.lua
|
||||
Util\EventUtil.lua
|
||||
Util\ItemUtil.lua
|
||||
Util\AddOnCompat.lua
|
||||
|
||||
@@ -8,5 +8,34 @@ if C_AddOns.DoesAddOnExist('pfUI') then
|
||||
unknownColor.colorStr = unknownColor:GenerateHexColor()
|
||||
return unknownColor
|
||||
end})
|
||||
|
||||
-- Compatibility with OLDER forks of pfUI. Our DLL adds the modern
|
||||
-- HookScript widget method; older pfUI forks branch on
|
||||
-- `if button.HookScript` and take a modern code path they were never
|
||||
-- written for on their pfActionBar buttons, which breaks them. While
|
||||
-- the actionbar module builds those buttons, temporarily wrap
|
||||
-- CreateFrame to shadow HookScript with a falsy field so those forks
|
||||
-- fall back to their vanilla SetScript path. The maintained "brues"
|
||||
-- fork handles HookScript itself, so it's detected via the TOC
|
||||
-- X-Website tag and left alone. The wrapper is removed as soon as the
|
||||
-- actionbar module finishes loading so it affects nothing else.
|
||||
local website = GetAddOnMetadata("pfUI", "X-Website")
|
||||
if not website or not strfind(website, 'brues') then
|
||||
local _createFrame = CreateFrame
|
||||
local HookedCreateFrame = function(frameType, name, parent, template)
|
||||
local frame = _createFrame(frameType, name, parent, template)
|
||||
if frameType == "Button" and strfind(name or "", "pfActionBar") then
|
||||
frame.HookScript = false
|
||||
end
|
||||
return frame
|
||||
end
|
||||
hooksecurefunc(pfUI, 'LoadModule', function(frame, moduleName)
|
||||
if moduleName == "updatenotify" then
|
||||
CreateFrame = HookedCreateFrame
|
||||
elseif moduleName == "actionbar" then
|
||||
CreateFrame = _createFrame
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
end
|
||||
@@ -15,8 +15,6 @@
|
||||
-- - 1.12 has no concept of an item GUID and no public item-lock API,
|
||||
-- so those return nil / no-op.
|
||||
|
||||
C_Item.GetItemInfo = GetItemInfo
|
||||
|
||||
----------------------------------------------------------------------
|
||||
|
||||
Item = {}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
-- Backport of the modern `PlayerLocation` / `PlayerLocationMixin` parameter
|
||||
-- object (Blizzard_ObjectAPI). Like `ItemLocation`, it's a lightweight
|
||||
-- descriptor that wraps one of several ways to identify a player — a GUID, a
|
||||
-- unit token, a chat line, a /who index, a battlefield-scoreboard index, etc.
|
||||
-- — so retail/Classic addon code can construct and inspect it unchanged.
|
||||
--
|
||||
-- It's pure table bookkeeping: every Create/Set/Get/Is is just fields, no
|
||||
-- engine access. The GUID / unit / chatLine / whoIndex / battlefieldScore
|
||||
-- kinds all map to things vanilla 1.12 genuinely has. The community, voice,
|
||||
-- and Battle.net kinds have no vanilla producer, but they're kept as harmless
|
||||
-- data holders so the mixin matches retail exactly for any code that builds
|
||||
-- or reads them. Only the two methods that reach into systems 1.12 lacks —
|
||||
-- `IsValid` (Club) and `IsBattleNetGUID` (account info) — are guarded so they
|
||||
-- degrade gracefully instead of erroring on the missing `C_Club` /
|
||||
-- `C_AccountInfo` namespaces.
|
||||
|
||||
local CreateFromMixins = CreateFromMixins
|
||||
|
||||
PlayerLocation = PlayerLocation or {}
|
||||
PlayerLocationMixin = PlayerLocationMixin or {}
|
||||
|
||||
--[[static]] function PlayerLocation:CreateFromGUID(guid)
|
||||
local playerLocation = CreateFromMixins(PlayerLocationMixin)
|
||||
playerLocation:SetGUID(guid)
|
||||
return playerLocation
|
||||
end
|
||||
|
||||
--[[static]] function PlayerLocation:CreateFromUnit(unit)
|
||||
local playerLocation = CreateFromMixins(PlayerLocationMixin)
|
||||
playerLocation:SetUnit(unit)
|
||||
return playerLocation
|
||||
end
|
||||
|
||||
--[[static]] function PlayerLocation:CreateFromChatLineID(lineID)
|
||||
local playerLocation = CreateFromMixins(PlayerLocationMixin)
|
||||
playerLocation:SetChatLineID(lineID)
|
||||
return playerLocation
|
||||
end
|
||||
|
||||
--[[static]] function PlayerLocation:CreateFromBattlefieldScoreIndex(battlefieldScoreIndex)
|
||||
local playerLocation = CreateFromMixins(PlayerLocationMixin)
|
||||
playerLocation:SetBattlefieldScoreIndex(battlefieldScoreIndex)
|
||||
return playerLocation
|
||||
end
|
||||
|
||||
--[[static]] function PlayerLocation:CreateFromWhoIndex(whoIndex)
|
||||
local playerLocation = CreateFromMixins(PlayerLocationMixin)
|
||||
playerLocation:SetWhoIndex(whoIndex)
|
||||
return playerLocation
|
||||
end
|
||||
|
||||
--[[static]] function PlayerLocation:CreateFromVoiceID(memberID, channelID)
|
||||
local playerLocation = CreateFromMixins(PlayerLocationMixin)
|
||||
playerLocation:SetVoiceID(memberID, channelID)
|
||||
return playerLocation
|
||||
end
|
||||
|
||||
--[[static]] function PlayerLocation:CreateFromBattleNetID(battleNetID)
|
||||
local playerLocation = CreateFromMixins(PlayerLocationMixin)
|
||||
playerLocation:SetBattleNetID(battleNetID)
|
||||
return playerLocation
|
||||
end
|
||||
|
||||
--[[static]] function PlayerLocation:CreateFromCommunityChatData(clubID, streamID, epoch, position)
|
||||
local playerLocation = CreateFromMixins(PlayerLocationMixin)
|
||||
playerLocation:SetCommunityData(clubID, streamID, epoch, position)
|
||||
return playerLocation
|
||||
end
|
||||
|
||||
--[[static]] function PlayerLocation:CreateFromCommunityInvitation(clubID, guid)
|
||||
local playerLocation = CreateFromMixins(PlayerLocationMixin)
|
||||
playerLocation:SetCommunityInvitation(clubID, guid)
|
||||
return playerLocation
|
||||
end
|
||||
|
||||
--[[public api]]
|
||||
function PlayerLocationMixin:SetGUID(guid)
|
||||
self:ClearAndSetField("guid", guid)
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:IsValid()
|
||||
if self:IsCommunityData() then
|
||||
-- Vanilla has no Community/Club system; if the namespace is
|
||||
-- absent a community location can never be resolved.
|
||||
if C_Club and C_Club.CanResolvePlayerLocationFromClubMessageData then
|
||||
return C_Club.CanResolvePlayerLocationFromClubMessageData(self.communityClubID, self.communityStreamID, self.communityEpoch, self.communityPosition)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:IsGUID()
|
||||
return self.guid ~= nil
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:IsBattleNetGUID()
|
||||
-- No Battle.net accounts on 1.12; guarded so it returns false instead
|
||||
-- of erroring on a missing C_AccountInfo.
|
||||
if self.guid and C_AccountInfo and C_AccountInfo.IsGUIDBattleNetAccountType then
|
||||
return C_AccountInfo.IsGUIDBattleNetAccountType(self.guid)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:GetGUID()
|
||||
return self.guid or self.communityClubInviterGUID
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:SetUnit(unit)
|
||||
self:ClearAndSetField("unit", unit)
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:IsUnit()
|
||||
return self.unit ~= nil
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:GetUnit()
|
||||
return self.unit
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:SetChatLineID(lineID)
|
||||
self:ClearAndSetField("chatLineID", lineID)
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:IsChatLineID()
|
||||
return self.chatLineID ~= nil
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:GetChatLineID()
|
||||
return self.chatLineID
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:SetBattlefieldScoreIndex(index)
|
||||
self:ClearAndSetField("battlefieldScoreIndex", index)
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:IsBattlefieldScoreIndex()
|
||||
return self.battlefieldScoreIndex ~= nil
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:GetBattlefieldScoreIndex()
|
||||
return self.battlefieldScoreIndex
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:SetWhoIndex(whoIndex)
|
||||
self:ClearAndSetField("whoIndex", whoIndex)
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:IsWhoIndex()
|
||||
return self.whoIndex ~= nil
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:GetWhoIndex()
|
||||
return self.whoIndex
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:SetVoiceID(memberID, channelID)
|
||||
self:Clear()
|
||||
self.voiceMemberID = memberID
|
||||
self.voiceChannelID = channelID
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:IsVoiceID()
|
||||
return self.voiceMemberID ~= nil and self.voiceChannelID ~= nil
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:GetVoiceID()
|
||||
return self.voiceMemberID, self.voiceChannelID
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:SetBattleNetID(battleNetID)
|
||||
self:Clear()
|
||||
self.battleNetID = battleNetID
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:IsBattleNetID()
|
||||
return self.battleNetID ~= nil
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:GetBattleNetID()
|
||||
return self.battleNetID
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:SetCommunityData(clubID, streamID, epoch, position)
|
||||
self:Clear()
|
||||
self.communityClubID = clubID
|
||||
self.communityStreamID = streamID
|
||||
self.communityEpoch = epoch
|
||||
self.communityPosition = position
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:IsCommunityData()
|
||||
return self.communityClubID ~= nil and self.communityStreamID ~= nil and self.communityEpoch ~= nil and self.communityPosition ~= nil
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:SetCommunityInvitation(clubID, guid)
|
||||
self:Clear()
|
||||
self.communityClubID = clubID
|
||||
self.communityClubInviterGUID = guid
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:IsCommunityInvitation()
|
||||
return self.communityClubID ~= nil and self.communityClubInviterGUID ~= nil
|
||||
end
|
||||
|
||||
--[[private api]]
|
||||
function PlayerLocationMixin:Clear()
|
||||
self.guid = nil
|
||||
self.unit = nil
|
||||
self.chatLineID = nil
|
||||
self.battlefieldScoreIndex = nil
|
||||
self.whoIndex = nil
|
||||
self.voiceMemberID = nil
|
||||
self.voiceChannelID = nil
|
||||
self.communityClubID = nil
|
||||
self.communityStreamID = nil
|
||||
self.communityEpoch = nil
|
||||
self.communityPosition = nil
|
||||
self.communityClubInviterGUID = nil
|
||||
self.battleNetID = nil
|
||||
end
|
||||
|
||||
function PlayerLocationMixin:ClearAndSetField(fieldName, field)
|
||||
self:Clear()
|
||||
self[fieldName] = field
|
||||
end
|
||||
@@ -1,13 +0,0 @@
|
||||
|
||||
function string.match(s, pattern, init)
|
||||
if s == nil then return nil end
|
||||
local r1, r2, c1, c2, c3, c4, c5, c6, c7, c8, c9 =
|
||||
string.find(s, pattern, init)
|
||||
if r1 == nil then return nil end
|
||||
if c1 ~= nil then
|
||||
return c1, c2, c3, c4, c5, c6, c7, c8, c9
|
||||
end
|
||||
return string.sub(s, r1, r2)
|
||||
end
|
||||
|
||||
string.gmatch = string.gfind
|
||||
@@ -36,7 +36,9 @@ local LO = TURTLE and {
|
||||
summary = { "TOPRIGHT", -110, -70 },
|
||||
drop = { 40, -60 },
|
||||
scroll = { 296, 330, 37, -98 },
|
||||
detail = { 379, -113 },
|
||||
-- detail scroll frame: { x, y, width, height }. Tall enough that the
|
||||
-- content fits (scrollbar auto-hides) — Turtle's right column is roomy.
|
||||
detail = { 371, -110, 340, 290 },
|
||||
exit = { "CENTER", "BOTTOMRIGHT", -128, 92 },
|
||||
} or {
|
||||
hit = { 0, 34, 0, 75 },
|
||||
@@ -52,10 +54,32 @@ local LO = TURTLE and {
|
||||
summary = { "TOPRIGHT", -40, -58 },
|
||||
drop = { 12, -62 },
|
||||
scroll = { 296, 130, 21, -96 },
|
||||
detail = { 28, -237 },
|
||||
-- detail scroll frame { x, y, width, height } — native TradeSkillFrame's
|
||||
-- TradeSkillDetailScrollFrame values. Small enough that many-reagent
|
||||
-- recipes scroll (clipping partial rows), like native.
|
||||
detail = { 20, -234, 297, 176 },
|
||||
exit = { "CENTER", "TOPLEFT", 305, -422 },
|
||||
sep = { 15, -221, 331 },
|
||||
};
|
||||
|
||||
-- Reagents render in a 2-column grid inside the detail scroll child (see
|
||||
-- the detail pane below). The detail is a real ScrollFrame like the
|
||||
-- native TradeSkillFrame, so the whole pane pixel-scrolls and clips
|
||||
-- partial reagent rows — `REAGENT_ROW_HEIGHT` is just the vertical stride
|
||||
-- of one reagent row.
|
||||
local REAGENT_ROW_HEIGHT = 43;
|
||||
|
||||
-- Scroll-frame templates. Stock mimics the native TradeSkillFrame, whose
|
||||
-- list/detail scrolls use the ClassTrainer templates (they add the
|
||||
-- decorative UI-ClassTrainer-ScrollBar track). Turtle's HD frame art
|
||||
-- doesn't want that groove, so it keeps the plain templates. Both detail
|
||||
-- templates are real ScrollFrames (ClassTrainerDetail... inherits
|
||||
-- UIPanelScrollFrameTemplate), so the pixel-scroll logic is identical.
|
||||
local LIST_SCROLL_TEMPLATE = TURTLE and "FauxScrollFrameTemplate"
|
||||
or "ClassTrainerListScrollFrameTemplate";
|
||||
local DETAIL_SCROLL_TEMPLATE = TURTLE and "UIPanelScrollFrameTemplate"
|
||||
or "ClassTrainerDetailScrollFrameTemplate";
|
||||
|
||||
local PROFESSION_ICONS = {
|
||||
[171] = "Trade_Alchemy",
|
||||
[164] = "Trade_BlackSmithing",
|
||||
@@ -233,6 +257,7 @@ local function CreateFrame_TradeSkillLink()
|
||||
f:SetScript("OnDragStop", function() this:StopMovingOrSizing(); end);
|
||||
f:SetFrameStrata("DIALOG");
|
||||
f:Hide();
|
||||
tinsert(UISpecialFrames, f:GetName())
|
||||
|
||||
local portrait = f:CreateTexture(nil, "BACKGROUND");
|
||||
portrait:SetSize(60, 60);
|
||||
@@ -247,6 +272,20 @@ local function CreateFrame_TradeSkillLink()
|
||||
tx:SetPoint(q[4]);
|
||||
end
|
||||
|
||||
-- Horizontal divider between the recipe list and the detail pane.
|
||||
-- Stock only (Turtle's HD frame art already has its own).
|
||||
if LO.sep then
|
||||
-- Decoded the BLP: it's a thin divider rod whose graphic lives in the
|
||||
-- top quarter of the 256x64 texture (a 2nd rod fills the next quarter,
|
||||
-- bottom half empty). So sample the top quarter, stretched to width —
|
||||
-- the same region the native TradeSkillFrame uses for this divider.
|
||||
local bar = f:CreateTexture(nil, "ARTWORK");
|
||||
bar:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-HorizontalBar");
|
||||
bar:SetTexCoord(0, 1, 0, 0.25);
|
||||
bar:SetSize(LO.sep[3], 16);
|
||||
bar:SetPoint("TOPLEFT", LO.sep[1], LO.sep[2]);
|
||||
end
|
||||
|
||||
local title = f:CreateFontString(nil, "OVERLAY", "GameFontNormal");
|
||||
title:SetPoint("TOP", 0, -18);
|
||||
f.title = title;
|
||||
@@ -299,7 +338,7 @@ local function CreateFrame_TradeSkillLink()
|
||||
f.subclassFilter = nil; -- nil = all subclasses
|
||||
|
||||
local scroll = CreateFrame("ScrollFrame", "ClassicAPITradeSkillLinkScroll",
|
||||
f, "FauxScrollFrameTemplate");
|
||||
f, LIST_SCROLL_TEMPLATE);
|
||||
scroll:SetSize(LO.scroll[1], LO.scroll[2]);
|
||||
scroll:SetPoint("TOPLEFT", LO.scroll[3], LO.scroll[4]);
|
||||
scroll:SetScript("OnVerticalScroll", function()
|
||||
@@ -345,17 +384,54 @@ local function CreateFrame_TradeSkillLink()
|
||||
f.rows[i] = row;
|
||||
end
|
||||
|
||||
local NUM_REAGENTS = 8;
|
||||
-- Detail pane: a real ScrollFrame (like the native TradeSkillFrame's
|
||||
-- TradeSkillDetailScrollFrame) whose scroll child holds the product
|
||||
-- header + reagent grid, so the whole pane pixel-scrolls — clipping
|
||||
-- partial reagent rows — with the standard UIPanelScrollBar. The bar
|
||||
-- auto-hides when the content fits (scrollBarHideable).
|
||||
local detailScroll = CreateFrame("ScrollFrame",
|
||||
"ClassicAPITradeSkillLinkDetailScroll", f, DETAIL_SCROLL_TEMPLATE);
|
||||
detailScroll:SetSize(LO.detail[3], LO.detail[4]);
|
||||
detailScroll:SetPoint("TOPLEFT", LO.detail[1], LO.detail[2]);
|
||||
detailScroll.scrollBarHideable = 1; -- template sets this too; belt-and-suspenders
|
||||
f.detailScroll = detailScroll;
|
||||
-- Off until a recipe overflows the pane (toggled in RenderDetail).
|
||||
-- The template shows its slider and enables the wheel by default; on
|
||||
-- the roomy Turtle pane that leaves a dead scrollbar that still eats
|
||||
-- wheel events and over-scrolls a hair. Start both disabled.
|
||||
detailScroll:EnableMouseWheel(false);
|
||||
local detailSB = getglobal("ClassicAPITradeSkillLinkDetailScrollScrollBar");
|
||||
if detailSB then
|
||||
detailSB:Hide();
|
||||
end
|
||||
-- The template re-shows its slider when the frame transitions to shown
|
||||
-- (on first open, before the auto-selected recipe's RenderDetail runs),
|
||||
-- leaving a dead scrollbar until the first click. Re-assert the hidden
|
||||
-- state on show unless the current recipe genuinely overflows.
|
||||
detailScroll:SetScript("OnShow", function()
|
||||
f:UpdateDetailScrollbar();
|
||||
RunNextFrame(function()
|
||||
if f:IsShown() then
|
||||
f:UpdateDetailScrollbar();
|
||||
end
|
||||
end);
|
||||
end);
|
||||
|
||||
local prodIcon = CreateFrame("Button", nil, f);
|
||||
prodIcon:SetSize(37, 37); -- TradeSkillSkillIcon is 37x37
|
||||
prodIcon:SetPoint("TOPLEFT", LO.detail[1], LO.detail[2]);
|
||||
local detailChild = CreateFrame("Frame",
|
||||
"ClassicAPITradeSkillLinkDetailChild", detailScroll);
|
||||
detailChild:SetSize(LO.detail[3], LO.detail[4]);
|
||||
detailScroll:SetScrollChild(detailChild);
|
||||
f.detailChild = detailChild;
|
||||
|
||||
local prodIcon = CreateFrame("Button", nil, detailChild);
|
||||
prodIcon:SetSize(37, 37); -- native TradeSkillSkillIcon (37x37 @ 8,-3)
|
||||
prodIcon:SetPoint("TOPLEFT", 8, -3);
|
||||
prodIcon:SetNormalTexture("Interface\\Icons\\INV_Misc_QuestionMark");
|
||||
local hdrLeft = prodIcon:CreateTexture(nil, "BACKGROUND");
|
||||
local hdrLeft = detailChild:CreateTexture(nil, "BACKGROUND");
|
||||
hdrLeft:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-DetailHeaderLeft");
|
||||
hdrLeft:SetSize(256, 64); -- Blizzard's native fill width (+64 cap = 320 total)
|
||||
hdrLeft:SetPoint("TOPLEFT", prodIcon, "TOPLEFT", -8, 6);
|
||||
local hdrRight = prodIcon:CreateTexture(nil, "BACKGROUND");
|
||||
local hdrRight = detailChild:CreateTexture(nil, "BACKGROUND");
|
||||
hdrRight:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-DetailHeaderRight");
|
||||
hdrRight:SetSize(64, 64);
|
||||
hdrRight:SetPoint("TOPLEFT", hdrLeft, "TOPRIGHT", 0, 0);
|
||||
@@ -364,44 +440,65 @@ local function CreateFrame_TradeSkillLink()
|
||||
prodIcon.count = prodCount;
|
||||
f.prodIcon = prodIcon;
|
||||
|
||||
local prodName = prodIcon:CreateFontString(nil, "ARTWORK", "GameFontNormal");
|
||||
local prodName = detailChild:CreateFontString(nil, "ARTWORK", "GameFontNormal");
|
||||
prodName:SetPoint("LEFT", prodIcon, "RIGHT", 5, 0);
|
||||
prodName:SetWidth(240);
|
||||
prodName:SetWidth(LO.detail[3] - 60);
|
||||
prodName:SetJustifyH("LEFT");
|
||||
prodName:SetJustifyV("MIDDLE");
|
||||
f.prodName = prodName;
|
||||
|
||||
local prodDesc = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall");
|
||||
local prodDesc = detailChild:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall");
|
||||
prodDesc:SetPoint("TOPLEFT", prodIcon, "BOTTOMLEFT", 0, -6);
|
||||
prodDesc:SetWidth(280);
|
||||
prodDesc:SetWidth(LO.detail[3] - 20);
|
||||
prodDesc:SetJustifyH("LEFT");
|
||||
prodDesc:SetJustifyV("TOP");
|
||||
f.prodDesc = prodDesc;
|
||||
|
||||
local reagentLabel = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall");
|
||||
local reagentLabel = detailChild:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall");
|
||||
reagentLabel:SetPoint("TOPLEFT", prodDesc, "BOTTOMLEFT", 0, -8);
|
||||
reagentLabel:SetText(SPELL_REAGENTS or "Reagents:");
|
||||
f.reagentLabel = reagentLabel;
|
||||
|
||||
prodIcon:SetScript("OnEnter", function()
|
||||
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
|
||||
if this.itemLink then
|
||||
GameTooltip:SetHyperlink(this.itemLink);
|
||||
elseif this.spellID then
|
||||
GameTooltip:SetSpellByID(this.spellID);
|
||||
end
|
||||
GameTooltip:Show();
|
||||
end);
|
||||
prodIcon:SetScript("OnLeave", GameTooltip_Hide);
|
||||
|
||||
-- Reagent buttons live in the scroll child (2 per row), built on
|
||||
-- demand so any reagent count is handled and they scroll/clip with
|
||||
-- the child.
|
||||
f.reagents = {};
|
||||
for i = 1, NUM_REAGENTS do
|
||||
local function AcquireReagent(i)
|
||||
if f.reagents[i] then
|
||||
return f.reagents[i];
|
||||
end
|
||||
local rowN = math.floor((i - 1) / 2);
|
||||
local col = (i - 1) - rowN * 2;
|
||||
local btn = CreateFrame("Button", nil, f);
|
||||
local btn = CreateFrame("Button", nil, detailChild);
|
||||
btn:SetSize(39, 39);
|
||||
btn:SetPoint("TOPLEFT", reagentLabel, "BOTTOMLEFT",
|
||||
col * 147 - 5, -3 - rowN * 43);
|
||||
col * 134 - 5, -3 - rowN * REAGENT_ROW_HEIGHT);
|
||||
-- Extend the hit area rightward over the name plate so the tooltip
|
||||
-- fires anywhere on the element, not just the icon. Stop short of the
|
||||
-- next column (pitch 134 − icon 39) so hovers don't bleed across.
|
||||
btn:SetHitRectInsets(0, -(134 - 39), 0, 0);
|
||||
btn:SetNormalTexture("Interface\\Icons\\INV_Misc_QuestionMark");
|
||||
local plate = btn:CreateTexture(nil, "BACKGROUND");
|
||||
plate:SetTexture("Interface\\QuestFrame\\UI-QuestItemNameFrame");
|
||||
plate:SetSize(128, 64);
|
||||
plate:SetSize(114, 64);
|
||||
plate:SetPoint("LEFT", btn, "RIGHT", -10, 0);
|
||||
local cnt = btn:CreateFontString(nil, "ARTWORK", "NumberFontNormal");
|
||||
cnt:SetPoint("BOTTOMRIGHT", -4, 1);
|
||||
btn.count = cnt;
|
||||
local nm = btn:CreateFontString(nil, "ARTWORK", "GameFontHighlight");
|
||||
nm:SetPoint("LEFT", plate, "LEFT", 15, 0);
|
||||
nm:SetSize(90, 36);
|
||||
nm:SetSize(80, 36);
|
||||
nm:SetJustifyH("LEFT");
|
||||
nm:SetJustifyV("MIDDLE");
|
||||
btn.nameText = nm;
|
||||
@@ -413,8 +510,22 @@ local function CreateFrame_TradeSkillLink()
|
||||
end
|
||||
end);
|
||||
btn:SetScript("OnLeave", GameTooltip_Hide);
|
||||
btn:Hide();
|
||||
f.reagents[i] = btn;
|
||||
return btn;
|
||||
end
|
||||
|
||||
-- Show the detail slider + enable the wheel only when the current
|
||||
-- recipe overflows the pane (tracked by `detailNeedScroll`).
|
||||
function f:UpdateDetailScrollbar()
|
||||
local need = self.detailNeedScroll and true or false;
|
||||
self.detailScroll:EnableMouseWheel(need);
|
||||
local base = "ClassicAPITradeSkillLinkDetailScroll";
|
||||
local sb = getglobal(base .. "ScrollBar");
|
||||
if sb then sb:SetShown(need) end
|
||||
local top = getglobal(base .. "Top");
|
||||
if top then top:SetShown(need) end
|
||||
local bottom = getglobal(base .. "Bottom");
|
||||
if bottom then bottom:SetShown(need) end
|
||||
end
|
||||
|
||||
function f:RenderDetail(entry)
|
||||
@@ -441,11 +552,9 @@ local function CreateFrame_TradeSkillLink()
|
||||
prodText = entry.name;
|
||||
prodTex = C_Spell.GetSpellTexture(entry.spellID);
|
||||
end
|
||||
if created > 0 then
|
||||
self.prodDesc:SetText("");
|
||||
else
|
||||
self.prodDesc:SetText(C_Spell.GetSpellDescription(entry.spellID) or "");
|
||||
end
|
||||
local descText = (created > 0) and ""
|
||||
or (C_Spell.GetSpellDescription(entry.spellID) or "");
|
||||
self.prodDesc:SetText(descText);
|
||||
self.prodIcon:SetNormalTexture(prodTex or
|
||||
"Interface\\Icons\\INV_Misc_QuestionMark");
|
||||
self.prodName:SetText(prodText or "");
|
||||
@@ -456,50 +565,66 @@ local function CreateFrame_TradeSkillLink()
|
||||
self.prodIcon.count:SetText(made > 1 and tostring(made) or "");
|
||||
self.prodIcon:Show();
|
||||
|
||||
-- Fill every reagent slot; the scroll child clips/scrolls the
|
||||
-- overflow. Buttons past the count are hidden.
|
||||
local reagents = entry.reagents or {};
|
||||
for i = 1, NUM_REAGENTS do
|
||||
local btn = self.reagents[i];
|
||||
local total = table.getn(reagents);
|
||||
for i = 1, total do
|
||||
local btn = AcquireReagent(i);
|
||||
local r = reagents[i];
|
||||
if r then
|
||||
local name, tex, link = itemInfo(r.itemID);
|
||||
btn:SetNormalTexture(tex or
|
||||
"Interface\\Icons\\INV_Misc_QuestionMark");
|
||||
btn.itemLink = link;
|
||||
btn.nameText:SetText(name or ("item:" .. r.itemID));
|
||||
local need = r.count or 1;
|
||||
local have = C_Item.GetItemCount(r.itemID) or 0;
|
||||
if have >= need then
|
||||
btn:GetNormalTexture():SetVertexColor(1, 1, 1);
|
||||
btn.nameText:SetTextColor(HIGHLIGHT_FONT_COLOR:GetRGB());
|
||||
else
|
||||
btn:GetNormalTexture():SetVertexColor(0.5, 0.5, 0.5);
|
||||
btn.nameText:SetTextColor(GRAY_FONT_COLOR:GetRGB());
|
||||
end
|
||||
local haveText = (have >= 100) and "*" or tostring(have);
|
||||
btn.count:SetText(haveText .. " /" .. need);
|
||||
btn:Show();
|
||||
local name, tex, link = itemInfo(r.itemID);
|
||||
btn:SetNormalTexture(tex or "Interface\\Icons\\INV_Misc_QuestionMark");
|
||||
btn.itemLink = link;
|
||||
btn.nameText:SetText(name or ("item:" .. r.itemID));
|
||||
local need = r.count or 1;
|
||||
local have = C_Item.GetItemCount(r.itemID) or 0;
|
||||
if have >= need then
|
||||
btn:GetNormalTexture():SetVertexColor(1, 1, 1);
|
||||
btn.nameText:SetTextColor(HIGHLIGHT_FONT_COLOR:GetRGB());
|
||||
else
|
||||
btn.itemLink = nil;
|
||||
btn:Hide();
|
||||
btn:GetNormalTexture():SetVertexColor(0.5, 0.5, 0.5);
|
||||
btn.nameText:SetTextColor(GRAY_FONT_COLOR:GetRGB());
|
||||
end
|
||||
local haveText = (have >= 100) and "*" or tostring(have);
|
||||
btn.count:SetText(haveText .. " /" .. need);
|
||||
btn:Show();
|
||||
end
|
||||
for i = total + 1, table.getn(self.reagents) do
|
||||
self.reagents[i].itemLink = nil;
|
||||
self.reagents[i]:Hide();
|
||||
end
|
||||
end
|
||||
|
||||
prodIcon:SetScript("OnEnter", function()
|
||||
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
|
||||
if this.itemLink then
|
||||
GameTooltip:SetHyperlink(this.itemLink);
|
||||
elseif this.spellID then
|
||||
GameTooltip:SetSpellByID(this.spellID);
|
||||
-- Grow the scroll child to the content height so the pane scrolls
|
||||
-- once the header + reagent rows exceed the visible area. Reagent
|
||||
-- row 0 sits ~67px below the child top (icon + label); add the
|
||||
-- wrapped description height for spell recipes.
|
||||
local descH = (descText ~= "") and self.prodDesc:GetHeight() or 0;
|
||||
local rows = math.ceil(total / 2);
|
||||
local contentH = 67 + descH + rows * REAGENT_ROW_HEIGHT + 8;
|
||||
self.detailChild:SetHeight(math.max(contentH, LO.detail[4]));
|
||||
-- Reset to the top and manage the slider explicitly: a range that
|
||||
-- stays 0 (content fits, e.g. Turtle's roomy pane) never fires the
|
||||
-- range-changed event that `scrollBarHideable` relies on, so the
|
||||
-- slider would otherwise sit visible-but-useless. Show it only when
|
||||
-- the content actually overflows.
|
||||
self.detailNeedScroll = contentH > LO.detail[4];
|
||||
local sb = getglobal("ClassicAPITradeSkillLinkDetailScrollScrollBar");
|
||||
if sb then
|
||||
sb:SetValue(0);
|
||||
end
|
||||
GameTooltip:Show();
|
||||
end);
|
||||
prodIcon:SetScript("OnLeave", GameTooltip_Hide);
|
||||
self.detailScroll:SetVerticalScroll(0);
|
||||
self:UpdateDetailScrollbar();
|
||||
RunNextFrame(function()
|
||||
if self:IsShown() then
|
||||
self:UpdateDetailScrollbar();
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
function f:SelectRecipe(entry)
|
||||
self.selectedSpellID = entry.spellID;
|
||||
self.selectedEntry = entry;
|
||||
self:RenderDetail(entry);
|
||||
self:RenderDetail(entry); -- also resets the detail scroll to top
|
||||
self:Refresh();
|
||||
end
|
||||
|
||||
@@ -509,9 +634,14 @@ local function CreateFrame_TradeSkillLink()
|
||||
self.prodIcon:Hide();
|
||||
self.prodName:SetText("");
|
||||
self.prodDesc:SetText("");
|
||||
for i = 1, NUM_REAGENTS do
|
||||
for i = 1, table.getn(self.reagents) do
|
||||
self.reagents[i].itemLink = nil;
|
||||
self.reagents[i]:Hide();
|
||||
end
|
||||
self.detailChild:SetHeight(LO.detail[4]);
|
||||
self.detailNeedScroll = false;
|
||||
self:UpdateDetailScrollbar();
|
||||
self.detailScroll:SetVerticalScroll(0);
|
||||
end
|
||||
|
||||
function f:Refresh()
|
||||
@@ -663,9 +793,24 @@ local function CreateProfessionLinkButton(parentName, getLink)
|
||||
if _G[btnName] or not parent then
|
||||
return;
|
||||
end
|
||||
local title = _G[parentName .. "TitleText"];
|
||||
local btn = CreateFrame("Button", btnName, parent);
|
||||
btn:SetSize(16, 16);
|
||||
btn:SetPoint("LEFT", _G[parentName .. "TitleText"], "RIGHT", 4, 0);
|
||||
|
||||
local function reposition()
|
||||
if not title then
|
||||
return;
|
||||
end
|
||||
btn:ClearAllPoints();
|
||||
if title:GetJustifyH() == "LEFT" then
|
||||
btn:SetPoint("LEFT", title, "LEFT", title:GetStringWidth() + 4, 0);
|
||||
else
|
||||
btn:SetPoint("LEFT", title, "CENTER", title:GetStringWidth() / 2 + 4, 0);
|
||||
end
|
||||
end
|
||||
reposition()
|
||||
btn:SetScript("OnShow", function() RunNextFrame(reposition) end)
|
||||
hooksecurefunc(title, "SetText", function() RunNextFrame(reposition) end)
|
||||
|
||||
local icon = "Interface\\GossipFrame\\PetitionGossipIcon";
|
||||
btn:SetNormalTexture(icon);
|
||||
@@ -691,8 +836,20 @@ end
|
||||
|
||||
EventUtil.ContinueOnAddOnLoaded("Blizzard_TradeSkillUI", function()
|
||||
CreateProfessionLinkButton("TradeSkillFrame", C_TradeSkillUI.GetTradeSkillListLink);
|
||||
end);
|
||||
end)
|
||||
|
||||
EventUtil.ContinueOnAddOnLoaded("Blizzard_CraftUI", function()
|
||||
CreateProfessionLinkButton("CraftFrame", C_TradeSkillUI.GetCraftListLink);
|
||||
end);
|
||||
CreateProfessionLinkButton("CraftFrame", C_TradeSkillUI.GetCraftListLink)
|
||||
end)
|
||||
|
||||
EventUtil.ContinueOnAddOnLoaded("AdvancedTradeSkillWindow", function()
|
||||
CreateProfessionLinkButton("ATSWFrame", function()
|
||||
return atsw_oldmode and C_TradeSkillUI.GetCraftListLink() or C_TradeSkillUI.GetTradeSkillListLink()
|
||||
end)
|
||||
end)
|
||||
|
||||
EventUtil.ContinueOnAddOnLoaded("AdvancedTradeSkillWindow2", function()
|
||||
CreateProfessionLinkButton("ATSWFrame", function()
|
||||
return ATSW_TradeSkill() and C_TradeSkillUI.GetTradeSkillListLink() or C_TradeSkillUI.GetCraftListLink()
|
||||
end)
|
||||
end)
|
||||
|
||||
@@ -9,4 +9,45 @@ ITEMS_IN_INVENTORY = "%d |4Gegenstand:Gegenstände; im Inventar";
|
||||
ITEM_SLOTS_IGNORED = "%d |4Platz:Plätze; ignoriert";
|
||||
ITEM_MISSING = "%s fehlt";
|
||||
ITEMS_NOT_IN_INVENTORY = "%d |4Gegenstand fehlt:Gegenstände fehlen;";
|
||||
TARGETFOCUS = "Fokussierte Einheit anvisieren";
|
||||
TARGETFOCUS = "Fokussierte Einheit anvisieren";
|
||||
-- BINDING_NAME_AOELOOT = "AoE Loot";
|
||||
COPPER_AMOUNT_SYMBOL = "K";
|
||||
GENERIC_FRACTION_STRING = "%d/%d";
|
||||
GOLD_AMOUNT_SYMBOL = "G";
|
||||
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
|
||||
ITEM_DELTA_DESCRIPTION = "Wenn Ihr diesen Gegenstand ersetzt, werden sich folgende Werte ändern:";
|
||||
ITEM_MOD_MANA_SHORT = "Mana";
|
||||
ITEM_MOD_HEALTH_SHORT = "Gesundheit";
|
||||
ITEM_MOD_AGILITY_SHORT = "Beweglichkeit";
|
||||
ITEM_MOD_STRENGTH_SHORT = "Stärke";
|
||||
ITEM_MOD_INTELLECT_SHORT = "Intelligenz";
|
||||
ITEM_MOD_SPIRIT_SHORT = "Willenskraft";
|
||||
ITEM_MOD_STAMINA_SHORT = "Ausdauer";
|
||||
ITEM_MOD_ATTACK_POWER_SHORT = "Angriffskraft";
|
||||
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "Distanzangriffskraft";
|
||||
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "Kritischer Trefferwert (Nah)";
|
||||
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "Kritischer Trefferwert (Distanz)";
|
||||
ITEM_MOD_HIT_MELEE_RATING_SHORT = "Trefferwert (Nah)";
|
||||
ITEM_MOD_HIT_RANGED_RATING_SHORT = "Trefferwert (Distanz)";
|
||||
ITEM_MOD_HIT_SPELL_RATING_SHORT = "Trefferwert (Zauber)";
|
||||
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "Kritischer Trefferwert (Zauber)";
|
||||
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "Bonusschaden";
|
||||
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "Bonusheilung";
|
||||
ITEM_MOD_MANA_REGENERATION_SHORT = "Manaregeneration";
|
||||
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Verteidigung";
|
||||
ITEM_MOD_DODGE_RATING_SHORT = "Ausweichen";
|
||||
ITEM_MOD_PARRY_RATING_SHORT = "Parieren";
|
||||
ITEM_MOD_BLOCK_RATING_SHORT = "Blocken";
|
||||
ITEM_MOD_BLOCK_VALUE_SHORT = "Blocken";
|
||||
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Schaden pro Sekunde";
|
||||
LARGE_NUMBER_SEPERATOR = ".";
|
||||
MINUTES_SECONDS = "%.2d:%.2d";
|
||||
PERCENTAGE_STRING = "%d%%";
|
||||
SILVER_AMOUNT_SYMBOL = "S";
|
||||
SLASH_CLEARFOCUS1 = "/fokusaufheben";
|
||||
SLASH_CLEARFOCUS2 = "/clearfocus";
|
||||
SLASH_EQUIP_SET1 = "/Ausrüstungsset";
|
||||
SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_FOCUS1 = "/fokus";
|
||||
SLASH_FOCUS2 = "/focus";
|
||||
LINK_TRADESKILL_TOOLTIP = "Hier klicken, um einen Link zu Eurem Beruf zu erstellen.";
|
||||
|
||||
@@ -7,6 +7,30 @@ GENERIC_FRACTION_STRING = "%d/%d";
|
||||
GOLD_AMOUNT_SYMBOL = "g";
|
||||
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
|
||||
ITEM_DELTA_DESCRIPTION = "If you replace this item, the following stat changes will occur:";
|
||||
ITEM_MOD_MANA_SHORT = "Mana";
|
||||
ITEM_MOD_HEALTH_SHORT = "Health";
|
||||
ITEM_MOD_AGILITY_SHORT = "Agility";
|
||||
ITEM_MOD_STRENGTH_SHORT = "Strength";
|
||||
ITEM_MOD_INTELLECT_SHORT = "Intellect";
|
||||
ITEM_MOD_SPIRIT_SHORT = "Spirit";
|
||||
ITEM_MOD_STAMINA_SHORT = "Stamina";
|
||||
ITEM_MOD_ATTACK_POWER_SHORT = "Attack Power";
|
||||
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "Ranged Attack Power";
|
||||
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "Critical Strike (Melee)";
|
||||
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "Critical Strike (Ranged)";
|
||||
ITEM_MOD_HIT_MELEE_RATING_SHORT = "Hit (Melee)";
|
||||
ITEM_MOD_HIT_RANGED_RATING_SHORT = "Hit (Ranged)";
|
||||
ITEM_MOD_HIT_SPELL_RATING_SHORT = "Hit (Spell)";
|
||||
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "Critical Strike (Spell)";
|
||||
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "Spell Damage";
|
||||
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "Spell Healing";
|
||||
ITEM_MOD_MANA_REGENERATION_SHORT = "Mana Regeneration";
|
||||
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Defense";
|
||||
ITEM_MOD_DODGE_RATING_SHORT = "Dodge";
|
||||
ITEM_MOD_PARRY_RATING_SHORT = "Parry";
|
||||
ITEM_MOD_BLOCK_RATING_SHORT = "Block";
|
||||
ITEM_MOD_BLOCK_VALUE_SHORT = "Block Value";
|
||||
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Damage Per Second";
|
||||
ITEMS_EQUIPPED = "%d |4item:items; equipped";
|
||||
ITEMS_IN_INVENTORY = "%d |4item:items; in inventory";
|
||||
ITEMS_NOT_IN_INVENTORY = "%d |4item:items; missing";
|
||||
|
||||
@@ -9,4 +9,45 @@ ITEMS_IN_INVENTORY = "En inventario: %d |4objeto:objetos;";
|
||||
ITEM_SLOTS_IGNORED = "Ignorado: %d |4casilla:casillas;";
|
||||
ITEM_MISSING = "Falta: %s";
|
||||
ITEMS_NOT_IN_INVENTORY = "Faltan: %d |4objeto:objetos;";
|
||||
TARGETFOCUS = "Fijar unidad objetivo";
|
||||
TARGETFOCUS = "Fijar unidad objetivo";
|
||||
-- BINDING_NAME_AOELOOT = "AoE Loot";
|
||||
COPPER_AMOUNT_SYMBOL = "c";
|
||||
GENERIC_FRACTION_STRING = "%d/%d";
|
||||
GOLD_AMOUNT_SYMBOL = "o";
|
||||
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
|
||||
ITEM_DELTA_DESCRIPTION = "Si reemplazas este objeto, se producirán los siguientes cambios en las estadísticas:";
|
||||
ITEM_MOD_MANA_SHORT = "Maná";
|
||||
ITEM_MOD_HEALTH_SHORT = "Salud";
|
||||
ITEM_MOD_AGILITY_SHORT = "agilidad";
|
||||
ITEM_MOD_STRENGTH_SHORT = "fuerza";
|
||||
ITEM_MOD_INTELLECT_SHORT = "intelecto";
|
||||
ITEM_MOD_SPIRIT_SHORT = "espíritu";
|
||||
ITEM_MOD_STAMINA_SHORT = "aguante";
|
||||
ITEM_MOD_ATTACK_POWER_SHORT = "Poder de ataque";
|
||||
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "Poder de ataque a distancia";
|
||||
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "Golpe crítico (cuerpo a cuerpo)";
|
||||
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "Golpe crítico (a distancia)";
|
||||
ITEM_MOD_HIT_MELEE_RATING_SHORT = "Golpe (cuerpo a cuerpo)";
|
||||
ITEM_MOD_HIT_RANGED_RATING_SHORT = "Golpe (a distancia)";
|
||||
ITEM_MOD_HIT_SPELL_RATING_SHORT = "Golpe (hechizo)";
|
||||
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "Golpe crítico (hechizo)";
|
||||
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "Bon. daño";
|
||||
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "Bon. sanac.";
|
||||
ITEM_MOD_MANA_REGENERATION_SHORT = "Regeneración de maná";
|
||||
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Defensa";
|
||||
ITEM_MOD_DODGE_RATING_SHORT = "Esquiva";
|
||||
ITEM_MOD_PARRY_RATING_SHORT = "Parada";
|
||||
ITEM_MOD_BLOCK_RATING_SHORT = "Bloqueo";
|
||||
ITEM_MOD_BLOCK_VALUE_SHORT = "Valor de bloqueo";
|
||||
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "daño por segundo";
|
||||
LARGE_NUMBER_SEPERATOR = "";
|
||||
MINUTES_SECONDS = "%.2d:%.2d";
|
||||
PERCENTAGE_STRING = "%d%%";
|
||||
SILVER_AMOUNT_SYMBOL = "p";
|
||||
SLASH_CLEARFOCUS1 = "/quitarenfoque";
|
||||
SLASH_CLEARFOCUS2 = "/clearfocus";
|
||||
SLASH_EQUIP_SET1 = "/conjequip";
|
||||
SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_FOCUS1 = "/enfoque";
|
||||
SLASH_FOCUS2 = "/focus";
|
||||
LINK_TRADESKILL_TOOLTIP = "Haz clic aquí para crear un vínculo a tu profesión.";
|
||||
|
||||
@@ -9,4 +9,45 @@ ITEMS_IN_INVENTORY = "%d |4objet:objets; dans l'inventaire";
|
||||
ITEM_SLOTS_IGNORED = "%d |4emplacement ignoré:emplacements ignorés;";
|
||||
ITEM_MISSING = "Objet manquant : %s";
|
||||
ITEMS_NOT_IN_INVENTORY = "%d |4objet:objets; manquants";
|
||||
TARGETFOCUS = "Cibler l'unité focalisée";
|
||||
TARGETFOCUS = "Cibler l'unité focalisée";
|
||||
-- BINDING_NAME_AOELOOT = "AoE Loot";
|
||||
COPPER_AMOUNT_SYMBOL = "c";
|
||||
GENERIC_FRACTION_STRING = "%d/%d";
|
||||
GOLD_AMOUNT_SYMBOL = "o";
|
||||
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
|
||||
ITEM_DELTA_DESCRIPTION = "Si vous remplacez cet objet, les caractéristiques suivantes seront modifiées :";
|
||||
ITEM_MOD_MANA_SHORT = "Mana";
|
||||
ITEM_MOD_HEALTH_SHORT = "Vie";
|
||||
ITEM_MOD_AGILITY_SHORT = "Agilité";
|
||||
ITEM_MOD_STRENGTH_SHORT = "Force";
|
||||
ITEM_MOD_INTELLECT_SHORT = "Intelligence";
|
||||
ITEM_MOD_SPIRIT_SHORT = "Esprit";
|
||||
ITEM_MOD_STAMINA_SHORT = "Endurance";
|
||||
ITEM_MOD_ATTACK_POWER_SHORT = "Puissance d'attaque";
|
||||
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "Puissance d'attaque à distance";
|
||||
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "Score de critique (mêlée)";
|
||||
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "Score de critique (distance)";
|
||||
ITEM_MOD_HIT_MELEE_RATING_SHORT = "Toucher (mêlée)";
|
||||
ITEM_MOD_HIT_RANGED_RATING_SHORT = "Toucher (distance)";
|
||||
ITEM_MOD_HIT_SPELL_RATING_SHORT = "Toucher (sorts)";
|
||||
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "Score de critique (sorts)";
|
||||
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "Bonus aux dégâts";
|
||||
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "Bonus aux soins";
|
||||
ITEM_MOD_MANA_REGENERATION_SHORT = "Régénération du mana";
|
||||
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Défense";
|
||||
ITEM_MOD_DODGE_RATING_SHORT = "Esquive";
|
||||
ITEM_MOD_PARRY_RATING_SHORT = "Parade";
|
||||
ITEM_MOD_BLOCK_RATING_SHORT = "Blocage";
|
||||
ITEM_MOD_BLOCK_VALUE_SHORT = "Valeur de blocage";
|
||||
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Dégâts par seconde";
|
||||
LARGE_NUMBER_SEPERATOR = "";
|
||||
MINUTES_SECONDS = "%.2d:%.2d";
|
||||
PERCENTAGE_STRING = "%d%%";
|
||||
SILVER_AMOUNT_SYMBOL = "a";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/annulerfocal";
|
||||
SLASH_EQUIP_SET1 = "/equipset";
|
||||
SLASH_EQUIP_SET2 = "/équipens";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
SLASH_FOCUS2 = "/focal";
|
||||
LINK_TRADESKILL_TOOLTIP = "Cliquez ici pour créer un lien vers votre profession.";
|
||||
|
||||
@@ -9,4 +9,45 @@ ITEMS_IN_INVENTORY = "%d |4предмет:предмета:предметов;
|
||||
ITEM_SLOTS_IGNORED = "%d |4ячейка пропущена:ячейки пропущены:ячейки пропущены;";
|
||||
ITEM_MISSING = "Предмет для ячейки %s отсутствует";
|
||||
ITEMS_NOT_IN_INVENTORY = "%d |4предмет отсутствует:предмета отсутствует:предметов отсутствуют;";
|
||||
TARGETFOCUS = "Восстановить цель";
|
||||
TARGETFOCUS = "Восстановить цель";
|
||||
-- BINDING_NAME_AOELOOT = "AoE Loot";
|
||||
COPPER_AMOUNT_SYMBOL = "м";
|
||||
GENERIC_FRACTION_STRING = "%d/%d";
|
||||
GOLD_AMOUNT_SYMBOL = "з";
|
||||
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
|
||||
ITEM_DELTA_DESCRIPTION = "Если вы замените этот предмет, произойдут следующие изменения:";
|
||||
ITEM_MOD_MANA_SHORT = "к мане";
|
||||
ITEM_MOD_HEALTH_SHORT = "к здоровью";
|
||||
ITEM_MOD_AGILITY_SHORT = "к ловкости";
|
||||
ITEM_MOD_STRENGTH_SHORT = "к силе";
|
||||
ITEM_MOD_INTELLECT_SHORT = "к интеллекту";
|
||||
ITEM_MOD_SPIRIT_SHORT = "к духу";
|
||||
ITEM_MOD_STAMINA_SHORT = "к выносливости";
|
||||
ITEM_MOD_ATTACK_POWER_SHORT = "к силе атаки";
|
||||
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "к силе атаки дальнего боя";
|
||||
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "к показателю критического удара в ближнем бою";
|
||||
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "к показателю критического удара в дальнем бою";
|
||||
ITEM_MOD_HIT_MELEE_RATING_SHORT = "к меткости в ближнем бою";
|
||||
ITEM_MOD_HIT_RANGED_RATING_SHORT = "к меткости в дальнем бою";
|
||||
ITEM_MOD_HIT_SPELL_RATING_SHORT = "к меткости заклинаний";
|
||||
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "к критическому эффекту заклинаний";
|
||||
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "к дополнительному урону";
|
||||
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "к дополнительному исцелению";
|
||||
ITEM_MOD_MANA_REGENERATION_SHORT = "к восполнению маны";
|
||||
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Защита";
|
||||
ITEM_MOD_DODGE_RATING_SHORT = "Уклонение";
|
||||
ITEM_MOD_PARRY_RATING_SHORT = "Парирование";
|
||||
ITEM_MOD_BLOCK_RATING_SHORT = "Блок";
|
||||
ITEM_MOD_BLOCK_VALUE_SHORT = "к показателю блокирования";
|
||||
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "к урону в секунду";
|
||||
LARGE_NUMBER_SEPERATOR = "";
|
||||
MINUTES_SECONDS = "%.2d:%.2d";
|
||||
PERCENTAGE_STRING = "%d%%";
|
||||
SILVER_AMOUNT_SYMBOL = "с";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/сбросфокуса";
|
||||
SLASH_EQUIP_SET1 = "/комплект";
|
||||
SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
SLASH_FOCUS2 = "/фокус";
|
||||
LINK_TRADESKILL_TOOLTIP = "Щелкните здесь, чтобы создать ссылку на вашу профессию.";
|
||||
|
||||
@@ -9,4 +9,45 @@ ITEMS_IN_INVENTORY = "背包中有%d件物品";
|
||||
ITEM_SLOTS_IGNORED = "忽略%d个栏位";
|
||||
ITEM_MISSING = "%s缺失";
|
||||
ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
|
||||
TARGETFOCUS = "选中焦点目标";
|
||||
TARGETFOCUS = "选中焦点目标";
|
||||
-- BINDING_NAME_AOELOOT = "AoE Loot";
|
||||
COPPER_AMOUNT_SYMBOL = "铜";
|
||||
GENERIC_FRACTION_STRING = "%d/%d";
|
||||
GOLD_AMOUNT_SYMBOL = "金";
|
||||
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
|
||||
ITEM_DELTA_DESCRIPTION = "如果你替换该物品,将会产生以下的属性变更:";
|
||||
ITEM_MOD_MANA_SHORT = "法力值";
|
||||
ITEM_MOD_HEALTH_SHORT = "生命值";
|
||||
ITEM_MOD_AGILITY_SHORT = "敏捷";
|
||||
ITEM_MOD_STRENGTH_SHORT = "力量";
|
||||
ITEM_MOD_INTELLECT_SHORT = "智力";
|
||||
ITEM_MOD_SPIRIT_SHORT = "精神";
|
||||
ITEM_MOD_STAMINA_SHORT = "耐力";
|
||||
ITEM_MOD_ATTACK_POWER_SHORT = "攻击强度";
|
||||
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "远程攻击强度";
|
||||
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "爆击(近战)";
|
||||
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "爆击(远程)";
|
||||
ITEM_MOD_HIT_MELEE_RATING_SHORT = "命中(近战)";
|
||||
ITEM_MOD_HIT_RANGED_RATING_SHORT = "命中(远程)";
|
||||
ITEM_MOD_HIT_SPELL_RATING_SHORT = "命中(法术)";
|
||||
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "爆击(法术)";
|
||||
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "伤害加成";
|
||||
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "治疗加成";
|
||||
ITEM_MOD_MANA_REGENERATION_SHORT = "法力回复";
|
||||
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "防御";
|
||||
ITEM_MOD_DODGE_RATING_SHORT = "躲闪";
|
||||
ITEM_MOD_PARRY_RATING_SHORT = "招架";
|
||||
ITEM_MOD_BLOCK_RATING_SHORT = "格挡";
|
||||
ITEM_MOD_BLOCK_VALUE_SHORT = "格挡值";
|
||||
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "每秒伤害";
|
||||
LARGE_NUMBER_SEPERATOR = ",";
|
||||
MINUTES_SECONDS = "%.2d:%.2d";
|
||||
PERCENTAGE_STRING = "%d%%";
|
||||
SILVER_AMOUNT_SYMBOL = "银";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/clearfocus";
|
||||
SLASH_EQUIP_SET1 = "/equipset";
|
||||
SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
SLASH_FOCUS2 = "/focus";
|
||||
LINK_TRADESKILL_TOOLTIP = "点击这里以创建一个链接到你的专业的快捷方式。";
|
||||
|
||||
@@ -9,4 +9,45 @@ ITEMS_IN_INVENTORY = "%d件物品在物品欄中";
|
||||
ITEM_SLOTS_IGNORED = "忽略%d個欄位";
|
||||
ITEM_MISSING = "缺少%s物品";
|
||||
ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
|
||||
TARGETFOCUS = "選定鎖定單位";
|
||||
TARGETFOCUS = "選定鎖定單位";
|
||||
-- BINDING_NAME_AOELOOT = "AoE Loot";
|
||||
COPPER_AMOUNT_SYMBOL = "銅";
|
||||
GENERIC_FRACTION_STRING = "%d/%d";
|
||||
GOLD_AMOUNT_SYMBOL = "金";
|
||||
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
|
||||
ITEM_DELTA_DESCRIPTION = "若你更換此裝備,下列屬性將會改變:";
|
||||
ITEM_MOD_MANA_SHORT = "法力";
|
||||
ITEM_MOD_HEALTH_SHORT = "生命力";
|
||||
ITEM_MOD_AGILITY_SHORT = "敏捷";
|
||||
ITEM_MOD_STRENGTH_SHORT = "力量";
|
||||
ITEM_MOD_INTELLECT_SHORT = "智力";
|
||||
ITEM_MOD_SPIRIT_SHORT = "精神";
|
||||
ITEM_MOD_STAMINA_SHORT = "耐力";
|
||||
ITEM_MOD_ATTACK_POWER_SHORT = "攻擊強度";
|
||||
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "遠程攻擊強度";
|
||||
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "致命一擊(近戰)";
|
||||
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "致命一擊(遠程)";
|
||||
ITEM_MOD_HIT_MELEE_RATING_SHORT = "命中(近戰)";
|
||||
ITEM_MOD_HIT_RANGED_RATING_SHORT = "命中(遠程)";
|
||||
ITEM_MOD_HIT_SPELL_RATING_SHORT = "命中(法術)";
|
||||
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "致命一擊(法術)";
|
||||
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "傷害加成";
|
||||
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "治療加成";
|
||||
ITEM_MOD_MANA_REGENERATION_SHORT = "法力恢復";
|
||||
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "防禦";
|
||||
ITEM_MOD_DODGE_RATING_SHORT = "閃躲";
|
||||
ITEM_MOD_PARRY_RATING_SHORT = "招架";
|
||||
ITEM_MOD_BLOCK_RATING_SHORT = "格擋";
|
||||
ITEM_MOD_BLOCK_VALUE_SHORT = "格擋值";
|
||||
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "每秒傷害";
|
||||
LARGE_NUMBER_SEPERATOR = ",";
|
||||
MINUTES_SECONDS = "%.2d:%.2d";
|
||||
PERCENTAGE_STRING = "%d%%";
|
||||
SILVER_AMOUNT_SYMBOL = "銀";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/清除專注目標";
|
||||
SLASH_EQUIP_SET1 = "/equipset";
|
||||
SLASH_EQUIP_SET2 = "/裝備套裝";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
SLASH_FOCUS2 = "/專注目標";
|
||||
LINK_TRADESKILL_TOOLTIP = "點擊這裡以製造一個你專業技能的連結。";
|
||||
|
||||
+21
-5
@@ -1,6 +1,6 @@
|
||||
cmake_minimum_required(VERSION 3.10)
|
||||
|
||||
project(ClassicAPI LANGUAGES CXX)
|
||||
project(ClassicAPI LANGUAGES CXX RC)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
@@ -77,17 +77,20 @@ add_library(${PROJECT_NAME} SHARED
|
||||
# `CLASSIC_API_VERSION >= X` always pass during local development;
|
||||
# CI release builds overwrite it via `-DCLASSICAPI_TAG=vX.Y.Z`.
|
||||
set(CLASSICAPI_VERSION_VALUE 99999999)
|
||||
set(CLASSICAPI_VER_MAJOR 9999)
|
||||
set(CLASSICAPI_VER_MINOR 99)
|
||||
set(CLASSICAPI_VER_PATCH 99)
|
||||
|
||||
set(CLASSICAPI_TAG
|
||||
""
|
||||
CACHE STRING "Release tag like v1.2.3")
|
||||
|
||||
if(CLASSICAPI_TAG MATCHES "^v([0-9]+)\\.([0-9]+)\\.([0-9]+)$")
|
||||
set(_MAJOR "${CMAKE_MATCH_1}")
|
||||
set(_MINOR "${CMAKE_MATCH_2}")
|
||||
set(_PATCH "${CMAKE_MATCH_3}")
|
||||
set(CLASSICAPI_VER_MAJOR "${CMAKE_MATCH_1}")
|
||||
set(CLASSICAPI_VER_MINOR "${CMAKE_MATCH_2}")
|
||||
set(CLASSICAPI_VER_PATCH "${CMAKE_MATCH_3}")
|
||||
math(EXPR CLASSICAPI_VERSION_VALUE
|
||||
"${_MAJOR} * 10000 + ${_MINOR} * 100 + ${_PATCH}")
|
||||
"${CLASSICAPI_VER_MAJOR} * 10000 + ${CLASSICAPI_VER_MINOR} * 100 + ${CLASSICAPI_VER_PATCH}")
|
||||
elseif(CLASSICAPI_TAG)
|
||||
message(
|
||||
FATAL_ERROR
|
||||
@@ -99,6 +102,19 @@ target_compile_definitions(
|
||||
${PROJECT_NAME}
|
||||
PRIVATE CLASSICAPI_VERSION_VALUE=${CLASSICAPI_VERSION_VALUE})
|
||||
|
||||
# Stamp the PE VERSIONINFO resource from the same version components, so the
|
||||
# DLL's file/product version (Explorer -> Properties -> Details) matches the
|
||||
# CLASSIC_API_VERSION Lua global. The .rsrc section the resource lands in is
|
||||
# untouched by the .rdata/.pdata section merges below.
|
||||
set(CLASSICAPI_VER_STRING
|
||||
"${CLASSICAPI_VER_MAJOR}.${CLASSICAPI_VER_MINOR}.${CLASSICAPI_VER_PATCH}.0")
|
||||
set(CLASSICAPI_VERSION_RC "${CMAKE_BINARY_DIR}/generated/version.rc")
|
||||
configure_file(
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/cmake/version.rc.in"
|
||||
"${CLASSICAPI_VERSION_RC}"
|
||||
@ONLY)
|
||||
target_sources(${PROJECT_NAME} PRIVATE "${CLASSICAPI_VERSION_RC}")
|
||||
|
||||
# ole32: COM (CoInitializeEx / CoCreateInstance / CoTaskMemFree) for the
|
||||
# SAPI text-to-speech backend in src/voicechat/Tts.cpp. The SAPI GUIDs are
|
||||
# instantiated in that TU via <initguid.h>, so no sapi.lib is needed.
|
||||
|
||||
@@ -18,15 +18,15 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| Namespace | Calls |
|
||||
|-----------|-------|
|
||||
| [Action](docs/API.md#action) | `GetActionInfo` |
|
||||
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
|
||||
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
|
||||
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem` |
|
||||
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
|
||||
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
|
||||
| [Combat](docs/API.md#combat) | `InCombatLockdown` |
|
||||
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
|
||||
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
|
||||
| [Container](docs/API.md#container) | `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetItemCooldown`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
|
||||
| [Coroutine](docs/API.md#coroutine) | `coroutine.create`, `coroutine.resume`, `coroutine.yield`, `coroutine.status`, `coroutine.wrap` |
|
||||
| [Creature](docs/API.md#creature) | `C_CreatureInfo.GetCreatureID`, `C_CreatureInfo.GetCreatureInfoByID`, `C_CreatureInfo.RequestLoadCreatureByID` |
|
||||
| [Container](docs/API.md#container) | `C_Container.CalculateTotalNumberOfFreeBagSlots`, `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemInfo`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetItemCooldown`, `C_Container.HasContainerItem`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
|
||||
| [Creature](docs/API.md#creature) | `C_CreatureInfo.GetCreatureID`, `C_CreatureInfo.GetCreatureInfoByID`, `C_CreatureInfo.RequestLoadCreatureByID`, `C_CreatureInfo.GetRaceInfo`, `C_CreatureInfo.GetClassInfo`, `C_CreatureInfo.GetCreatureFamilyInfo`, `C_CreatureInfo.GetCreatureFamilyIDs`, `C_CreatureInfo.GetFactionInfo`, `C_CreatureInfo.GetCreatureTypeInfo`, `C_CreatureInfo.GetCreatureTypeIDs` |
|
||||
| EncodingUtil | `C_EncodingUtil.CompressString`, `C_EncodingUtil.DecompressString`, `C_EncodingUtil.EncodeBase64`, `C_EncodingUtil.DecodeBase64`, `C_EncodingUtil.EncodeHex`, `C_EncodingUtil.DecodeHex`, `C_EncodingUtil.SerializeJSON`, `C_EncodingUtil.DeserializeJSON`, `C_EncodingUtil.SerializeCBOR`, `C_EncodingUtil.DeserializeCBOR` |
|
||||
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` |
|
||||
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid` |
|
||||
@@ -39,28 +39,33 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex` |
|
||||
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:IsEquippedItem`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem` (script) |
|
||||
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
|
||||
| [Input](docs/API.md#input) | `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
|
||||
| [Input](docs/API.md#input) | `GetMouseButtonClicked`, `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.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `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.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemSubClassInfo`, `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.IsItemGUIDInInventory`, `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.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
|
||||
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfo`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `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.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsConsumableItem`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemInRange`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.PickupItem`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `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` |
|
||||
| [LootHistory](docs/API.md#loothistory) | `C_LootHistory.GetNumItems`, `C_LootHistory.GetItem`, `C_LootHistory.GetPlayerInfo`, `C_LootHistory.Clear` |
|
||||
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount` |
|
||||
| [Lua](docs/API.md#lua) | `coroutine.create`, `coroutine.resume`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `math.fmod`, `select`, `string.gmatch`, `string.match`, `table.wipe` |
|
||||
| [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`, `C_Map.GetMapOverlays` |
|
||||
| [Map](docs/API.md#map) | `C_Map.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapOverlays`, `C_Map.GetMapWorldSize` |
|
||||
| [MapExplorationInfo](docs/API.md#mapexplorationinfo) | `C_MapExplorationInfo.GetExploredMapTextures`, `C_MapExplorationInfo.GetUnexploredMapTextures` |
|
||||
| [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_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.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
|
||||
| [NameCache](docs/API.md#namecache) | `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `GetPlayerInfoByGUID`, `UnitNameFromGUID` |
|
||||
| [NewItems](docs/API.md#newitems) | `C_NewItems.ClearAll`, `C_NewItems.IsNewItem`, `C_NewItems.RemoveNewItem` |
|
||||
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GetClass`, `C_PlayerInfo.GetName`, `C_PlayerInfo.GetRace`, `C_PlayerInfo.GetSex`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `C_PlayerInfo.IsConnected` |
|
||||
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetHeaderIndexForQuest`, `C_QuestLog.GetLogIndexForQuestID`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
|
||||
| [Screen](docs/API.md#screen) | `GetPhysicalScreenSize` |
|
||||
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `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`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `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` |
|
||||
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `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.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
|
||||
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
|
||||
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsStealthed`, `IsSwimming` |
|
||||
| [Table](docs/API.md#table) | `table.wipe` |
|
||||
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
|
||||
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes` |
|
||||
| [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` |
|
||||
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
|
||||
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
|
||||
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitGUID`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
|
||||
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `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`, `C_UnitAuras.RegisterComboDuration` |
|
||||
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
|
||||
| [XMLUtil](docs/API.md#xmlutil) | `C_XMLUtil.DoesTemplateExist`, `C_XMLUtil.GetTemplateInfo`, `C_XMLUtil.GetTemplates` |
|
||||
@@ -112,6 +117,7 @@ when launching with `-console`), not as Lua functions. See the
|
||||
|
||||
| Event | Payload |
|
||||
|-------|---------|
|
||||
| `BAG_NEW_ITEMS_UPDATED` | *(none)* |
|
||||
| `BAG_UPDATE_DELAYED` | *(none)* |
|
||||
| `EQUIPMENT_SETS_CHANGED` | *(none)* |
|
||||
| `EQUIPMENT_SWAP_PENDING` | `setID` |
|
||||
@@ -121,6 +127,8 @@ when launching with `-console`), not as Lua functions. See the
|
||||
| `GLOBAL_MOUSE_UP` | `button` |
|
||||
| `HEARTHSTONE_BOUND` | *(none)* |
|
||||
| `ITEM_DATA_LOAD_RESULT` | `itemID, success` |
|
||||
| `LOOT_HISTORY_ROLL_CHANGED` | `itemIndex, playerIndex` |
|
||||
| `LOOT_HISTORY_ROLL_COMPLETE` | `itemIndex` |
|
||||
| `LOOT_SCAN_COMPLETED` | *(none)* |
|
||||
| `LOSS_OF_CONTROL_ADDED` | `eventIndex` |
|
||||
| `LOSS_OF_CONTROL_UPDATE` | `unitToken` (always `"player"`) |
|
||||
|
||||
@@ -3615,3 +3615,203 @@ vs. any hidden fixed-30 assumption elsewhere; and that the Lua-created
|
||||
lines inherit height/spacing identical to the template's 1..30 so the
|
||||
tooltip grows seamlessly. Both are observable on the first >30-line
|
||||
hover.
|
||||
|
||||
## 98. `UnitThreatSituation` / `UnitDetailedThreatSituation` / `GetThreatStatusColor` — NOT FEASIBLE in a client DLL
|
||||
|
||||
Modern (WotLK+) exposes three threat APIs:
|
||||
[`UnitThreatSituation`](https://warcraft.wiki.gg/wiki/API_UnitThreatSituation)
|
||||
(unit → 0..3 aggro status),
|
||||
[`UnitDetailedThreatSituation`](https://warcraft.wiki.gg/wiki/API_UnitDetailedThreatSituation)
|
||||
(isTanking, status, threatPct, rawThreatPct, threatValue), and
|
||||
[`GetThreatStatusColor`](https://warcraft.wiki.gg/wiki/API_GetThreatStatusColor)
|
||||
(status → RGB). None of them can be backed by ClassicAPI, because
|
||||
**the underlying data does not exist on the 1.12 client** — not in a
|
||||
form a DLL can read, and not by any amount of RE.
|
||||
|
||||
### Threat is 100% server-authoritative in vanilla; no threat opcode exists
|
||||
|
||||
Vanilla 1.12's threat lives entirely inside the server's
|
||||
`ThreatManager` (per-creature `ThreatList` of `HostileReference`s —
|
||||
see Turtle's
|
||||
[`src/game/Threat/ThreatManager.cpp`](C:\Git\turtle-wow\Dumps\Source%20Code\16%20-%20Development_server\patch_1172\src\game\Threat\ThreatManager.cpp)).
|
||||
The 3.3.5 client can show a threat meter because WotLK added
|
||||
`SMSG_THREAT_UPDATE` / `SMSG_HIGHEST_THREAT_UPDATE` (and the client
|
||||
keeps a per-unit threat table those packets feed). **1.12 has no such
|
||||
opcode** — the protocol never transmits per-unit threat values, so
|
||||
there is nothing in client memory to read. This is the same class of
|
||||
limitation as `C_QuestLog.IsQuestFlaggedCompleted` (TODO #1): the
|
||||
client simply isn't told. Unlike cast timing (TODO #91, recoverable
|
||||
from `SMSG_SPELL_START`) or aura casters (recoverable from
|
||||
`SMSG_SPELL_GO`), there is **no incidental packet** that leaks threat
|
||||
either — the server computes tanking/aggro decisions internally and
|
||||
broadcasts none of the numbers.
|
||||
|
||||
### How Turtle actually does it — a *server-side* addon-channel protocol
|
||||
|
||||
TWThreat (`C:\WoW\Octo\Interface\Addons\TWThreat`) is not reading
|
||||
client memory; it round-trips through a **custom server patch**:
|
||||
|
||||
1. Client → server: the addon `SendAddonMessage("TWT_UDTSv4"[.._TM],
|
||||
"limit=NN", "PARTY"/"RAID")` — a normal `CMSG_MESSAGECHAT` addon
|
||||
message (`TWThreat.lua:1207` `TWT.UnitDetailedThreatSituation`).
|
||||
2. Server intercepts the prefix in
|
||||
[`ChatHandler.cpp:1051`](turtle-wow\Dumps\Source%20Code\16%20-%20Development_server\patch_1172\src\game\Handlers\ChatHandler.cpp)
|
||||
(`strstr(msg, "TWT_UDTSv4")`), validates
|
||||
`GetSelectedCreature()->CanHaveThreatList()`, and calls
|
||||
`ThreatManager::UnitDetailedThreatSituation(creature, player, limit,
|
||||
tankMode)`.
|
||||
3. That serializer (`ThreatManager.cpp:441`) walks the real
|
||||
`getThreatList()` and builds a `TWTv4=name:pct:threat;…` string,
|
||||
then `SendAddonMessage`s it back.
|
||||
4. Client parses it in `CHAT_MSG_ADDON` → `TWT.handleThreatPacket`
|
||||
(`TWThreat.lua:790`).
|
||||
|
||||
Every threat number the meter shows originated on the server and was
|
||||
put on the wire by a server-side code change ClassicAPI has no
|
||||
counterpart to. A client DLL cannot manufacture data the server never
|
||||
sends.
|
||||
|
||||
### Even where a server *does* expose it, the transport is async
|
||||
|
||||
The Turtle path is a request/response over the addon chat channel —
|
||||
fundamentally asynchronous (poll on `OnUpdate`, handle the reply on a
|
||||
later frame). It cannot back a **synchronous** `UnitThreatSituation()`
|
||||
that returns a value in the same call, which is exactly why TWThreat is
|
||||
structured around events and a cached `TWT.threats` table rather than a
|
||||
blocking query. A ClassicAPI shim would at best have to fake a
|
||||
synchronous return from a stale cache, and the cache only exists if a
|
||||
Turtle-style server is feeding it — i.e. the shim would add nothing the
|
||||
addon isn't already doing in Lua.
|
||||
|
||||
### `GetThreatStatusColor` is the only client-pure piece — and it's trivial Lua
|
||||
|
||||
`GetThreatStatusColor(status)` is just a fixed 0..3 → RGB map (grey /
|
||||
yellow / orange / red); no engine data involved. But it's meaningless
|
||||
without a real `status` to feed it, and it's a two-line Lua table
|
||||
lookup — no reason to spend a DLL global on it. If we ever ship a
|
||||
threat polyfill it belongs beside the (Lua, server-dependent) status
|
||||
source, not in the DLL.
|
||||
|
||||
**Verdict:** skip all three. The data is server-only with no vanilla
|
||||
opcode, the only working implementation (Turtle) is a server patch plus
|
||||
an async addon-channel protocol that lives correctly in Lua, and the
|
||||
one client-pure function is a trivial color table. Nothing here is a
|
||||
client-DLL problem.
|
||||
|
||||
### If we synthesized it anyway
|
||||
|
||||
"Can't read it" and "can't estimate it" are separate claims. We can't
|
||||
read threat (above); *estimating* it is possible, but it splits into two
|
||||
tiers, and neither one is a DLL feature.
|
||||
|
||||
**Tier 1 — client-only proxy from the mob's target (small, honest,
|
||||
nearly useless).** The one threat-correlated fact vanilla *does* sync to
|
||||
the client is the creature's current target (`UNIT_FIELD_TARGET` in the
|
||||
descriptor — the same field `"<mob>target"` / `targettarget` reads). A
|
||||
mob attacks its highest-threat valid target, so `mobTarget == unit` ≈
|
||||
"unit is tanking". That yields:
|
||||
|
||||
- `UnitThreatSituation(unit, mob)` → `3` when the mob targets `unit`,
|
||||
else `nil`. It **cannot** produce `1` ("higher than tank, about to
|
||||
pull") or distinguish `2` from `3` — those are numeric comparisons and
|
||||
we have no numbers.
|
||||
- `UnitDetailedThreatSituation` → `isTanking` only; `status` degenerate,
|
||||
`threatpct` / `rawthreatpct` / `threatvalue` all unavailable.
|
||||
- Laggy: it changes only when the mob switches target (a descriptor
|
||||
update), so it *trails* real threat and gives zero predictive warning
|
||||
— the entire reason a threat meter exists.
|
||||
|
||||
This needs no DLL: `UnitIsUnit("<mob>target", unit)` already does it in
|
||||
pure Lua. Shipping it under the real API names would be actively
|
||||
misleading (callers expect the 0–3 gradient plus percentages), so it's
|
||||
not worth it even as a stub.
|
||||
|
||||
**Tier 2 — a real threat model (accurate-ish, huge, cooperative, ~95%
|
||||
Lua).** This is what KTM / KLHThreatMeter / vanilla-Omen actually do, and
|
||||
the only synthesis that fills the percentage/prediction dimensions the
|
||||
API is *for*:
|
||||
|
||||
1. **Model your own threat.** Parse your outgoing combat events and
|
||||
convert them to threat via a per-spell coefficient database (damage ≈
|
||||
1 threat per point, healing ≈ 0.5/HP applied to *every* mob in combat,
|
||||
flat-threat abilities like Sunder Armor / Demo Shout, per-spell
|
||||
bonus/penalty coefficients), then apply your active multipliers —
|
||||
stance/form (Defensive 1.3×, Berserker 0.8×, Bear/Cat), Righteous Fury
|
||||
(+Holy threat), Blessing of Salvation (0.7×), threat-reduction
|
||||
trinkets — and handle the sets/wipes: taunt (sets you to current top),
|
||||
Feign Death, Vanish, Fade, Cower.
|
||||
2. **Broadcast** your per-mob estimate over `SendAddonMessage` and
|
||||
**aggregate** everyone else's broadcasts into a per-mob table.
|
||||
3. Derive `isTanking` / `status` / the percentages by comparing each
|
||||
member to the current tank against the **110% melee / 130% ranged**
|
||||
pull threshold — the exact constants both Turtle's server serializer
|
||||
(`ThreatManager.cpp:494`, `threatValue*100 / (tankThreat * (isMelee ?
|
||||
1.1 : 1.3))`) and TWThreat's client fallback (`TWThreat.lua:932`)
|
||||
use. Worth stealing verbatim so a synthesized `threatpct` lines up
|
||||
with what real-threat clients show.
|
||||
|
||||
Why it isn't ours to build:
|
||||
|
||||
- The coefficient DB is **game-knowledge, not in the binary** — you
|
||||
cannot RE it out of the client; it's the classic hand-maintained
|
||||
Threat-1.0/KTM data. The parsing, broadcast, and aggregation are all
|
||||
Lua. The talent/aura/stance *inputs* are already exposed by existing
|
||||
ClassicAPI functions (`GetTalentInfo`, the aura APIs, `IsPlayerSpell`,
|
||||
the spell-mod reads). **None of it is a client-DLL problem.**
|
||||
- **Cooperative-only.** You can precisely model only members running the
|
||||
same addon and broadcasting; a non-participating tank is invisible (the
|
||||
original KTM caveat). The intuitive objection — "I can see everyone's
|
||||
damage in the combat log, why can't I model them from one client?" —
|
||||
fails for three compounding reasons, none liftable by the DLL:
|
||||
1. **You don't actually see everyone's events.** Vanilla's combat log
|
||||
is visibility/range-gated — the server only broadcasts combat events
|
||||
for units near you, not the whole raid. A stacked 5-man logs most of
|
||||
it (so a single-observer model degrades *gracefully* in dungeons); a
|
||||
spread 40-man leaves you blind to the ranged camp, the back-line
|
||||
healers, anyone outside your bubble. The DLL can't recover these —
|
||||
the server never sends the packets, so there's nothing to hook.
|
||||
2. **Threat ≠ damage, and the missing factor is hidden.** Threat is
|
||||
`damage × stance × talents × buffs`, not 1:1. The talent factor
|
||||
(Defiance, Subtlety, Feral Instinct, Shadow Affinity, Frost
|
||||
Channeling — the passive threat-mod talents) is **unreadable for
|
||||
other players in vanilla**; there's no inspect path to it. Stance /
|
||||
Blessing of Salvation / Fade are at least observable auras when the
|
||||
unit is in range, but talents are pure hidden state, so an observer
|
||||
can't derive a stranger's coefficient from their damage.
|
||||
3. **Much threat has no attributable log line.** In-combat healing
|
||||
generates threat on *every* mob the healer is engaged with (not the
|
||||
mob the heal touched); buffs / HoTs / PW:Fortitude, flat-threat
|
||||
abilities, and pet threat either don't appear in the log or can't be
|
||||
pinned to the right mob from a single line.
|
||||
|
||||
Broadcasting sidesteps all three because each client computes threat
|
||||
from *private, authoritative* knowledge — its own exact casts, its own
|
||||
talents, its own active stance/buffs — then shares the finished number.
|
||||
The Tier-1 mob-target read is worth keeping here *only* as a cross-check
|
||||
— it reveals who actually holds aggro regardless of who's broadcasting.
|
||||
- **It's an estimate that drifts.** Addon threat models desync from the
|
||||
server's true numbers (rounding, unmodeled effects, out-of-range or
|
||||
missed events). That drift is precisely why Blizzard later added
|
||||
`SMSG_THREAT_UPDATE` and why Turtle chose a server serializer over
|
||||
blessing an addon guess.
|
||||
|
||||
**The only DLL-shaped angle — and it's an *input*, not threat.** The one
|
||||
place a DLL beats Lua is the feed: vanilla's combat log is localized text
|
||||
keyed by unit *name* (ambiguous with duplicate names, breaks per-locale).
|
||||
Hooking the damage/heal packet handlers (`SMSG_SPELLNONMELEEDAMAGELOG`,
|
||||
`SMSG_ATTACKERSTATEUPDATE`, `SMSG_PERIODICAURALOG`) to emit precise,
|
||||
GUID-keyed, unlocalized damage/heal events would make any Lua threat
|
||||
model far more robust. But (a) that's a **separate feature** — a
|
||||
`COMBAT_LOG_EVENT`-style feed, useful well beyond threat — that should be
|
||||
filed on its own merits; (b) it still only *feeds* the Lua model — threat
|
||||
stays an estimate, computed cooperatively in Lua; and (c) on the actual
|
||||
target environment SuperWoW already exposes GUID-keyed combat info +
|
||||
`UNIT_CASTEVENT`, so even this niche is largely pre-filled.
|
||||
|
||||
**Bottom line on synthesis:** Tier 1 is a Lua one-liner too degenerate to
|
||||
wear the API's name; Tier 2 is a full KTM/Omen reimplementation that is
|
||||
almost entirely Lua data + logic, cooperative-only, and inherently an
|
||||
estimate. Anyone who wants vanilla threat should run an existing threat
|
||||
addon. ClassicAPI's only worthwhile contribution in this space is an
|
||||
unrelated precise-combat-log event feed — filed as its own thing, not as
|
||||
"threat".
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Generated from cmake/version.rc.in by CMake configure_file().
|
||||
// Stamps the PE VERSIONINFO resource so Explorer -> Properties -> Details
|
||||
// and GetFileVersionInfo() report the same version as the CLASSIC_API_VERSION
|
||||
// Lua global. Both derive from the CLASSICAPI_TAG (vMAJOR.MINOR.PATCH) cache
|
||||
// var; an untagged dev build gets the 9999.99.99 "newer than everything"
|
||||
// sentinel.
|
||||
|
||||
#include <winver.h>
|
||||
|
||||
VS_VERSION_INFO VERSIONINFO
|
||||
FILEVERSION @CLASSICAPI_VER_MAJOR@,@CLASSICAPI_VER_MINOR@,@CLASSICAPI_VER_PATCH@,0
|
||||
PRODUCTVERSION @CLASSICAPI_VER_MAJOR@,@CLASSICAPI_VER_MINOR@,@CLASSICAPI_VER_PATCH@,0
|
||||
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
|
||||
FILEFLAGS 0x0L
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
FILETYPE VFT_DLL
|
||||
FILESUBTYPE VFT2_UNKNOWN
|
||||
BEGIN
|
||||
BLOCK "StringFileInfo"
|
||||
BEGIN
|
||||
BLOCK "040904b0"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "ClassicAPI Team"
|
||||
VALUE "FileDescription", "ClassicAPI - modern WoW API backport for 1.12.1"
|
||||
VALUE "FileVersion", "@CLASSICAPI_VER_STRING@"
|
||||
VALUE "InternalName", "ClassicAPI"
|
||||
VALUE "LegalCopyright", "Licensed under GPLv3"
|
||||
VALUE "OriginalFilename", "ClassicAPI.dll"
|
||||
VALUE "ProductName", "ClassicAPI"
|
||||
VALUE "ProductVersion", "@CLASSICAPI_VER_STRING@"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
BEGIN
|
||||
// 0x0409 = U.S. English, 1200 = Unicode codepage.
|
||||
VALUE "Translation", 0x409, 1200
|
||||
END
|
||||
END
|
||||
+1898
-234
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -56,7 +56,8 @@ namespace Lua {
|
||||
F(NewThread, lua_newthread, LUA_NEW_THREAD) \
|
||||
F(Resume, lua_resume, LUA_RESUME) \
|
||||
F(Yield, lua_yield, LUA_YIELD) \
|
||||
F(ArgError, luaL_argerror, LUAL_ARG_ERROR)
|
||||
F(ArgError, luaL_argerror, LUAL_ARG_ERROR) \
|
||||
F(SetN, luaL_setn, LUAL_SETN)
|
||||
|
||||
#define CLASSICAPI_BIND_LUA(Name, Typedef, Offset) \
|
||||
const Typedef##_t Name = reinterpret_cast<Typedef##_t>(Offsets::Offset);
|
||||
|
||||
@@ -99,6 +99,7 @@ using lua_newthread_t = void *(__fastcall *)(void *L);
|
||||
using lua_resume_t = int(__fastcall *)(void *L, int nargs);
|
||||
using lua_yield_t = int(__fastcall *)(void *L, int nresults);
|
||||
using luaL_argerror_t = void(__fastcall *)(void *L, int narg, const char *msg);
|
||||
using luaL_setn_t = void(__fastcall *)(void *L, int t, int n);
|
||||
|
||||
extern const lua_isnumber_t IsNumber;
|
||||
extern const lua_isstring_t IsString;
|
||||
@@ -140,6 +141,7 @@ extern const lua_newthread_t NewThread;
|
||||
extern const lua_resume_t Resume;
|
||||
extern const lua_yield_t Yield;
|
||||
extern const luaL_argerror_t ArgError;
|
||||
extern const luaL_setn_t SetN;
|
||||
|
||||
// Returns the global `lua_State *` (read on demand from the engine's global).
|
||||
// Callable outside a Lua callback, e.g. during LoadScriptFunctions setup.
|
||||
|
||||
+492
@@ -287,6 +287,18 @@ enum Offsets {
|
||||
// the item isn't in the player's bag/equipment.
|
||||
FUN_GAMETOOLTIP_BUILD_ITEM_LINK = 0x0052AE00,
|
||||
|
||||
// CGItem → decorated instance display name. __thiscall(ecx = CGItem *,
|
||||
// char *outBuf, uint outSize). Reads the instance's random-suffix ID
|
||||
// off the descriptor (+0x98, gated on the broken flag) and writes the
|
||||
// plain — uncolored, unbracketed — display name into outBuf:
|
||||
// "Ethereum Torque of the Sorcerer" when a suffix is present, the base
|
||||
// ItemStats name otherwise (via the item cache + ItemRandomProperties
|
||||
// localized suffix at record +0x1c). This is the inner name builder
|
||||
// FUN_GAMETOOLTIP_BUILD_ITEM_LINK wraps in brackets, so the name it
|
||||
// produces matches GetItemLink's bracketed name and modern
|
||||
// C_Item.GetItemName exactly. Inner formatter FUN_005D8B00.
|
||||
FUN_ITEM_BUILD_INSTANCE_NAME = 0x005D8BC0,
|
||||
|
||||
// Engine's inventory swap-and-send. Same primitive
|
||||
// `Script_EquipCursorItem` (0x00489660) uses after the cursor's
|
||||
// source location has been resolved. Sends opcode 0x10D
|
||||
@@ -504,6 +516,38 @@ enum Offsets {
|
||||
// layout.
|
||||
OFF_UNIT_FIELD_TARGET = 0x28,
|
||||
|
||||
// Owner/controller GUID fields within `m_objectFields`, each a 64-bit
|
||||
// GUID (lo/hi). Both byte-verified from engine readers:
|
||||
// CHARMEDBY +0x10 — `Script_UnitIsCharmed`
|
||||
// (`mov ecx,[eax+0x10]; or ecx,[eax+0x14]; jz`).
|
||||
// CREATEDBY +0x20 — the unit owner/title builder `FUN_0052FD30`
|
||||
// reads `[desc+0x20]`/`[+0x24]` as the summoner GUID.
|
||||
// A pet/guardian/totem's owner lives in CREATEDBY; a charmed unit's in
|
||||
// CHARMEDBY. `Unit::Pet` reads these to find a minion's player owner.
|
||||
// (The engine uses only these two for ownership — there's a GUID slot
|
||||
// at +0x18 too, but nothing reads it as an owner, so it's left out.)
|
||||
OFF_UNIT_FIELD_CHARMEDBY = 0x10,
|
||||
OFF_UNIT_FIELD_CREATEDBY = 0x20,
|
||||
|
||||
// Pet-vs-minion discriminator for an owned unit: `int __fastcall(unit)`.
|
||||
// `0x00605570` is really the engine's **creature-type resolver** — it's
|
||||
// `Script_UnitCreatureType`'s (`0x0051A280`) inner helper and returns the
|
||||
// `CreatureType.dbc` id (1=Beast, 3=Demon, 7=Humanoid, …), via three
|
||||
// paths in order: a display-override at descriptor `+0x212`, then the
|
||||
// creature-cache type at `[unit+0xB30]+0x18`, then the player-race type
|
||||
// at `ChrRaces[[unit+0x110]+0x78]+0x24`. `FUN_UNIT_CREATURE_TYPE` is the
|
||||
// canonical name; `FUN_UNIT_PET_MINION_CLASS` is a historical alias for
|
||||
// the same address.
|
||||
//
|
||||
// The pet/minion use: the unit-title builder `FUN_0052FD30` calls it as
|
||||
// `(fn(unit) != 1) + 1` → 1 = "X's Pet" title, 2 = "X's Minion". That
|
||||
// works because it's really testing "is this a Beast (type 1)": hunter
|
||||
// pets are Beasts, warlock minions are Demons (type 3) — so `!= 1`
|
||||
// cleanly separates them, which neither the GUID (pets and guardians
|
||||
// share the 0xF14… prefix) nor UNIT_FLAG_PLAYER_CONTROLLED reliably does.
|
||||
FUN_UNIT_PET_MINION_CLASS = 0x00605570,
|
||||
FUN_UNIT_CREATURE_TYPE = 0x00605570,
|
||||
|
||||
// Party / raid roster counts and the party GUID array referenced
|
||||
// in the `FUN_TOKEN_TO_GUID` dispatch comment above. Used by
|
||||
// `UnitTokenFromGUID` to cap its candidate iteration — solo
|
||||
@@ -671,11 +715,19 @@ enum Offsets {
|
||||
// +0x00 ?
|
||||
// +0x0C name C-string ptr (alias of UNIT_NAME for creatures)
|
||||
// +0x10 subName C-string ptr ("Innkeeper", "<Master Trainer>", etc.)
|
||||
// +0x1C CreatureFamily id (→ CreatureFamily.dbc), 0 = none
|
||||
//
|
||||
// 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,
|
||||
// CreatureFamily id within the +0xB30 cache row — the value the engine's
|
||||
// `Script_UnitCreatureFamily` (`0x0051A310`) reads via its family helper
|
||||
// `FUN_006055e0` (`[unit+0xB30] ? [row+0x1C] : 0`) before mapping it
|
||||
// through `CreatureFamily.dbc` for the localized name. `UnitCreatureFamilyID`
|
||||
// returns the raw id. Same field as `OFF_CREATURE_FAMILY` in the creature
|
||||
// cache data block, reached through the unit here.
|
||||
OFF_CREATURE_CACHE_FAMILY = 0x1C,
|
||||
// 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
|
||||
@@ -746,6 +798,19 @@ enum Offsets {
|
||||
// mov edx, [eax + 0x110]
|
||||
// movzx eax, byte ptr [edx + 0x7B]
|
||||
OFF_UNIT_DESCRIPTOR_POWER_TYPE_BYTE = 0x7B,
|
||||
// UNIT_FIELD_STAT0..4 — the fully-computed (base+item+buff) primary
|
||||
// stats, broadcast in the descriptor. Order Str/Agi/Sta/Int/Spirit;
|
||||
// STAT4 (Spirit) = fieldIndex 0x94 → +0x250 (from the UpdateField name
|
||||
// table; cross-checked in-game: descriptor +0x250 read 173→182 when a
|
||||
// +9-Spirit item was equipped). What GetStat(STAT_SPIRIT) returns
|
||||
// server-side; used by GetSpellBonusHealing for the Spiritual-Guidance
|
||||
// (aura 175) spirit→healing conversion.
|
||||
OFF_UNIT_FIELD_STAT_SPIRIT = 0x250,
|
||||
// UNIT_FIELD_RESISTANCES[0] = armor (the fully-computed total), fieldIndex
|
||||
// 0x95 → +0x254 (7 resistances: armor, then the 6 magic schools). What
|
||||
// GetArmor() / GetResistance(SPELL_SCHOOL_NORMAL) returns; used by
|
||||
// GetSpellBonusHealing for Turtle's Ironclad (aura 199) armor→healing.
|
||||
OFF_UNIT_FIELD_RESISTANCE_ARMOR = 0x254,
|
||||
OFF_UNIT_FIELD_FLAGS = 0xA0,
|
||||
UNIT_FLAG_PLAYER_CONTROLLED = 0x08,
|
||||
// Bit 19 of UNIT_FIELD_FLAGS — `Script_UnitAffectingCombat` at
|
||||
@@ -816,6 +881,27 @@ enum Offsets {
|
||||
UNIT_AURA_TOTAL = 48,
|
||||
UNIT_AURA_VISIBLE_MASK = 0x0E, // nibble mask used by the engine's visibility gate
|
||||
|
||||
// PLAYER_FIELD_MOD_DAMAGE_DONE_POS/NEG — the player's per-school spell
|
||||
// damage bonus. Offsets from the engine's own UpdateField name/index
|
||||
// table in `.rdata` (20-byte entries `{char* name, u32 fieldIndex, u32
|
||||
// count, ...}` at VA 0x83B6C8): POS = fieldIndex 0x3F5 count 7 → byte
|
||||
// offset fieldIndex*4 = 0xFD4; NEG = 0x3FC → 0xFF0. Seven int32 each,
|
||||
// one per school (0 physical .. 6 arcane); net bonus = POS − NEG.
|
||||
//
|
||||
// **Read these off `[player + OFF_CGPLAYER_INFO]` (the +0xE68 CGPlayer
|
||||
// sub-struct), NOT the broadcast descriptor at +0x110.** The descriptor
|
||||
// copy is never sent to the client and reads zero (verified in-game: the
|
||||
// whole post-skills PLAYER descriptor region is zero on a geared L60 —
|
||||
// vanilla never displayed spell power, so the server doesn't broadcast
|
||||
// it). The +0xE68 struct holds the client's fully-computed value
|
||||
// (gear + enchants + buffs + talents + sets baked in). Same base +
|
||||
// offsets nampower's GetSpellPower uses; verified 613 generic / 647 Fire
|
||||
// on a geared mage. Read by GetSpellBonusDamage in [src/spell/BonusDamage.cpp].
|
||||
// (No MOD_HEALING_DONE equivalent — vanilla lacks the field entirely;
|
||||
// GetSpellBonusHealing is derived instead.)
|
||||
OFF_PLAYER_FIELD_MOD_DAMAGE_DONE_POS = 0xFD4,
|
||||
OFF_PLAYER_FIELD_MOD_DAMAGE_DONE_NEG = 0xFF0,
|
||||
|
||||
// Reusable "is this spell record a user-visible aura" predicate.
|
||||
// `__fastcall(spellRecord*) -> bool`. Checks Spell.dbc Attributes
|
||||
// (high bit of byte at +0x18 must be clear), AttributesEx bit
|
||||
@@ -903,6 +989,22 @@ enum Offsets {
|
||||
// folds in the descriptor cost mods).
|
||||
FUN_GET_SPELL_COST = 0x006E31B0,
|
||||
|
||||
// Full spell-castability check for the LOCAL player:
|
||||
// `char __fastcall(const uint8_t *spellRecord /*ecx*/,
|
||||
// int *outNoMana /*edx*/)`.
|
||||
// Resolves the local player internally (no unit arg), then gates
|
||||
// on casting state, stun/confuse flags, mechanic immunities,
|
||||
// shapeshift/form + required stances, aura-state requirements,
|
||||
// combo points (finishers), spell RequiredSkill, and finally the
|
||||
// power check: `cost(FUN_GET_SPELL_COST) <= currentPower` → usable,
|
||||
// else sets `*outNoMana = 1`. Returns nonzero (low byte) when
|
||||
// usable. Does NOT check spell knowledge — so it's valid to feed
|
||||
// an item's on-use spell record (which the player never "knows").
|
||||
// This is the helper the action-usability recompute uses for
|
||||
// player spell slots; `Item::Usable` reuses it for item on-use
|
||||
// spells. Returns 0 cleanly pre-world (no player).
|
||||
FUN_SPELL_IS_USABLE = 0x006E3D60,
|
||||
|
||||
// Effective spell/channel duration (ms):
|
||||
// `__fastcall int(const void *spellRecord, int unit /*0 = player,
|
||||
// nonzero = pet*/, int skipMod /*0 = apply mods*/)`. SpellDuration
|
||||
@@ -912,6 +1014,25 @@ enum Offsets {
|
||||
// RECORD pointer, not a spellID.
|
||||
FUN_GET_SPELL_DURATION = 0x006EA000,
|
||||
|
||||
// Pure spell range check against a target:
|
||||
// `char __fastcall(void *caster /*ecx*/, void *target /*edx*/,
|
||||
// int spellID, char *outMinMaxFlag)`.
|
||||
// Computes the spell's min/max range via `FUN_006e3480` (which folds
|
||||
// in the target's bounding radius), reads both objects' world
|
||||
// positions (GetPosition, vtable+0x14), and compares center-to-center
|
||||
// squared distance. Returns low byte 1 = in range, 0 = out (too close
|
||||
// OR too far); `*outMinMaxFlag` is set to 1 on a max-range (too far)
|
||||
// failure so the caller can pick a "too close" vs "out of range" error
|
||||
// string — we ignore it. Both objects must be non-null (it dereferences
|
||||
// the GetPosition result without a null check). This is the geometric
|
||||
// core the action-button range coloring uses: `Script_IsActionInRange`
|
||||
// (`0x004E7550`) → `FUN_004E56F0` → `FUN_006E4440` → here. We call it
|
||||
// directly for `C_Spell.IsSpellInRange` rather than `FUN_006E4440`,
|
||||
// which additionally gates on target type and folds "range N/A" into
|
||||
// the same truthy return as "in range" (so it can't yield true/false/
|
||||
// nil cleanly).
|
||||
FUN_SPELL_RANGE_CHECK = 0x006E47B0,
|
||||
|
||||
// Spell.dbc `m_durationIndex` field — pointer into SpellDuration.dbc.
|
||||
// Verified via `FUN_004E44B0` (`0x004e44b0`) and `FUN_006EA000`
|
||||
// (`0x006ea000`), both of which read `[spellRec + 0x78]` and use
|
||||
@@ -1137,8 +1258,33 @@ enum Offsets {
|
||||
// locLeft/locRight/locTop/locBottom floats}
|
||||
VAR_WORLDMAP_AREA_RECORDS = 0x00C0D5BC,
|
||||
VAR_WORLDMAP_AREA_COUNT = 0x00C0D5C0,
|
||||
OFF_WMA_MAP_ID = 0x04,
|
||||
OFF_WMA_AREA_ID = 0x08,
|
||||
OFF_WMA_NAME = 0x0C,
|
||||
OFF_WMA_LOC_LEFT = 0x10, // world Y at the map's left edge (max Y)
|
||||
OFF_WMA_LOC_RIGHT = 0x14, // world Y at the map's right edge (min Y)
|
||||
OFF_WMA_LOC_TOP = 0x18, // world X at the map's top edge (max X)
|
||||
OFF_WMA_LOC_BOTTOM = 0x1C, // world X at the map's bottom edge (min X)
|
||||
|
||||
// AreaTrigger.dbc record (10 fields, 0x28 bytes) — instance cataloged
|
||||
// in docs/DBCs.md (records slot 0x00C0E034, count 0x00C0E038). The
|
||||
// client loads it but exposes NO geometry to Lua, which is why map
|
||||
// addons (pfQuest et al.) ship hand-scraped trigger tables. Schema:
|
||||
// {ID@+0, mapID@+4 (Map.dbc — continent or instance),
|
||||
// x@+8, y@+0xC, z@+0x10 (continent-space world coords),
|
||||
// radius@+0x14 (sphere trigger; 0 => box trigger),
|
||||
// boxLength@+0x18, boxWidth@+0x1C, boxHeight@+0x20, boxYaw@+0x24}
|
||||
VAR_AREATRIGGER_RECORDS = 0x00C0E034,
|
||||
VAR_AREATRIGGER_COUNT = 0x00C0E038,
|
||||
OFF_AT_MAP_ID = 0x04,
|
||||
OFF_AT_X = 0x08,
|
||||
OFF_AT_Y = 0x0C,
|
||||
OFF_AT_Z = 0x10,
|
||||
OFF_AT_RADIUS = 0x14,
|
||||
OFF_AT_BOX_LENGTH = 0x18,
|
||||
OFF_AT_BOX_WIDTH = 0x1C,
|
||||
OFF_AT_BOX_HEIGHT = 0x20,
|
||||
OFF_AT_BOX_YAW = 0x24,
|
||||
|
||||
// WorldMapOverlay.dbc record (17 fields, 0x44 bytes):
|
||||
// {ID@+0, worldMapAreaID@+4 (-> WorldMapArea row),
|
||||
@@ -1153,6 +1299,7 @@ enum Offsets {
|
||||
VAR_WORLDMAP_OVERLAY_RECORDS = 0x00C0D594,
|
||||
VAR_WORLDMAP_OVERLAY_COUNT = 0x00C0D598,
|
||||
OFF_WMO_WORLDMAP_AREA = 0x04,
|
||||
OFF_WMO_AREA_ID = 0x08, // areaID[4] @ +0x08..+0x14 (AreaTable ids revealed)
|
||||
OFF_WMO_MAP_POINT_X = 0x18,
|
||||
OFF_WMO_MAP_POINT_Y = 0x1C,
|
||||
OFF_WMO_TEXTURE_NAME = 0x20,
|
||||
@@ -1160,6 +1307,10 @@ enum Offsets {
|
||||
OFF_WMO_TEXTURE_HEIGHT = 0x28,
|
||||
OFF_WMO_OFFSET_X = 0x2C,
|
||||
OFF_WMO_OFFSET_Y = 0x30,
|
||||
OFF_WMO_HITRECT_TOP = 0x34, // hit rect in world-map canvas px (~1002x668):
|
||||
OFF_WMO_HITRECT_LEFT = 0x38, // the tight clickable bounds of the landmass,
|
||||
OFF_WMO_HITRECT_BOTTOM = 0x3C, // used for subzone-center placement (pfQuest's
|
||||
OFF_WMO_HITRECT_RIGHT = 0x40, // pfDB["zones"] was scraped from these)
|
||||
|
||||
// Current world-map view selection — the globals the engine's
|
||||
// current-map-name resolver (FUN_004a6cf0, behind GetMapInfo) reads:
|
||||
@@ -1218,6 +1369,42 @@ enum Offsets {
|
||||
// aura that's still inside its computed duration window.
|
||||
FUN_ON_AURA_REMOVED = 0x00612320,
|
||||
|
||||
// Group-loot roll packet handlers (the inner display functions in
|
||||
// LootRoll.cpp). Each is `__fastcall`; the deserialized 0x48-byte
|
||||
// PendingLootRoll message struct is fully populated at entry and freed
|
||||
// at exit, so read its fields BEFORE calling the original. The register
|
||||
// the message arrives in differs per handler (matched in
|
||||
// loot/History.cpp): ROLL passes it in ECX, WON / ALL_PASSED in EDX.
|
||||
// Backs the C_LootHistory backport (loot/History.cpp) — 1.12 has no
|
||||
// loot-history store, so we reconstruct one from these packets.
|
||||
FUN_LOOT_ROLL_HANDLER = 0x0061C0B0, // SMSG_LOOT_ROLL (msg=ECX)
|
||||
FUN_LOOT_ROLL_WON_HANDLER = 0x0061B9E0, // SMSG_LOOT_ROLL_WON (msg=EDX)
|
||||
FUN_LOOT_ALL_PASSED_HANDLER = 0x0061B640, // SMSG_LOOT_ALL_PASSED (msg=EDX)
|
||||
|
||||
// SMSG_LOOT_START_ROLL (opcode 0x2A1) handler — `__fastcall(ECX = packet)`,
|
||||
// RET 0. The result packets (ROLL/WON/PASSED) carry 0 for the item's
|
||||
// random suffix/seed; only START_ROLL does. It deserializes a fresh
|
||||
// PendingLootRoll node (same 0x20/0x24 field offsets as the message) and
|
||||
// publishes it as the store head at VAR_LOOTROLL_STORE_HEAD, so we read
|
||||
// the suffix straight off that node right after the original runs.
|
||||
FUN_LOOT_START_ROLL_HANDLER = 0x0061B310,
|
||||
VAR_LOOTROLL_STORE_HEAD = 0x00C4DC00, // PendingLootRoll* — newest node
|
||||
|
||||
// PendingLootRoll message field offsets, derived from the SMSG_LOOT_ROLL
|
||||
// deserializer FUN_0061BFA0 (reads GUID/u32/byte fields off the packet).
|
||||
OFF_LOOTROLL_SOURCE_GUID = 0x10, // u64 looted-object GUID (roll identity)
|
||||
OFF_LOOTROLL_SLOT = 0x18, // u32 loot slot
|
||||
OFF_LOOTROLL_ITEM_ID = 0x1C, // u32 itemID
|
||||
// Random-enchant fields for the item link `item:id:enchant:suffix:unique`.
|
||||
// +0x20 is the suffix/random-property ID the engine's item-name builder
|
||||
// FUN_005D8B00 uses (indexes the random-properties DBC for the "of the X"
|
||||
// name); +0x24 is the paired seed/unique value.
|
||||
OFF_LOOTROLL_ITEM_SUFFIX = 0x20, // i32 random suffix / property ID
|
||||
OFF_LOOTROLL_ITEM_SEED = 0x24, // u32 random seed (link unique field)
|
||||
OFF_LOOTROLL_ROLLER_GUID = 0x30, // u64 roller / winner GUID
|
||||
OFF_LOOTROLL_ROLL_NUM = 0x38, // u8 roll (1..100); high bit = didn't roll
|
||||
OFF_LOOTROLL_ROLL_TYPE = 0x40, // u32 rollType: 0 = need, 2 = greed
|
||||
|
||||
// CGPlayer-side sub-struct, allocated for any player-controlled
|
||||
// unit (local self, target, party, raid, inspect targets — all of
|
||||
// them). Holds player-specific data that's *not* in the broadcast
|
||||
@@ -1251,6 +1438,22 @@ enum Offsets {
|
||||
// (see OFF_UNIT_FIELD_CHANNEL_SPELL below); PLAYER_FLAGS only
|
||||
// exists on the +0xE68 sub-struct here.
|
||||
OFF_CGPLAYER_INFO = 0xE68,
|
||||
// Explored-areas bitfield inside the CGPlayer info sub-struct
|
||||
// (`[player+0xE68] + 0xE6C`): bit `AreaBit` set == that AreaTable area
|
||||
// is explored. 0x100 bytes. Read by the discovered-overlay rebuild
|
||||
// FUN_004a67a0 as `bits[AreaBit>>3] & (1 << (AreaBit&7))`.
|
||||
OFF_PLAYER_EXPLORED_BITS = 0xE6C,
|
||||
|
||||
// PvP rank fields within the CGPlayer +0xE68 sub-struct (siblings of
|
||||
// the combo-point byte at +0x1029). Read by the item tooltip's
|
||||
// "Requires <rank>" / PVP_MEDAL red-text logic (FUN_0052B650) and
|
||||
// mirrored by C_PlayerInfo.CanUseItem:
|
||||
// +0x102B u8 current PvP honor rank (compared >= item
|
||||
// RequiredHonorRank).
|
||||
// +0x1034 u32 earned PvP-medal/rank bitmask (item RequiredCityRank
|
||||
// R passes when bit (R-1) is set).
|
||||
OFF_CGPLAYER_HONOR_RANK = 0x102B,
|
||||
OFF_CGPLAYER_PVP_MEDALS = 0x1034,
|
||||
|
||||
// CGObject's `GetPosition` virtual — vtable slot 5 (offset 0x14).
|
||||
// Signature: `__thiscall(this, float outBuf[3]) → float *position`.
|
||||
@@ -1264,6 +1467,32 @@ enum Offsets {
|
||||
// `0x0048BACF` / `0x0048BADC`.
|
||||
OFF_CGOBJECT_VTBL_GET_POSITION = 0x14,
|
||||
|
||||
// CGObject `m_objectType` field — 0 = (base) object, 1 = item,
|
||||
// 2 = container, 3 = unit, 4 = player, 5 = gameobject, … Read as
|
||||
// `*(int *)(obj + 0x14)`. (Distinct from GET_POSITION above, which
|
||||
// is a *vtable* byte-offset that happens to also be 0x14.)
|
||||
OFF_CGOBJECT_TYPE_ID = 0x14,
|
||||
OBJECT_TYPE_UNIT = 3,
|
||||
OBJECT_TYPE_PLAYER = 4,
|
||||
|
||||
// World line-segment collision test (client-side LoS core):
|
||||
// `char __fastcall(const Vec3 *start /*ecx*/, const Vec3 *end /*edx*/,
|
||||
// int unused, Vec3 *hitOut, float *tOut, uint32 flags)`.
|
||||
// Returns nonzero if the ray hit world geometry; `*tOut` is the hit
|
||||
// fraction along start→end (t in [0,1] ⇒ hit lies between the two
|
||||
// points ⇒ blocked; t outside [0,1] or no hit ⇒ clear). `flags`
|
||||
// selects geometry: 0x100111 = terrain + WMO (the LoS combo;
|
||||
// 0x100171 also tests M2 doodads but crashes in some dungeons per
|
||||
// UnitXP_SP3). Thin `__fastcall` shim that tail-calls the real
|
||||
// intersect `FUN_0069BFF0`; verified in-binary and cross-checked
|
||||
// against UnitXP_SP3's `CWorld_Intersect` (same VA + flags).
|
||||
FUN_CWORLD_INTERSECT = 0x00672170,
|
||||
// Collision-box height of a unit, read from its CMovement sub-struct
|
||||
// (`[unit + OFF_UNIT_MOVEMENT_INFO_PTR]`) at `+0xB4`. Used as the
|
||||
// eye-height offset for LoS so foot-to-foot traces don't false-block
|
||||
// on terrain. (CMovement pointer offset 0x118 = OFF_UNIT_MOVEMENT_INFO_PTR.)
|
||||
OFF_CMOVEMENT_COLLISION_BOX_HEIGHT = 0xB4,
|
||||
|
||||
// Quest list array inside the `+0xE68` sub-struct. 20 fixed
|
||||
// slots of 0xC (12) bytes each — `questID` at slot+0x00, and
|
||||
// some flags/state in the remaining 8 bytes. Walked by
|
||||
@@ -1344,6 +1573,25 @@ enum Offsets {
|
||||
// Reads bagID at Lua stack[1] and slot at stack[2], validates them, and
|
||||
// returns the inventory manager + linear slot ready to feed into GetItemBySlot.
|
||||
FUN_PACK_BAG_SLOT = 0x004F9820,
|
||||
// Equipped-bag container-GUID getter — `uint64 __fastcall(uint bagIndex0)`
|
||||
// where `bagIndex0` is 0-based (Lua bagID 1..4 → 0..3; 4..9 are bank bags,
|
||||
// gated on the bank-open globals). Returns the CGContainer GUID of the bag
|
||||
// equipped in that slot, or 0 if none. This is the internal PackBagSlot
|
||||
// uses to resolve bags 1..4: get the bag GUID here, resolve the container
|
||||
// via FUN_OBJECT_RESOLVE_BY_GUID(OBJ_TYPE_CONTAINER, guid), then call the
|
||||
// container's vtable[+OFF_CONTAINER_GET_INVENTORY] to get the inventory
|
||||
// object GetItemBySlot indexes. Lets us enumerate bag contents in pure C++
|
||||
// without PackBagSlot's Lua-stack coupling. (Reads GUID arrays at
|
||||
// 0x00BDD060 lo / 0x00BDD064 hi, stride 8.)
|
||||
FUN_GET_EQUIPPED_BAG_GUID = 0x004F93E0,
|
||||
// CGContainer vtable slot (byte offset into the vtable) whose method
|
||||
// returns the container's inventory object — `void* __thiscall(container)`.
|
||||
// The inventory object's slot count lives at its +0x00 (OFF_INVMGR_SLOT_COUNT).
|
||||
OFF_CONTAINER_GET_INVENTORY = 0x10,
|
||||
// Backpack (bagID 0) linear-slot base in the player invMgr GUID/item
|
||||
// arrays: a 1-based backpack slot S maps to linear (S - 1) + 23. Matches
|
||||
// PackBagSlot's `*param_3 += 0x17` for the backpack branch.
|
||||
BACKPACK_LINEAR_BASE = 23,
|
||||
// Player inventory manager layout — used for direct GUID-array reads
|
||||
// that bypass `GetItemBySlot`'s bank gate at `0x006228C1`.
|
||||
// +0x00 uint32 max slot count
|
||||
@@ -1452,6 +1700,17 @@ enum Offsets {
|
||||
// an item, swaps with the bag slot). Used by `EquipItemByName` to
|
||||
// pick up the source item before dispatching to the equip helpers.
|
||||
FUN_SCRIPT_PICKUP_CONTAINER_ITEM = 0x004F9B30,
|
||||
// Container-content cursor-pickup PRIMITIVE — the plain-pickup branch
|
||||
// inside `Script_PickupContainerItem`'s state machine, called directly
|
||||
// (no Lua stack, no state-machine modes).
|
||||
// void __fastcall(uint linearSlot, int count, uint itemGuidLo,
|
||||
// uint itemGuidHi, uint containerGuidLo,
|
||||
// uint containerGuidHi, int flag)
|
||||
// Self-resolves the player, sets the cursor-state globals
|
||||
// (VAR_CURSOR_ITEM_GUID_*, cursor type = 1) and fires the cursor-update
|
||||
// event itself. Does NOT apply the cursor-empty / item-lock guards the
|
||||
// Script wrapper does — callers replicate those (Item::Cursor::PickupBagItem).
|
||||
FUN_CURSOR_PICKUP_ITEM = 0x00494B60,
|
||||
// `CGItem::UseItem` — the engine's actual "use this item" primitive.
|
||||
// It's the fallback dispatch in `Script_UseContainerItem` (the call
|
||||
// site at 0x004FA430 after every special-cursor-mode branch is
|
||||
@@ -1579,6 +1838,16 @@ enum Offsets {
|
||||
// last use"). Negative = single-use, destroyed when count hits 0.
|
||||
// `GetItemCount(includeUses=true)` uses abs() for the multiplier.
|
||||
OFF_DESCRIPTOR_SPELL_CHARGES_0 = 0x28,
|
||||
// ITEM_FIELD_ENCHANTMENT block (fields 16..36, per the +0x48
|
||||
// enchant-charges note above) — 7 enchant slots, 3 dwords each
|
||||
// `{enchantID, duration, charges}`. Slot 0 = permanent enchant,
|
||||
// slot 1 = temporary (weapon oils / sharpening stones). The
|
||||
// enchant ID indexes SpellItemEnchantment.dbc. Block base = field
|
||||
// 16 = +0x40 (field 15 FLAGS at +0x3C precedes it); slot stride
|
||||
// 0x0C, enchant ID at slot+0x00. Used by spell/BonusDamage.cpp to
|
||||
// fold enchant-granted spell power into GetSpellBonusDamage.
|
||||
OFF_DESCRIPTOR_ENCHANTMENT_ID = 0x40,
|
||||
DESCRIPTOR_ENCHANTMENT_SLOT_STRIDE = 0x0C,
|
||||
// ITEM_FIELD_DURABILITY (current) and ITEM_FIELD_MAXDURABILITY (max) live
|
||||
// adjacent to each other in the descriptor as plain dwords. Verified in
|
||||
// `Script_GetInventoryItemBroken` (`0x004C8590`): after resolving the
|
||||
@@ -1587,6 +1856,12 @@ enum Offsets {
|
||||
// no durability concept (consumables, materials) have both fields 0.
|
||||
OFF_DESCRIPTOR_DURABILITY = 0xA0,
|
||||
OFF_DESCRIPTOR_MAX_DURABILITY = 0xA4,
|
||||
// Per-instance "readable text" id (letters / mail-scroll items carry a
|
||||
// server-assigned text id here). Drives the `readable` return of
|
||||
// `Script_GetContainerItemInfo` (`0x004F9670`): at `0x004F97C1` it reads
|
||||
// `[descriptor+0x9C]` and, when non-zero, reports the slot as readable
|
||||
// even when the static ItemStats page-text is 0. Field index 0x27.
|
||||
OFF_DESCRIPTOR_READABLE_TEXT_ID = 0x9C,
|
||||
|
||||
// Per-item repair-cost helper used by Script_GameTooltip_SetInventoryItem
|
||||
// (3rd return value). __fastcall(ecx = CGItem *) -> int copperCost.
|
||||
@@ -2023,6 +2298,56 @@ enum Offsets {
|
||||
OFF_CHRRACES_FILENAME = 0x3C,
|
||||
OFF_CHRRACES_NAMES = 0x44,
|
||||
|
||||
// CreatureFamily.dbc — pet/beast family metadata. Standard 5-DWORD
|
||||
// class shape (instance 0x00C0DE74; records 0x00C0DE7C, count
|
||||
// 0x00C0DE80 — the same globals `Script_UnitCreatureFamily`
|
||||
// (`0x0051A310`) bounds-checks/indexes). 72-byte stride, familyID-keyed
|
||||
// and sparse (10/13/14/22/… are unused). Localized `Name[]` at +0x20
|
||||
// (the engine reads `record + 0x20 + locale*4`), `IconFile` texture-path
|
||||
// string at +0x44 (last field; empty for warlock-pet families). Backs
|
||||
// `C_CreatureInfo.GetCreatureFamilyInfo` / `GetCreatureFamilyIDs`.
|
||||
VAR_CREATUREFAMILY_RECORDS = 0x00C0DE7C,
|
||||
VAR_CREATUREFAMILY_COUNT = 0x00C0DE80,
|
||||
OFF_CREATUREFAMILY_NAMES = 0x20,
|
||||
OFF_CREATUREFAMILY_ICON = 0x44,
|
||||
|
||||
// CreatureType.dbc — pet/NPC classification (1=Beast … 11=Totem;
|
||||
// contiguous ids, 11 rows). Records 0x00C0DE2C, count 0x00C0DE30 (the
|
||||
// globals `Script_UnitCreatureType` (`0x0051A280`) bounds-checks/indexes),
|
||||
// localized `Name[]` at +0x04 (engine reads `record + 0x04 + locale*4`).
|
||||
// Backs `C_CreatureInfo.GetCreatureTypeInfo` / `GetCreatureTypeIDs`;
|
||||
// `UnitCreatureTypeID` returns the id `FUN_UNIT_CREATURE_TYPE` resolves.
|
||||
VAR_CREATURETYPE_RECORDS = 0x00C0DE2C,
|
||||
VAR_CREATURETYPE_COUNT = 0x00C0DE30,
|
||||
OFF_CREATURETYPE_NAMES = 0x04,
|
||||
|
||||
// Race → faction group, mirroring the player branch of
|
||||
// `Script_UnitFactionGroup` (`0x00516630`) — backs
|
||||
// `C_CreatureInfo.GetFactionInfo(raceID)`. Chain:
|
||||
// ChrRaces[race] + 0x08 → FactionTemplate id
|
||||
// FactionTemplate[id] + 0x0C → group mask (ourMask)
|
||||
// scan FactionGroup for the row whose bit is set (and whose
|
||||
// localized name is non-empty — that non-empty test is what
|
||||
// skips the always-set "Player" bit and lands on Alliance/Horde)
|
||||
// push { groupTag = row+0x08 (english), name = row+0x0C+locale*4 }
|
||||
// FactionTemplate is a pointer-array DBC (records[id]); FactionGroup
|
||||
// is stored CONTIGUOUSLY (records-base + i*stride, direct field reads,
|
||||
// NOT a pointer array), so iterate it by hand rather than via DBC::Record.
|
||||
// FactionTemplate globals match docs/DBCs.md; the FactionGroup
|
||||
// records-base/count globals are the ones the engine actually
|
||||
// dereferences per the disassembly (0x00C0DD5C / 0x00C0DD60), which
|
||||
// differ from DBCs.md's standard-shape tabulation for this table.
|
||||
OFF_CHRRACES_FACTION_TEMPLATE = 0x08,
|
||||
VAR_FACTIONTEMPLATE_RECORDS = 0x00C0DD3C,
|
||||
VAR_FACTIONTEMPLATE_COUNT = 0x00C0DD40,
|
||||
OFF_FACTIONTEMPLATE_GROUP_MASK = 0x0C,
|
||||
VAR_FACTIONGROUP_RECORDS = 0x00C0DD5C,
|
||||
VAR_FACTIONGROUP_COUNT = 0x00C0DD60,
|
||||
FACTIONGROUP_STRIDE = 0x30,
|
||||
OFF_FACTIONGROUP_BIT = 0x04,
|
||||
OFF_FACTIONGROUP_ENGLISH = 0x08,
|
||||
OFF_FACTIONGROUP_NAMES = 0x0C,
|
||||
|
||||
// AreaTable.dbc — used by `Script_GetCharacterInfo` to resolve
|
||||
// each character's last-known-area to a localized zone name.
|
||||
// Same shape as the other DBCs (records pointer + count globals,
|
||||
@@ -2030,6 +2355,55 @@ enum Offsets {
|
||||
VAR_AREATABLE_RECORDS = 0x00C0E048,
|
||||
VAR_AREATABLE_COUNT = 0x00C0E04C,
|
||||
OFF_AREATABLE_NAMES = 0x2C,
|
||||
// AreaBit @ +0x0C: index into the player's explored-areas bitfield
|
||||
// (OFF_PLAYER_EXPLORED_BITS). Gate @ +0x28: when < 0 the area needs no
|
||||
// exploration (always counts explored); when >= 0 the AreaBit check
|
||||
// applies. Source: the discovered-overlay rebuild FUN_004a67a0.
|
||||
OFF_AREATABLE_AREA_BIT = 0x0C,
|
||||
OFF_AREATABLE_EXPLORE_GATE = 0x28,
|
||||
|
||||
// TaxiNodes.dbc (16 fields, 0x40 bytes) — flight points. records/count
|
||||
// per docs/DBCs.md. mapID @ +0x04 is the Map.dbc continent; x/y/z floats;
|
||||
// Name[8] localized @ +0x14; the two MountCreatureID columns are
|
||||
// [Horde @ +0x38, Alliance @ +0x3C] (0 = that faction has no flight
|
||||
// master at this node — both 0 = a transport/system waypoint).
|
||||
VAR_TAXINODES_RECORDS = 0x00C0D6C0,
|
||||
VAR_TAXINODES_COUNT = 0x00C0D6C4,
|
||||
OFF_TAXINODE_MAP_ID = 0x04,
|
||||
OFF_TAXINODE_X = 0x08,
|
||||
OFF_TAXINODE_Y = 0x0C,
|
||||
OFF_TAXINODE_Z = 0x10,
|
||||
OFF_TAXINODE_NAME = 0x14,
|
||||
OFF_TAXINODE_HORDE_MOUNT = 0x38,
|
||||
OFF_TAXINODE_ALLIANCE_MOUNT = 0x3C,
|
||||
|
||||
// Live taxi session — the open flight master's reachable node list,
|
||||
// populated between TAXIMAP_OPENED/CLOSED (count 0 otherwise). Backs the
|
||||
// legacy NumTaxiNodes/TaxiNodeName/TaxiNodePosition/TaxiNodeGetType Lua
|
||||
// globals. Node slots are 0x20-stride records at VAR_TAXI_NODE_ARRAY:
|
||||
// +0x00 = pointer to the node's TaxiNodes.dbc record (id @ +0, name @
|
||||
// +0x14), +0x04 = flight-map x (0-1 float), +0x08 = y. Count at
|
||||
// VAR_TAXI_NODE_COUNT. Derived from Script_TaxiNode* (FUN_004dc380 name,
|
||||
// FUN_004dc3b0 position); the reachability type comes from
|
||||
// FUN_TAXI_NODE_TYPE.
|
||||
VAR_TAXI_NODE_COUNT = 0x00BB4AA8,
|
||||
VAR_TAXI_NODE_ARRAY = 0x00BB4F10,
|
||||
TAXI_NODE_STRIDE = 0x20,
|
||||
OFF_TAXI_SLOT_RECORD = 0x00,
|
||||
OFF_TAXI_SLOT_X = 0x04,
|
||||
OFF_TAXI_SLOT_Y = 0x08,
|
||||
// __fastcall(uint slot0Based) -> const char* ("CURRENT" / "REACHABLE" /
|
||||
// "DISTANT" / other) — the node's reachability for the open session.
|
||||
FUN_TAXI_NODE_TYPE = 0x004DC4E0,
|
||||
|
||||
// TaxiPath.dbc (4 fields, 0x10 bytes): {id, fromNode@+0x04, toNode@+0x08,
|
||||
// cost}. A real flight master is a node some flight path connects to;
|
||||
// Northshire Abbey / spurious duplicate rows / standalone markers are not
|
||||
// endpoints and so are filtered out of GetTaxiNodesForMap.
|
||||
VAR_TAXIPATH_RECORDS = 0x00C0D698,
|
||||
VAR_TAXIPATH_COUNT = 0x00C0D69C,
|
||||
OFF_TAXIPATH_FROM = 0x04,
|
||||
OFF_TAXIPATH_TO = 0x08,
|
||||
|
||||
// Engine player-info cache — populated by the
|
||||
// SMSG_NAME_QUERY_RESPONSE handler (opcode 0x51) at 0x005551A0,
|
||||
@@ -2806,6 +3180,29 @@ enum Offsets {
|
||||
// size matches Spell.dbc's row count. Pre-login the slot is NULL.
|
||||
VAR_PLAYER_SPELL_BITMAP = 0x00B710FC,
|
||||
|
||||
// Player spell-knowledge writers — the two functions that mutate the
|
||||
// bitmap above. Learn (`FUN_004b25b0`) sets the bit, rebuilds the
|
||||
// spellbook arrays, and fires SPELLS_CHANGED; unlearn (`FUN_004b2c50`)
|
||||
// clears the bit and removes the spell. Both `__fastcall`: learn is
|
||||
// (uint spellID, int notify, uint replacedSpellID), unlearn is
|
||||
// (uint spellID, int). Co-hooked (spell/Info.cpp) to bump
|
||||
// Player::StatSignal so a talent respec invalidates GetSpellBonusHealing's
|
||||
// talent-conversion cache. They fire at login (SMSG_INITIAL_SPELLS) and on
|
||||
// each learn/unlearn — never per-frame, so cool hook targets.
|
||||
FUN_LEARN_SPELL = 0x004B25B0,
|
||||
FUN_UNLEARN_SPELL = 0x004B2C50,
|
||||
|
||||
// Spell-rank-chain knowledge check — `char __thiscall(player /*ecx*/,
|
||||
// spellID)`. Walks `spellID`'s forward rank chain (the class/race-
|
||||
// resolved chain via `0x0060C7C0`) and returns nonzero if the player
|
||||
// knows any *higher* rank than `spellID` (each node tested through the
|
||||
// bitmap helper `0x0060C740`). The item tooltip's "Requires <spell>"
|
||||
// gate is `bitmap(spellID) OR this(spellID)`; C_PlayerInfo.CanUseItem
|
||||
// mirrors that so a RequiredSpell satisfied by a higher known rank
|
||||
// still passes. Does NOT test `spellID` itself — that's the caller's
|
||||
// direct bitmap check.
|
||||
FUN_SPELL_RANK_CHAIN_KNOWN = 0x0060C8D0,
|
||||
|
||||
// Per-spell cooldown query. `__fastcall(int spellID, int bookType,
|
||||
// int *outDuration, int *outStart, int *outEnable)`. Same path
|
||||
// `Script_GetSpellCooldown` (`0x004B40A0`) uses internally after
|
||||
@@ -3261,6 +3658,15 @@ enum Offsets {
|
||||
// shape from SMSG_PARTY_MEMBER_STATS_FULL).
|
||||
FUN_GROUP_MEMBER_STATS_LOOKUP = 0x00496400,
|
||||
OFF_GROUP_MEMBER_AREA_ID = 0x14,
|
||||
// `+0x08` status-flags byte of the stats block (see field map above);
|
||||
// bit 0 is the online flag `Script_UnitIsConnected` tests. Sibling
|
||||
// `FUN_GROUP_MEMBER_SLOT_LOOKUP` (`0x00496420`, `__fastcall(u64 *guid)
|
||||
// → member slot*`) is the presence fallback UnitIsConnected consults
|
||||
// when no stats block exists — a non-null slot means the member is
|
||||
// present (and thus connected). Both take a pointer to the 64-bit GUID.
|
||||
OFF_GROUP_MEMBER_STATUS_FLAGS = 0x08,
|
||||
GROUP_MEMBER_STATUS_ONLINE = 0x1,
|
||||
FUN_GROUP_MEMBER_SLOT_LOOKUP = 0x00496420,
|
||||
|
||||
// Per-slot GUID tables — used to map party/raid token strings
|
||||
// ("party1", "raid17") to GUIDs without going through
|
||||
@@ -3379,6 +3785,38 @@ enum Offsets {
|
||||
// '%s' (%s)" (or "calling '%s' on bad self (%s)" for method-style
|
||||
// calls) and calls `lua_error`. Doesn't return.
|
||||
LUAL_ARG_ERROR = 0x6F4810,
|
||||
// `luaL_setn(L, t, n)` — Lua 5.0 auxlib table-length setter
|
||||
// (verified: `FUN_006f4ea0`, __fastcall L/ecx, t/edx, n/stack,
|
||||
// RET 4). Matches 5.0.2 `luaL_setn`: sets `t.n = n` if a numeric
|
||||
// `n` field exists, else `sizes[t] = n` in the registry's weak
|
||||
// LUA_SIZES table. Found via `table.insert` (`FUN_007fb6b0`), which
|
||||
// calls it to record the new length; `luaL_getn` is `FUN_006f5050`.
|
||||
// Needed by `table.wipe` to reset the length after clearing keys —
|
||||
// the length is out-of-band from the keys, so a `lua_next` +
|
||||
// `rawset(nil)` clear leaves it stale and subsequent `table.insert`
|
||||
// appends past the wiped slots.
|
||||
LUAL_SETN = 0x6F4EA0,
|
||||
// `str_find` — the Lua 5.0 string-library `string.find` C function
|
||||
// (`int __fastcall(void *L)`), entry in the strlib luaL_reg table at
|
||||
// `0x00822dd0`. Returns `1` (nil pushed, no match), `2` (start, end),
|
||||
// or `2 + ncaptures`. `string.match` (a Lua 5.1 addition, missing here)
|
||||
// shares the exact same pattern engine — it's `find` that yields the
|
||||
// captures / whole match instead of the indices — so `BaseLib::StringLib`
|
||||
// calls this and transforms the result rather than reimplementing the
|
||||
// matcher. Verified by decoding the function at this address.
|
||||
FUN_LUA_STR_FIND = 0x007FC3B0,
|
||||
// `str_gfind` — the Lua 5.0 `string.gfind` C function (strlib entry at
|
||||
// `0x00822dd8`). 5.1 renamed it to `string.gmatch` with identical
|
||||
// behaviour (returns a stateful iterator closure over the matches), so
|
||||
// `BaseLib::StringLib` registers `string.gmatch` as a direct alias of
|
||||
// this function pointer — no wrapper.
|
||||
FUN_LUA_STR_GFIND = 0x007FCFA0,
|
||||
// `math_fmod` — the Lua 5.0 `math.mod` C function (mathlib entry at
|
||||
// `0x00822cb0`): `luaL_checknumber(1/2)` → `fmod(x, y)` → `lua_pushnumber`.
|
||||
// 5.1 renamed the Lua-facing name to `math.fmod` with the identical C
|
||||
// implementation, so `BaseLib::MathLib` registers `math.fmod` as a direct
|
||||
// alias of this function pointer — no wrapper. Verified by decode.
|
||||
FUN_LUA_MATH_FMOD = 0x007FAFD0,
|
||||
// `lua_iscfunction(L, idx)` — returns 1 if value is a function
|
||||
// closure with the `isC` byte set (`Closure.c.isC == 1`), 0
|
||||
// otherwise. Used by `coroutine.create` to reject C functions
|
||||
@@ -3834,6 +4272,30 @@ enum Offsets {
|
||||
FUN_ADDON_GET_TITLE_BY_NAME = 0x0051DF20, // (name) → title string or NULL
|
||||
FUN_ADDON_GET_NOTES_BY_NAME = 0x0051E050, // (name) → notes string or NULL
|
||||
|
||||
// `__fastcall(const char *nameOrEntry) → AddOnEntry*+0x48 or NULL`.
|
||||
// Hash-table lookup by name (accepts an entry pointer too — its
|
||||
// first 12 bytes are the inline name), returning a pointer to the
|
||||
// matched record's RequiredDeps descriptor (`record + 0x48`), or
|
||||
// NULL on a miss. Stock `Script_GetAddOnDependencies` (`0x0048E5E0`)
|
||||
// calls this then reads count@+4 / data@+8 off the return to walk
|
||||
// the required deps. We reuse it only to recover the record base for
|
||||
// string input: `record = resolve(name) - OFF_ADDON_REQDEPS_DESC`.
|
||||
FUN_ADDON_RESOLVE_REQ_DEPS = 0x0051E350,
|
||||
|
||||
// AddOnEntry dependency-array descriptors are `{cap, count, data,
|
||||
// quantum}` (each field 4 bytes) laid out 0x10 apart, parsed by the
|
||||
// TOC parser `FUN_0051c9b0`:
|
||||
// OptionalDeps desc@+0x38 (count@+0x3C, data@+0x40)
|
||||
// RequiredDeps desc@+0x48 (count@+0x4C, data@+0x50) ← stock walks
|
||||
// LoadWith desc@+0x58
|
||||
// Optional offsets verified against the parser's append site at
|
||||
// `0x0051CDBA` (`mov esi,[rec+0x3C]` count, `lea edi,[rec+0x38]`);
|
||||
// required desc@+0x48 confirmed by `FUN_ADDON_RESOLVE_REQ_DEPS`'s
|
||||
// `lea eax,[rec+0x48]` return.
|
||||
OFF_ADDON_REQDEPS_DESC = 0x48,
|
||||
OFF_ADDON_OPTIONALDEPS_COUNT = 0x3C,
|
||||
OFF_ADDON_OPTIONALDEPS_ARRAY = 0x40,
|
||||
|
||||
// Per-field accessors `Script_GetAddOnInfo` calls internally for
|
||||
// returns 5 (loadable), 6 (reason string), 7 (security category).
|
||||
// We dispatch to these directly so the wrappers don't need to
|
||||
@@ -4016,6 +4478,15 @@ enum Offsets {
|
||||
// assemble the pickup→equip chain for each set item.
|
||||
FUN_SCRIPT_CLEAR_CURSOR = 0x004895B0,
|
||||
FUN_SCRIPT_PICKUP_INVENTORY_ITEM = 0x004C8DA0,
|
||||
// Equipment/bag-slot pickup PRIMITIVE behind Script_PickupInventoryItem
|
||||
// (`FUN_004C8DA0` is just `FUN_004C7300(arg - 1)`). Called directly:
|
||||
// void __fastcall(uint slot0Based)
|
||||
// Self-contained — resolves the player, applies its own cursor/lock
|
||||
// guards, and handles the drop case (slot == 0xFFFFFFFF). Covers the
|
||||
// 0-based equipment (0..18) and equipped-bag (19..22) slots; container
|
||||
// contents go through FUN_CURSOR_PICKUP_ITEM instead. Modern
|
||||
// `equipmentSlotIndex` is 1-based, so pass `equipmentSlotIndex - 1`.
|
||||
FUN_PICKUP_INVENTORY_SLOT = 0x004C7300,
|
||||
|
||||
// Bag-update fire sites — see TODO #67 for the full Ghidra
|
||||
// investigation. Hook these three to detect BAG_UPDATE fires and
|
||||
@@ -4508,8 +4979,27 @@ enum Offsets {
|
||||
// Auction sell slot — holds a single CGItem GUID for the item
|
||||
// currently sitting in the "sell" frame. Engine resolves it via
|
||||
// a typed object lookup; pass `OBJECT_TYPE_ITEM` and the GUID.
|
||||
// `Script_StartAuction` posts whatever GUID is here, so writing an
|
||||
// arbitrary bag item's GUID + calling it posts that item — no cursor,
|
||||
// no ClickAuctionSellItemButton. Used by `AuctionHouse::PostItem`.
|
||||
VAR_AUCTION_SELL_GUID_LO = 0x00B72608,
|
||||
VAR_AUCTION_SELL_GUID_HI = 0x00B7260C,
|
||||
// Auctioneer NPC GUID — set while an auction house window is open;
|
||||
// `Script_StartAuction` writes it as the packet target and bails if
|
||||
// it's zero (so this doubles as an "AH is open" probe).
|
||||
VAR_AUCTION_AUCTIONEER_GUID_LO = 0x00B725F8,
|
||||
VAR_AUCTION_AUCTIONEER_GUID_HI = 0x00B725FC,
|
||||
// `Script_StartAuction` (`0x004CE770`) — `int __fastcall(void *L)`;
|
||||
// reads (minBid, buyout, runTime) from L[1..3], validates the sell
|
||||
// slot + auctioneer + runTime, and sends CMSG_AUCTION_SELL_ITEM
|
||||
// (opcode 0x256 = { auctioneerGuid, sellItemGuid, bid, buyout,
|
||||
// runTime }). Reused verbatim by `AuctionHouse::PostItem` per stack.
|
||||
FUN_SCRIPT_START_AUCTION = 0x004CE770,
|
||||
// Valid `runTime` values `Script_StartAuction` accepts, in minutes:
|
||||
// {120, 480, 1440} = 2h / 8h / 24h (vanilla's three durations). The
|
||||
// modern `duration` arg (1/2/3) indexes this table.
|
||||
VAR_AUCTION_DURATION_TABLE = 0x00807028,
|
||||
AUCTION_DURATION_TABLE_COUNT = 3,
|
||||
// `__fastcall(ECX = typeCode, EDX = unused, [stack] = guidLo,
|
||||
// [stack] = guidHi, [stack] = anyInt) → CGObject *`.
|
||||
// Returns NULL when no object matches or the type doesn't match.
|
||||
@@ -4651,6 +5141,8 @@ enum Offsets {
|
||||
// sum). AttributesEx2 (+0x24) bit 0x20000000 disables spell mods.
|
||||
OFF_SPELL_RECORD_FAMILY_NAME = 0x280, // u32
|
||||
OFF_SPELL_RECORD_FAMILY_FLAGS = 0x284, // u64
|
||||
OFF_SPELL_RECORD_ATTRIBUTES = 0x18, // u32 (base Attributes)
|
||||
SPELL_ATTR_PASSIVE = 0x40, // bit 6 — always-on aura
|
||||
OFF_SPELL_RECORD_ATTRIBUTES_EX2 = 0x24, // u32
|
||||
SPELL_ATTR_EX2_NO_SPELL_MODS = 0x20000000,
|
||||
|
||||
|
||||
@@ -41,6 +41,11 @@ using CanLoadFn_t = uint8_t(__fastcall *)(const uint8_t *entry, char fullCheck,
|
||||
int *outStatus, int *outDepHandle,
|
||||
const char *accountName);
|
||||
using GetAccountName_t = const char *(*)();
|
||||
// Hash-lookup by name → the matched record's RequiredDeps descriptor
|
||||
// (`record + 0x48`), NULL on miss. Accepts an entry pointer too (its
|
||||
// first bytes are the inline name). We use it only to recover the
|
||||
// record base for string input; see `Script_GetAddOnOptionalDependencies`.
|
||||
using ResolveReqDeps_t = uint8_t *(__fastcall *)(const char *nameOrEntry);
|
||||
|
||||
// Resolves the user's arg to a `const uint8_t *` that the engine's
|
||||
// per-field accessors can consume. They all read the addon name as
|
||||
@@ -295,6 +300,63 @@ int __fastcall Script_DoesAddOnExist(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_AddOns.GetAddOnOptionalDependencies(indexOrName)` — the addon's
|
||||
// `## OptionalDeps:` names as multiple return values, in declared order.
|
||||
// The exact shape of the stock `GetAddOnDependencies`, but reading the
|
||||
// *optional* dep array instead of the required one. No optional deps →
|
||||
// returns nothing.
|
||||
//
|
||||
// Vanilla parses `## OptionalDeps:` in the TOC parser `FUN_0051c9b0`
|
||||
// into a `{cap, count, data, quantum}` name-array descriptor at
|
||||
// `record+0x38` (count@+0x3C, data@+0x40), but ships no getter for it.
|
||||
//
|
||||
// Arg/error handling mirrors stock `Script_GetAddOnDependencies`
|
||||
// (`0x0048E5E0`): numeric out-of-range or a non-string/non-number arg
|
||||
// raises the usage error; an unknown *name* string returns nothing
|
||||
// (no error), matching the engine's own not-found path.
|
||||
//
|
||||
// Resolution mirrors stock `Script_GetAddOnDependencies` exactly: the
|
||||
// arg is first turned into a name-usable pointer (`ResolveAddOnName` —
|
||||
// numeric via `FUN_ADDON_GET_BY_INDEX`, string is the raw string), then
|
||||
// routed through the by-name hash lookup `FUN_ADDON_RESOLVE_REQ_DEPS`
|
||||
// to reach the deps-bearing record. `FUN_ADDON_GET_BY_INDEX` returns a
|
||||
// *name pointer*, NOT the record — reading dep offsets off it lands in
|
||||
// string memory and crashes (a numeric-path caller like pfUI's addon
|
||||
// list hit exactly that). The resolver returns the RequiredDeps
|
||||
// descriptor at `record+0x48`; subtract to recover the base. An unknown
|
||||
// name yields NULL → return nothing (no error), like stock.
|
||||
int __fastcall Script_GetAddOnOptionalDependencies(void *L) {
|
||||
static constexpr const char *kUsage =
|
||||
"Usage: C_AddOns.GetAddOnOptionalDependencies(index or \"name\")";
|
||||
|
||||
// NULL ⇒ out-of-range numeric index or a non-string/non-number arg;
|
||||
// stock raises the usage error in both cases.
|
||||
const uint8_t *nameOrEntry = ResolveAddOnName(L);
|
||||
if (nameOrEntry == nullptr) {
|
||||
Game::Lua::Error(L, "%s", kUsage);
|
||||
return 0;
|
||||
}
|
||||
|
||||
auto resolve = reinterpret_cast<ResolveReqDeps_t>(
|
||||
Offsets::FUN_ADDON_RESOLVE_REQ_DEPS);
|
||||
uint8_t *reqDesc = resolve(reinterpret_cast<const char *>(nameOrEntry));
|
||||
if (reqDesc == nullptr)
|
||||
return 0; // unknown addon name — no error, push nothing
|
||||
const uint8_t *record = reqDesc - Offsets::OFF_ADDON_REQDEPS_DESC;
|
||||
|
||||
const uint32_t count = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ADDON_OPTIONALDEPS_COUNT);
|
||||
if (count == 0)
|
||||
return 0;
|
||||
auto names = *reinterpret_cast<const char *const *const *>(
|
||||
record + Offsets::OFF_ADDON_OPTIONALDEPS_ARRAY);
|
||||
if (names == nullptr)
|
||||
return 0;
|
||||
for (uint32_t i = 0; i < count; ++i)
|
||||
Game::Lua::PushString(L, names[i]); // NULL → pushnil, per engine
|
||||
return static_cast<int>(count);
|
||||
}
|
||||
|
||||
// `Enum.AddOnSecurityStatus` — what `C_AddOns.GetAddOnSecurity`
|
||||
// returns. Slots 0..2 match the engine's own ordering at
|
||||
// `VAR_ADDON_SECURITY_TABLE`; slot 3 is our addition for
|
||||
@@ -323,6 +385,8 @@ static void RegisterLuaFunctions() {
|
||||
&Script_DoesAddOnExist);
|
||||
Game::Lua::RegisterTableFunction("C_AddOns", "IsAddOnLoaded",
|
||||
&Script_IsAddOnLoaded);
|
||||
Game::Lua::RegisterTableFunction("C_AddOns", "GetAddOnOptionalDependencies",
|
||||
&Script_GetAddOnOptionalDependencies);
|
||||
Game::Lua::RegisterIntegerEnum(
|
||||
"Enum", "AddOnSecurityStatus",
|
||||
kAddOnSecurityStatusEntries,
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_AuctionHouse.PostItem(itemLocation, duration, quantity, numStacks, bid,
|
||||
// buyout)` — post `numStacks` auctions of `quantity` each from a single
|
||||
// source stack, in one call. A ClassicAPI extension modelled on the retail
|
||||
// multi-sell flow; vanilla's `StartAuction` posts exactly one item object.
|
||||
//
|
||||
// Why this is a tick-driven state machine (not a synchronous loop):
|
||||
// vanilla's `CMSG_AUCTION_SELL_ITEM` (opcode 0x256) is
|
||||
// `{ auctioneerGuid, itemGuid, bid, buyout, runTime }` — NO count field
|
||||
// (WotLK added the server-side count; 1.12 has none, verified from
|
||||
// `Script_StartAuction`). So the server posts the *entire* item object a
|
||||
// GUID points at. To post a partial `quantity` out of a larger source, the
|
||||
// client must first materialize that quantity as its own server-committed
|
||||
// item object — i.e. split it into a bag slot (`CMSG_SPLIT_ITEM`) — and both
|
||||
// the split and the resulting post are server-authoritative and async (the
|
||||
// new stack's GUID doesn't exist client-side until `SMSG_UPDATE_OBJECT`
|
||||
// lands). We drive the split→post→split→post sequence off `WorldTick`,
|
||||
// keying each transition on observed bag state.
|
||||
//
|
||||
// Free-slot cost: a partial post needs at most ONE reused general-bag slot
|
||||
// (split `quantity` off → post that stack → the post empties the slot →
|
||||
// reuse it for the next split). The final stack (once the source is whittled
|
||||
// to exactly `quantity`) posts the source object directly, no split. Posting
|
||||
// a whole existing stack (`quantity` == the source count) needs zero free
|
||||
// slots.
|
||||
//
|
||||
// Posting reuses the engine's `Script_StartAuction` verbatim (write the
|
||||
// target stack's GUID into the sell-slot globals, push bid/buyout/runTime,
|
||||
// call it) so all of its validation runs per stack — damaged-item reject,
|
||||
// buyout normalization, runTime table check, auctioneer/"AH open" check.
|
||||
//
|
||||
// Events (retail multi-sell names, fired via Event::Custom):
|
||||
// AUCTION_MULTISELL_START — job accepted, posting begins
|
||||
// AUCTION_MULTISELL_UPDATE(done, total) — after each stack lands
|
||||
// AUCTION_MULTISELL_FAILURE — aborted (source changed, no slot,
|
||||
// AH closed, or a step timed out)
|
||||
//
|
||||
// Limitations (v1): source is the single stack at `itemLocation` (not
|
||||
// aggregated across bags); temp splits use general-purpose bags only, so
|
||||
// family-restricted items (arrows/shards in specialty bags) aren't
|
||||
// supported; one job at a time.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "event/Custom.h"
|
||||
#include "item/ID.h"
|
||||
#include "item/Location.h"
|
||||
#include "item/Swap.h"
|
||||
#include "tick/WorldTick.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace AuctionHouse::PostItem {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kEvtStart = "AUCTION_MULTISELL_START";
|
||||
constexpr const char *kEvtUpdate = "AUCTION_MULTISELL_UPDATE";
|
||||
constexpr const char *kEvtFailure = "AUCTION_MULTISELL_FAILURE";
|
||||
const Event::Custom::AutoReserve _r1{kEvtStart};
|
||||
const Event::Custom::AutoReserve _r2{kEvtUpdate};
|
||||
const Event::Custom::AutoReserve _r3{kEvtFailure};
|
||||
|
||||
// Generous per-step abort bound. A split or a post is one server round-trip
|
||||
// (~tens of frames); 600 frames (~10s at 60fps) means "something's wrong"
|
||||
// (disconnect, server refusal we can't see) rather than normal latency.
|
||||
constexpr int kMaxWaitTicks = 600;
|
||||
|
||||
enum class Phase { Idle, SplitWait, PostWait };
|
||||
|
||||
struct Job {
|
||||
bool active;
|
||||
int srcBag, srcSlot; // source stack (shrinks in place as we split off it)
|
||||
int itemID; // guards against the source slot changing under us
|
||||
int quantity;
|
||||
int totalStacks;
|
||||
int postedStacks;
|
||||
int bid, buyout, runTime;
|
||||
Phase phase;
|
||||
int workBag, workSlot; // slot currently being split-into / posted-from
|
||||
uint64_t postGuid; // GUID we posted — detect it leaving bags
|
||||
int waitTicks;
|
||||
};
|
||||
Job g_job{};
|
||||
|
||||
// --- small CGItem / cache readers ---------------------------------------
|
||||
|
||||
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
|
||||
const uint64_t *guid, void *callback,
|
||||
void *userData, int unused);
|
||||
const uint8_t *PeekItemRecord(uint32_t itemID) {
|
||||
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
|
||||
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
|
||||
const uint64_t zeroGuid = 0;
|
||||
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
|
||||
}
|
||||
|
||||
int CGItemCount(const uint8_t *item) {
|
||||
auto *desc = *reinterpret_cast<const uint8_t *const *>(
|
||||
item + Offsets::OFF_ITEM_DESCRIPTOR);
|
||||
if (desc == nullptr)
|
||||
return 0;
|
||||
return *reinterpret_cast<const int *>(desc + Offsets::OFF_DESCRIPTOR_STACK_COUNT);
|
||||
}
|
||||
|
||||
uint64_t CGItemGuid(const uint8_t *item) {
|
||||
auto *inst = *reinterpret_cast<const uint8_t *const *>(
|
||||
item + Offsets::OFF_ITEM_INSTANCE_BLOCK);
|
||||
if (inst == nullptr)
|
||||
return 0;
|
||||
return *reinterpret_cast<const uint64_t *>(inst + Offsets::OFF_INSTANCE_BLOCK_GUID);
|
||||
}
|
||||
|
||||
struct SlotInfo {
|
||||
const uint8_t *item; // null = empty slot
|
||||
int itemID;
|
||||
int count;
|
||||
uint64_t guid;
|
||||
};
|
||||
|
||||
SlotInfo ReadSlot(void *L, int bag, int slot) {
|
||||
SlotInfo r{nullptr, 0, 0, 0};
|
||||
const uint8_t *item = Item::Location::ResolveBag(L, bag, slot);
|
||||
if (item == nullptr)
|
||||
return r;
|
||||
r.item = item;
|
||||
r.itemID = Item::ID::FromCGItem(item);
|
||||
r.count = CGItemCount(item);
|
||||
r.guid = CGItemGuid(item);
|
||||
return r;
|
||||
}
|
||||
|
||||
// --- auction-house globals ----------------------------------------------
|
||||
|
||||
uint64_t AuctioneerGuid() {
|
||||
const uint32_t lo = *reinterpret_cast<const uint32_t *>(
|
||||
Offsets::VAR_AUCTION_AUCTIONEER_GUID_LO);
|
||||
const uint32_t hi = *reinterpret_cast<const uint32_t *>(
|
||||
Offsets::VAR_AUCTION_AUCTIONEER_GUID_HI);
|
||||
return (static_cast<uint64_t>(hi) << 32) | lo;
|
||||
}
|
||||
bool AHOpen() { return AuctioneerGuid() != 0; }
|
||||
|
||||
void SetSellGuid(uint64_t guid) {
|
||||
*reinterpret_cast<uint32_t *>(Offsets::VAR_AUCTION_SELL_GUID_LO) =
|
||||
static_cast<uint32_t>(guid);
|
||||
*reinterpret_cast<uint32_t *>(Offsets::VAR_AUCTION_SELL_GUID_HI) =
|
||||
static_cast<uint32_t>(guid >> 32);
|
||||
}
|
||||
|
||||
// Point the sell slot at `guid` and fire the engine's own StartAuction for
|
||||
// this bid/buyout/runTime. StartAuction reads its args from L[1..3]; we own
|
||||
// the stack at frame-tail, so set top to 0, push the three, call, reset.
|
||||
void PostStack(void *L, uint64_t guid) {
|
||||
SetSellGuid(guid);
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(g_job.bid));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(g_job.buyout));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(g_job.runTime));
|
||||
auto fn = reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_SCRIPT_START_AUCTION);
|
||||
fn(L);
|
||||
Game::Lua::SetTop(L, 0);
|
||||
}
|
||||
|
||||
// --- free-slot discovery (general-purpose bags only) --------------------
|
||||
|
||||
bool BagIsGeneral(int bag) {
|
||||
if (bag == 0)
|
||||
return true; // backpack is always general
|
||||
if (bag < 1 || bag > 4)
|
||||
return false;
|
||||
const uint8_t *bagItem =
|
||||
Item::Location::ResolveEquipmentSlot(Offsets::INVSLOT_BAG1 + bag - 1);
|
||||
if (bagItem == nullptr)
|
||||
return false; // no bag equipped there
|
||||
const int id = Item::ID::FromCGItem(bagItem);
|
||||
if (id == 0)
|
||||
return false;
|
||||
const uint8_t *rec = PeekItemRecord(static_cast<uint32_t>(id));
|
||||
if (rec == nullptr)
|
||||
return false;
|
||||
return *reinterpret_cast<const uint32_t *>(
|
||||
rec + Offsets::OFF_ITEMSTATS_BAG_FAMILY) == 0;
|
||||
}
|
||||
|
||||
bool FindFreeGeneralSlot(void *L, int *outBag, int *outSlot) {
|
||||
for (int bag = 0; bag <= 4; ++bag) {
|
||||
if (!BagIsGeneral(bag))
|
||||
continue;
|
||||
const int n = Item::Location::GetBagSlotCount(bag);
|
||||
for (int slot = 1; slot <= n; ++slot) {
|
||||
if (Item::Location::ResolveBag(L, bag, slot) == nullptr) {
|
||||
*outBag = bag;
|
||||
*outSlot = slot;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- state machine ------------------------------------------------------
|
||||
|
||||
void Finish(bool success) {
|
||||
g_job.active = false;
|
||||
if (!success)
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kEvtFailure), "");
|
||||
// success needs no event: the last PostWait already fired
|
||||
// AUCTION_MULTISELL_UPDATE(total, total).
|
||||
}
|
||||
|
||||
void Tick() {
|
||||
if (!g_job.active)
|
||||
return;
|
||||
void *L = Game::Lua::State();
|
||||
if (L == nullptr)
|
||||
return;
|
||||
const int savedTop = Game::Lua::GetTop(L);
|
||||
|
||||
if (!AHOpen()) { // window closed / walked away mid-job
|
||||
Finish(false);
|
||||
Game::Lua::SetTop(L, savedTop);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (g_job.phase) {
|
||||
case Phase::Idle: {
|
||||
if (g_job.postedStacks >= g_job.totalStacks) {
|
||||
Finish(true);
|
||||
break;
|
||||
}
|
||||
const SlotInfo s = ReadSlot(L, g_job.srcBag, g_job.srcSlot);
|
||||
if (s.item == nullptr || s.itemID != g_job.itemID || s.count < g_job.quantity) {
|
||||
Finish(false); // source moved / consumed / insufficient
|
||||
break;
|
||||
}
|
||||
if (s.count == g_job.quantity) {
|
||||
// Final exact stack — post the source object directly, no split.
|
||||
g_job.workBag = g_job.srcBag;
|
||||
g_job.workSlot = g_job.srcSlot;
|
||||
g_job.postGuid = s.guid;
|
||||
PostStack(L, s.guid);
|
||||
g_job.phase = Phase::PostWait;
|
||||
g_job.waitTicks = 0;
|
||||
} else {
|
||||
int fb = 0, fs = 0;
|
||||
if (!FindFreeGeneralSlot(L, &fb, &fs)) {
|
||||
Finish(false); // no room to materialize the stack
|
||||
break;
|
||||
}
|
||||
if (!Item::Swap::MoveCount(L, g_job.srcBag, g_job.srcSlot, fb, fs,
|
||||
g_job.quantity)) {
|
||||
Finish(false);
|
||||
break;
|
||||
}
|
||||
g_job.workBag = fb;
|
||||
g_job.workSlot = fs;
|
||||
g_job.phase = Phase::SplitWait;
|
||||
g_job.waitTicks = 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Phase::SplitWait: {
|
||||
const SlotInfo w = ReadSlot(L, g_job.workBag, g_job.workSlot);
|
||||
if (w.item != nullptr && w.itemID == g_job.itemID && w.count == g_job.quantity) {
|
||||
g_job.postGuid = w.guid;
|
||||
PostStack(L, w.guid);
|
||||
g_job.phase = Phase::PostWait;
|
||||
g_job.waitTicks = 0;
|
||||
} else if (++g_job.waitTicks > kMaxWaitTicks) {
|
||||
Finish(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Phase::PostWait: {
|
||||
const SlotInfo w = ReadSlot(L, g_job.workBag, g_job.workSlot);
|
||||
// Post confirmed once the server pulls the item out of the slot.
|
||||
if (w.item == nullptr || w.guid != g_job.postGuid) {
|
||||
++g_job.postedStacks;
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kEvtUpdate), "%d%d",
|
||||
g_job.postedStacks, g_job.totalStacks);
|
||||
g_job.phase = Phase::Idle;
|
||||
g_job.waitTicks = 0;
|
||||
} else if (++g_job.waitTicks > kMaxWaitTicks) {
|
||||
Finish(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Game::Lua::SetTop(L, savedTop);
|
||||
}
|
||||
|
||||
const Tick::WorldTick::AutoSubscribe _tick{&Tick};
|
||||
|
||||
// --- Lua entry ----------------------------------------------------------
|
||||
|
||||
// Reads `loc.field` as an int. False if missing/non-numeric. Leaves the
|
||||
// stack as found. (Same shape as Item::Location's file-static helper.)
|
||||
bool ReadIntField(void *L, int locIdx, const char *field, int *out) {
|
||||
Game::Lua::PushString(L, field);
|
||||
Game::Lua::GetTable(L, locIdx);
|
||||
const bool ok = Game::Lua::IsNumber(L, -1);
|
||||
if (ok)
|
||||
*out = static_cast<int>(Game::Lua::ToNumber(L, -1));
|
||||
Game::Lua::SetTop(L, -2);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Map the modern `duration` arg to a valid engine runTime (minutes).
|
||||
// Accepts the 1/2/3 index form and the raw {120,480,1440} value form.
|
||||
// Returns 0 for anything else.
|
||||
int MapDuration(int d) {
|
||||
auto *table = reinterpret_cast<const uint32_t *>(Offsets::VAR_AUCTION_DURATION_TABLE);
|
||||
if (d >= 1 && d <= Offsets::AUCTION_DURATION_TABLE_COUNT)
|
||||
return static_cast<int>(table[d - 1]);
|
||||
for (int i = 0; i < Offsets::AUCTION_DURATION_TABLE_COUNT; ++i)
|
||||
if (static_cast<int>(table[i]) == d)
|
||||
return d;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Push (false, reason) and return 2 — the immediate-rejection shape.
|
||||
int Reject(void *L, const char *reason) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
Game::Lua::PushString(L, reason);
|
||||
return 2;
|
||||
}
|
||||
|
||||
constexpr int kLuaTable = 5; // LUA_TTABLE (no named constant in Game::Lua)
|
||||
|
||||
int __fastcall Script_C_AuctionHouse_PostItem(void *L) {
|
||||
if (Game::Lua::Type(L, 1) != kLuaTable) {
|
||||
Game::Lua::Error(L,
|
||||
"Usage: C_AuctionHouse.PostItem(itemLocation, duration, quantity, numStacks, bid, buyout)");
|
||||
return 0;
|
||||
}
|
||||
if (!Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3) ||
|
||||
!Game::Lua::IsNumber(L, 4) || !Game::Lua::IsNumber(L, 5)) {
|
||||
Game::Lua::Error(L,
|
||||
"Usage: C_AuctionHouse.PostItem(itemLocation, duration, quantity, numStacks, bid, buyout)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int bagID = 0, slotIndex = 0;
|
||||
if (!ReadIntField(L, 1, "bagID", &bagID) ||
|
||||
!ReadIntField(L, 1, "slotIndex", &slotIndex))
|
||||
return Reject(L, "itemLocation must be a { bagID, slotIndex } table");
|
||||
|
||||
const int duration = static_cast<int>(Game::Lua::ToNumber(L, 2));
|
||||
const int quantity = static_cast<int>(Game::Lua::ToNumber(L, 3));
|
||||
const int numStacks = static_cast<int>(Game::Lua::ToNumber(L, 4));
|
||||
const int bid = static_cast<int>(Game::Lua::ToNumber(L, 5));
|
||||
const int buyout = Game::Lua::IsNumber(L, 6) ? static_cast<int>(Game::Lua::ToNumber(L, 6)) : 0;
|
||||
|
||||
const int runTime = MapDuration(duration);
|
||||
if (runTime == 0)
|
||||
return Reject(L, "invalid duration (use 1/2/3 or 120/480/1440)");
|
||||
if (quantity < 1 || quantity > 255)
|
||||
return Reject(L, "quantity must be 1..255");
|
||||
if (numStacks < 1)
|
||||
return Reject(L, "numStacks must be >= 1");
|
||||
if (bid < 1)
|
||||
return Reject(L, "bid must be >= 1");
|
||||
|
||||
if (!AHOpen())
|
||||
return Reject(L, "auction house is not open");
|
||||
if (g_job.active)
|
||||
return Reject(L, "a multi-post is already in progress");
|
||||
|
||||
const SlotInfo s = ReadSlot(L, bagID, slotIndex);
|
||||
if (s.item == nullptr)
|
||||
return Reject(L, "source slot is empty");
|
||||
|
||||
const long need = static_cast<long>(quantity) * numStacks;
|
||||
if (static_cast<long>(s.count) < need)
|
||||
return Reject(L, "not enough items for numStacks x quantity");
|
||||
|
||||
// A partial post (source larger than one stack) needs one reusable
|
||||
// general-bag slot to materialize each split. A whole-stack post
|
||||
// (count == quantity, numStacks == 1) needs none.
|
||||
if (s.count > quantity) {
|
||||
int fb = 0, fs = 0;
|
||||
if (!FindFreeGeneralSlot(L, &fb, &fs))
|
||||
return Reject(L, "need at least one free general bag slot to split");
|
||||
}
|
||||
|
||||
g_job = Job{};
|
||||
g_job.active = true;
|
||||
g_job.srcBag = bagID;
|
||||
g_job.srcSlot = slotIndex;
|
||||
g_job.itemID = s.itemID;
|
||||
g_job.quantity = quantity;
|
||||
g_job.totalStacks = numStacks;
|
||||
g_job.postedStacks = 0;
|
||||
g_job.bid = bid;
|
||||
g_job.buyout = buyout;
|
||||
g_job.runTime = runTime;
|
||||
g_job.phase = Phase::Idle;
|
||||
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kEvtStart), "");
|
||||
Game::Lua::PushBool(L, true);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_AuctionHouse", "PostItem",
|
||||
&Script_C_AuctionHouse_PostItem);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace AuctionHouse::PostItem
|
||||
@@ -492,6 +492,31 @@ void PushFromCache(void *L, const uint8_t *unit,
|
||||
c.casterGuid);
|
||||
}
|
||||
|
||||
// Reconciles the `Aura::Source` cache against `unit`'s descriptor: when the
|
||||
// descriptor holds at least one raw aura the unit is in view and fully synced,
|
||||
// so it's authoritative — any cached entry for the unit whose spell isn't in
|
||||
// the array was removed while we couldn't see it (e.g. cancelled out of range,
|
||||
// so no OnAuraRemoved reached us) and must be dropped before the fallback
|
||||
// resurfaces it as a phantom (an enumerated aura whose SetUnitAura tooltip is
|
||||
// empty). Reads the RAW slots (not IsSlotPopulated): a spell that's present
|
||||
// but flag-hidden is still legitimately on the unit. Skips when the array is
|
||||
// empty — that can't be told apart from an out-of-range descriptor drop, and
|
||||
// reconciling then would wipe still-valid out-of-range entries.
|
||||
void ReconcileCache(const uint8_t *unit) {
|
||||
if (unit == nullptr)
|
||||
return;
|
||||
uint32_t present[Offsets::UNIT_AURA_TOTAL];
|
||||
int n = 0;
|
||||
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
|
||||
const uint32_t id = ReadSpellID(unit, slot);
|
||||
if (id != 0)
|
||||
present[n++] = id;
|
||||
}
|
||||
if (n == 0)
|
||||
return;
|
||||
Aura::Source::EvictAbsent(UnitGuid(unit), present, n);
|
||||
}
|
||||
|
||||
// True if `unit`'s descriptor currently exposes any visible aura in either
|
||||
// range. The cache fallback is only trustworthy when the descriptor has been
|
||||
// wholesale-cleared: the two states it exists for — rogue stealth and party
|
||||
@@ -559,6 +584,7 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
|
||||
Filter filter, bool playerOnly) {
|
||||
if (unit == nullptr)
|
||||
return false;
|
||||
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
|
||||
if (DescriptorHasVisibleAura(unit))
|
||||
return false; // descriptor authoritative — see DescriptorHasVisibleAura
|
||||
// The descriptor held `descCount` matches; the caller already found fewer
|
||||
@@ -589,6 +615,7 @@ void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
|
||||
bool playerOnly, int outerIdx, int &nextKey) {
|
||||
if (unit == nullptr)
|
||||
return;
|
||||
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
|
||||
if (DescriptorHasVisibleAura(unit))
|
||||
return; // descriptor authoritative — see DescriptorHasVisibleAura
|
||||
const uint64_t guid = UnitGuid(unit);
|
||||
|
||||
+72
-4
@@ -20,6 +20,7 @@
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "net/PacketReader.h"
|
||||
#include "player/StatSignal.h"
|
||||
#include "spell/Lookup.h"
|
||||
#include "tick/WorldTick.h"
|
||||
#include "unit/Identity.h"
|
||||
@@ -150,10 +151,40 @@ void Evict(uint64_t targetGuid, uint32_t spellId) {
|
||||
}
|
||||
}
|
||||
|
||||
// Wipe the whole cache. Used on a map transition (see OnWorldTick).
|
||||
void FlushAll() {
|
||||
for (auto &e : g_cache)
|
||||
e.used = false;
|
||||
}
|
||||
|
||||
// -1 = no map seen yet (never a valid Map.dbc id), so the first tick just
|
||||
// records the current map without flushing.
|
||||
int g_lastMapId = -1;
|
||||
|
||||
// Drop entries whose timed aura has elapsed so the table doesn't fill with
|
||||
// stale combat debuffs. Infinite-duration entries (expirationMs == 0) stay
|
||||
// until overwritten.
|
||||
//
|
||||
// Also flush the entire cache on a map-ID change. A battleground/instance/
|
||||
// continent transition (including the teleport out of a BG via /afk) bulk-
|
||||
// clears the player's aura descriptor WITHOUT firing per-aura OnAuraRemoved,
|
||||
// so entries for auras that are now gone would linger and the
|
||||
// GetAuraDataByIndex fallback would keep surfacing them — the "buffs/debuffs
|
||||
// stuck after leaving a battleground" report. Every other unit from the old
|
||||
// map has despawned too, and the local player's real auras re-sync into the
|
||||
// descriptor / PLAYER_BUFF tables on entering the new world, so a full flush
|
||||
// loses nothing legitimate. Crucially, the descriptor drops the fallback
|
||||
// must survive — rogue stealth and party-range fluctuation — do NOT change
|
||||
// the map ID, so they're unaffected.
|
||||
void OnWorldTick() {
|
||||
const int mapId = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_CURRENT_MAP_ID));
|
||||
if (mapId != g_lastMapId) {
|
||||
if (g_lastMapId != -1)
|
||||
FlushAll();
|
||||
g_lastMapId = mapId;
|
||||
}
|
||||
|
||||
const uint32_t now = NowMs();
|
||||
for (auto &e : g_cache) {
|
||||
if (e.used && e.expirationMs != 0 && now >= e.expirationMs)
|
||||
@@ -258,6 +289,15 @@ int8_t KindForSlot(int slot) {
|
||||
return slot >= Offsets::UNIT_AURA_BUFF_COUNT ? KIND_HARMFUL : KIND_HELPFUL;
|
||||
}
|
||||
|
||||
// Bump the player-stat-inputs signal when an aura change hits the LOCAL
|
||||
// player — buffs/debuffs move GetSpellBonusHealing's flat and Spirit/Armor
|
||||
// terms, so its lazy cache must invalidate. Guarded on the player object so
|
||||
// other units' aura churn (combat) doesn't needlessly invalidate it.
|
||||
void NotifyIfPlayer(void *unit) {
|
||||
if (static_cast<const uint8_t *>(unit) == Unit::Identity::PlayerObject())
|
||||
Player::StatSignal::Notify();
|
||||
}
|
||||
|
||||
// OnAuraAdded — a new aura occupies a slot (gives the spellId directly).
|
||||
using OnAuraAdded_t = void(__fastcall *)(void *unit, void *edx, uint32_t slot,
|
||||
uint32_t spellId);
|
||||
@@ -267,6 +307,7 @@ void __fastcall OnAuraAdded_h(void *unit, void *edx, uint32_t slot,
|
||||
uint32_t spellId) {
|
||||
g_origOnAuraAdded(unit, edx, slot, spellId);
|
||||
StampApplication(unit, spellId, KindForSlot(static_cast<int>(slot)));
|
||||
NotifyIfPlayer(unit);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookAuraAdded{
|
||||
@@ -287,6 +328,7 @@ void __fastcall OnAuraStacksChanged_h(void *unit, void *edx, int slot,
|
||||
unit,
|
||||
Aura::Data::ReadSpellID(static_cast<const uint8_t *>(unit), slot),
|
||||
KindForSlot(slot));
|
||||
NotifyIfPlayer(unit);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookAuraStacks{
|
||||
@@ -295,10 +337,18 @@ const Game::HookAutoRegister _hookAuraStacks{
|
||||
reinterpret_cast<void **>(&g_origOnAuraStacksChanged)};
|
||||
|
||||
// OnAuraRemoved — a descriptor aura slot went empty: the aura fell off, was
|
||||
// dispelled, or was replaced by a higher rank (the diff dispatcher fires
|
||||
// Removed(old) + Added(new)). Evict the cache entry so the fallback stops
|
||||
// surfacing it. Same ABI as OnAuraAdded (unit in ecx, slot + spellId on the
|
||||
// stack); `slot` is unused here — we evict by spellId.
|
||||
// dispelled, was cancelled by its owner, or was replaced by a higher rank
|
||||
// (the diff dispatcher fires Removed(old) + Added(new)). Evict the cache
|
||||
// entry so the descriptor-drop fallback stops surfacing it. Same ABI as
|
||||
// OnAuraAdded (unit in ecx, slot + spellId on the stack); `slot` is unused
|
||||
// here — we evict by spellId.
|
||||
//
|
||||
// Always evict, even for a death-persistent aura (flask, world buff) on a
|
||||
// dead unit: the server keeps those through death WITHOUT changing any
|
||||
// field, so death itself fires no OnAuraRemoved. Any removal we do receive
|
||||
// for one is therefore genuine — e.g. the owner cancelled the flask while
|
||||
// dead — and must clear the cache, or the fallback keeps surfacing a phantom
|
||||
// whose SetUnitAura tooltip is empty.
|
||||
using OnAuraRemoved_t = void(__fastcall *)(void *unit, void *edx, uint32_t slot,
|
||||
uint32_t spellId);
|
||||
OnAuraRemoved_t g_origOnAuraRemoved = nullptr;
|
||||
@@ -308,6 +358,7 @@ void __fastcall OnAuraRemoved_h(void *unit, void *edx, uint32_t slot,
|
||||
g_origOnAuraRemoved(unit, edx, slot, spellId);
|
||||
(void)slot;
|
||||
Evict(Unit::Identity::GuidForObject(unit), spellId);
|
||||
NotifyIfPlayer(unit);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookAuraRemoved{
|
||||
@@ -331,6 +382,23 @@ bool Get(uint64_t unitGuid, uint32_t spellId, uint64_t *outCaster,
|
||||
return false;
|
||||
}
|
||||
|
||||
void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count) {
|
||||
if (unitGuid == 0 || presentSpellIds == nullptr || count <= 0)
|
||||
return;
|
||||
for (auto &e : g_cache) {
|
||||
if (!e.used || e.targetGuid != unitGuid)
|
||||
continue;
|
||||
bool present = false;
|
||||
for (int i = 0; i < count; ++i)
|
||||
if (presentSpellIds[i] == e.spellId) {
|
||||
present = true;
|
||||
break;
|
||||
}
|
||||
if (!present)
|
||||
e.used = false;
|
||||
}
|
||||
}
|
||||
|
||||
int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut) {
|
||||
if (unitGuid == 0 || out == nullptr || maxOut <= 0)
|
||||
return 0;
|
||||
|
||||
@@ -50,6 +50,18 @@ struct CachedAura {
|
||||
uint32_t durationMs; // applied duration (incl. caster mods); 0 = none
|
||||
};
|
||||
|
||||
// Evicts every cached entry for `unitGuid` whose spellId is NOT present in
|
||||
// `presentSpellIds[0..count)`. Used to reconcile the cache against a unit's
|
||||
// authoritative descriptor when it is back in view (a populated aura array
|
||||
// means the unit is fully synced): an entry the descriptor no longer lists
|
||||
// was removed while we couldn't observe it — e.g. a buff the owner cancelled
|
||||
// while out of our range, whose `OnAuraRemoved` we never received — so drop
|
||||
// it before the descriptor-drop fallback resurfaces it as a phantom. The
|
||||
// caller must only invoke this when the descriptor is populated (count > 0);
|
||||
// an empty array can't distinguish "out of range" from "genuinely buffless",
|
||||
// so reconciling then would wrongly wipe still-valid out-of-range entries.
|
||||
void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count);
|
||||
|
||||
// Fills `out` with up to `maxOut` cached, non-expired auras on `unitGuid`
|
||||
// whose helpful/harmful classification matches `harmful`. Returns the count
|
||||
// written. The classification is recorded from the aura's descriptor slot at
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "bag/UpdateDelayed.h"
|
||||
#include "event/Custom.h"
|
||||
#include "tick/WorldTick.h"
|
||||
|
||||
@@ -71,8 +72,15 @@ const Event::Custom::AutoReserve _reserve{kEventName};
|
||||
// Single bool, no atomics needed — the engine is single-threaded.
|
||||
bool g_pending = false;
|
||||
|
||||
// C++ subscribers notified once per bag-change frame (see UpdateDelayed.h).
|
||||
AutoSubscribe *g_subs = nullptr;
|
||||
|
||||
} // namespace
|
||||
|
||||
AutoSubscribe::AutoSubscribe(Callback cb) : cb(cb), next(g_subs) {
|
||||
g_subs = this;
|
||||
}
|
||||
|
||||
// `FUN_004F91A0` — `__cdecl void()`. Bag-slot diff loop. Walks the
|
||||
// 10 bag slots, fires BAG_UPDATE for each that changed.
|
||||
using BagSlotDiff_t = void(__cdecl *)();
|
||||
@@ -123,6 +131,9 @@ void OnWorldTick() {
|
||||
const int slot = Event::Custom::Lookup(kEventName);
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "");
|
||||
// Notify C++ subscribers of the bag-change frame (see UpdateDelayed.h).
|
||||
for (auto *node = g_subs; node != nullptr; node = node->next)
|
||||
node->cb();
|
||||
}
|
||||
|
||||
static const Game::HookAutoRegister _hookSlotDiff{
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
// Authoritative "a bag changed this frame" subscription. `Bag::UpdateDelayed`
|
||||
// co-hooks the three (and only three) engine `BAG_UPDATE` fire sites and
|
||||
// coalesces them into a single per-frame signal — the same signal that backs
|
||||
// the `BAG_UPDATE_DELAYED` event. Subscribe here to run C++ work exactly once
|
||||
// per frame in which any bag actually changed, instead of polling every frame.
|
||||
//
|
||||
// Callbacks fire at the tail of the world tick, after every BAG_UPDATE for the
|
||||
// frame has already fired (so bag contents are settled). No firing on frames
|
||||
// where nothing changed. Order across subscribers is unspecified.
|
||||
//
|
||||
// Module pattern, matching `Tick::WorldTick::AutoSubscribe`: declare a
|
||||
// file-scope `static const Bag::UpdateDelayed::AutoSubscribe _s{&Callback};`.
|
||||
|
||||
namespace Bag::UpdateDelayed {
|
||||
|
||||
using Callback = void (*)();
|
||||
|
||||
struct AutoSubscribe {
|
||||
explicit AutoSubscribe(Callback cb);
|
||||
Callback cb;
|
||||
AutoSubscribe *next;
|
||||
};
|
||||
|
||||
} // namespace Bag::UpdateDelayed
|
||||
@@ -0,0 +1,47 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Lua 5.1 math-library additions that 1.12's Lua 5.0 is missing:
|
||||
//
|
||||
// - `math.fmod(x, y)` — floating-point remainder of `x / y`.
|
||||
//
|
||||
// This is a pure rename: Lua 5.0 ships the exact same C function under the
|
||||
// name `math.mod`; 5.1 renamed the Lua-facing entry to `math.fmod`. We
|
||||
// register `math.fmod` as a direct alias of the engine's `math.mod` C
|
||||
// function (`FUN_LUA_MATH_FMOD`) — no wrapper. `math.mod` stays available too
|
||||
// (we don't touch it), so code written for either name works.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
namespace BaseLib::MathLib {
|
||||
|
||||
namespace {
|
||||
|
||||
// `math.fmod` is 5.0's `math.mod` verbatim — register the engine function
|
||||
// pointer directly rather than wrapping it.
|
||||
const auto Script_math_fmod =
|
||||
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_MATH_FMOD);
|
||||
|
||||
void RegisterFns() {
|
||||
Game::Lua::RegisterTableFunction("math", "fmod", Script_math_fmod);
|
||||
}
|
||||
|
||||
// Both states are Lua 5.0 and equally missing this; RegisterTableFunction
|
||||
// targets whichever state is active in each registration hook.
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
|
||||
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace BaseLib::MathLib
|
||||
@@ -0,0 +1,87 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `select(index, ...)` — the Lua 5.1 base-library vararg helper, missing
|
||||
// from 1.12's Lua 5.0 (it predates the function). Two forms:
|
||||
// - `select('#', ...)` → the number of extra arguments.
|
||||
// - `select(n, ...)` → all arguments from position `n` onward.
|
||||
// `n` may be negative to index from the end (`-1` = the last arg).
|
||||
//
|
||||
// Ported from Lua 5.1's `luaB_select` (lbaselib.c). The numeric form needs
|
||||
// no pushes at all: the varargs are already sitting on the stack at
|
||||
// positions `2..top`, so returning `top - n` hands back exactly the slice
|
||||
// from `n+1` onward — the same trick the reference implementation uses.
|
||||
// The `'#'` form pushes the count and returns 1.
|
||||
//
|
||||
// Registered on BOTH Lua states (in-game + glue): it's a language-level
|
||||
// helper, state-agnostic, and both states are 5.0 and equally missing it.
|
||||
|
||||
#include "Game.h"
|
||||
|
||||
namespace BaseLib::Select {
|
||||
|
||||
namespace {
|
||||
|
||||
int __fastcall Script_select(void *L) {
|
||||
const int n = Game::Lua::GetTop(L);
|
||||
if (n < 1) {
|
||||
Game::Lua::Error(L, "bad argument #1 to 'select' (number expected, got no value)");
|
||||
return 0; // unreachable
|
||||
}
|
||||
|
||||
// `select('#', ...)` — count form. Only inspect the string when the
|
||||
// arg *is* a string: calling lua_tostring on a number would convert
|
||||
// the stack value in place (Lua 5.0/5.1 semantics), corrupting arg 1.
|
||||
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_STRING) {
|
||||
const char *s = Game::Lua::ToString(L, 1);
|
||||
if (s != nullptr && s[0] == '#') {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(n - 1));
|
||||
return 1;
|
||||
}
|
||||
// A non-'#' string falls through: lua_isnumber accepts numeric
|
||||
// strings (e.g. "2"), matching the reference's luaL_checkint
|
||||
// coercion; a non-numeric string errors below.
|
||||
}
|
||||
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L, "bad argument #1 to 'select' (number expected)");
|
||||
return 0; // unreachable
|
||||
}
|
||||
int i = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (i < 0)
|
||||
i = n + i; // negative indexes count back from the last argument
|
||||
else if (i > n)
|
||||
i = n;
|
||||
if (i < 1) {
|
||||
Game::Lua::Error(L, "bad argument #1 to 'select' (index out of range)");
|
||||
return 0; // unreachable
|
||||
}
|
||||
// The varargs from position i+1..n are already on the stack; returning
|
||||
// their count hands exactly that slice back to the caller.
|
||||
return n - i;
|
||||
}
|
||||
|
||||
void RegisterInGame() {
|
||||
Game::Lua::RegisterGlobalFunction("select", &Script_select);
|
||||
}
|
||||
|
||||
void RegisterGlue() {
|
||||
Game::Lua::RegisterGlueFunction("select", &Script_select);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
|
||||
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace BaseLib::Select
|
||||
@@ -0,0 +1,98 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Lua 5.1 string-library additions that 1.12's Lua 5.0 is missing:
|
||||
//
|
||||
// - `string.match(s, pattern [, init])` — first-match extraction.
|
||||
// - `string.gmatch(s, pattern)` — match iterator.
|
||||
//
|
||||
// Both are the same underlying pattern engine as functions 5.0 already ships:
|
||||
// - `match` is `find` returning captures / the whole match instead of the
|
||||
// start/end indices — so we call the engine's `str_find` and transform.
|
||||
// - `gmatch` is exactly 5.0's `string.gfind` (renamed in 5.1); we register
|
||||
// it as a direct alias of the engine's `gfind` C function.
|
||||
//
|
||||
// NOTE: only the `string.foo(s, ...)` call form works — NOT the
|
||||
// `("x"):foo(...)` method sugar. WoW's Lua VM strips type-metatables for
|
||||
// every value except tables/userdata (both `lua_setmetatable` and the VM's
|
||||
// `luaT_gettmbyobj` hard-return "no metamethod" for strings), so strings have
|
||||
// no `__index`, and there's no client-side way to add one short of hooking a
|
||||
// hot VM-core function. This is the long-standing vanilla constraint.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace BaseLib::StringLib {
|
||||
|
||||
namespace {
|
||||
|
||||
// `string.match(s, pattern [, init])`. Calls the engine `str_find` and
|
||||
// reshapes its result: no match → nil; match with captures → the captures;
|
||||
// match with no captures → the whole matched substring.
|
||||
int __fastcall Script_string_match(void *L) {
|
||||
// Normalize to exactly (s, pattern, init): pad missing args with nil so
|
||||
// str_find defaults `init` to 1, and drop any stray 4th value so it can't
|
||||
// be misread as find's `plain` flag (match has no plain form).
|
||||
Game::Lua::SetTop(L, 3);
|
||||
constexpr int kBase = 3;
|
||||
|
||||
auto strFind = reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_STR_FIND);
|
||||
const int nres = strFind(L); // pushes start,end,caps.. above kBase, or a nil
|
||||
|
||||
if (nres <= 1) {
|
||||
// No match. str_find pushed the nil (return 1); guard the degenerate
|
||||
// nres==0 case by pushing our own nil so the shape holds.
|
||||
if (nres < 1)
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (nres == 2) {
|
||||
// No captures → the whole matched substring s[start..end] (1-based,
|
||||
// inclusive). An empty match has end == start-1 → an empty string.
|
||||
const int start = static_cast<int>(Game::Lua::ToNumber(L, kBase + 1));
|
||||
const int end = static_cast<int>(Game::Lua::ToNumber(L, kBase + 2));
|
||||
// str_find already coerced arg 1 to a string, so this is safe.
|
||||
const char *s = Game::Lua::ToString(L, 1);
|
||||
Game::Lua::SetTop(L, kBase); // drop start, end
|
||||
const size_t len = (end >= start) ? static_cast<size_t>(end - start + 1) : 0;
|
||||
Game::Lua::PushLString(L, s + start - 1, static_cast<unsigned int>(len));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Captures present → drop start & end, hand back only the captures.
|
||||
Game::Lua::Remove(L, kBase + 1); // remove start; end → kBase+1, caps → kBase+2..
|
||||
Game::Lua::Remove(L, kBase + 1); // remove end; caps → kBase+1..
|
||||
return nres - 2;
|
||||
}
|
||||
|
||||
// `string.gmatch` is 5.0's `string.gfind` verbatim — register the engine
|
||||
// function pointer directly rather than wrapping it.
|
||||
const auto Script_string_gmatch =
|
||||
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_STR_GFIND);
|
||||
|
||||
void RegisterFns() {
|
||||
Game::Lua::RegisterTableFunction("string", "match", &Script_string_match);
|
||||
Game::Lua::RegisterTableFunction("string", "gmatch", Script_string_gmatch);
|
||||
}
|
||||
|
||||
// Both states are Lua 5.0 and equally missing these; RegisterTableFunction
|
||||
// targets whichever state is active in each registration hook.
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
|
||||
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace BaseLib::StringLib
|
||||
@@ -130,11 +130,39 @@ int __fastcall Script_C_Container_GetContainerNumFreeSlots(void *L) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
// `C_Container.CalculateTotalNumberOfFreeBagSlots()` — the total number of
|
||||
// free slots across the player's *general-purpose* bags (backpack + equipped
|
||||
// bags 0..4 whose BagFamily is 0). Specialty bags (quivers, soul bags, herb
|
||||
// bags, …) are excluded, mirroring FrameXML's implementation exactly:
|
||||
//
|
||||
// for bag = BACKPACK .. lastBag:
|
||||
// free, family = C_Container.GetContainerNumFreeSlots(bag)
|
||||
// if family == 0 then total = total + free end
|
||||
//
|
||||
// Takes no arguments. Caveat inherited from `GetContainerNumFreeSlots`:
|
||||
// vanilla-era server data leaves most bags' BagFamily at 0, so specialty
|
||||
// bags that retail *would* exclude (Soul Pouch, Enchanting Bag, …) get
|
||||
// counted here — the client just doesn't carry the family bit for them. The
|
||||
// result stays consistent with what `GetContainerNumFreeSlots` reports for
|
||||
// those same bags.
|
||||
int __fastcall Script_C_Container_CalculateTotalNumberOfFreeBagSlots(void *L) {
|
||||
int total = 0;
|
||||
for (int bagID = 0; bagID <= 4; bagID++) {
|
||||
const BagInfo info = ResolveBagInfo(bagID);
|
||||
if (info.slotCount > 0 && info.bagType == 0)
|
||||
total += CountFreeSlotsInBag(L, bagID, info.slotCount);
|
||||
}
|
||||
Game::Lua::PushNumber(L, static_cast<double>(total));
|
||||
return 1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Container", "GetContainerNumFreeSlots",
|
||||
&Script_C_Container_GetContainerNumFreeSlots);
|
||||
Game::Lua::RegisterTableFunction("C_Container", "CalculateTotalNumberOfFreeBagSlots",
|
||||
&Script_C_Container_CalculateTotalNumberOfFreeBagSlots);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Container.HasContainerItem(bagIndex, slotIndex)` — returns whether the
|
||||
// given bag slot currently holds an item (a Classic Era addition). It's the
|
||||
// occupancy sibling of `GetContainerItemID`: resolves the slot to its CGItem
|
||||
// the same way (`Item::Location::ResolveBag` → engine `PackBagSlot` →
|
||||
// `GetItemBySlot`), and a populated slot yields a non-null pointer while an
|
||||
// empty slot yields null. Presence only — it does not depend on the item's
|
||||
// static data being cached. Returns `false` for empty slots and invalid
|
||||
// bag/slot indices.
|
||||
|
||||
#include "Game.h"
|
||||
#include "item/Location.h"
|
||||
|
||||
namespace Container::HasItem {
|
||||
|
||||
namespace {
|
||||
|
||||
int __fastcall Script_C_Container_HasContainerItem(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
|
||||
Game::Lua::Error(L, "Usage: C_Container.HasContainerItem(bagIndex, slotIndex)");
|
||||
return 0;
|
||||
}
|
||||
const int bagID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
const int slotIndex = static_cast<int>(Game::Lua::ToNumber(L, 2));
|
||||
|
||||
const bool hasItem = Item::Location::ResolveBag(L, bagID, slotIndex) != nullptr;
|
||||
Game::Lua::PushBool(L, hasItem);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Container", "HasContainerItem",
|
||||
&Script_C_Container_HasContainerItem);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Container::HasItem
|
||||
@@ -0,0 +1,194 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Container.GetContainerItemInfo(containerIndex, slotIndex)` — the modern
|
||||
// structured-table accessor (namespaced + table-returning since Patch
|
||||
// 10.0.2). Vanilla's global `GetContainerItemInfo` returns a flat 5-tuple
|
||||
// (`texture, itemCount, locked, quality, readable` — verified by decoding
|
||||
// `Script_GetContainerItemInfo` at `0x004F9670`); backported addons expect
|
||||
// the `ContainerItemInfo` table with the wider modern field set. We assemble
|
||||
// that table from the same sources the engine reads plus the extra fields it
|
||||
// never surfaced in 1.12.
|
||||
//
|
||||
// Field sources:
|
||||
// iconFileID — ItemStats displayInfoID → ItemDisplayInfo.dbc icon path.
|
||||
// (1.12 has no fileID system; we return the icon *path*
|
||||
// string, same convention as `GetItemIcon`.)
|
||||
// stackCount — CGItem descriptor `+OFF_DESCRIPTOR_STACK_COUNT` (the
|
||||
// engine's `itemCount` return).
|
||||
// isLocked — CGItem `+OFF_ITEM_CLIENT_LOCK & 1` (the `locked` return).
|
||||
// quality — ItemStats `+OFF_ITEMSTATS_QUALITY`; nil when uncached
|
||||
// (modern contract: `Enum.ItemQuality or nil`).
|
||||
// isReadable — static page text OR the per-instance readable-text id,
|
||||
// mirroring the engine's `readable` branch at `0x004F97BD`.
|
||||
// hasLoot — ItemStats LOOTABLE flag (best-effort static approximation:
|
||||
// vanilla can't track post-loot dynamic emptiness client-side).
|
||||
// hyperlink — engine link builder via `Item::Link::FromCGItem` (enchant /
|
||||
// random-suffix decorated, same as `GetContainerItemLink`).
|
||||
// isFiltered — always false (vanilla has no bag search-filter system).
|
||||
// hasNoValue — ItemStats sellPrice == 0.
|
||||
// itemID — instance block itemID (`Item::ID::FromCGItem`).
|
||||
// isBound — CGItem descriptor SOULBOUND flag (`C_Item.IsBound`'s bit).
|
||||
// itemName — instance-decorated display name, base ItemStats name on miss.
|
||||
//
|
||||
// Returns nil for an empty slot or an invalid bag/slot index (modern
|
||||
// contract). Cache-derived fields (icon / quality / hasLoot / hasNoValue /
|
||||
// name) are omitted/nil when the item's static data isn't cached yet — the
|
||||
// live per-instance fields (count / locked / bound / itemID / link) are always
|
||||
// present. Passive: does not warm the item cache.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "item/ID.h"
|
||||
#include "item/Link.h"
|
||||
#include "item/Location.h"
|
||||
#include "item/StatAccum.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
|
||||
namespace Container::ItemInfo {
|
||||
|
||||
namespace {
|
||||
|
||||
bool BuildIconPath(uint32_t displayInfoID, char *out, size_t outSize) {
|
||||
out[0] = '\0';
|
||||
const char *iconName = DBC::StringField(
|
||||
Offsets::VAR_ITEMDISPLAYINFO_RECORDS, Offsets::VAR_ITEMDISPLAYINFO_COUNT,
|
||||
displayInfoID, Offsets::OFF_ITEMDISPLAYINFO_ICON);
|
||||
if (iconName == nullptr || iconName[0] == '\0')
|
||||
return false;
|
||||
std::snprintf(out, outSize, "Interface\\Icons\\%s", iconName);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Mirrors the engine's `readable` decision (0x004F97BD..0x004F97CF): a slot
|
||||
// is readable when its static page text is set OR the per-instance
|
||||
// readable-text id is non-zero (letters / server-authored notes).
|
||||
bool ItemReadable(const uint8_t *item, const uint8_t *record) {
|
||||
if (record != nullptr &&
|
||||
*reinterpret_cast<const uint32_t *>(record + Offsets::OFF_ITEMSTATS_PAGE_TEXT) != 0)
|
||||
return true;
|
||||
const uint8_t *descriptor = *reinterpret_cast<const uint8_t *const *>(
|
||||
item + Offsets::OFF_ITEM_DESCRIPTOR);
|
||||
return descriptor != nullptr &&
|
||||
*reinterpret_cast<const uint32_t *>(
|
||||
descriptor + Offsets::OFF_DESCRIPTOR_READABLE_TEXT_ID) != 0;
|
||||
}
|
||||
|
||||
int __fastcall Script_C_Container_GetContainerItemInfo(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
|
||||
Game::Lua::Error(L,
|
||||
"Usage: C_Container.GetContainerItemInfo(containerIndex, slotIndex)");
|
||||
return 0;
|
||||
}
|
||||
const int bagID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
const int slotIndex = static_cast<int>(Game::Lua::ToNumber(L, 2));
|
||||
|
||||
const uint8_t *item = Item::Location::ResolveBag(L, bagID, slotIndex);
|
||||
if (item == nullptr) {
|
||||
Game::Lua::PushNil(L); // empty slot / bad index → nil (modern contract)
|
||||
return 1;
|
||||
}
|
||||
|
||||
// --- Gather everything into locals BEFORE touching the Lua stack. The
|
||||
// engine link/name builders and cache/DBC reads below are pure C; keeping
|
||||
// them off the half-built table avoids any stack interleaving. ---
|
||||
const int itemID = Item::ID::FromCGItem(item);
|
||||
const uint8_t *record = (itemID > 0)
|
||||
? Item::StatAccum::FetchRecord(static_cast<uint32_t>(itemID))
|
||||
: nullptr;
|
||||
|
||||
// Live per-instance descriptor fields.
|
||||
const uint8_t *descriptor = *reinterpret_cast<const uint8_t *const *>(
|
||||
item + Offsets::OFF_ITEM_DESCRIPTOR);
|
||||
int stackCount = 0;
|
||||
bool isBound = false;
|
||||
if (descriptor != nullptr) {
|
||||
stackCount = *reinterpret_cast<const int *>(
|
||||
descriptor + Offsets::OFF_DESCRIPTOR_STACK_COUNT);
|
||||
isBound = (*reinterpret_cast<const uint32_t *>(
|
||||
descriptor + Offsets::OFF_DESCRIPTOR_FLAGS) &
|
||||
Offsets::ITEM_FLAG_SOULBOUND) != 0;
|
||||
}
|
||||
const bool isLocked = (*reinterpret_cast<const uint32_t *>(
|
||||
item + Offsets::OFF_ITEM_CLIENT_LOCK) &
|
||||
Offsets::ITEM_CLIENT_LOCK_BIT) != 0;
|
||||
const bool isReadable = ItemReadable(item, record);
|
||||
|
||||
// Static template fields (cache-dependent).
|
||||
bool haveIcon = false;
|
||||
char iconPath[260];
|
||||
bool haveQuality = false;
|
||||
uint32_t quality = 0;
|
||||
bool hasLoot = false;
|
||||
bool hasNoValue = false;
|
||||
if (record != nullptr) {
|
||||
const uint32_t displayInfoID = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID);
|
||||
haveIcon = BuildIconPath(displayInfoID, iconPath, sizeof(iconPath));
|
||||
quality = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_QUALITY);
|
||||
haveQuality = true;
|
||||
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_FLAGS);
|
||||
hasLoot = (flags & Offsets::ITEM_FLAG_LOOTABLE) != 0;
|
||||
hasNoValue = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SELL_PRICE) == 0;
|
||||
}
|
||||
|
||||
const char *hyperlink = Item::Link::FromCGItem(item);
|
||||
|
||||
char nameBuf[256];
|
||||
const char *itemName = nullptr;
|
||||
if (Item::Link::NameFromCGItem(item, nameBuf, sizeof(nameBuf))) {
|
||||
itemName = nameBuf;
|
||||
} else if (record != nullptr) {
|
||||
const char *baseName = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSTATS_NAME);
|
||||
if (baseName != nullptr && baseName[0] != '\0')
|
||||
itemName = baseName;
|
||||
}
|
||||
|
||||
// --- Build the ContainerItemInfo table (only stack-safe SetField* now). ---
|
||||
Game::Lua::NewTable(L);
|
||||
if (haveIcon)
|
||||
Game::Lua::SetFieldString(L, "iconFileID", iconPath);
|
||||
Game::Lua::SetFieldNumber(L, "stackCount", static_cast<double>(stackCount));
|
||||
Game::Lua::SetFieldBool(L, "isLocked", isLocked);
|
||||
if (haveQuality)
|
||||
Game::Lua::SetFieldNumber(L, "quality", static_cast<double>(quality));
|
||||
Game::Lua::SetFieldBool(L, "isReadable", isReadable);
|
||||
Game::Lua::SetFieldBool(L, "hasLoot", hasLoot);
|
||||
if (hyperlink != nullptr && hyperlink[0] != '\0')
|
||||
Game::Lua::SetFieldString(L, "hyperlink", hyperlink);
|
||||
Game::Lua::SetFieldBool(L, "isFiltered", false);
|
||||
Game::Lua::SetFieldBool(L, "hasNoValue", hasNoValue);
|
||||
Game::Lua::SetFieldNumber(L, "itemID", static_cast<double>(itemID));
|
||||
Game::Lua::SetFieldBool(L, "isBound", isBound);
|
||||
if (itemName != nullptr)
|
||||
Game::Lua::SetFieldString(L, "itemName", itemName);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Container", "GetContainerItemInfo",
|
||||
&Script_C_Container_GetContainerItemInfo);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Container::ItemInfo
|
||||
@@ -36,10 +36,24 @@
|
||||
// Field offsets verified against the binary (rank read by the engine's
|
||||
// classification helper as `[block+0x20]`) and real creaturecache.wdb
|
||||
// rows. See Offsets.h `OFF_CREATURE_*`.
|
||||
//
|
||||
// This file also hosts the two static-DBC lookups on the same table:
|
||||
//
|
||||
// C_CreatureInfo.GetRaceInfo(raceID) -> { raceName, clientFileString, raceID }
|
||||
// C_CreatureInfo.GetClassInfo(classID) -> { className, classFile, classID }
|
||||
//
|
||||
// retail backports over `ChrRaces.dbc` / `ChrClasses.dbc`. Unlike the
|
||||
// creature cache these DBCs are always loaded, localized, and synchronous —
|
||||
// so a by-id lookup works for any race/class id at any time, with no WDB
|
||||
// cold-cache problem. The file-string columns (`clientFileString` /
|
||||
// `classFile`) are the same non-localized tokens `UnitRace`/`UnitClass`
|
||||
// return as their 2nd value ("NightElf", "WARRIOR"), so they round-trip
|
||||
// with the unit APIs. See Offsets.h `OFF_CHRRACES_*` / `OFF_CHRCLASSES_*`.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "cache/QueryLoad.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "event/Custom.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -122,11 +136,242 @@ int __fastcall Script_RequestLoadCreatureByID(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_CreatureInfo.GetRaceInfo(raceID)` — RaceInfo table, or nil for a
|
||||
// non-numeric / non-positive id or one with no ChrRaces row.
|
||||
int __fastcall Script_GetRaceInfo(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1))
|
||||
return 0; // nil
|
||||
const int raceID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
const char *file =
|
||||
(raceID > 0) ? DBC::StringField(Offsets::VAR_CHRRACES_RECORDS,
|
||||
Offsets::VAR_CHRRACES_COUNT,
|
||||
static_cast<uint32_t>(raceID),
|
||||
Offsets::OFF_CHRRACES_FILENAME)
|
||||
: nullptr;
|
||||
if (file == nullptr)
|
||||
return 0; // unknown id -> nil
|
||||
const char *name = DBC::LocalizedField(Offsets::VAR_CHRRACES_RECORDS,
|
||||
Offsets::VAR_CHRRACES_COUNT,
|
||||
static_cast<uint32_t>(raceID),
|
||||
Offsets::OFF_CHRRACES_NAMES);
|
||||
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldString(L, "raceName", name); // NULL coerced to ""
|
||||
Game::Lua::SetFieldString(L, "clientFileString", file);
|
||||
Game::Lua::SetFieldNumber(L, "raceID", raceID);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_CreatureInfo.GetClassInfo(classID)` — ClassInfo table, or nil for a
|
||||
// non-numeric / non-positive id or one with no ChrClasses row.
|
||||
int __fastcall Script_GetClassInfo(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1))
|
||||
return 0; // nil
|
||||
const int classID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
const char *file =
|
||||
(classID > 0) ? DBC::StringField(Offsets::VAR_CHRCLASSES_RECORDS,
|
||||
Offsets::VAR_CHRCLASSES_COUNT,
|
||||
static_cast<uint32_t>(classID),
|
||||
Offsets::OFF_CHRCLASSES_FILENAME)
|
||||
: nullptr;
|
||||
if (file == nullptr)
|
||||
return 0; // unknown id -> nil
|
||||
const char *name = DBC::LocalizedField(Offsets::VAR_CHRCLASSES_RECORDS,
|
||||
Offsets::VAR_CHRCLASSES_COUNT,
|
||||
static_cast<uint32_t>(classID),
|
||||
Offsets::OFF_CHRCLASSES_NAMES);
|
||||
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldString(L, "className", name); // NULL coerced to ""
|
||||
Game::Lua::SetFieldString(L, "classFile", file);
|
||||
Game::Lua::SetFieldNumber(L, "classID", classID);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_CreatureInfo.GetCreatureFamilyInfo(creatureFamilyID)` — CreatureFamilyInfo
|
||||
// table for a `CreatureFamily.dbc` row (1 = Wolf, 27 = Wind Serpent, …), or
|
||||
// nil for a non-numeric / non-positive / unused id. `iconFile` is a texture
|
||||
// path string here (retail exposes a numeric fileID; vanilla's DBC stores the
|
||||
// path) — empty "" for families with no icon (e.g. warlock pets).
|
||||
int __fastcall Script_GetCreatureFamilyInfo(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1))
|
||||
return 0; // nil
|
||||
const int familyID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
const char *name =
|
||||
(familyID > 0) ? DBC::LocalizedField(Offsets::VAR_CREATUREFAMILY_RECORDS,
|
||||
Offsets::VAR_CREATUREFAMILY_COUNT,
|
||||
static_cast<uint32_t>(familyID),
|
||||
Offsets::OFF_CREATUREFAMILY_NAMES)
|
||||
: nullptr;
|
||||
if (name == nullptr)
|
||||
return 0; // unused / OOR id -> nil
|
||||
const char *icon = DBC::StringField(Offsets::VAR_CREATUREFAMILY_RECORDS,
|
||||
Offsets::VAR_CREATUREFAMILY_COUNT,
|
||||
static_cast<uint32_t>(familyID),
|
||||
Offsets::OFF_CREATUREFAMILY_ICON);
|
||||
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "id", familyID);
|
||||
Game::Lua::SetFieldString(L, "name", name);
|
||||
Game::Lua::SetFieldString(L, "iconFile", icon); // NULL/empty coerced to ""
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_CreatureInfo.GetCreatureFamilyIDs()` — array of every populated
|
||||
// `CreatureFamily.dbc` id (sparse: skips the unused rows). Round-trips with
|
||||
// GetCreatureFamilyInfo for each element.
|
||||
int __fastcall Script_GetCreatureFamilyIDs(void *L) {
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
const int count =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_CREATUREFAMILY_COUNT);
|
||||
int n = 0;
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const char *name = DBC::LocalizedField(Offsets::VAR_CREATUREFAMILY_RECORDS,
|
||||
Offsets::VAR_CREATUREFAMILY_COUNT,
|
||||
static_cast<uint32_t>(id),
|
||||
Offsets::OFF_CREATUREFAMILY_NAMES);
|
||||
if (name == nullptr)
|
||||
continue; // unused row
|
||||
++n;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(n));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(id));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_CreatureInfo.GetFactionInfo(raceID)` — faction-group info for a race:
|
||||
// { name = localized ("Alliance"/"Horde"/…), groupTag = english }. Mirrors
|
||||
// the player branch of `Script_UnitFactionGroup` (`0x00516630`): resolve the
|
||||
// race's FactionTemplate group mask, then scan `FactionGroup.dbc` for the row
|
||||
// whose bit the mask sets and whose localized name is non-empty (which is what
|
||||
// skips the always-set "Player" / the nameless "Monster" rows and lands on
|
||||
// Alliance / Horde). nil for a non-numeric / non-positive / unknown race or a
|
||||
// race with no named group. See Offsets.h for the full chain.
|
||||
int __fastcall Script_GetFactionInfo(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1))
|
||||
return 0; // nil
|
||||
const int raceID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (raceID <= 0)
|
||||
return 0;
|
||||
|
||||
const uint8_t *crRec = DBC::Record(Offsets::VAR_CHRRACES_RECORDS,
|
||||
Offsets::VAR_CHRRACES_COUNT,
|
||||
static_cast<uint32_t>(raceID));
|
||||
if (crRec == nullptr)
|
||||
return 0; // unknown race
|
||||
const int ftId = *reinterpret_cast<const int *>(
|
||||
crRec + Offsets::OFF_CHRRACES_FACTION_TEMPLATE);
|
||||
if (ftId <= 0)
|
||||
return 0;
|
||||
|
||||
const uint8_t *ftRec = DBC::Record(Offsets::VAR_FACTIONTEMPLATE_RECORDS,
|
||||
Offsets::VAR_FACTIONTEMPLATE_COUNT,
|
||||
static_cast<uint32_t>(ftId));
|
||||
if (ftRec == nullptr)
|
||||
return 0;
|
||||
const uint32_t mask = *reinterpret_cast<const uint32_t *>(
|
||||
ftRec + Offsets::OFF_FACTIONTEMPLATE_GROUP_MASK);
|
||||
|
||||
// FactionGroup is stored contiguously (records-base + i*stride), not as a
|
||||
// pointer array — iterate by hand rather than via DBC::Record.
|
||||
auto *fgBase = *reinterpret_cast<const uint8_t *const *>(
|
||||
Offsets::VAR_FACTIONGROUP_RECORDS);
|
||||
const int fgCount =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_FACTIONGROUP_COUNT);
|
||||
const int locale = *reinterpret_cast<const int *>(Offsets::VAR_LOCALE_INDEX);
|
||||
if (fgBase == nullptr)
|
||||
return 0;
|
||||
|
||||
for (int i = 0; i < fgCount; ++i) {
|
||||
const uint8_t *row = fgBase + i * Offsets::FACTIONGROUP_STRIDE;
|
||||
const int bit = *reinterpret_cast<const int *>(
|
||||
row + Offsets::OFF_FACTIONGROUP_BIT);
|
||||
if ((mask & (1u << (bit & 0x1F))) == 0)
|
||||
continue;
|
||||
const char *loc = *reinterpret_cast<const char *const *>(
|
||||
row + Offsets::OFF_FACTIONGROUP_NAMES + locale * 4);
|
||||
if (loc == nullptr || *loc == '\0')
|
||||
continue; // "Player" / "Monster" have empty localized names
|
||||
const char *eng = *reinterpret_cast<const char *const *>(
|
||||
row + Offsets::OFF_FACTIONGROUP_ENGLISH);
|
||||
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldString(L, "name", loc);
|
||||
Game::Lua::SetFieldString(L, "groupTag", eng);
|
||||
return 1;
|
||||
}
|
||||
return 0; // no named group -> nil
|
||||
}
|
||||
|
||||
// `C_CreatureInfo.GetCreatureTypeInfo(creatureTypeID)` — CreatureTypeInfo
|
||||
// table for a `CreatureType.dbc` row (1 = Beast … 11 = Totem), or nil for a
|
||||
// non-numeric / non-positive / unused id.
|
||||
int __fastcall Script_GetCreatureTypeInfo(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1))
|
||||
return 0; // nil
|
||||
const int typeID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
const char *name =
|
||||
(typeID > 0) ? DBC::LocalizedField(Offsets::VAR_CREATURETYPE_RECORDS,
|
||||
Offsets::VAR_CREATURETYPE_COUNT,
|
||||
static_cast<uint32_t>(typeID),
|
||||
Offsets::OFF_CREATURETYPE_NAMES)
|
||||
: nullptr;
|
||||
if (name == nullptr)
|
||||
return 0; // unused / OOR id -> nil
|
||||
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "id", typeID);
|
||||
Game::Lua::SetFieldString(L, "name", name);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_CreatureInfo.GetCreatureTypeIDs()` — array of every populated
|
||||
// `CreatureType.dbc` id (contiguous 1..N in vanilla). Round-trips with
|
||||
// GetCreatureTypeInfo for each element.
|
||||
int __fastcall Script_GetCreatureTypeIDs(void *L) {
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
const int count =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_CREATURETYPE_COUNT);
|
||||
int n = 0;
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const char *name = DBC::LocalizedField(Offsets::VAR_CREATURETYPE_RECORDS,
|
||||
Offsets::VAR_CREATURETYPE_COUNT,
|
||||
static_cast<uint32_t>(id),
|
||||
Offsets::OFF_CREATURETYPE_NAMES);
|
||||
if (name == nullptr)
|
||||
continue;
|
||||
++n;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(n));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(id));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetCreatureInfoByID",
|
||||
&Script_GetCreatureInfoByID);
|
||||
Game::Lua::RegisterTableFunction("C_CreatureInfo", "RequestLoadCreatureByID",
|
||||
&Script_RequestLoadCreatureByID);
|
||||
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetRaceInfo",
|
||||
&Script_GetRaceInfo);
|
||||
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetClassInfo",
|
||||
&Script_GetClassInfo);
|
||||
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetCreatureFamilyInfo",
|
||||
&Script_GetCreatureFamilyInfo);
|
||||
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetCreatureFamilyIDs",
|
||||
&Script_GetCreatureFamilyIDs);
|
||||
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetFactionInfo",
|
||||
&Script_GetFactionInfo);
|
||||
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetCreatureTypeInfo",
|
||||
&Script_GetCreatureTypeInfo);
|
||||
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetCreatureTypeIDs",
|
||||
&Script_GetCreatureTypeIDs);
|
||||
Cache::QueryLoad::Register(reinterpret_cast<void *>(Offsets::VAR_CREATURE_CACHE),
|
||||
kCreatureDataLoadResult, Offsets::FUN_CREATURE_GET_RECORD);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@
|
||||
// `CursorHasItem` / engine `PickupX` calls when they need to detect
|
||||
// that.
|
||||
|
||||
#include "cursor/Info.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "item/ID.h"
|
||||
@@ -273,4 +275,9 @@ const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
bool HasItem() {
|
||||
return ReadVar(Offsets::VAR_CURSOR_ITEM_GUID_LO) != 0 ||
|
||||
ReadVar(Offsets::VAR_CURSOR_ITEM_GUID_HI) != 0;
|
||||
}
|
||||
|
||||
} // namespace Cursor::Info
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace Cursor::Info {
|
||||
|
||||
// True if a GUID-bearing item is currently on the cursor —
|
||||
// `VAR_CURSOR_ITEM_GUID != 0`, the same globals `GetCursorInfo`'s bag-item
|
||||
// path reads and the exact signal the engine's item-pickup path guards on.
|
||||
// Used to gate cursor pickups (don't clobber a held item).
|
||||
bool HasItem();
|
||||
|
||||
} // namespace Cursor::Info
|
||||
@@ -18,6 +18,7 @@
|
||||
#include "event/Custom.h"
|
||||
#include "item/ID.h"
|
||||
#include "item/Location.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
@@ -31,24 +32,15 @@ std::string g_path;
|
||||
bool g_loaded = false;
|
||||
bool g_ignoredForSave[SLOT_COUNT] = {};
|
||||
|
||||
void *ResolvePlayerInvMgr() {
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *);
|
||||
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
auto *player = static_cast<uint8_t *>(fn("player"));
|
||||
if (player == nullptr)
|
||||
return nullptr;
|
||||
return player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
|
||||
}
|
||||
|
||||
// Reads the GUID currently in paperdoll slot `slot1Based` (1..19) by
|
||||
// indexing the player invMgr's flat GUID array. Slot 1 (HEAD) lives
|
||||
// at linear index 0; the equipment paperdoll occupies indices 0..18.
|
||||
// Returns 0 for empty slots or unresolved player.
|
||||
uint64_t ReadEquippedGUID(int slot1Based) {
|
||||
auto *invMgr = static_cast<uint8_t *>(ResolvePlayerInvMgr());
|
||||
const uint8_t *invMgr = Unit::Identity::PlayerInventoryManager();
|
||||
if (invMgr == nullptr)
|
||||
return 0;
|
||||
auto *guids = *reinterpret_cast<uint64_t **>(
|
||||
auto *guids = *reinterpret_cast<uint64_t *const *>(
|
||||
invMgr + Offsets::OFF_INVMGR_GUID_ARRAY);
|
||||
if (guids == nullptr)
|
||||
return 0;
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
#include "Offsets.h"
|
||||
#include "item/Location.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
@@ -41,19 +42,6 @@ constexpr int LINEAR_BAG_SLOT_FIRST = 19; // bag1 → linear 19
|
||||
constexpr int LINEAR_BACKPACK_FIRST = 23;
|
||||
constexpr int LINEAR_BACKPACK_LAST = 38;
|
||||
|
||||
void *ResolvePlayer() {
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *);
|
||||
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
return fn("player");
|
||||
}
|
||||
|
||||
uint8_t *ResolvePlayerInvMgr() {
|
||||
auto *player = static_cast<uint8_t *>(ResolvePlayer());
|
||||
if (player == nullptr)
|
||||
return nullptr;
|
||||
return player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
|
||||
}
|
||||
|
||||
uint64_t *InvMgrGuidArray(const uint8_t *invMgr) {
|
||||
if (invMgr == nullptr)
|
||||
return nullptr;
|
||||
@@ -79,20 +67,6 @@ uint8_t *ResolveByGUID(int type, uint64_t guid) {
|
||||
0x172));
|
||||
}
|
||||
|
||||
// CGContainer exposes its own `CContainerInventory *` via vtable
|
||||
// `+0x10` — same dispatch `GetItemCount`'s bank walk uses. The
|
||||
// returned invMgr has the standard shape: slot count at +0x00,
|
||||
// flat GUID array at +0x04.
|
||||
uint8_t *BagInvMgr(uint8_t *bag) {
|
||||
if (bag == nullptr)
|
||||
return nullptr;
|
||||
auto *vtable = *reinterpret_cast<uint8_t **>(bag);
|
||||
using GetInvMgr_t = void *(__thiscall *)(void *);
|
||||
auto getInvMgr = reinterpret_cast<GetInvMgr_t>(
|
||||
*reinterpret_cast<uintptr_t *>(vtable + 0x10));
|
||||
return static_cast<uint8_t *>(getInvMgr(bag));
|
||||
}
|
||||
|
||||
// Walks one bag's contents for a matching GUID. Returns the 1-based
|
||||
// slot index of the match, or 0 if not found / bag unresolvable.
|
||||
// Works for both player bags (linear 19..22 of player invMgr) and
|
||||
@@ -102,7 +76,8 @@ int WalkBagContents(uint64_t bagGuid, uint64_t targetGuid) {
|
||||
auto *bag = ResolveByGUID(Offsets::OBJ_TYPE_CONTAINER, bagGuid);
|
||||
if (bag == nullptr)
|
||||
return 0;
|
||||
auto *bagInvMgr = BagInvMgr(bag);
|
||||
auto *bagInvMgr =
|
||||
static_cast<const uint8_t *>(Item::Location::ContainerInventory(bag));
|
||||
if (bagInvMgr == nullptr)
|
||||
return 0;
|
||||
const int count = InvMgrSlotCount(bagInvMgr);
|
||||
@@ -122,7 +97,7 @@ int FindGUID(uint64_t targetGuid) {
|
||||
if (targetGuid == GUID_EMPTY || targetGuid == GUID_IGNORED)
|
||||
return 0;
|
||||
|
||||
auto *invMgr = ResolvePlayerInvMgr();
|
||||
auto *invMgr = Unit::Identity::PlayerInventoryManager();
|
||||
auto *guids = InvMgrGuidArray(invMgr);
|
||||
if (guids == nullptr)
|
||||
return 0;
|
||||
@@ -180,7 +155,7 @@ const uint8_t *ResolveItemByGUID(uint64_t guid) {
|
||||
void SnapshotPaperdoll(uint64_t *out) {
|
||||
for (int i = 0; i < SLOT_COUNT; ++i)
|
||||
out[i] = 0;
|
||||
auto *invMgr = ResolvePlayerInvMgr();
|
||||
auto *invMgr = Unit::Identity::PlayerInventoryManager();
|
||||
auto *guids = InvMgrGuidArray(invMgr);
|
||||
if (guids == nullptr)
|
||||
return;
|
||||
|
||||
+11
-1
@@ -28,7 +28,17 @@ namespace {
|
||||
// any engine code runs. `RetryClaims` (triggered by the
|
||||
// `Frame::RegisterEvent` hook) walks this list and claims a NULL slot
|
||||
// for each unclaimed entry.
|
||||
constexpr int MAX_RESERVED = 32;
|
||||
//
|
||||
// MUST stay comfortably above the total number of `AutoReserve` instances
|
||||
// across the codebase (file-scope + the lazily-registered TTS ones) — the
|
||||
// constructor SILENTLY drops any reservation past the cap, and which ones
|
||||
// overflow depends on unspecified cross-TU static-init order, so exceeding
|
||||
// it makes an arbitrary subset of custom events quietly stop firing (their
|
||||
// `Lookup` returns -1 and `Fire` no-ops). We hit exactly that at 32: ~40
|
||||
// reservations meant a shifting handful (e.g. ITEM_DATA_LOAD_RESULT /
|
||||
// GET_ITEM_INFO_RECEIVED) never claimed a slot. Grep `AutoReserve` before
|
||||
// bumping the reservation count near this ceiling.
|
||||
constexpr int MAX_RESERVED = 64;
|
||||
struct ReservedName {
|
||||
const char *name;
|
||||
int slot; // -1 until claimed
|
||||
|
||||
+3
-2
@@ -37,8 +37,9 @@ namespace Event::Custom {
|
||||
// DLL operate on the table independently of the others.
|
||||
//
|
||||
// The name pointer must outlive the engine (a string literal does).
|
||||
// Same name reserved twice is deduped; reserving more than 32 names
|
||||
// total silently drops the overflow.
|
||||
// Same name reserved twice is deduped; reserving more than `MAX_RESERVED`
|
||||
// names total (see Custom.cpp) silently drops the overflow — keep that cap
|
||||
// above the codebase-wide `AutoReserve` count.
|
||||
struct AutoReserve {
|
||||
explicit AutoReserve(const char *name);
|
||||
};
|
||||
|
||||
@@ -24,6 +24,26 @@
|
||||
// as the events (single source of truth — events firing and the
|
||||
// state being readable happen at the same point in the pipeline).
|
||||
//
|
||||
// `GetMouseButtonClicked()` — the button name of the most recent
|
||||
// mouse button message (`"LeftButton"` / … / `"Button5"`), or nil
|
||||
// when no click is being handled. Modern addons call it inside a
|
||||
// mouse handler to learn which button drove it; the value is evicted
|
||||
// shortly after, so — like the real API — it reads nil outside a
|
||||
// handler rather than returning a stale last-click forever.
|
||||
//
|
||||
// Modern WoW backs this with a frame-manager field set right before a
|
||||
// mouse handler runs and cleared right after (verified in 3.3.5:
|
||||
// `GetMouseButtonClicked` pushes `[frameMgr + 0x1234]`, nil when that
|
||||
// field is null). We can't bracket the handler the same way here: the
|
||||
// `WH_GETMESSAGE` hook sees the button at message-dequeue, but WoW
|
||||
// dispatches the frame-script mouse handlers *deferred* (during the
|
||||
// frame update, not the message pump). So instead we capture the
|
||||
// button on the message and evict it from the per-frame `WorldTick`
|
||||
// once a couple of quiet ticks pass with no button held — the value
|
||||
// survives the whole frame its click lands in (so every handler,
|
||||
// including multiple addons hooking one `OnClick`, sees it) then
|
||||
// clears. A held button keeps it set so `OnDragStart` still reads it.
|
||||
//
|
||||
// Same `WH_GETMESSAGE` thread-message hook pattern as
|
||||
// [Input::Modifier](Modifier.cpp): we intercept the WM_*BUTTON*
|
||||
// messages on the engine's main thread *before* dispatch. The hook
|
||||
@@ -33,6 +53,7 @@
|
||||
|
||||
#include "Game.h"
|
||||
#include "event/Custom.h"
|
||||
#include "tick/WorldTick.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
@@ -59,6 +80,24 @@ constexpr uint32_t MASK_ANY_BUTTON = 0x1F;
|
||||
|
||||
uint32_t g_mouseButtonMask = 0;
|
||||
|
||||
// Name of the button from the most recent mouse-button message, or
|
||||
// nullptr when no click is being handled. Points at one of
|
||||
// DecodeButton's string literals (stable for the process lifetime),
|
||||
// so storing the pointer is safe. `lua_pushstring(L, nullptr)`
|
||||
// tail-jumps to pushnil, so a null here surfaces as nil to the Lua
|
||||
// caller with no extra branch. Set on each button message; evicted by
|
||||
// EvictTick (see the header note on GetMouseButtonClicked).
|
||||
const char *g_lastButton = nullptr;
|
||||
|
||||
// Set true whenever g_lastButton is (re)assigned, so EvictTick can
|
||||
// tell a just-clicked frame from a quiet one. Number of consecutive
|
||||
// quiet ticks (no activity, nothing held) required before eviction —
|
||||
// >1 so a handler dispatched a frame after its message still reads
|
||||
// the button.
|
||||
bool g_buttonActivity = false;
|
||||
int g_quietTicks = 0;
|
||||
constexpr int kQuietTicksToEvict = 2;
|
||||
|
||||
HHOOK g_msgHook = nullptr;
|
||||
|
||||
// Maps WM_*BUTTON{DOWN,UP} → ("LeftButton"|..., isDown). Returns
|
||||
@@ -101,6 +140,13 @@ void ProcessMouseMessage(UINT msg, WPARAM wParam) {
|
||||
if (name == nullptr)
|
||||
return;
|
||||
|
||||
// Capture for GetMouseButtonClicked on both press and release —
|
||||
// during an OnClick / OnMouseUp handler the "clicked" button is the
|
||||
// one just released, same as the press for a normal click.
|
||||
g_lastButton = name;
|
||||
g_buttonActivity = true;
|
||||
g_quietTicks = 0;
|
||||
|
||||
const int bitIdx = NameToBitIdx(name);
|
||||
if (bitIdx >= 0) {
|
||||
const uint32_t bit = 1u << bitIdx;
|
||||
@@ -136,6 +182,39 @@ int __fastcall Script_IsMouseButtonDown(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetMouseButtonClicked(void *L) {
|
||||
// Null → pushnil (the engine's pushstring tail-jumps on NULL), so
|
||||
// callers outside a mouse handler see nil, matching the modern API.
|
||||
Game::Lua::PushString(L, g_lastButton);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Per-frame eviction of g_lastButton. Runs at the tail of each frame's
|
||||
// world tick. While a button is held we keep the value (drag handlers
|
||||
// still read it) and reset the quiet counter. A frame that saw a fresh
|
||||
// button message consumes its activity flag but doesn't yet evict —
|
||||
// that one-frame grace lets a handler dispatched the frame after its
|
||||
// message still read the button. Only after kQuietTicksToEvict ticks
|
||||
// with nothing held and no activity do we clear, so outside a click
|
||||
// GetMouseButtonClicked reads nil instead of a stale last-click.
|
||||
void EvictTick() {
|
||||
if (g_lastButton == nullptr)
|
||||
return;
|
||||
if (g_mouseButtonMask != 0) {
|
||||
g_quietTicks = 0;
|
||||
return;
|
||||
}
|
||||
if (g_buttonActivity) {
|
||||
g_buttonActivity = false;
|
||||
g_quietTicks = 0;
|
||||
return;
|
||||
}
|
||||
if (++g_quietTicks >= kQuietTicksToEvict) {
|
||||
g_lastButton = nullptr;
|
||||
g_quietTicks = 0;
|
||||
}
|
||||
}
|
||||
|
||||
LRESULT CALLBACK GetMsgHook(int code, WPARAM wParam, LPARAM lParam) {
|
||||
// Only process when the message is being removed from the queue
|
||||
// (`PM_REMOVE` / `GetMessage`); `PM_NOREMOVE` peeks would deliver
|
||||
@@ -157,11 +236,14 @@ void InstallHook() {
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("IsMouseButtonDown",
|
||||
&Script_IsMouseButtonDown);
|
||||
Game::Lua::RegisterGlobalFunction("GetMouseButtonClicked",
|
||||
&Script_GetMouseButtonClicked);
|
||||
InstallHook();
|
||||
}
|
||||
|
||||
const Event::Custom::AutoReserve _reserveDown{kDownEvent};
|
||||
const Event::Custom::AutoReserve _reserveUp{kUpEvent};
|
||||
const Tick::WorldTick::AutoSubscribe _evict{&EvictTick};
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
+68
-19
@@ -63,6 +63,14 @@ const char *const kKeyName[6] = {
|
||||
"LSHIFT", "RSHIFT", "LCTRL", "RCTRL", "LALT", "RALT",
|
||||
};
|
||||
|
||||
// L/R-specific virtual keys, one per bit, for the focus-regain resync.
|
||||
// `GetAsyncKeyState` accepts the L/R-distinguishing VKs directly (unlike
|
||||
// the keyboard-message path, where `VK_SHIFT` arrives merged and has to be
|
||||
// disambiguated via the scancode).
|
||||
const int kBitVk[6] = {
|
||||
VK_LSHIFT, VK_RSHIFT, VK_LCONTROL, VK_RCONTROL, VK_LMENU, VK_RMENU,
|
||||
};
|
||||
|
||||
constexpr const char *kModifierStateChanged = "MODIFIER_STATE_CHANGED";
|
||||
|
||||
// Static-init reservation — runs before DllMain. The
|
||||
@@ -72,6 +80,7 @@ const Event::Custom::AutoReserve _reserveModifierStateChanged{kModifierStateChan
|
||||
|
||||
uint32_t g_modifierMask = 0;
|
||||
HHOOK g_msgHook = nullptr;
|
||||
HHOOK g_wndProcHook = nullptr;
|
||||
|
||||
void PushDown(void *L, bool down) {
|
||||
if (down)
|
||||
@@ -80,6 +89,28 @@ void PushDown(void *L, bool down) {
|
||||
Game::Lua::PushNil(L);
|
||||
}
|
||||
|
||||
// Apply a single modifier bit transition: update the cached mask and, on a
|
||||
// genuine change, fire `MODIFIER_STATE_CHANGED`. Both the keyboard-message
|
||||
// path and the focus-regain resync funnel through here so the "only
|
||||
// transitions fire" invariant holds regardless of source.
|
||||
void SetBit(int bitIdx, bool down) {
|
||||
const uint32_t bit = 1u << bitIdx;
|
||||
const bool wasDown = (g_modifierMask & bit) != 0;
|
||||
if (down == wasDown)
|
||||
return;
|
||||
|
||||
if (down) g_modifierMask |= bit;
|
||||
else g_modifierMask &= ~bit;
|
||||
|
||||
// Slot was assigned when the engine populated its event table; our
|
||||
// hook on `RebuildEventTable` recorded the index. Lookup at fire
|
||||
// time rather than caching because `/reload` re-runs the rebuild
|
||||
// and the index may shift.
|
||||
const int slot = Event::Custom::Lookup(kModifierStateChanged);
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "%s%d", kKeyName[bitIdx], static_cast<int>(down));
|
||||
}
|
||||
|
||||
template <int BitIdx>
|
||||
int __fastcall Script_IsKeyDown(void *L) {
|
||||
const bool down = (g_modifierMask & (1u << BitIdx)) != 0;
|
||||
@@ -131,23 +162,22 @@ void ProcessKeyMessage(UINT msg, WPARAM wParam, LPARAM lParam) {
|
||||
const int bitIdx = VkToBitIdx(wParam, lParam);
|
||||
if (bitIdx < 0)
|
||||
return;
|
||||
|
||||
const uint32_t bit = 1u << bitIdx;
|
||||
const bool wasDown = (g_modifierMask & bit) != 0;
|
||||
const bool nowDown = (msg == WM_KEYDOWN || msg == WM_SYSKEYDOWN);
|
||||
if (nowDown == wasDown)
|
||||
return;
|
||||
SetBit(bitIdx, nowDown);
|
||||
}
|
||||
|
||||
if (nowDown) g_modifierMask |= bit;
|
||||
else g_modifierMask &= ~bit;
|
||||
|
||||
// Slot was assigned when the engine populated its event table; our
|
||||
// hook on `RebuildEventTable` recorded the index. Lookup at fire
|
||||
// time rather than caching because `/reload` re-runs the rebuild
|
||||
// and the index may shift.
|
||||
const int slot = Event::Custom::Lookup(kModifierStateChanged);
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "%s%d", kKeyName[bitIdx], static_cast<int>(nowDown));
|
||||
// Reconcile the cached mask with the true physical key state. Needed on
|
||||
// focus regain: keyboard messages only arrive while WoW has focus, so a
|
||||
// modifier released while WoW was in the background is never seen by the
|
||||
// `WH_GETMESSAGE` hook and the bit stays stuck down (and the release event
|
||||
// never fires). `GetAsyncKeyState` reads global async keyboard state
|
||||
// regardless of focus, so polling it on activation recovers the missed
|
||||
// transitions — `SetBit` fires `MODIFIER_STATE_CHANGED` for each drift.
|
||||
void ResyncFromPhysicalState() {
|
||||
for (int b = 0; b < 6; ++b) {
|
||||
const bool down = (GetAsyncKeyState(kBitVk[b]) & 0x8000) != 0;
|
||||
SetBit(b, down);
|
||||
}
|
||||
}
|
||||
|
||||
LRESULT CALLBACK GetMsgHook(int code, WPARAM wParam, LPARAM lParam) {
|
||||
@@ -164,11 +194,30 @@ LRESULT CALLBACK GetMsgHook(int code, WPARAM wParam, LPARAM lParam) {
|
||||
return CallNextHookEx(nullptr, code, wParam, lParam);
|
||||
}
|
||||
|
||||
// `WM_ACTIVATEAPP` is *sent* (via the window manager), not *posted* to the
|
||||
// queue, so `WH_GETMESSAGE` never sees it — a `WH_CALLWNDPROC` hook catches
|
||||
// sent messages before the target WNDPROC runs. On app activation
|
||||
// (`wParam != FALSE`) we resync from physical key state to recover any
|
||||
// modifier released while WoW was in the background. Like the message hook,
|
||||
// this is thread-level (not per-`HWND`), so it survives window recreation.
|
||||
LRESULT CALLBACK CallWndProcHook(int code, WPARAM wParam, LPARAM lParam) {
|
||||
if (code == HC_ACTION) {
|
||||
const CWPSTRUCT *cwp = reinterpret_cast<const CWPSTRUCT *>(lParam);
|
||||
if (cwp->message == WM_ACTIVATEAPP && cwp->wParam != FALSE)
|
||||
ResyncFromPhysicalState();
|
||||
}
|
||||
return CallNextHookEx(nullptr, code, wParam, lParam);
|
||||
}
|
||||
|
||||
void InstallHook() {
|
||||
if (g_msgHook != nullptr)
|
||||
return;
|
||||
g_msgHook = SetWindowsHookExA(WH_GETMESSAGE, &GetMsgHook, nullptr,
|
||||
GetCurrentThreadId());
|
||||
if (g_msgHook == nullptr) {
|
||||
g_msgHook = SetWindowsHookExA(WH_GETMESSAGE, &GetMsgHook, nullptr,
|
||||
GetCurrentThreadId());
|
||||
}
|
||||
if (g_wndProcHook == nullptr) {
|
||||
g_wndProcHook = SetWindowsHookExA(WH_CALLWNDPROC, &CallWndProcHook,
|
||||
nullptr, GetCurrentThreadId());
|
||||
}
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
#include "item/Data.h"
|
||||
#include "item/ID.h"
|
||||
#include "item/Location.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
@@ -153,21 +154,11 @@ ItemInfo ReadItemInfo(const uint8_t *cgItem) {
|
||||
// Bank-walk helpers — bypass the bank-window gate on `GetItemBySlot`
|
||||
// by reading the player's invMgr GUID array directly. Same shape as
|
||||
// `C_Item.GetItemCount`'s bank path.
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
using ResolveObjectByGuid_t = void *(__fastcall *)(int type,
|
||||
const char *debugName,
|
||||
uint32_t guidLo,
|
||||
uint32_t guidHi,
|
||||
int priority);
|
||||
using GetBagInvMgr_t = void *(__thiscall *)(void *bag);
|
||||
|
||||
const uint8_t *PlayerInvMgr() {
|
||||
auto resolveUnit = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
auto *player = static_cast<uint8_t *>(resolveUnit("player"));
|
||||
if (player == nullptr)
|
||||
return nullptr;
|
||||
return player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
|
||||
}
|
||||
|
||||
const uint8_t *ResolveByGuid(int type, uint64_t guid) {
|
||||
if (guid == 0)
|
||||
@@ -229,7 +220,7 @@ void WalkGuidArrayRange(const uint8_t *invMgr, int firstSlot, int lastSlot,
|
||||
// player invMgr hold the bank bags themselves; each bag has its own
|
||||
// invMgr accessed via the bag CGContainer's vtable+0x10 method).
|
||||
void WalkBankBags(std::vector<Candidate> &candidates) {
|
||||
auto *playerInvMgr = PlayerInvMgr();
|
||||
auto *playerInvMgr = Unit::Identity::PlayerInventoryManager();
|
||||
if (playerInvMgr == nullptr)
|
||||
return;
|
||||
auto *playerGuidArray = *reinterpret_cast<const uint64_t *const *>(
|
||||
@@ -242,11 +233,8 @@ void WalkBankBags(std::vector<Candidate> &candidates) {
|
||||
playerGuidArray[slot]);
|
||||
if (bag == nullptr)
|
||||
continue;
|
||||
auto *vtable = *reinterpret_cast<const uint8_t *const *>(bag);
|
||||
auto getInvMgr = reinterpret_cast<GetBagInvMgr_t>(
|
||||
*reinterpret_cast<const uintptr_t *>(vtable + 0x10));
|
||||
auto *bagInvMgr = static_cast<const uint8_t *>(
|
||||
getInvMgr(const_cast<uint8_t *>(bag)));
|
||||
auto *bagInvMgr =
|
||||
static_cast<const uint8_t *>(Item::Location::ContainerInventory(bag));
|
||||
if (bagInvMgr == nullptr)
|
||||
continue;
|
||||
const int bagSlots =
|
||||
@@ -295,7 +283,7 @@ static int __fastcall Script_GetAverageItemLevel(void *L) {
|
||||
// session — GUIDs are populated from server data at login. Main
|
||||
// bank first (linear slots 39..62 in the player invMgr), then
|
||||
// each equipped bank bag's contents via the per-bag CGContainer.
|
||||
const uint8_t *playerInvMgr = PlayerInvMgr();
|
||||
const uint8_t *playerInvMgr = Unit::Identity::PlayerInventoryManager();
|
||||
WalkGuidArrayRange(playerInvMgr,
|
||||
Offsets::INVMGR_BANK_MAIN_FIRST_SLOT,
|
||||
Offsets::INVMGR_BANK_MAIN_LAST_SLOT,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Item.IsConsumableItem(item)` — true iff the item is a consumable:
|
||||
// item class `Consumable` (0), or an inventory type of `INVTYPE_AMMO`
|
||||
// (24) / `INVTYPE_THROWN` (25) — ammo and thrown weapons being consumed
|
||||
// in use. `item` is an itemID, `"item:NNN"` string, or item link (names
|
||||
// aren't resolvable in vanilla → false).
|
||||
//
|
||||
// Definition established by decoding 3.3.5's `Script_IsConsumableItem`
|
||||
// (`0x005173E0`) and then verifying the surviving contract against a
|
||||
// modern client with `/dump`: it is NOT 3.3.5's on-use-spell-effect walk
|
||||
// (an on-use trinket like Talisman of Ephemeral Power returns *false*),
|
||||
// but a plain class-or-ammo/thrown check. Confirmed true: Minor Healing
|
||||
// Potion / Linen Bandage / Faintly Glowing Skull (all class 0), Rough
|
||||
// Arrow (Ammo). Confirmed false: Hearthstone (class Misc, valueless
|
||||
// teleport), on-use trinkets, plain armor.
|
||||
//
|
||||
// Cache-miss handling mirrors `C_Item.IsEquippableItem`: returns false
|
||||
// without firing a load (sync call, matches modern semantics). Callers
|
||||
// that want auto-warm behavior should run `C_Item.RequestLoadItemDataByID`
|
||||
// first and re-check on `ITEM_DATA_LOAD_RESULT`.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "item/Arg.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Item::Consumable {
|
||||
|
||||
namespace {
|
||||
|
||||
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
|
||||
const uint64_t *guid, void *callback,
|
||||
void *userData, int unused);
|
||||
|
||||
const uint8_t *PeekItemRecord(uint32_t itemID) {
|
||||
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
|
||||
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
|
||||
const uint64_t zeroGuid = 0;
|
||||
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
|
||||
}
|
||||
|
||||
constexpr uint32_t kItemClassConsumable = 0;
|
||||
constexpr uint32_t kInvTypeAmmo = 24; // INVTYPE_AMMO
|
||||
constexpr uint32_t kInvTypeThrown = 25; // INVTYPE_THROWN
|
||||
|
||||
int __fastcall Script_C_Item_IsConsumableItem(void *L) {
|
||||
const int itemID = Item::Arg::ResolveItemID(L, 1);
|
||||
if (itemID <= 0) {
|
||||
Game::Lua::PushBoolean(L, 0);
|
||||
return 1;
|
||||
}
|
||||
const uint8_t *record = PeekItemRecord(static_cast<uint32_t>(itemID));
|
||||
if (record == nullptr) {
|
||||
Game::Lua::PushBoolean(L, 0);
|
||||
return 1;
|
||||
}
|
||||
const uint32_t classID = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_CLASS);
|
||||
const uint32_t invType = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_INVENTORY_TYPE);
|
||||
|
||||
const bool consumable = classID == kItemClassConsumable ||
|
||||
invType == kInvTypeAmmo || invType == kInvTypeThrown;
|
||||
Game::Lua::PushBoolean(L, consumable);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
// Modern exposes both the namespaced `C_Item.IsConsumableItem` and the
|
||||
// bare global `IsConsumableItem` (the older, still-live form); register
|
||||
// both against the same handler, like `IsUsableItem`.
|
||||
Game::Lua::RegisterTableFunction("C_Item", "IsConsumableItem",
|
||||
&Script_C_Item_IsConsumableItem);
|
||||
Game::Lua::RegisterGlobalFunction("IsConsumableItem",
|
||||
&Script_C_Item_IsConsumableItem);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Item::Consumable
|
||||
+5
-19
@@ -16,6 +16,7 @@
|
||||
#include "item/Arg.h"
|
||||
#include "item/ID.h"
|
||||
#include "item/Location.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
@@ -94,22 +95,12 @@ int CountInBag(void *L, int bagID, int targetItemID, bool includeUses) {
|
||||
// object resolver `FUN_OBJECT_RESOLVE_BY_GUID` (the same function
|
||||
// `GetItemBySlot` would call internally if the gate let us through).
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
using ResolveObjectByGuid_t = void *(__fastcall *)(int type,
|
||||
const char *debugName,
|
||||
uint32_t guidLo,
|
||||
uint32_t guidHi,
|
||||
int priority);
|
||||
|
||||
const uint8_t *PlayerInvMgr() {
|
||||
auto ResolveUnitToken =
|
||||
reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
auto *player = static_cast<uint8_t *>(ResolveUnitToken("player"));
|
||||
if (player == nullptr)
|
||||
return nullptr;
|
||||
return player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
|
||||
}
|
||||
|
||||
const uint8_t *ResolveByGuid(int type, uint64_t guid) {
|
||||
if (guid == 0)
|
||||
return nullptr;
|
||||
@@ -155,7 +146,7 @@ int CountInGuidArray(const uint8_t *invMgr, int firstSlot, int lastSlot,
|
||||
// Layout: bag invMgr+0x00 = max slot count (the bag's size); bag
|
||||
// invMgr+0x04 = its GUID array. Same shape as the player invMgr.
|
||||
int CountInBankBags(int targetItemID, bool includeUses) {
|
||||
auto *playerInvMgr = PlayerInvMgr();
|
||||
auto *playerInvMgr = Unit::Identity::PlayerInventoryManager();
|
||||
if (playerInvMgr == nullptr)
|
||||
return 0;
|
||||
auto *playerGuidArray = *reinterpret_cast<const uint64_t *const *>(
|
||||
@@ -164,19 +155,14 @@ int CountInBankBags(int targetItemID, bool includeUses) {
|
||||
return 0;
|
||||
|
||||
int total = 0;
|
||||
using GetBagInvMgr_t = void *(__thiscall *)(void *bag);
|
||||
for (int slot = Offsets::INVMGR_BANK_BAG_FIRST_SLOT;
|
||||
slot <= Offsets::INVMGR_BANK_BAG_LAST_SLOT; slot++) {
|
||||
const uint8_t *bag = ResolveByGuid(Offsets::OBJ_TYPE_CONTAINER,
|
||||
playerGuidArray[slot]);
|
||||
if (bag == nullptr)
|
||||
continue;
|
||||
// vtable +0x10 returns the bag's own invMgr pointer
|
||||
auto *vtable = *reinterpret_cast<const uint8_t *const *>(bag);
|
||||
auto getInvMgr = reinterpret_cast<GetBagInvMgr_t>(
|
||||
*reinterpret_cast<const uintptr_t *>(vtable + 0x10));
|
||||
auto *bagInvMgr = static_cast<const uint8_t *>(
|
||||
getInvMgr(const_cast<uint8_t *>(bag)));
|
||||
auto *bagInvMgr =
|
||||
static_cast<const uint8_t *>(Item::Location::ContainerInventory(bag));
|
||||
if (bagInvMgr == nullptr)
|
||||
continue;
|
||||
const int bagSlots = static_cast<int>(*reinterpret_cast<const uint32_t *>(bagInvMgr));
|
||||
@@ -251,7 +237,7 @@ int __fastcall Script_C_Item_GetItemCount(void *L) {
|
||||
// Direct GUID-array reads — bypass the bank gate so this works
|
||||
// without the bank window having ever been opened in the
|
||||
// session.
|
||||
total += CountInGuidArray(PlayerInvMgr(),
|
||||
total += CountInGuidArray(Unit::Identity::PlayerInventoryManager(),
|
||||
Offsets::INVMGR_BANK_MAIN_FIRST_SLOT,
|
||||
Offsets::INVMGR_BANK_MAIN_LAST_SLOT,
|
||||
itemID, includeUses);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#include "item/Cursor.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "cursor/Info.h"
|
||||
#include "item/Location.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Item::Cursor {
|
||||
|
||||
namespace {
|
||||
|
||||
using PickupInventorySlot_t = void(__fastcall *)(uint32_t slot0Based);
|
||||
using CursorPickupItem_t = void(__fastcall *)(uint32_t linearSlot, int count,
|
||||
uint32_t itemGuidLo, uint32_t itemGuidHi,
|
||||
uint32_t containerGuidLo,
|
||||
uint32_t containerGuidHi, int flag);
|
||||
using LockByGuid_t = void(__stdcall *)(uint32_t guidLo, uint32_t guidHi);
|
||||
|
||||
// The container object stores its own GUID inline at +0x08/+0x0C (unlike a
|
||||
// CGItem, whose GUID sits behind the +0x08 instance-block pointer). This
|
||||
// is the "source container" the engine records for the eventual drop.
|
||||
constexpr int OFF_CONTAINER_GUID_LO = 0x08;
|
||||
constexpr int OFF_CONTAINER_GUID_HI = 0x0C;
|
||||
|
||||
bool ItemLocked(const uint8_t *item) {
|
||||
return (*reinterpret_cast<const uint32_t *>(item + Offsets::OFF_ITEM_CLIENT_LOCK) & 1) != 0;
|
||||
}
|
||||
|
||||
// Reads a CGItem's 64-bit instance GUID into lo/hi. False if the instance
|
||||
// block pointer is absent.
|
||||
bool ReadItemGuid(const uint8_t *item, uint32_t *lo, uint32_t *hi) {
|
||||
auto *instance = *reinterpret_cast<const uint8_t *const *>(
|
||||
item + Offsets::OFF_ITEM_INSTANCE_BLOCK);
|
||||
if (instance == nullptr)
|
||||
return false;
|
||||
*lo = *reinterpret_cast<const uint32_t *>(instance + Offsets::OFF_INSTANCE_BLOCK_GUID);
|
||||
*hi = *reinterpret_cast<const uint32_t *>(instance + Offsets::OFF_INSTANCE_BLOCK_GUID + 4);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void PickupEquipmentSlot(int equipmentSlotIndex) {
|
||||
// The primitive takes a 0-based slot; modern equipmentSlotIndex is 1-based.
|
||||
reinterpret_cast<PickupInventorySlot_t>(Offsets::FUN_PICKUP_INVENTORY_SLOT)(
|
||||
static_cast<uint32_t>(equipmentSlotIndex - 1));
|
||||
}
|
||||
|
||||
bool PickupBagItem(void *L, int bagID, int slotIndex) {
|
||||
Item::Location::BagSlotDetail d;
|
||||
if (!Item::Location::ResolveBagSlotDetail(L, bagID, slotIndex, &d) || d.item == nullptr)
|
||||
return false;
|
||||
|
||||
// Guards the Script_ wrapper applies that the raw primitive omits: only
|
||||
// pick up onto an empty cursor, and never a locked (mid-transaction) item.
|
||||
// `::Cursor` — unqualified `Cursor` would bind to `Item::Cursor` here.
|
||||
if (::Cursor::Info::HasItem() || ItemLocked(d.item))
|
||||
return false;
|
||||
|
||||
uint32_t itemLo = 0, itemHi = 0;
|
||||
if (!ReadItemGuid(d.item, &itemLo, &itemHi))
|
||||
return false;
|
||||
const uint32_t containerLo =
|
||||
*reinterpret_cast<const uint32_t *>(d.container + OFF_CONTAINER_GUID_LO);
|
||||
const uint32_t containerHi =
|
||||
*reinterpret_cast<const uint32_t *>(d.container + OFF_CONTAINER_GUID_HI);
|
||||
|
||||
reinterpret_cast<CursorPickupItem_t>(Offsets::FUN_CURSOR_PICKUP_ITEM)(
|
||||
static_cast<uint32_t>(d.linearSlot), 1, itemLo, itemHi, containerLo, containerHi, 0);
|
||||
// Visually lock the source item while it's held — the Script pickup
|
||||
// does this immediately after the cursor primitive.
|
||||
reinterpret_cast<LockByGuid_t>(Offsets::FUN_ITEM_LOCK_BY_GUID)(itemLo, itemHi);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace Item::Cursor
|
||||
@@ -0,0 +1,35 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
// Cursor pickup primitives — thin C++ wrappers over the engine's own
|
||||
// pickup functions (`FUN_PICKUP_INVENTORY_SLOT` / `FUN_CURSOR_PICKUP_ITEM`),
|
||||
// called directly rather than through the Lua `Script_Pickup*` state-machine
|
||||
// wrappers (which would require faking the Lua stack). Shared by
|
||||
// `C_Item.PickupItem` and `C_Item.EquipItemByName`'s auto-slot path.
|
||||
namespace Item::Cursor {
|
||||
|
||||
// Picks the item in a 1-based character-pane equipment slot onto the
|
||||
// cursor via the engine's self-contained equipment-pickup primitive
|
||||
// (which resolves the player and applies its own cursor/lock guards).
|
||||
void PickupEquipmentSlot(int equipmentSlotIndex);
|
||||
|
||||
// Picks the item in a bag slot onto the cursor. Guarded to match the Lua
|
||||
// wrapper's behavior: no-op (returns false) when the slot is empty, the
|
||||
// cursor already holds an item, or the item is locked (mid-transaction).
|
||||
// Returns true when the pickup was issued. Needs `L` for slot resolution
|
||||
// (PackBagSlot); stomps the Lua stack.
|
||||
bool PickupBagItem(void *L, int bagID, int slotIndex);
|
||||
|
||||
} // namespace Item::Cursor
|
||||
+15
-36
@@ -14,12 +14,12 @@
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "item/Arg.h"
|
||||
#include "item/Cursor.h"
|
||||
#include "item/ID.h"
|
||||
#include "item/Location.h"
|
||||
#include "item/Swap.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string.h>
|
||||
|
||||
namespace Item::Equipment {
|
||||
|
||||
@@ -132,31 +132,13 @@ int __fastcall Script_C_Item_IsEquippedItem(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Equipment-first walk; itemID or decorated-name match via the shared
|
||||
// `Item::Location::MatchesArg` predicate (same logic the bag find-by-
|
||||
// name uses, so match semantics live in one place).
|
||||
for (int slot = Offsets::EQUIPMENT_SLOT_FIRST; slot <= Offsets::EQUIPMENT_SLOT_LAST; ++slot) {
|
||||
auto *item = Item::Location::ResolveEquipmentSlot(slot);
|
||||
if (item == nullptr) continue;
|
||||
|
||||
const int id = Item::ID::FromCGItem(item);
|
||||
if (id == 0) continue;
|
||||
|
||||
if (arg.itemID > 0) {
|
||||
if (id == arg.itemID) {
|
||||
Game::Lua::PushBoolean(L, 1);
|
||||
return 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Name-match path — peek the item-cache record and compare
|
||||
// `m_name[0]`. Uncached items short-circuit to "no match"
|
||||
// rather than firing a load; matches modern API semantics
|
||||
// (sync call, possibly stale, never blocks).
|
||||
auto *record = PeekItemRecord(static_cast<uint32_t>(id));
|
||||
if (record == nullptr) continue;
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSTATS_NAME);
|
||||
if (name == nullptr) continue;
|
||||
if (_stricmp(name, arg.name) == 0) {
|
||||
if (Item::Location::MatchesArg(item, arg)) {
|
||||
Game::Lua::PushBoolean(L, 1);
|
||||
return 1;
|
||||
}
|
||||
@@ -200,8 +182,6 @@ int __fastcall Script_C_Item_EquipItemByName(void *L) {
|
||||
const bool hasDstSlot = Game::Lua::IsNumber(L, 2);
|
||||
const int dstSlot = hasDstSlot ? static_cast<int>(Game::Lua::ToNumber(L, 2)) : 0;
|
||||
|
||||
using ScriptFn_t = int(__fastcall *)(void *L);
|
||||
|
||||
// Return any held cursor item to its slot BEFORE searching for
|
||||
// the target. FUN_INVENTORY_SWAP's end-of-call cleanup at
|
||||
// FUN_00495190(0, 1) clears LOCAL cursor globals but skips the
|
||||
@@ -222,17 +202,16 @@ int __fastcall Script_C_Item_EquipItemByName(void *L) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Auto-slot path: pickup the item via Script_PickupContainerItem
|
||||
// (its state-machine handles spell-cast targeting / repair /
|
||||
// enchant-scroll / etc., none of which we want to replicate), then
|
||||
// call the engine's `CGPlayer::AutoEquipCursorItem` helper directly
|
||||
// — `Script_AutoEquipCursorItem` is a thin wrapper that just
|
||||
// resolves the local player and calls this with flag=0.
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(found.bagID));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(found.slotIndex));
|
||||
auto pickup = reinterpret_cast<ScriptFn_t>(Offsets::FUN_SCRIPT_PICKUP_CONTAINER_ITEM);
|
||||
pickup(L);
|
||||
// Auto-slot path: pick the item up onto the cursor, then call the
|
||||
// engine's `CGPlayer::AutoEquipCursorItem` helper directly
|
||||
// (`Script_AutoEquipCursorItem` is a thin wrapper that just resolves
|
||||
// the local player and calls this with flag=0). The pickup goes through
|
||||
// the shared `Item::Cursor::PickupBagItem`, which drives the engine's
|
||||
// cursor primitive directly (no Script_PickupContainerItem stack-fake);
|
||||
// its empty-cursor guard is satisfied by the ClearCursor above. Bail if
|
||||
// the pickup didn't take (locked item / busy cursor) — nothing to equip.
|
||||
if (!Item::Cursor::PickupBagItem(L, found.bagID, found.slotIndex))
|
||||
return 0;
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
using AutoEquipCursor_t = void (__thiscall *)(void *player, int flag);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Item.IsItemInRange(item, targetUnit)` — true if the item is in
|
||||
// range of the unit, false if out of range, nil when the range check
|
||||
// doesn't apply. `item` is an itemID, `"item:NNN"` string, or item link
|
||||
// (names aren't resolvable — vanilla has no name→ID map — and yield nil);
|
||||
// `targetUnit` is a unit token ("target", "mouseover", "party1", …).
|
||||
//
|
||||
// An item's range comes from the spell it fires on use: we resolve the
|
||||
// item to its on-use spell (`Item::Spell::OnUseSpellIDForItemID`) and run
|
||||
// the same range test as `C_Spell.IsSpellInRange`, via the shared
|
||||
// `Spell::Range::PlayerVsUnit` core. So items with a targeted on-use
|
||||
// effect that has a range (nets, bombs, thrown/targeted quest items, …)
|
||||
// get a true/false answer; items with no on-use spell, or whose on-use
|
||||
// spell is rangeless (potions, food, self-buff trinkets, hearthstone),
|
||||
// return nil — matching retail, which also reports nil for items without
|
||||
// a range restriction.
|
||||
//
|
||||
// Range-only, like `IsSpellInRange`: ignores line of sight and target
|
||||
// faction. Passive reader — an item not yet in the client item cache
|
||||
// returns nil (no background fetch); items you'd range-check are normally
|
||||
// in bags / on the action bar and already cached.
|
||||
|
||||
#include "Game.h"
|
||||
#include "item/Arg.h"
|
||||
#include "item/Spell.h"
|
||||
#include "spell/Range.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Item::InRange {
|
||||
|
||||
namespace {
|
||||
|
||||
int __fastcall Script_C_Item_IsItemInRange(void *L) {
|
||||
const int itemID = Item::Arg::ResolveItemID(L, 1);
|
||||
const int spellID =
|
||||
itemID > 0 ? Item::Spell::OnUseSpellIDForItemID(
|
||||
static_cast<uint32_t>(itemID))
|
||||
: 0;
|
||||
const char *unit =
|
||||
Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : nullptr;
|
||||
|
||||
// `::Spell` — unqualified `Spell` would bind to `Item::Spell` here.
|
||||
const int result = ::Spell::Range::PlayerVsUnit(spellID, unit);
|
||||
if (result < 0) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
Game::Lua::PushBool(L, result == 1);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Item", "IsItemInRange",
|
||||
&Script_C_Item_IsItemInRange);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Item::InRange
|
||||
@@ -17,6 +17,7 @@
|
||||
#include "item/Arg.h"
|
||||
#include "item/Data.h"
|
||||
#include "item/ID.h"
|
||||
#include "item/Link.h"
|
||||
#include "item/Location.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -245,7 +246,89 @@ static int __fastcall Script_C_Item_GetItemFamily(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Item.GetItemInfo(itemInfo)` — the full modern info tuple, sourced
|
||||
// entirely from the cached `ItemStats_C` record (+ the class/subclass/invtype
|
||||
// DBC name lookups this file already does for `GetItemInfoInstant`). Accepts
|
||||
// a numeric itemID, an `"item:NNN"` string, or a full chat link — same parser
|
||||
// as `GetItemInfoInstant`.
|
||||
//
|
||||
// Returns (18 values):
|
||||
// itemName, itemLink, itemQuality, itemLevel, itemMinLevel, itemType,
|
||||
// itemSubType, itemStackCount, itemEquipLoc, itemTexture, sellPrice,
|
||||
// classID, subclassID, bindType, expansionID, setID, isCraftingReagent,
|
||||
// itemDescription
|
||||
//
|
||||
// `expansionID` is always 0 (vanilla) and `isCraftingReagent` always false
|
||||
// (no such flag in 1.12's item data); `setID` is nil when the item has no
|
||||
// set. On a cache miss it warms the cache and returns nothing (nil) —
|
||||
// the value lands on a retry after `GET_ITEM_INFO_RECEIVED`,
|
||||
// matching the modern async contract.
|
||||
static int __fastcall Script_C_Item_GetItemInfo(void *L) {
|
||||
const int itemID = Item::Arg::ResolveItemID(L, 1);
|
||||
if (itemID <= 0)
|
||||
return 0;
|
||||
|
||||
const uint8_t *record = FetchItemRecord(static_cast<uint32_t>(itemID));
|
||||
if (record == nullptr) {
|
||||
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
|
||||
return 0; // nil this call; GET_ITEM_INFO_RECEIVED fires when ready
|
||||
}
|
||||
|
||||
auto u32 = [record](int off) {
|
||||
return *reinterpret_cast<const uint32_t *>(record + off);
|
||||
};
|
||||
const uint32_t quality = u32(Offsets::OFF_ITEMSTATS_QUALITY);
|
||||
const uint32_t itemLevel = u32(Offsets::OFF_ITEMSTATS_ITEM_LEVEL);
|
||||
const uint32_t reqLevel = u32(Offsets::OFF_ITEMSTATS_REQUIRED_LEVEL);
|
||||
const uint32_t classID = u32(Offsets::OFF_ITEMSTATS_CLASS);
|
||||
const uint32_t subClassID = u32(Offsets::OFF_ITEMSTATS_SUBCLASS);
|
||||
const uint32_t stackCount = u32(Offsets::OFF_ITEMSTATS_STACKABLE);
|
||||
const uint32_t invType = u32(Offsets::OFF_ITEMSTATS_INVENTORY_TYPE);
|
||||
const uint32_t displayInfoID = u32(Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID);
|
||||
const uint32_t sellPrice = u32(Offsets::OFF_ITEMSTATS_SELL_PRICE);
|
||||
const uint32_t bindType = u32(Offsets::OFF_ITEMSTATS_BONDING);
|
||||
const uint32_t setID = u32(Offsets::OFF_ITEMSTATS_ITEM_SET);
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSTATS_NAME);
|
||||
const char *description = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSTATS_DESCRIPTION);
|
||||
|
||||
char iconPath[260];
|
||||
if (!BuildIconPath(displayInfoID, iconPath, sizeof(iconPath)))
|
||||
iconPath[0] = 0;
|
||||
char link[256];
|
||||
const bool haveLink = Item::Link::BasicFromItemID(static_cast<uint32_t>(itemID),
|
||||
link, sizeof(link));
|
||||
|
||||
Game::Lua::PushString(L, PushedOrEmpty(name)); // 1 itemName
|
||||
if (haveLink)
|
||||
Game::Lua::PushString(L, link); // 2 itemLink
|
||||
else
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(quality)); // 3 itemQuality
|
||||
Game::Lua::PushNumber(L, static_cast<double>(itemLevel)); // 4 itemLevel
|
||||
Game::Lua::PushNumber(L, static_cast<double>(reqLevel)); // 5 itemMinLevel
|
||||
Game::Lua::PushString(L, LookupItemClassName(classID)); // 6 itemType
|
||||
Game::Lua::PushString(L, LookupItemSubClassName(classID, subClassID)); // 7 itemSubType
|
||||
Game::Lua::PushNumber(L, static_cast<double>(stackCount)); // 8 itemStackCount
|
||||
Game::Lua::PushString(L, LookupInvType(invType)); // 9 itemEquipLoc
|
||||
Game::Lua::PushString(L, iconPath); // 10 itemTexture
|
||||
Game::Lua::PushNumber(L, static_cast<double>(sellPrice)); // 11 sellPrice
|
||||
Game::Lua::PushNumber(L, static_cast<double>(classID)); // 12 classID
|
||||
Game::Lua::PushNumber(L, static_cast<double>(subClassID)); // 13 subclassID
|
||||
Game::Lua::PushNumber(L, static_cast<double>(bindType)); // 14 bindType
|
||||
Game::Lua::PushNumber(L, 0.0); // 15 expansionID (classic)
|
||||
if (setID != 0)
|
||||
Game::Lua::PushNumber(L, static_cast<double>(setID)); // 16 setID
|
||||
else
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushBool(L, false); // 17 isCraftingReagent
|
||||
Game::Lua::PushString(L, PushedOrEmpty(description)); // 18 itemDescription
|
||||
return 18;
|
||||
}
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Item", "GetItemInfo", &Script_C_Item_GetItemInfo);
|
||||
Game::Lua::RegisterTableFunction("C_Item", "GetItemInfoInstant", &Script_GetItemInfoInstant);
|
||||
Game::Lua::RegisterGlobalFunction("GetItemIcon", &Script_GetItemIcon);
|
||||
Game::Lua::RegisterTableFunction("C_Item", "GetItemIcon", &Script_C_Item_GetItemIcon);
|
||||
|
||||
@@ -27,6 +27,9 @@ namespace {
|
||||
|
||||
using BuildItemLink_t = const char *(__fastcall *)(const void *cgItem);
|
||||
|
||||
using BuildInstanceName_t = void(__thiscall *)(const void *cgItem, char *out,
|
||||
unsigned outSize);
|
||||
|
||||
// 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.
|
||||
@@ -50,6 +53,16 @@ const char *FromCGItem(const uint8_t *cgItem) {
|
||||
return fn(cgItem);
|
||||
}
|
||||
|
||||
bool NameFromCGItem(const uint8_t *cgItem, char *out, size_t outSize) {
|
||||
if (cgItem == nullptr || out == nullptr || outSize == 0)
|
||||
return false;
|
||||
out[0] = '\0';
|
||||
auto fn = reinterpret_cast<BuildInstanceName_t>(
|
||||
Offsets::FUN_ITEM_BUILD_INSTANCE_NAME);
|
||||
fn(cgItem, out, static_cast<unsigned>(outSize));
|
||||
return out[0] != '\0';
|
||||
}
|
||||
|
||||
bool BasicFromItemID(uint32_t itemID, char *out, size_t outSize) {
|
||||
if (out == nullptr || outSize == 0)
|
||||
return false;
|
||||
|
||||
@@ -29,6 +29,17 @@ namespace Item::Link {
|
||||
// produce for the same slot.
|
||||
const char *FromCGItem(const uint8_t *cgItem);
|
||||
|
||||
// Writes the item's per-instance decorated *display name* into `out` —
|
||||
// the same `[Name]` the engine stamps into `FromCGItem`'s link, e.g.
|
||||
// "Iridium Chain of the Owl" (or the base name when the instance carries
|
||||
// no random suffix). Reads the suffix ID off the CGItem descriptor
|
||||
// (`+0x98`) and formats it through the engine's own name builder
|
||||
// (`FUN_ITEM_BUILD_INSTANCE_NAME`). Returns false (and leaves `out`
|
||||
// empty) for a null item or an empty build result, so callers can fall
|
||||
// back to the base ItemStats name. Use whenever a name is returned
|
||||
// alongside a `FromCGItem` link, so the two agree.
|
||||
bool NameFromCGItem(const uint8_t *cgItem, char *out, size_t outSize);
|
||||
|
||||
// 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
|
||||
|
||||
+102
-75
@@ -16,8 +16,10 @@
|
||||
#include "../Game.h"
|
||||
#include "../Offsets.h"
|
||||
#include "../guid/Guid.h"
|
||||
#include "../unit/Identity.h"
|
||||
#include "Arg.h"
|
||||
#include "ID.h"
|
||||
#include "Link.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
@@ -28,20 +30,10 @@ namespace {
|
||||
using GetItemBySlot_t = void *(__thiscall *)(void *thisInvMgr, int slot);
|
||||
using PackBagSlot_t = int(__fastcall *)(void *L, void **outInvMgr, int *outLinearSlot,
|
||||
int *outUnused);
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
|
||||
const uint64_t *guid, void *callback,
|
||||
void *userData, int unused);
|
||||
|
||||
void *ResolveActivePlayerInvMgr() {
|
||||
auto ResolveUnitToken =
|
||||
reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
auto *player = static_cast<uint8_t *>(ResolveUnitToken("player"));
|
||||
if (player == nullptr)
|
||||
return nullptr;
|
||||
return player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
|
||||
}
|
||||
|
||||
// Reads `loc.fieldName` and returns it as an int. Returns false via the
|
||||
// boolean result if the field is missing or non-numeric. Always leaves the
|
||||
// Lua stack as it found it.
|
||||
@@ -56,24 +48,10 @@ bool TryReadIntField(void *L, int locIdx, const char *fieldName, int *out) {
|
||||
}
|
||||
|
||||
const uint8_t *ResolveBagSlot(void *L, int bagID, int slotIndex) {
|
||||
// PackBagSlot reads bagID/slotIndex from Lua stack[1] and stack[2] (it's
|
||||
// designed to be called from a Lua-callback context like
|
||||
// Script_GetContainerItemInfo). Replace the stack so positions 1 and 2
|
||||
// hold the raw values for PackBagSlot's internal lua_tonumber reads.
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(bagID));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(slotIndex));
|
||||
|
||||
void *invMgr = nullptr;
|
||||
int linearSlot = 0;
|
||||
int unused = 0;
|
||||
auto PackBagSlot = reinterpret_cast<PackBagSlot_t>(Offsets::FUN_PACK_BAG_SLOT);
|
||||
if (!PackBagSlot(L, &invMgr, &linearSlot, &unused) || invMgr == nullptr)
|
||||
BagSlotDetail d;
|
||||
if (!ResolveBagSlotDetail(L, bagID, slotIndex, &d))
|
||||
return nullptr;
|
||||
|
||||
auto GetItemBySlot = reinterpret_cast<GetItemBySlot_t>(
|
||||
Offsets::FUN_ITEMMGR_GET_ITEM_BY_SLOT);
|
||||
return static_cast<const uint8_t *>(GetItemBySlot(invMgr, linearSlot));
|
||||
return d.item;
|
||||
}
|
||||
|
||||
// --- GUID-walk helpers ---------------------------------------------------
|
||||
@@ -97,7 +75,7 @@ uint64_t ReadCGItemGUID(const uint8_t *item) {
|
||||
} // namespace
|
||||
|
||||
const uint8_t *ResolveEquipmentSlot(int slot1Based) {
|
||||
void *invMgr = ResolveActivePlayerInvMgr();
|
||||
void *invMgr = const_cast<uint8_t *>(Unit::Identity::PlayerInventoryManager());
|
||||
if (invMgr == nullptr)
|
||||
return nullptr;
|
||||
// GetItemBySlot expects 0-based linearized slot indices. The built-in
|
||||
@@ -108,6 +86,34 @@ const uint8_t *ResolveEquipmentSlot(int slot1Based) {
|
||||
return static_cast<const uint8_t *>(GetItemBySlot(invMgr, slot1Based - 1));
|
||||
}
|
||||
|
||||
void *ContainerInventory(const uint8_t *container) {
|
||||
if (container == nullptr)
|
||||
return nullptr;
|
||||
auto *vtable = *reinterpret_cast<const uint8_t *const *>(container);
|
||||
using GetInventory_t = void *(__thiscall *)(void *container);
|
||||
auto getInventory = *reinterpret_cast<const GetInventory_t *>(
|
||||
vtable + Offsets::OFF_CONTAINER_GET_INVENTORY);
|
||||
return getInventory(const_cast<uint8_t *>(container));
|
||||
}
|
||||
|
||||
void *EquippedBagInventory(int bagID) {
|
||||
if (bagID < 1 || bagID > 4)
|
||||
return nullptr;
|
||||
using GetBagGuid_t = uint64_t(__fastcall *)(uint32_t bagIndex0);
|
||||
auto getBagGuid =
|
||||
reinterpret_cast<GetBagGuid_t>(Offsets::FUN_GET_EQUIPPED_BAG_GUID);
|
||||
const uint64_t bagGuid = getBagGuid(static_cast<uint32_t>(bagID - 1));
|
||||
if (bagGuid == 0)
|
||||
return nullptr; // no bag equipped in that slot
|
||||
using ResolveByGUID_t = void *(__fastcall *)(int, const char *, uint32_t,
|
||||
uint32_t, int);
|
||||
auto resolve = reinterpret_cast<ResolveByGUID_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
|
||||
auto *container = static_cast<const uint8_t *>(
|
||||
resolve(Offsets::OBJ_TYPE_CONTAINER, "ItemMgr", static_cast<uint32_t>(bagGuid),
|
||||
static_cast<uint32_t>(bagGuid >> 32), 0x172));
|
||||
return ContainerInventory(container);
|
||||
}
|
||||
|
||||
// Mirrors `Item::Bag::ResolveBagInfo`'s slot-count derivation: backpack
|
||||
// is fixed at 16, equipped bags read `m_containerSlots` off the cache
|
||||
// record. Returns 0 for empty bag slots or out-of-range bagIDs.
|
||||
@@ -129,6 +135,30 @@ int GetBagSlotCount(int bagID) {
|
||||
record + Offsets::OFF_ITEMSTATS_CONTAINER_SLOTS));
|
||||
}
|
||||
|
||||
bool ResolveBagSlotDetail(void *L, int bagID, int slotIndex, BagSlotDetail *out) {
|
||||
// PackBagSlot reads bagID/slotIndex from Lua stack[1] and stack[2] (it's
|
||||
// designed to be called from a Lua-callback context like
|
||||
// Script_GetContainerItemInfo). Replace the stack so positions 1 and 2
|
||||
// hold the raw values for PackBagSlot's internal lua_tonumber reads.
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(bagID));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(slotIndex));
|
||||
|
||||
void *container = nullptr;
|
||||
int linearSlot = 0;
|
||||
int unused = 0;
|
||||
auto PackBagSlot = reinterpret_cast<PackBagSlot_t>(Offsets::FUN_PACK_BAG_SLOT);
|
||||
if (!PackBagSlot(L, &container, &linearSlot, &unused) || container == nullptr)
|
||||
return false;
|
||||
|
||||
auto GetItemBySlot = reinterpret_cast<GetItemBySlot_t>(
|
||||
Offsets::FUN_ITEMMGR_GET_ITEM_BY_SLOT);
|
||||
out->container = static_cast<const uint8_t *>(container);
|
||||
out->linearSlot = linearSlot;
|
||||
out->item = static_cast<const uint8_t *>(GetItemBySlot(container, linearSlot));
|
||||
return true;
|
||||
}
|
||||
|
||||
const uint8_t *ResolveBag(void *L, int bagID, int slotIndex) {
|
||||
return ResolveBagSlot(L, bagID, slotIndex);
|
||||
}
|
||||
@@ -152,38 +182,29 @@ const uint8_t *ResolveByGUID(uint64_t guid) {
|
||||
bool FindByItemID(void *L, int itemID, ByGUIDResult *out) {
|
||||
if (itemID <= 0)
|
||||
return false;
|
||||
Item::Arg::Resolved arg{};
|
||||
arg.itemID = itemID;
|
||||
arg.name = nullptr;
|
||||
return FindByArg(L, arg, out);
|
||||
}
|
||||
|
||||
for (int slot = Offsets::EQUIPMENT_SLOT_FIRST;
|
||||
slot <= Offsets::EQUIPMENT_SLOT_LAST; ++slot) {
|
||||
auto *item = ResolveEquipmentSlot(slot);
|
||||
if (item == nullptr)
|
||||
continue;
|
||||
if (Item::ID::FromCGItem(item) == itemID) {
|
||||
out->equipmentSlotIndex = slot;
|
||||
out->bagID = 0;
|
||||
out->slotIndex = 0;
|
||||
out->item = item;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (int bagID = 0; bagID <= 4; ++bagID) {
|
||||
const int slotCount = GetBagSlotCount(bagID);
|
||||
for (int slotIndex = 1; slotIndex <= slotCount; ++slotIndex) {
|
||||
auto *item = ResolveBag(L, bagID, slotIndex);
|
||||
if (item == nullptr)
|
||||
continue;
|
||||
if (Item::ID::FromCGItem(item) == itemID) {
|
||||
out->equipmentSlotIndex = 0;
|
||||
out->bagID = bagID;
|
||||
out->slotIndex = slotIndex;
|
||||
out->item = item;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
bool MatchesArg(const uint8_t *cgItem, const Item::Arg::Resolved &arg) {
|
||||
const int id = Item::ID::FromCGItem(cgItem);
|
||||
if (id == 0)
|
||||
return false;
|
||||
if (arg.itemID > 0)
|
||||
return id == arg.itemID;
|
||||
if (arg.name == nullptr)
|
||||
return false;
|
||||
// Compare against the item's *decorated* display name (random suffix
|
||||
// included) — modern IsEquippedItem / find-by-name match the full name
|
||||
// only ("Foo of the Owl", not the base "Foo"; verified on the modern
|
||||
// client). NameFromCGItem falls back to the base name for unsuffixed
|
||||
// items, so plain items still match; uncached/unnameable → no match.
|
||||
char name[128];
|
||||
if (!Item::Link::NameFromCGItem(cgItem, name, sizeof(name)))
|
||||
return false;
|
||||
return _stricmp(name, arg.name) == 0;
|
||||
}
|
||||
|
||||
bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out) {
|
||||
@@ -196,24 +217,9 @@ bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out)
|
||||
auto *item = ResolveBag(L, bagID, slotIndex);
|
||||
if (item == nullptr)
|
||||
continue;
|
||||
|
||||
const int id = Item::ID::FromCGItem(item);
|
||||
if (id == 0)
|
||||
if (!MatchesArg(item, arg))
|
||||
continue;
|
||||
|
||||
if (arg.itemID > 0) {
|
||||
if (id != arg.itemID)
|
||||
continue;
|
||||
} else {
|
||||
auto *record = PeekItemRecord(static_cast<uint32_t>(id));
|
||||
if (record == nullptr)
|
||||
continue;
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSTATS_NAME);
|
||||
if (name == nullptr || _stricmp(name, arg.name) != 0)
|
||||
continue;
|
||||
}
|
||||
|
||||
out->equipmentSlotIndex = 0;
|
||||
out->bagID = bagID;
|
||||
out->slotIndex = slotIndex;
|
||||
@@ -224,6 +230,27 @@ bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out)
|
||||
return false;
|
||||
}
|
||||
|
||||
bool FindByArg(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out) {
|
||||
if (arg.itemID <= 0 && arg.name == nullptr)
|
||||
return false;
|
||||
|
||||
// Equipment slots first (no Lua stack interaction, cheaper), matching
|
||||
// `FindByItemID`'s equipment-before-bags order; then fall through to the
|
||||
// bag walk. Both compare with the shared `MatchesArg` predicate.
|
||||
for (int slot = Offsets::EQUIPMENT_SLOT_FIRST;
|
||||
slot <= Offsets::EQUIPMENT_SLOT_LAST; ++slot) {
|
||||
auto *item = ResolveEquipmentSlot(slot);
|
||||
if (item != nullptr && MatchesArg(item, arg)) {
|
||||
out->equipmentSlotIndex = slot;
|
||||
out->bagID = 0;
|
||||
out->slotIndex = 0;
|
||||
out->item = item;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return FindByArgInBags(L, arg, out);
|
||||
}
|
||||
|
||||
bool FindByGUID(void *L, uint64_t guid, ByGUIDResult *out) {
|
||||
if (guid == 0)
|
||||
return false;
|
||||
|
||||
+58
-3
@@ -69,6 +69,28 @@ const uint8_t *ResolveEquipmentSlot(int slot1Based);
|
||||
// guaranteed cached by the engine's bag-sync flow at login.
|
||||
int GetBagSlotCount(int bagID);
|
||||
|
||||
// Given a `CGContainer*` (an equipped bag or bank bag, resolved by GUID via
|
||||
// `FUN_OBJECT_RESOLVE_BY_GUID` with `OBJ_TYPE_CONTAINER`), returns its own
|
||||
// inventory-manager object via the container's vtable method at
|
||||
// `+OFF_CONTAINER_GET_INVENTORY`. The returned object has the same layout as
|
||||
// the player inventory manager: slot count at `+0x00`, flat GUID array at
|
||||
// `+OFF_INVMGR_GUID_ARRAY`. Null-safe (null container → null). Pure C — no
|
||||
// Lua stack, safe from any context. Consolidates the vtable dispatch that was
|
||||
// hand-rolled across the bag/bank walks in `item/Count`, `item/AverageLevel`,
|
||||
// `equipmentset/Locations`, and `item/NewItems`.
|
||||
void *ContainerInventory(const uint8_t *container);
|
||||
|
||||
// Returns the inventory object of the equipped bag in player bag slot `bagID`
|
||||
// (1..4): its container GUID via `FUN_GET_EQUIPPED_BAG_GUID`, resolved to the
|
||||
// CGContainer, then its inventory via `ContainerInventory`. The result has the
|
||||
// player-invMgr shape (slot count at `+0x00`, GUID array at
|
||||
// `+OFF_INVMGR_GUID_ARRAY`) and is what `GetItemBySlot` indexes with a 0-based
|
||||
// slot. Returns null for `bagID` outside 1..4 or when no bag is equipped
|
||||
// there. Pure C — no Lua stack. (The backpack, `bagID` 0, has no container
|
||||
// object; index it directly off the player inventory manager at
|
||||
// `BACKPACK_LINEAR_BASE + slot - 1`.)
|
||||
void *EquippedBagInventory(int bagID);
|
||||
|
||||
// GUID-walk result. `equipmentSlotIndex != 0` means the item was
|
||||
// found in a character-pane equipment slot; otherwise it's in
|
||||
// `bagID`/`slotIndex`. `item` always points to the found CGItem
|
||||
@@ -96,6 +118,22 @@ bool FindByGUID(void *L, uint64_t guid, ByGUIDResult *out);
|
||||
// for link-decoration purposes. Stomps the Lua stack.
|
||||
bool FindByItemID(void *L, int itemID, ByGUIDResult *out);
|
||||
|
||||
// Full detail for a bag slot, for callers that drive the engine's
|
||||
// cursor-pickup primitive — which needs the *container object* and the
|
||||
// engine *linear slot*, not just the CGItem.
|
||||
struct BagSlotDetail {
|
||||
const uint8_t *container; // PackBagSlot container (invMgr for the backpack, else the bag)
|
||||
int linearSlot; // engine linear inventory slot
|
||||
const uint8_t *item; // CGItem at the slot, or null if the slot is empty
|
||||
};
|
||||
|
||||
// Resolves `(bagID, slotIndex)` to its `BagSlotDetail`. Returns false when
|
||||
// the coordinates don't resolve (bad bag / out-of-range slot); a resolved
|
||||
// but empty slot returns true with `out->item == nullptr`. Stomps the Lua
|
||||
// stack (PackBagSlot). `ResolveBag`/`ResolveBagSlot` are thin wrappers that
|
||||
// keep only `out->item`.
|
||||
bool ResolveBagSlotDetail(void *L, int bagID, int slotIndex, BagSlotDetail *out);
|
||||
|
||||
} // namespace Item::Location
|
||||
|
||||
// Forward-declare for `FindByArgInBags` below.
|
||||
@@ -103,10 +141,19 @@ namespace Item::Arg { struct Resolved; }
|
||||
|
||||
namespace Item::Location {
|
||||
|
||||
// The single item name/id match predicate: true if `cgItem` satisfies
|
||||
// the resolved arg. `arg.itemID > 0` ⇒ exact itemID compare; otherwise
|
||||
// `arg.name` is matched case-insensitively against the item's *decorated*
|
||||
// display name (random suffix included — via `Item::Link::NameFromCGItem`,
|
||||
// which falls back to the base name for unsuffixed items). Matches modern
|
||||
// `IsEquippedItem` / by-name semantics: "Foo of the Owl" matches, the base
|
||||
// "Foo" does not. Null / uncached / unnameable items → false. Shared by
|
||||
// `FindByArgInBags` and `C_Item.IsEquippedItem` so the match logic lives
|
||||
// in exactly one place.
|
||||
bool MatchesArg(const uint8_t *cgItem, const Item::Arg::Resolved &arg);
|
||||
|
||||
// Walks the player's bags (0..4 only — no equipment) looking for the
|
||||
// first item matching `arg`. `arg.itemID > 0` ⇒ direct ID compare;
|
||||
// otherwise `arg.name` is matched case-insensitively against the
|
||||
// cached `ItemStats_C.m_name[0]` of each candidate. On hit, populates
|
||||
// first item matching `arg` (via `MatchesArg`). On hit, populates
|
||||
// `out->bagID` / `out->slotIndex` / `out->item` and sets
|
||||
// `equipmentSlotIndex = 0`. Stomps the Lua stack.
|
||||
//
|
||||
@@ -116,6 +163,14 @@ namespace Item::Location {
|
||||
// equipped or use-from-equipped; the item has to be in bags first).
|
||||
bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out);
|
||||
|
||||
// Like `FindByArgInBags` but searches **equipment (1..19) first, then
|
||||
// bags** — for callers that act on an item wherever it's carried (e.g.
|
||||
// `C_Item.PickupItem`). On hit `out->equipmentSlotIndex` is non-zero for
|
||||
// an equipped match, else `out->bagID`/`out->slotIndex` for a bag match.
|
||||
// `FindByItemID` is the itemID-only specialization of this. Stomps the
|
||||
// Lua stack.
|
||||
bool FindByArg(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out);
|
||||
|
||||
// Parses the `"0xHHHHHHHHLLLLLLLL"` GUID string format
|
||||
// `C_Item.GetItemGUID` returns. Strict: requires exactly `0x` prefix
|
||||
// + 16 case-insensitive hex digits. Returns false (and leaves `*out`
|
||||
|
||||
+25
-5
@@ -16,6 +16,7 @@
|
||||
#include "item/Arg.h"
|
||||
#include "item/Data.h"
|
||||
#include "item/ID.h"
|
||||
#include "item/Link.h"
|
||||
#include "item/Location.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -56,18 +57,37 @@ int PushNameForItemID(void *L, int itemID) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Pushes the suffix-decorated display name for a resolved `CGItem` via
|
||||
// the shared engine name builder. Returns 0 (pushes nothing) for a null
|
||||
// item or an empty build result, so the caller can fall back to the
|
||||
// base-name-by-itemID path.
|
||||
int PushInstanceName(void *L, const uint8_t *cgItem) {
|
||||
char buf[128];
|
||||
if (!Item::Link::NameFromCGItem(cgItem, buf, sizeof(buf)))
|
||||
return 0;
|
||||
Game::Lua::PushString(L, buf);
|
||||
return 1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// `C_Item.GetItemName(itemLocation)` — returns the cached display name
|
||||
// of the item at the location, or nil for empty / uncached / invalid
|
||||
// inputs. Reads `m_name[0]` straight off `ItemStats_C` — no engine
|
||||
// round-trip, no `GetItemInfo` chaining.
|
||||
// `C_Item.GetItemName(itemLocation)` — returns the display name of the
|
||||
// item at the location, or nil for empty / uncached / invalid inputs.
|
||||
// The location points at a real item instance, so the name carries any
|
||||
// random-suffix decoration ("... of the Sorcerer") — built off the
|
||||
// CGItem via the engine's own name builder, matching modern WoW and
|
||||
// `C_Item.GetItemLink`'s bracketed name. Falls back to the base
|
||||
// `ItemStats_C` name (and warms the cache) if the instance build yields
|
||||
// nothing.
|
||||
static int __fastcall Script_C_Item_GetItemName(void *L) {
|
||||
if (!Item::Location::IsLocationArg(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: C_Item.GetItemName(itemLocation)");
|
||||
return 0;
|
||||
}
|
||||
return PushNameForItemID(L, Item::ID::FromCGItem(Item::Location::Resolve(L, 1)));
|
||||
const uint8_t *cgItem = Item::Location::Resolve(L, 1);
|
||||
if (int pushed = PushInstanceName(L, cgItem))
|
||||
return pushed;
|
||||
return PushNameForItemID(L, Item::ID::FromCGItem(cgItem));
|
||||
}
|
||||
|
||||
// `C_Item.GetItemNameByID(itemInfo)` — returns the cached display name
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_NewItems` — the modern "new item glow" bookkeeping system. Retail
|
||||
// flags items freshly added to your bags as "new" (the sparkle/flash on
|
||||
// the bag button) and clears the flag once you've acknowledged them.
|
||||
// Vanilla 1.12 never had the feature — no DBC, no server data, no engine
|
||||
// bit; it's pure client-side bookkeeping over bag contents. So we add it,
|
||||
// entirely in C++ the same way retail's client owns it — no addon Lua.
|
||||
//
|
||||
// Public surface (matches retail exactly):
|
||||
// C_NewItems.IsNewItem(bagID, slotIndex) -> bool
|
||||
// C_NewItems.RemoveNewItem(bagID, slotIndex)
|
||||
// C_NewItems.ClearAll()
|
||||
//
|
||||
// Newness is keyed on each item's **instance GUID** (read off the CGItem),
|
||||
// not on (bagID, slotIndex). That's what makes a flag survive the player
|
||||
// rearranging a bag — exactly like retail, and impossible for a pure-Lua
|
||||
// addon (vanilla exposes no per-instance GUID to Lua).
|
||||
//
|
||||
// The feed is event-driven, not polled: it hangs off `Bag::UpdateDelayed`,
|
||||
// which co-hooks the engine's three `BAG_UPDATE` fire sites and signals once
|
||||
// per frame in which a bag actually changed. On that signal we scan bags
|
||||
// 0..4, diff the resident item GUIDs against the previous scan, flag anything
|
||||
// newly acquired, and prune flags for items that left. `BAG_NEW_ITEMS_UPDATED`
|
||||
// (no payload — matching retail) fires whenever the new-item set changes. So
|
||||
// nothing runs on quiet frames.
|
||||
//
|
||||
// The one exception is establishing the login baseline: a lightweight
|
||||
// WorldTick handler waits ~1.5s after the player is first resolvable (long
|
||||
// enough for the initial bag population to finish), takes a single scan as the
|
||||
// baseline, and then goes idle — items already owned at login are therefore
|
||||
// never flagged. It re-arms on player-GUID change (relog / character switch).
|
||||
//
|
||||
// The "already owned" baseline spans **everything the player owns** — bags,
|
||||
// worn equipment (paperdoll + equipped bags), AND the bank (main + bank bags)
|
||||
// — even though only *bag* items are ever flagged new. That's what stops a
|
||||
// move INTO a bag from any of those (unequip, or a pull from the bank) from
|
||||
// looking like a fresh loot: the item's GUID was already in the owned set, so
|
||||
// the bag diff treats it as a move. The bank is reachable without the bank
|
||||
// window open because its GUIDs are read straight from the invMgr array (the
|
||||
// same gate-bypass `C_Item.GetItemCount` uses; the array is server-populated
|
||||
// at login).
|
||||
//
|
||||
// Bag enumeration is pure C++: it replicates what `PackBagSlot` does
|
||||
// internally (see Offsets.h). The backpack is indexed straight off the
|
||||
// player inventory manager; each equipped bag 1..4 is resolved to its
|
||||
// container object and inventory via the same GUID getter + vtable call the
|
||||
// engine uses, then walked with `GetItemBySlot`. None of the Lua-stack-bound
|
||||
// helpers are touched, so the scan is safe from the frame-loop tick.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "bag/UpdateDelayed.h"
|
||||
#include "event/Custom.h"
|
||||
#include "item/Location.h"
|
||||
#include "tick/WorldTick.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Item::NewItems {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kEvtNewItems = "BAG_NEW_ITEMS_UPDATED";
|
||||
const Event::Custom::AutoReserve _r{kEvtNewItems};
|
||||
|
||||
// Sized for the whole owned universe the baseline tracks — backpack + four
|
||||
// bags + 19 worn slots + 4 equipped bags + main bank (24) + six bank bags.
|
||||
// A full inventory-and-bank can push ~300, so 512 leaves generous headroom.
|
||||
// Overflow silently drops (AddUnique bails at the cap) — a dropped GUID just
|
||||
// can't be flagged/recognized, never a crash.
|
||||
constexpr int kMaxItems = 512;
|
||||
|
||||
// Delay after the player is first resolvable before the baseline is taken,
|
||||
// giving the initial bag population time to finish. Wall-clock (via the
|
||||
// engine ms counter) so it's independent of frame rate.
|
||||
constexpr uint32_t kSettleMs = 1500;
|
||||
|
||||
uint64_t g_seen[kMaxItems]; // all owned GUIDs (bags + equipment + bank) at the last scan
|
||||
int g_seenCount = 0;
|
||||
uint64_t g_new[kMaxItems]; // subset currently flagged "new"
|
||||
int g_newCount = 0;
|
||||
bool g_seeded = false; // true once the login baseline has been taken
|
||||
uint64_t g_playerGuid = 0; // re-arm the baseline when this changes (relog)
|
||||
uint32_t g_loginMs = 0; // engine ms at which the current player resolved
|
||||
|
||||
uint32_t NowMs() {
|
||||
using TickCount_t = uint32_t(__fastcall *)();
|
||||
return reinterpret_cast<TickCount_t>(Offsets::FUN_OS_TICKCOUNT_MS)();
|
||||
}
|
||||
|
||||
bool Contains(const uint64_t *arr, int count, uint64_t guid) {
|
||||
for (int i = 0; i < count; ++i)
|
||||
if (arr[i] == guid)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool AddUnique(uint64_t *arr, int *count, uint64_t guid) {
|
||||
if (Contains(arr, *count, guid) || *count >= kMaxItems)
|
||||
return false;
|
||||
arr[(*count)++] = guid;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool RemoveFrom(uint64_t *arr, int *count, uint64_t guid) {
|
||||
for (int i = 0; i < *count; ++i) {
|
||||
if (arr[i] == guid) {
|
||||
arr[i] = arr[--(*count)];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
uint64_t ItemGUID(const uint8_t *item) {
|
||||
if (item == nullptr)
|
||||
return 0;
|
||||
auto *instance = *reinterpret_cast<const uint8_t *const *>(
|
||||
item + Offsets::OFF_ITEM_INSTANCE_BLOCK);
|
||||
if (instance == nullptr)
|
||||
return 0;
|
||||
return *reinterpret_cast<const uint64_t *>(
|
||||
instance + Offsets::OFF_INSTANCE_BLOCK_GUID);
|
||||
}
|
||||
|
||||
// --- pure-C++ bag enumeration (no Lua stack) -----------------------------
|
||||
|
||||
using GetItemBySlot_t = void *(__thiscall *)(void *inventory, int slot0Based);
|
||||
|
||||
// Collects the instance GUIDs of every item in bags 0..4 into `out`.
|
||||
// Returns the count. Pure C++ — safe to call off the frame-loop tick.
|
||||
int ScanBags(const uint8_t *player, uint64_t *out) {
|
||||
auto getItemBySlot =
|
||||
reinterpret_cast<GetItemBySlot_t>(Offsets::FUN_ITEMMGR_GET_ITEM_BY_SLOT);
|
||||
int count = 0;
|
||||
|
||||
// Backpack (bagID 0): indexed directly off the player inventory manager
|
||||
// at linear slot (S-1) + 23. GetItemBySlot only reads the manager, so the
|
||||
// const_cast to satisfy its `this` parameter is safe.
|
||||
auto *invMgr = const_cast<uint8_t *>(player) + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
|
||||
const int backpackSlots = Item::Location::GetBagSlotCount(0);
|
||||
for (int slot = 1; slot <= backpackSlots; ++slot) {
|
||||
auto *item = static_cast<const uint8_t *>(
|
||||
getItemBySlot(invMgr, Offsets::BACKPACK_LINEAR_BASE + slot - 1));
|
||||
const uint64_t guid = ItemGUID(item);
|
||||
if (guid != 0)
|
||||
AddUnique(out, &count, guid);
|
||||
}
|
||||
|
||||
// Equipped bags 1..4: resolve each bag's inventory object, then walk it.
|
||||
for (int bagID = 1; bagID <= 4; ++bagID) {
|
||||
const int slots = Item::Location::GetBagSlotCount(bagID);
|
||||
if (slots <= 0)
|
||||
continue;
|
||||
void *bagInv = Item::Location::EquippedBagInventory(bagID);
|
||||
if (bagInv == nullptr)
|
||||
continue;
|
||||
for (int slot = 1; slot <= slots; ++slot) {
|
||||
auto *item = static_cast<const uint8_t *>(getItemBySlot(bagInv, slot - 1));
|
||||
const uint64_t guid = ItemGUID(item);
|
||||
if (guid != 0)
|
||||
AddUnique(out, &count, guid);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// Appends GUIDs from an invMgr's GUID array (the flat `+OFF_INVMGR_GUID_ARRAY`
|
||||
// list `GetItemBySlot` indexes) over linear slots [first, last]. Reading the
|
||||
// array directly is how we reach BANK slots without the bank window open —
|
||||
// `GetItemBySlot` gates slots 39..68 on a live banker GUID, but the array
|
||||
// itself is server-populated at login (same bypass `C_Item.GetItemCount`
|
||||
// uses). We only need the GUID here, not the resolved object.
|
||||
void AppendGuidArray(const uint8_t *invMgr, int first, int last, uint64_t *out,
|
||||
int *count) {
|
||||
if (invMgr == nullptr)
|
||||
return;
|
||||
auto *arr = *reinterpret_cast<const uint64_t *const *>(
|
||||
invMgr + Offsets::OFF_INVMGR_GUID_ARRAY);
|
||||
if (arr == nullptr)
|
||||
return;
|
||||
for (int slot = first; slot <= last; ++slot)
|
||||
if (arr[slot] != 0)
|
||||
AddUnique(out, count, arr[slot]);
|
||||
}
|
||||
|
||||
using ResolveByGuid_t = void *(__fastcall *)(int type, const char *debugName,
|
||||
uint32_t guidLo, uint32_t guidHi,
|
||||
int priority);
|
||||
const uint8_t *ResolveByGuid(int type, uint64_t guid) {
|
||||
if (guid == 0)
|
||||
return nullptr;
|
||||
auto fn = reinterpret_cast<ResolveByGuid_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
|
||||
return static_cast<const uint8_t *>(
|
||||
fn(type, "ItemMgr", static_cast<uint32_t>(guid),
|
||||
static_cast<uint32_t>(guid >> 32), 0x172));
|
||||
}
|
||||
|
||||
// Appends every item the player owns that ISN'T in bags 0..4 — worn
|
||||
// equipment (paperdoll 1..19 + the four equipped-bag containers 20..23),
|
||||
// the main bank (24 slots), and each bank bag's contents. Used only to widen
|
||||
// the "already owned" baseline so a move INTO a bag from any of these
|
||||
// (unequip, or a pull from the bank) reads as a move, not a fresh loot. We
|
||||
// never *flag* these as new — this only feeds the seen-set. Pure C++ (no Lua
|
||||
// stack) — safe off the frame tick.
|
||||
void AppendNonBagOwned(uint64_t *out, int *count) {
|
||||
// Worn equipment + equipped bags: GetItemBySlot works for these (no
|
||||
// banker gate), so go through the normal per-slot resolver.
|
||||
for (int slot = Offsets::EQUIPMENT_SLOT_FIRST; slot <= Offsets::INVSLOT_BAG1 + 3;
|
||||
++slot) {
|
||||
const uint64_t guid = ItemGUID(Item::Location::ResolveEquipmentSlot(slot));
|
||||
if (guid != 0)
|
||||
AddUnique(out, count, guid);
|
||||
}
|
||||
|
||||
const uint8_t *playerInvMgr = Unit::Identity::PlayerInventoryManager();
|
||||
if (playerInvMgr == nullptr)
|
||||
return;
|
||||
// Main bank slots 39..62 live in the player invMgr's own GUID array.
|
||||
AppendGuidArray(playerInvMgr, Offsets::INVMGR_BANK_MAIN_FIRST_SLOT,
|
||||
Offsets::INVMGR_BANK_MAIN_LAST_SLOT, out, count);
|
||||
// Bank bags (containers at player slots 63..68) each carry their own
|
||||
// invMgr; resolve the container, then walk its GUID array.
|
||||
auto *playerArr = *reinterpret_cast<const uint64_t *const *>(
|
||||
playerInvMgr + Offsets::OFF_INVMGR_GUID_ARRAY);
|
||||
if (playerArr == nullptr)
|
||||
return;
|
||||
for (int slot = Offsets::INVMGR_BANK_BAG_FIRST_SLOT;
|
||||
slot <= Offsets::INVMGR_BANK_BAG_LAST_SLOT; ++slot) {
|
||||
const uint8_t *bag = ResolveByGuid(Offsets::OBJ_TYPE_CONTAINER, playerArr[slot]);
|
||||
if (bag == nullptr)
|
||||
continue;
|
||||
auto *bagInvMgr =
|
||||
static_cast<const uint8_t *>(Item::Location::ContainerInventory(bag));
|
||||
if (bagInvMgr == nullptr)
|
||||
continue;
|
||||
const int bagSlots = static_cast<int>(*reinterpret_cast<const uint32_t *>(bagInvMgr));
|
||||
if (bagSlots > 0)
|
||||
AppendGuidArray(bagInvMgr, 0, bagSlots - 1, out, count);
|
||||
}
|
||||
}
|
||||
|
||||
void FireChanged() {
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kEvtNewItems), "");
|
||||
}
|
||||
|
||||
void CopyToSeen(const uint64_t *current, int count) {
|
||||
for (int i = 0; i < count; ++i)
|
||||
g_seen[i] = current[i];
|
||||
g_seenCount = count;
|
||||
}
|
||||
|
||||
// Login-baseline handler. Runs every frame but does no work once the
|
||||
// baseline is taken — it only watches for (re)login and, after a short
|
||||
// settle, takes the single authoritative baseline scan. Steady state is just
|
||||
// a GUID read + `if (g_seeded) return`.
|
||||
void OnTick() {
|
||||
const uint64_t guid = Unit::Identity::PlayerGuid();
|
||||
if (guid == 0)
|
||||
return; // not in world yet
|
||||
|
||||
// Fresh login / character switch: drop all state and re-arm the settle.
|
||||
if (guid != g_playerGuid) {
|
||||
g_playerGuid = guid;
|
||||
g_newCount = 0;
|
||||
g_seenCount = 0;
|
||||
g_seeded = false;
|
||||
g_loginMs = NowMs();
|
||||
}
|
||||
|
||||
if (g_seeded)
|
||||
return; // baseline established; the bag-change signal drives the rest
|
||||
if (NowMs() - g_loginMs < kSettleMs)
|
||||
return; // let the initial bag population finish
|
||||
|
||||
const uint8_t *player = Unit::Identity::PlayerObject();
|
||||
if (player == nullptr)
|
||||
return; // not resolvable yet — retry next tick
|
||||
|
||||
// Everything currently owned — bags AND worn equipment — becomes the
|
||||
// baseline; nothing owned at login is "new". Including equipment means a
|
||||
// later unequip (paperdoll → bag) is recognized as a move, not a loot.
|
||||
g_seenCount = ScanBags(player, g_seen);
|
||||
AppendNonBagOwned(g_seen, &g_seenCount);
|
||||
g_newCount = 0;
|
||||
g_seeded = true;
|
||||
}
|
||||
|
||||
// Authoritative bag-change handler — fires only on frames where the engine
|
||||
// actually changed a bag (via Bag::UpdateDelayed). Diffs the current bag set
|
||||
// against the baseline, flags newly-acquired items, prunes departed ones.
|
||||
void OnBagChanged() {
|
||||
if (!g_seeded)
|
||||
return; // pre-baseline changes are folded into the login baseline
|
||||
const uint64_t guid = Unit::Identity::PlayerGuid();
|
||||
if (guid == 0 || guid != g_playerGuid)
|
||||
return; // mid-relog; OnTick re-arms the baseline
|
||||
const uint8_t *player = Unit::Identity::PlayerObject();
|
||||
if (player == nullptr)
|
||||
return;
|
||||
|
||||
uint64_t currentBags[kMaxItems];
|
||||
const int bagCount = ScanBags(player, currentBags);
|
||||
|
||||
bool changed = false;
|
||||
// Drop flags for items no longer in the bags (used, sold, equipped, banked).
|
||||
for (int i = 0; i < g_newCount;) {
|
||||
if (!Contains(currentBags, bagCount, g_new[i])) {
|
||||
g_new[i] = g_new[--g_newCount];
|
||||
changed = true;
|
||||
} else {
|
||||
++i;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < bagCount; ++i) {
|
||||
if (!Contains(g_seen, g_seenCount, currentBags[i]))
|
||||
changed |= AddUnique(g_new, &g_newCount, currentBags[i]);
|
||||
}
|
||||
CopyToSeen(currentBags, bagCount);
|
||||
AppendNonBagOwned(g_seen, &g_seenCount);
|
||||
|
||||
if (changed)
|
||||
FireChanged();
|
||||
}
|
||||
|
||||
const Tick::WorldTick::AutoSubscribe _tick{&OnTick};
|
||||
const Bag::UpdateDelayed::AutoSubscribe _bagChange{&OnBagChanged};
|
||||
|
||||
// --- C_NewItems Lua surface ----------------------------------------------
|
||||
|
||||
// Resolves a `(bagID, slotIndex)` Lua-arg pair to the item's instance GUID,
|
||||
// or 0 if the args are missing/non-numeric or the slot is empty. Uses the
|
||||
// Lua-context bag resolver — fine here, these run as Lua C functions.
|
||||
uint64_t SlotGUID(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2))
|
||||
return 0;
|
||||
const int bagID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
const int slotIndex = static_cast<int>(Game::Lua::ToNumber(L, 2));
|
||||
return ItemGUID(Item::Location::ResolveBag(L, bagID, slotIndex));
|
||||
}
|
||||
|
||||
int __fastcall Script_IsNewItem(void *L) {
|
||||
const uint64_t guid = SlotGUID(L);
|
||||
Game::Lua::PushBool(L, guid != 0 && Contains(g_new, g_newCount, guid));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_RemoveNewItem(void *L) {
|
||||
const uint64_t guid = SlotGUID(L);
|
||||
if (guid != 0 && RemoveFrom(g_new, &g_newCount, guid))
|
||||
FireChanged();
|
||||
return 0;
|
||||
}
|
||||
|
||||
int __fastcall Script_ClearAll(void *L) {
|
||||
(void)L;
|
||||
if (g_newCount > 0) {
|
||||
g_newCount = 0;
|
||||
FireChanged();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_NewItems", "IsNewItem", &Script_IsNewItem);
|
||||
Game::Lua::RegisterTableFunction("C_NewItems", "RemoveNewItem", &Script_RemoveNewItem);
|
||||
Game::Lua::RegisterTableFunction("C_NewItems", "ClearAll", &Script_ClearAll);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Item::NewItems
|
||||
@@ -0,0 +1,63 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Item.PickupItem(itemInfo)` — finds the item matching `itemInfo` in
|
||||
// the player's equipment or bags and picks it up onto the cursor.
|
||||
// `itemInfo` is an itemID, `"item:NNN"` string, item link, or item name —
|
||||
// the same input `C_Item.UseItemByName` / `EquipItemByName` accept,
|
||||
// resolved via `Item::Arg::Resolve` and matched by `FindByArg` (itemID, or
|
||||
// decorated name for a name arg) across equipment (1..19, checked first)
|
||||
// and bags (0..4).
|
||||
//
|
||||
// The pickup goes through the shared `Item::Cursor` wrappers, which call
|
||||
// the engine's cursor primitives directly (no Lua `Script_Pickup*`
|
||||
// wrapper): an equipped match uses the equipment-slot primitive, a bag
|
||||
// match the bag primitive (guarded on an empty cursor + unlocked item).
|
||||
// Silently no-ops on bad input, no match, a busy cursor, or a locked item.
|
||||
|
||||
#include "Game.h"
|
||||
#include "item/Arg.h"
|
||||
#include "item/Cursor.h"
|
||||
#include "item/Location.h"
|
||||
|
||||
namespace Item::Pickup {
|
||||
|
||||
namespace {
|
||||
|
||||
int __fastcall Script_C_Item_PickupItem(void *L) {
|
||||
const auto arg = Item::Arg::Resolve(L, 1);
|
||||
if (arg.itemID <= 0 && arg.name == nullptr)
|
||||
return 0;
|
||||
|
||||
Item::Location::ByGUIDResult found;
|
||||
if (!Item::Location::FindByArg(L, arg, &found))
|
||||
return 0;
|
||||
|
||||
// equipmentSlotIndex != 0 → equipped (paperdoll slot); else a bag slot.
|
||||
if (found.equipmentSlotIndex != 0)
|
||||
Item::Cursor::PickupEquipmentSlot(found.equipmentSlotIndex);
|
||||
else
|
||||
Item::Cursor::PickupBagItem(L, found.bagID, found.slotIndex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Item", "PickupItem",
|
||||
&Script_C_Item_PickupItem);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Item::Pickup
|
||||
+23
-23
@@ -48,29 +48,6 @@ const uint8_t *FetchItemRecord(uint32_t itemID) {
|
||||
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, false);
|
||||
}
|
||||
|
||||
// Walks the item's 5 spell slots looking for the first ON_USE
|
||||
// (trigger=0) entry. Modern `GetItemSpell` returns only this kind;
|
||||
// passive-on-equip procs (trigger=1), weapon procs (trigger=2),
|
||||
// soulstones (trigger=4), and recipe-learn entries (trigger=6) all
|
||||
// stay invisible to the API.
|
||||
//
|
||||
// Vanilla server data on Turtle WoW has been observed to put the
|
||||
// ON_USE entry at slot index 0 for most items, but a few customized
|
||||
// items use later slots, so we scan all 5.
|
||||
int FindOnUseSpellIDInRecord(const uint8_t *record) {
|
||||
auto *spellIDs = reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SPELL_ID);
|
||||
auto *triggers = reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SPELL_TRIGGER);
|
||||
for (int i = 0; i < Offsets::ITEMSTATS_SPELL_SLOT_COUNT; i++) {
|
||||
if (spellIDs[i] != 0 &&
|
||||
triggers[i] == Offsets::ITEM_SPELLTRIGGER_ON_USE) {
|
||||
return static_cast<int>(spellIDs[i]);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const char *SpellNameForID(int spellID) {
|
||||
const uint8_t *record = ::Spell::Lookup::RecordForID(spellID);
|
||||
if (record == nullptr)
|
||||
@@ -110,6 +87,29 @@ int __fastcall Script_GetItemSpell(void *L) {
|
||||
|
||||
} // namespace
|
||||
|
||||
// Walks the item's 5 spell slots looking for the first ON_USE
|
||||
// (trigger=0) entry. Modern `GetItemSpell` returns only this kind;
|
||||
// passive-on-equip procs (trigger=1), weapon procs (trigger=2),
|
||||
// soulstones (trigger=4), and recipe-learn entries (trigger=6) all
|
||||
// stay invisible to the API.
|
||||
//
|
||||
// Vanilla server data on Turtle WoW has been observed to put the
|
||||
// ON_USE entry at slot index 0 for most items, but a few customized
|
||||
// items use later slots, so we scan all 5.
|
||||
int FindOnUseSpellIDInRecord(const uint8_t *record) {
|
||||
auto *spellIDs = reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SPELL_ID);
|
||||
auto *triggers = reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SPELL_TRIGGER);
|
||||
for (int i = 0; i < Offsets::ITEMSTATS_SPELL_SLOT_COUNT; i++) {
|
||||
if (spellIDs[i] != 0 &&
|
||||
triggers[i] == Offsets::ITEM_SPELLTRIGGER_ON_USE) {
|
||||
return static_cast<int>(spellIDs[i]);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int OnUseSpellIDForItemID(uint32_t itemID) {
|
||||
if (itemID == 0)
|
||||
return 0;
|
||||
|
||||
@@ -29,4 +29,12 @@ namespace Item::Spell {
|
||||
// the same use-spell still register).
|
||||
int OnUseSpellIDForItemID(uint32_t itemID);
|
||||
|
||||
// Walks a cached `ItemStats_C` record's 5 spell slots and returns the
|
||||
// first ON_USE (trigger=0) spell ID, or 0 if none. Split out so callers
|
||||
// that already hold the record (e.g. `Item::Usable`, which also reads the
|
||||
// record's use requirements) don't peek the cache twice or re-derive the
|
||||
// slot walk. `OnUseSpellIDForItemID` is the peek-by-itemID convenience
|
||||
// wrapper over this.
|
||||
int FindOnUseSpellIDInRecord(const uint8_t *record);
|
||||
|
||||
} // namespace Item::Spell
|
||||
|
||||
+39
-28
@@ -279,6 +279,15 @@ void AddByKey(Accum *acc, const char *key, long delta) {
|
||||
}
|
||||
}
|
||||
|
||||
long Value(const Accum *acc, const char *key) {
|
||||
if (acc == nullptr || key == nullptr)
|
||||
return 0;
|
||||
for (int i = 0; i < kCount; ++i)
|
||||
if (std::strcmp(acc[i].key, key) == 0)
|
||||
return acc[i].value;
|
||||
return 0;
|
||||
}
|
||||
|
||||
const uint8_t *FetchRecord(uint32_t itemID) {
|
||||
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
|
||||
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
|
||||
@@ -306,6 +315,34 @@ double ComputeDPS(const uint8_t *record) {
|
||||
return avgTotal / (static_cast<double>(delayMs) / 1000.0);
|
||||
}
|
||||
|
||||
void ApplyEnchant(Accum *acc, uint32_t enchantID, long sign) {
|
||||
if (enchantID == 0)
|
||||
return;
|
||||
const uint8_t *en = DBC::Record(Offsets::VAR_SPELLITEMENCHANT_RECORDS,
|
||||
Offsets::VAR_SPELLITEMENCHANT_COUNT, enchantID);
|
||||
if (en == nullptr)
|
||||
return;
|
||||
auto type = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_TYPE);
|
||||
auto amount = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_AMOUNT);
|
||||
auto arg = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_ARG);
|
||||
for (int i = 0; i < 3; ++i) { // SpellItemEnchantment has 3 effect slots
|
||||
switch (type[i]) {
|
||||
case 3: // equip spell — stat/resist value lives in the spell
|
||||
AddSpellStatAuras(acc, arg[i], sign);
|
||||
break;
|
||||
case 4: // resistance — arg = school index (0 = armor)
|
||||
AddByKey(acc, ResistKey(arg[i]), sign * static_cast<long>(amount[i]));
|
||||
break;
|
||||
case 5: // direct stat — arg = ItemModType, amount = value
|
||||
AddByKey(acc, StatKeyForItemModType(static_cast<uint32_t>(arg[i])),
|
||||
sign * static_cast<long>(amount[i]));
|
||||
break;
|
||||
default: // 1 proc / 2 weapon-damage / 6 totem / 7 use spell
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ApplyRandomSuffix(Accum *acc, int suffixID, long sign) {
|
||||
if (suffixID <= 0)
|
||||
return;
|
||||
@@ -316,34 +353,8 @@ void ApplyRandomSuffix(Accum *acc, int suffixID, long sign) {
|
||||
return;
|
||||
auto enchants = reinterpret_cast<const uint32_t *>(
|
||||
rp + Offsets::OFF_ITEMRANDOMPROP_ENCHANT);
|
||||
for (int s = 0; s < Offsets::ITEMRANDOMPROP_ENCHANT_SLOT_COUNT; ++s) {
|
||||
const uint32_t enchantID = enchants[s];
|
||||
if (enchantID == 0)
|
||||
continue;
|
||||
const uint8_t *en = DBC::Record(Offsets::VAR_SPELLITEMENCHANT_RECORDS,
|
||||
Offsets::VAR_SPELLITEMENCHANT_COUNT, enchantID);
|
||||
if (en == nullptr)
|
||||
continue;
|
||||
auto type = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_TYPE);
|
||||
auto amount = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_AMOUNT);
|
||||
auto arg = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_ARG);
|
||||
for (int i = 0; i < 3; ++i) { // SpellItemEnchantment has 3 effect slots
|
||||
switch (type[i]) {
|
||||
case 3: // equip spell — stat/resist value lives in the spell
|
||||
AddSpellStatAuras(acc, arg[i], sign);
|
||||
break;
|
||||
case 4: // resistance — arg = school index (0 = armor)
|
||||
AddByKey(acc, ResistKey(arg[i]), sign * static_cast<long>(amount[i]));
|
||||
break;
|
||||
case 5: // direct stat — arg = ItemModType, amount = value
|
||||
AddByKey(acc, StatKeyForItemModType(static_cast<uint32_t>(arg[i])),
|
||||
sign * static_cast<long>(amount[i]));
|
||||
break;
|
||||
default: // 1 proc / 2 weapon-damage / 6 totem / 7 use spell
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int s = 0; s < Offsets::ITEMRANDOMPROP_ENCHANT_SLOT_COUNT; ++s)
|
||||
ApplyEnchant(acc, enchants[s], sign);
|
||||
}
|
||||
|
||||
void AccumulateRecord(Accum *acc, const uint8_t *record, long sign) {
|
||||
|
||||
@@ -51,6 +51,9 @@ void Init(Accum *acc);
|
||||
// a zero delta). Linear scan — the table is small and fixed.
|
||||
void AddByKey(Accum *acc, const char *key, long delta);
|
||||
|
||||
// Read the accumulated value for `key` (0 for an unknown key). Linear scan.
|
||||
long Value(const Accum *acc, const char *key);
|
||||
|
||||
// Passive cache peek for an item's `ItemStats_C` record. Does NOT warm
|
||||
// the cache (returns nullptr for an uncached item) — see the item-cache
|
||||
// race note in CLAUDE.md.
|
||||
@@ -66,6 +69,13 @@ void AccumulateRecord(Accum *acc, const uint8_t *record, long sign);
|
||||
// suffixID <= 0.
|
||||
void ApplyRandomSuffix(Accum *acc, int suffixID, long sign);
|
||||
|
||||
// Fold a single SpellItemEnchantment record (by enchant ID) into `acc`,
|
||||
// scaled by `sign`. Handles equip-spell (type 3), resistance (4), and
|
||||
// direct-stat (5) enchant effects. Shared by `ApplyRandomSuffix` (a suffix's
|
||||
// enchant slots) and callers reading an item's *applied* permanent/temporary
|
||||
// enchants off its CGItem descriptor. No-op for enchantID 0.
|
||||
void ApplyEnchant(Accum *acc, uint32_t enchantID, long sign);
|
||||
|
||||
// Weapon DPS = average damage / swing time, or 0 for non-weapons.
|
||||
double ComputeDPS(const uint8_t *record);
|
||||
|
||||
|
||||
+8
-13
@@ -16,6 +16,7 @@
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "item/Location.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
@@ -23,7 +24,6 @@ namespace Item::Swap {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
using SwapFn_t = void(__thiscall *)(
|
||||
void *player,
|
||||
uint32_t srcItemLo, uint32_t srcItemHi,
|
||||
@@ -33,11 +33,6 @@ using SwapFn_t = void(__thiscall *)(
|
||||
uint32_t dstLinearSlot,
|
||||
int flag);
|
||||
|
||||
void *ResolvePlayer() {
|
||||
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
return fn("player");
|
||||
}
|
||||
|
||||
// Reads the 64-bit GUID stored at instance-block offset 0 of any
|
||||
// CGObject-derived pointer (CGItem, CGPlayer, CGContainer all use
|
||||
// the same +0x8 → instance block → +0x00 GUID layout).
|
||||
@@ -98,7 +93,7 @@ bool EncodeBagSlot(int bagID, int slotInBag,
|
||||
if (slotInBag < 1)
|
||||
return false;
|
||||
if (bagID == 0) {
|
||||
void *player = ResolvePlayer();
|
||||
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
|
||||
if (player == nullptr)
|
||||
return false;
|
||||
if (!ReadGuid(player, containerLo, containerHi))
|
||||
@@ -119,7 +114,7 @@ bool EncodeBagSlot(int bagID, int slotInBag,
|
||||
if (bagID == -1) {
|
||||
if (slotInBag > BANK_MAIN_NUM_SLOTS)
|
||||
return false;
|
||||
void *player = ResolvePlayer();
|
||||
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
|
||||
if (player == nullptr)
|
||||
return false;
|
||||
if (!ReadGuid(player, containerLo, containerHi))
|
||||
@@ -152,7 +147,7 @@ bool SendSwap(const void *srcItem,
|
||||
if (srcItem == nullptr)
|
||||
return false;
|
||||
|
||||
void *player = ResolvePlayer();
|
||||
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
|
||||
if (player == nullptr)
|
||||
return false;
|
||||
|
||||
@@ -187,7 +182,7 @@ bool FromPaperdoll(const void *cgItem, int srcPaperdollSlot, int dstPaperdollSlo
|
||||
if (srcPaperdollSlot < 1 || srcPaperdollSlot > 19)
|
||||
return false;
|
||||
|
||||
void *player = ResolvePlayer();
|
||||
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
|
||||
if (player == nullptr)
|
||||
return false;
|
||||
uint32_t playerLo = 0, playerHi = 0;
|
||||
@@ -205,7 +200,7 @@ bool ToBag(const void *cgItem, int srcPaperdollSlot, int dstBagID, int dstSlotIn
|
||||
if (cgItem == nullptr)
|
||||
return false;
|
||||
|
||||
void *player = ResolvePlayer();
|
||||
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
|
||||
if (player == nullptr)
|
||||
return false;
|
||||
uint32_t playerLo = 0, playerHi = 0;
|
||||
@@ -272,7 +267,7 @@ bool MoveCount(void *L, int srcBag, int srcSlot, int dstBag, int dstSlot, int co
|
||||
if (count == srcStack)
|
||||
return Containers(L, srcBag, srcSlot, dstBag, dstSlot);
|
||||
|
||||
void *player = ResolvePlayer();
|
||||
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
|
||||
if (player == nullptr)
|
||||
return false;
|
||||
|
||||
@@ -308,7 +303,7 @@ bool Containers(void *L, int srcBag, int srcSlot, int dstBag, int dstSlot) {
|
||||
if (srcItem == nullptr)
|
||||
return false; // empty source slot
|
||||
|
||||
void *player = ResolvePlayer();
|
||||
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
|
||||
if (player == nullptr)
|
||||
return false;
|
||||
|
||||
|
||||
+13
-18
@@ -220,24 +220,19 @@ static int __fastcall Script_GameTooltipGetItem(void *L) {
|
||||
const char *link = Item::Link::FromCGItem(
|
||||
static_cast<const uint8_t *>(cgItem));
|
||||
if (link != nullptr && *link != '\0') {
|
||||
// Engine's link builder also writes the dressed
|
||||
// (random-suffixed) name into the link's `[Name]`
|
||||
// slot; pull it out by reading the cached base name
|
||||
// for the return value. The engine doesn't expose
|
||||
// the dressed name as a separate string, so we
|
||||
// return the base name — matches modern semantics
|
||||
// where (name, link) name is the cached display name
|
||||
// and the link is the full hyperlink.
|
||||
const uint8_t *record = PeekItemRecord(static_cast<uint32_t>(itemID));
|
||||
if (record != nullptr) {
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSTATS_NAME);
|
||||
if (name != nullptr && *name != '\0') {
|
||||
Game::Lua::PushString(L, name);
|
||||
Game::Lua::PushString(L, link);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(itemID));
|
||||
return 3;
|
||||
}
|
||||
// Return the dressed (random-suffixed) name the engine
|
||||
// wrote into the link's `[Name]` slot — built off the same
|
||||
// CGItem via Item::Link::NameFromCGItem — so the returned
|
||||
// name agrees with the link ("Iridium Chain of the Owl",
|
||||
// not the base "Iridium Chain") and matches modern
|
||||
// GameTooltip:GetItem() on suffixed items.
|
||||
char name[128];
|
||||
if (Item::Link::NameFromCGItem(
|
||||
static_cast<const uint8_t *>(cgItem), name, sizeof(name))) {
|
||||
Game::Lua::PushString(L, name);
|
||||
Game::Lua::PushString(L, link);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(itemID));
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `IsUsableItem(item)` / `C_Item.IsUsableItem(item)` — is the item's
|
||||
// *on-use* ability usable by the local player right now, returning
|
||||
// `(usable, noMana)`. This is the item analog of `IsUsableSpell`; it has
|
||||
// nothing to do with whether the item can be *equipped* (armor
|
||||
// proficiency / class-restricted gear is not this function's concern —
|
||||
// that's the tooltip's red requirement lines, evaluated separately).
|
||||
//
|
||||
// Semantics (mirrors 3.3.5's `Script_IsUsableItem` structure, rebuilt on
|
||||
// 1.12 primitives — vanilla ships no `IsUsableItem` and its item-action
|
||||
// usability path never computed on-use power/noMana):
|
||||
// 1. Item must have an on-use spell (a `SPELL_TRIGGER[i] == ON_USE(0)`
|
||||
// slot with a non-zero `SPELL_ID[i]`). No on-use ability → not
|
||||
// usable (there's nothing to "use").
|
||||
// 2. The item's own *use* requirements must be met: RequiredLevel, and
|
||||
// the AllowableClass / AllowableRace masks. These gate activation
|
||||
// (e.g. a potion below your level, a class-restricted trinket) and
|
||||
// are use — not equip — requirements, so they belong here.
|
||||
// 3. The on-use spell must be castable now: delegated to the engine's
|
||||
// own spell-castability helper `FUN_SPELL_IS_USABLE`, which folds in
|
||||
// form/stance, mechanic immunities, aura state, and the power check.
|
||||
// Insufficient power is the only condition that flips `noMana` (and
|
||||
// leaves `usable` false), exactly like `IsUsableSpell`.
|
||||
//
|
||||
// Cache-miss handling matches `C_Item.IsEquippableItem`: a sync peek with
|
||||
// no load fired, returning `(false, false)` if the item isn't cached yet.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "item/Arg.h"
|
||||
#include "item/Spell.h"
|
||||
#include "spell/Lookup.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Item::Usable {
|
||||
|
||||
namespace {
|
||||
|
||||
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
|
||||
const uint64_t *guid, void *callback,
|
||||
void *userData, int unused);
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
using SpellIsUsable_t = int(__fastcall *)(const uint8_t *spellRecord, int *outNoMana);
|
||||
|
||||
// Same item-cache peek pattern as the other item modules (no shared
|
||||
// helper — each call site is one line different). Sync, no load fired.
|
||||
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);
|
||||
}
|
||||
|
||||
const uint8_t *PlayerDescriptor() {
|
||||
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
auto *player = static_cast<const uint8_t *>(resolve("player"));
|
||||
if (player == nullptr)
|
||||
return nullptr;
|
||||
return *reinterpret_cast<const uint8_t *const *>(
|
||||
player + Offsets::OFF_UNIT_DESCRIPTOR);
|
||||
}
|
||||
|
||||
// True iff `mask` restricts a 1-based `index` (class/race) the player
|
||||
// isn't part of. Mask 0 and 0xFFFFFFFF both mean "no restriction".
|
||||
bool RestrictedOut(uint32_t mask, uint32_t index1Based) {
|
||||
if (mask == 0 || mask == 0xFFFFFFFF || index1Based == 0)
|
||||
return false;
|
||||
return (mask & (1u << (index1Based - 1))) == 0;
|
||||
}
|
||||
|
||||
struct Usability {
|
||||
bool usable;
|
||||
bool noMana;
|
||||
};
|
||||
|
||||
Usability ComputeUsability(int itemID) {
|
||||
Usability r{false, false};
|
||||
if (itemID <= 0)
|
||||
return r;
|
||||
|
||||
auto *record = PeekItemRecord(static_cast<uint32_t>(itemID));
|
||||
if (record == nullptr)
|
||||
return r; // not cached → sync "false", no load fired
|
||||
|
||||
const int onUseSpellID = Item::Spell::FindOnUseSpellIDInRecord(record);
|
||||
if (onUseSpellID == 0)
|
||||
return r; // no on-use ability → nothing to use
|
||||
|
||||
auto *desc = PlayerDescriptor();
|
||||
if (desc == nullptr)
|
||||
return r; // pre-login / no player
|
||||
|
||||
// Use requirements (NOT equip requirements): level + class/race.
|
||||
const int playerLevel = *reinterpret_cast<const int *>(
|
||||
desc + Offsets::OFF_UNIT_FIELD_LEVEL);
|
||||
const uint32_t reqLevel = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_REQUIRED_LEVEL);
|
||||
if (static_cast<int>(reqLevel) > playerLevel)
|
||||
return r;
|
||||
|
||||
const uint32_t allowClass = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_ALLOWABLE_CLASS);
|
||||
const uint32_t playerClass = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
|
||||
if (RestrictedOut(allowClass, playerClass))
|
||||
return r;
|
||||
|
||||
const uint32_t allowRace = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_ALLOWABLE_RACE);
|
||||
const uint32_t playerRace = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_RACE_BYTE);
|
||||
if (RestrictedOut(allowRace, playerRace))
|
||||
return r;
|
||||
|
||||
// On-use spell castability + noMana, from the engine's own helper.
|
||||
// `::Spell::Lookup` (leading `::`) — inside `Item::Usable`, a bare
|
||||
// `Spell::` binds to the included `Item::Spell` namespace.
|
||||
auto *spellRec = ::Spell::Lookup::RecordForID(onUseSpellID);
|
||||
if (spellRec == nullptr)
|
||||
return r;
|
||||
|
||||
auto isUsable = reinterpret_cast<SpellIsUsable_t>(Offsets::FUN_SPELL_IS_USABLE);
|
||||
int noMana = 0;
|
||||
const int usable = isUsable(spellRec, &noMana) & 0xFF;
|
||||
r.usable = usable != 0;
|
||||
r.noMana = noMana != 0;
|
||||
return r;
|
||||
}
|
||||
|
||||
// `IsUsableItem(item)` — legacy global. Returns `(usable, noMana)` as
|
||||
// 1/nil pairs, matching stock 1.12's `IsUsableAction` / our
|
||||
// `IsUsableSpell` convention.
|
||||
int __fastcall Script_IsUsableItem(void *L) {
|
||||
const int itemID = Item::Arg::ResolveItemID(L, 1);
|
||||
const Usability r = ComputeUsability(itemID);
|
||||
if (r.usable) {
|
||||
Game::Lua::PushNumber(L, 1.0);
|
||||
Game::Lua::PushNil(L);
|
||||
} else if (r.noMana) {
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNumber(L, 1.0);
|
||||
} else {
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNil(L);
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
|
||||
// `C_Item.IsUsableItem(item)` — modern table-namespace form. Returns
|
||||
// proper booleans (`usable`, `noMana`) per the `C_Item.*` convention.
|
||||
int __fastcall Script_C_Item_IsUsableItem(void *L) {
|
||||
const int itemID = Item::Arg::ResolveItemID(L, 1);
|
||||
const Usability r = ComputeUsability(itemID);
|
||||
Game::Lua::PushBool(L, r.usable);
|
||||
Game::Lua::PushBool(L, r.noMana);
|
||||
return 2;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("IsUsableItem", &Script_IsUsableItem);
|
||||
Game::Lua::RegisterTableFunction("C_Item", "IsUsableItem",
|
||||
&Script_C_Item_IsUsableItem);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace Item::Usable
|
||||
+2
-59
@@ -48,7 +48,6 @@
|
||||
#include "item/Location.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace Item::Use {
|
||||
|
||||
@@ -59,67 +58,10 @@ using UseItem_t = unsigned(__thiscall *)(const void *item, const uint64_t *targe
|
||||
|
||||
using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
|
||||
|
||||
bool HasPrefix(const char *s, const char *prefix) {
|
||||
return std::strncmp(s, prefix, std::strlen(prefix)) == 0;
|
||||
}
|
||||
|
||||
// Walk past any "target" suffixes (`targettarget`, `focustargettarget`,
|
||||
// …); returns a pointer at the first non-"target" character.
|
||||
const char *SkipTargetSuffixes(const char *s) {
|
||||
while (HasPrefix(s, "target"))
|
||||
s += 6;
|
||||
return s;
|
||||
}
|
||||
|
||||
// Predicate mirror of the engine's token grammar inside
|
||||
// `FUN_TOKEN_TO_GUID` (`0x00515970`) plus the families our own
|
||||
// `Unit::TokenExtensions` hook adds (`focus`, `nameplateN`). The
|
||||
// engine raises a Lua error on unknown input, so we gate before
|
||||
// dispatching to keep our "silently no-ops on bad input" contract.
|
||||
//
|
||||
// Every base token may be followed by zero or more `target` suffixes
|
||||
// (`playertarget`, `party1targettarget`, …) which the engine's
|
||||
// resolver walks via `LAB_005159d3..0x00515A2C`.
|
||||
bool MatchBase(const char *s, const char *base, bool requireDigits) {
|
||||
if (!HasPrefix(s, base))
|
||||
return false;
|
||||
const char *rest = s + std::strlen(base);
|
||||
if (requireDigits) {
|
||||
const char *p = rest;
|
||||
while (*p >= '0' && *p <= '9')
|
||||
++p;
|
||||
if (p == rest)
|
||||
return false;
|
||||
rest = p;
|
||||
}
|
||||
return *SkipTargetSuffixes(rest) == '\0';
|
||||
}
|
||||
|
||||
bool LooksLikeToken(const char *s) {
|
||||
if (s == nullptr || *s == '\0')
|
||||
return false;
|
||||
// Order matters for shared roots: longer prefix first
|
||||
// (`partypet`/`raidpet` before `party`/`raid`).
|
||||
if (MatchBase(s, "partypet", true)) return true;
|
||||
if (MatchBase(s, "raidpet", true)) return true;
|
||||
if (MatchBase(s, "party", true)) return true;
|
||||
if (MatchBase(s, "raid", true)) return true;
|
||||
if (MatchBase(s, "nameplate", true)) return true;
|
||||
if (MatchBase(s, "mouseover", false)) return true;
|
||||
if (MatchBase(s, "player", false)) return true;
|
||||
if (MatchBase(s, "target", false)) return true;
|
||||
if (MatchBase(s, "focus", false)) return true;
|
||||
if (MatchBase(s, "npc", false)) return true;
|
||||
if (MatchBase(s, "pet", false)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
uint64_t ResolveUnitGuid(void *L, int idx) {
|
||||
if (!Game::Lua::IsString(L, idx))
|
||||
return 0;
|
||||
const char *token = Game::Lua::ToString(L, idx);
|
||||
if (!LooksLikeToken(token))
|
||||
return 0;
|
||||
auto fn = reinterpret_cast<TokenToGUID_t>(Offsets::FUN_TOKEN_TO_GUID);
|
||||
return fn(token);
|
||||
}
|
||||
@@ -130,12 +72,13 @@ int __fastcall Script_C_Item_UseItemByName(void *L) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const uint64_t targetGuid = ResolveUnitGuid(L, 2);
|
||||
|
||||
Item::Location::ByGUIDResult found;
|
||||
if (!Item::Location::FindByArgInBags(L, arg, &found)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const uint64_t targetGuid = ResolveUnitGuid(L, 2);
|
||||
auto useItem = reinterpret_cast<UseItem_t>(Offsets::FUN_ITEM_USE);
|
||||
useItem(found.item, &targetGuid, 0);
|
||||
return 0;
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// C_LootHistory backport.
|
||||
//
|
||||
// Modern WoW (2.x+, incl. 5.4.8) exposes a `C_LootHistory` namespace backed
|
||||
// by an engine-maintained ring buffer that a dedicated loot-history server
|
||||
// packet fills. Vanilla 1.12 has NO such packet or store — but it DOES
|
||||
// receive the raw group-loot roll traffic (SMSG_LOOT_ROLL per player,
|
||||
// SMSG_LOOT_ROLL_WON, SMSG_LOOT_ALL_PASSED), which the default UI only turns
|
||||
// into chat lines. So we reconstruct the history client-side (à la
|
||||
// Aura::Source): co-hook the three roll-result handlers and accumulate a ring
|
||||
// of rolled items + per-player results, then expose C_LootHistory reading
|
||||
// from it.
|
||||
//
|
||||
// The handlers run on the deserialized PendingLootRoll message (0x48 bytes,
|
||||
// LootRoll.cpp) with the fields at the OFF_LOOTROLL_* offsets. The message is
|
||||
// populated at handler entry and freed at exit, so each co-hook reads its
|
||||
// fields BEFORE calling the original.
|
||||
//
|
||||
// === Handler ABI (verified from disassembly — RET conventions DIFFER) ===
|
||||
// ROLL FUN_0061C0B0: __fastcall(msg=ECX, ctx=EDX, int stackArg) -> RET 4
|
||||
// WON FUN_0061B9E0: __fastcall(ctx=ECX, msg=EDX, int stackArg) -> RET 4
|
||||
// PASSED FUN_0061B640: __fastcall(ctx=ECX, msg=EDX) -> RET 0
|
||||
// The trailing stack arg (and matching callee cleanup) is LOAD-BEARING: an
|
||||
// earlier revision declared these as 2-arg hooks (implicit RET 0), so every
|
||||
// ROLL/WON call left ESP unbalanced by 4 -> stack corruption -> crash on the
|
||||
// first loot roll. Each hook below matches its handler's exact shape.
|
||||
//
|
||||
// Name + class per roller come from the engine NameCache (read at hook time,
|
||||
// guaranteed loaded then), so they resolve even for someone who has left the
|
||||
// group by the time their roll packet arrives. Surface: C_LootHistory
|
||||
// GetNumItems / GetItem / GetPlayerInfo, plus LOOT_HISTORY_ROLL_CHANGED /
|
||||
// _ROLL_COMPLETE events. The MoP master-loot management functions
|
||||
// (SetExpiration / GiveMasterLoot / CanMasterLoot) are intentionally omitted
|
||||
// — they depend on server support 1.12 doesn't have.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "event/Custom.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
|
||||
namespace Loot::History {
|
||||
|
||||
namespace {
|
||||
|
||||
template <typename T>
|
||||
T Read(const void *base, int off) {
|
||||
return *reinterpret_cast<const T *>(static_cast<const uint8_t *>(base) + off);
|
||||
}
|
||||
|
||||
// Engine millisecond clock — same source (and epoch) as Lua's `GetTime()`
|
||||
// (which reads this scaled by 0.001), so a value stored here is directly
|
||||
// comparable to `GetTime()` once divided by 1000.
|
||||
uint32_t NowMs() {
|
||||
using TickCount_t = uint32_t(__fastcall *)();
|
||||
return reinterpret_cast<TickCount_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
|
||||
}
|
||||
|
||||
// Modern rollType constants (what C_LootHistory.GetPlayerInfo returns).
|
||||
constexpr int ROLL_PASS = 0;
|
||||
constexpr int ROLL_NEED = 1;
|
||||
constexpr int ROLL_GREED = 2;
|
||||
|
||||
// The SMSG_LOOT_ROLL packet encodes rollType as 0 = need, 2 = greed; map to
|
||||
// the modern convention. A "didn't actually roll" (pass) is flagged by the
|
||||
// high bit of the roll-number byte, handled by the caller.
|
||||
int MapRollType(int packetType) {
|
||||
return packetType == 2 ? ROLL_GREED : ROLL_NEED;
|
||||
}
|
||||
|
||||
// GUID -> engine NameCache entry-data block. This is the same primitive the
|
||||
// engine's own `%N` name substitution uses (FUN_005083A0): for a LOADED entry
|
||||
// the lookup returns the data block (name[48] at +0, class at +0x140, …),
|
||||
// regardless of the (null) callback and regardless of current group
|
||||
// membership — so it resolves a roller who has since left the group, which
|
||||
// is exactly what a loot roll needs (the roll packet arrives after they may
|
||||
// have gone). Guaranteed loaded at hook time: the roll handler's wrapper
|
||||
// gates on this same lookup succeeding. Returns nullptr if not cached.
|
||||
const uint8_t *LookupPlayerRecord(uint64_t guid) {
|
||||
if (guid == 0)
|
||||
return nullptr;
|
||||
using Lookup_t = const uint8_t *(__thiscall *)(void *cache, uint32_t lo,
|
||||
uint32_t hi, void *cookie,
|
||||
void *cb, void *ud, char flag);
|
||||
auto fn = reinterpret_cast<Lookup_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_PLAYER_INFO_LOOKUP));
|
||||
uint32_t cookie[2] = {0, 0};
|
||||
return fn(reinterpret_cast<void *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_PLAYER_NAME_CACHE)),
|
||||
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32),
|
||||
cookie, nullptr, nullptr, 0);
|
||||
}
|
||||
|
||||
const char *NameFromRecord(const uint8_t *rec) {
|
||||
return rec != nullptr
|
||||
? reinterpret_cast<const char *>(rec + Offsets::OFF_PLAYER_INFO_NAME)
|
||||
: nullptr;
|
||||
}
|
||||
|
||||
uint32_t ClassIdFromRecord(const uint8_t *rec) {
|
||||
return rec != nullptr
|
||||
? *reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_PLAYER_INFO_CLASS)
|
||||
: 0;
|
||||
}
|
||||
|
||||
// ChrClasses.dbc record ID -> class token ("WARRIOR", "MAGE", …). The DBC's
|
||||
// filename field is the token; RAID_CLASS_COLORS et al. key on it. nullptr if
|
||||
// the ID is out of range or the record is missing.
|
||||
const char *ClassTokenForId(uint32_t classId) {
|
||||
if (classId == 0)
|
||||
return nullptr;
|
||||
const int count = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_CHRCLASSES_COUNT));
|
||||
const uint8_t *const *records = *reinterpret_cast<const uint8_t *const *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_CHRCLASSES_RECORDS));
|
||||
if (records == nullptr || static_cast<int>(classId) > count)
|
||||
return nullptr;
|
||||
const uint8_t *rec = records[classId];
|
||||
if (rec == nullptr)
|
||||
return nullptr;
|
||||
return *reinterpret_cast<const char *const *>(
|
||||
rec + Offsets::OFF_CHRCLASSES_FILENAME);
|
||||
}
|
||||
|
||||
constexpr int kNameLen = 48; // engine NameCache inline name[48]
|
||||
|
||||
void CopyName(char *dst, const char *src) {
|
||||
if (src == nullptr) {
|
||||
dst[0] = '\0';
|
||||
return;
|
||||
}
|
||||
int i = 0;
|
||||
for (; i < kNameLen - 1 && src[i] != '\0'; ++i)
|
||||
dst[i] = src[i];
|
||||
dst[i] = '\0';
|
||||
}
|
||||
|
||||
struct PlayerRoll {
|
||||
uint64_t guid;
|
||||
char name[kNameLen];
|
||||
uint32_t classId; // ChrClasses.dbc ID (0 = unknown)
|
||||
int rollType; // ROLL_* (modern); ROLL_PASS when the player passed
|
||||
int roll; // 1..100, or 0 if they passed / didn't roll
|
||||
};
|
||||
|
||||
constexpr int kMaxPlayers = 40;
|
||||
|
||||
struct HistItem {
|
||||
uint32_t rollID; // stable monotonic ID — survives ring-index shifts
|
||||
uint64_t sourceGuid; // looted-object GUID — the roll's identity
|
||||
uint32_t slot;
|
||||
uint32_t itemId;
|
||||
int32_t itemSuffix; // random suffix/property ID (link suffix field)
|
||||
uint32_t itemSeed; // random seed (link unique field)
|
||||
uint64_t winnerGuid; // winner's roller GUID (0 if undecided)
|
||||
bool hasWinner;
|
||||
bool isDone;
|
||||
uint32_t createdMs; // engine tick at creation (GetTime()*1000 epoch)
|
||||
PlayerRoll players[kMaxPlayers];
|
||||
int playerCount;
|
||||
bool used;
|
||||
};
|
||||
|
||||
// Ring of recent rolled items. g_write is the next slot to fill; g_count
|
||||
// saturates at kMaxItems. GetNumItems returns g_count; item index i (1-based,
|
||||
// oldest→newest) maps to ring[(g_write - g_count + i - 1) mod kMaxItems].
|
||||
// ~3 KB per item (players[40] dominates), so 128 is ~380 KB static — bump
|
||||
// freely if a longer history is wanted.
|
||||
constexpr int kMaxItems = 128;
|
||||
HistItem g_items[kMaxItems];
|
||||
int g_write = 0;
|
||||
int g_count = 0;
|
||||
uint32_t g_nextRollId = 1; // 0 is reserved as "invalid" (never assigned)
|
||||
|
||||
// Set by FindOrCreate for the most recent call, read by the hooks to decide
|
||||
// whether a structural LOOT_HISTORY_FULL_UPDATE is warranted (a new item
|
||||
// appeared, or the ring evicted an old one so every index shifted).
|
||||
bool g_createdNew = false;
|
||||
bool g_evicted = false;
|
||||
|
||||
HistItem *Find(uint64_t sourceGuid, uint32_t slot) {
|
||||
for (auto &e : g_items) {
|
||||
if (e.used && e.sourceGuid == sourceGuid && e.slot == slot)
|
||||
return &e;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
HistItem *FindOrCreate(uint64_t sourceGuid, uint32_t slot, uint32_t itemId) {
|
||||
g_createdNew = false;
|
||||
g_evicted = false;
|
||||
HistItem *e = Find(sourceGuid, slot);
|
||||
if (e != nullptr) {
|
||||
if (itemId != 0)
|
||||
e->itemId = itemId;
|
||||
return e;
|
||||
}
|
||||
e = &g_items[g_write];
|
||||
g_evicted = e->used; // overwriting a live slot => ring full => eviction
|
||||
g_write = (g_write + 1) % kMaxItems;
|
||||
if (g_count < kMaxItems)
|
||||
++g_count;
|
||||
*e = HistItem{};
|
||||
e->used = true;
|
||||
e->rollID = g_nextRollId++;
|
||||
e->createdMs = NowMs();
|
||||
e->sourceGuid = sourceGuid;
|
||||
e->slot = slot;
|
||||
e->itemId = itemId;
|
||||
g_createdNew = true;
|
||||
return e;
|
||||
}
|
||||
|
||||
// Find the player-roll slot for `guid`, appending a fresh one (name/class
|
||||
// resolved from the NameCache) if absent and there's room. Returns the
|
||||
// 0-based index, or -1 when the roster is full. Shared by the roll recorder
|
||||
// and the winner recorder — the winner may never have sent an individual
|
||||
// SMSG_LOOT_ROLL (joined late / master loot), so ensuring they're present
|
||||
// lets GetItem's winnerIdx resolve through GetPlayerInfo.
|
||||
int FindOrAddPlayer(HistItem *e, uint64_t guid) {
|
||||
for (int i = 0; i < e->playerCount; ++i) {
|
||||
if (e->players[i].guid == guid)
|
||||
return i;
|
||||
}
|
||||
if (e->playerCount >= kMaxPlayers)
|
||||
return -1;
|
||||
const int idx = e->playerCount++;
|
||||
const uint8_t *rec = LookupPlayerRecord(guid); // cache loaded at hook time
|
||||
e->players[idx] = PlayerRoll{};
|
||||
e->players[idx].guid = guid;
|
||||
CopyName(e->players[idx].name, NameFromRecord(rec));
|
||||
e->players[idx].classId = ClassIdFromRecord(rec);
|
||||
return idx;
|
||||
}
|
||||
|
||||
// 1-based index within players[] of the winner, or 0 if undecided / absent.
|
||||
int WinnerIndex(const HistItem *e) {
|
||||
if (!e->hasWinner || e->winnerGuid == 0)
|
||||
return 0;
|
||||
for (int i = 0; i < e->playerCount; ++i) {
|
||||
if (e->players[i].guid == e->winnerGuid)
|
||||
return i + 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 1-based access in oldest→newest order (matching GetNumItems' count).
|
||||
HistItem *GetEntry(int index1Based) {
|
||||
if (index1Based < 1 || index1Based > g_count)
|
||||
return nullptr;
|
||||
const int start = (g_write - g_count + kMaxItems) % kMaxItems;
|
||||
return &g_items[(start + index1Based - 1) % kMaxItems];
|
||||
}
|
||||
|
||||
// The 1-based index of an entry in oldest→newest order (0 if not live).
|
||||
int IndexOf(const HistItem *e) {
|
||||
const int start = (g_write - g_count + kMaxItems) % kMaxItems;
|
||||
for (int i = 0; i < g_count; ++i) {
|
||||
if (&g_items[(start + i) % kMaxItems] == e)
|
||||
return i + 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Modern loot-history events. ROLL_CHANGED(itemIdx, playerIdx) fires on each
|
||||
// roll; ROLL_COMPLETE(itemIdx) when the item is decided (winner / all-passed).
|
||||
// FULL_UPDATE is reserved for API/IsEventValid completeness (the UI fires it
|
||||
// on open); we don't dispatch it from C.
|
||||
constexpr const char *kRollChanged = "LOOT_HISTORY_ROLL_CHANGED";
|
||||
constexpr const char *kRollComplete = "LOOT_HISTORY_ROLL_COMPLETE";
|
||||
constexpr const char *kFullUpdate = "LOOT_HISTORY_FULL_UPDATE";
|
||||
const Event::Custom::AutoReserve _evRollChanged{kRollChanged};
|
||||
const Event::Custom::AutoReserve _evRollComplete{kRollComplete};
|
||||
const Event::Custom::AutoReserve _evFullUpdate{kFullUpdate};
|
||||
|
||||
// Returns the 1-based item index and writes the 1-based player index to
|
||||
// `*outPlayerIdx` (0 if none), so the hook can fire ROLL_CHANGED.
|
||||
int RecordRoll(const void *msg, int *outPlayerIdx) {
|
||||
*outPlayerIdx = 0;
|
||||
const uint64_t src = Read<uint64_t>(msg, Offsets::OFF_LOOTROLL_SOURCE_GUID);
|
||||
const uint32_t slot = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_SLOT);
|
||||
const uint32_t itemId = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_ITEM_ID);
|
||||
const uint64_t roller = Read<uint64_t>(msg, Offsets::OFF_LOOTROLL_ROLLER_GUID);
|
||||
const uint8_t rollByte = Read<uint8_t>(msg, Offsets::OFF_LOOTROLL_ROLL_NUM);
|
||||
const int packetType = Read<int32_t>(msg, Offsets::OFF_LOOTROLL_ROLL_TYPE);
|
||||
|
||||
const bool didRoll = (rollByte & 0x80) == 0;
|
||||
const int rollNum = didRoll ? (rollByte & 0x7F) : 0;
|
||||
const int rollType = didRoll ? MapRollType(packetType) : ROLL_PASS;
|
||||
|
||||
HistItem *e = FindOrCreate(src, slot, itemId);
|
||||
if (roller == 0)
|
||||
return IndexOf(e);
|
||||
|
||||
const int slotIdx = FindOrAddPlayer(e, roller);
|
||||
if (slotIdx >= 0) {
|
||||
e->players[slotIdx].rollType = rollType;
|
||||
e->players[slotIdx].roll = rollNum;
|
||||
*outPlayerIdx = slotIdx + 1;
|
||||
}
|
||||
return IndexOf(e);
|
||||
}
|
||||
|
||||
int RecordWinner(const void *msg) {
|
||||
const uint64_t src = Read<uint64_t>(msg, Offsets::OFF_LOOTROLL_SOURCE_GUID);
|
||||
const uint32_t slot = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_SLOT);
|
||||
const uint32_t itemId = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_ITEM_ID);
|
||||
const uint64_t winner = Read<uint64_t>(msg, Offsets::OFF_LOOTROLL_ROLLER_GUID);
|
||||
|
||||
HistItem *e = FindOrCreate(src, slot, itemId);
|
||||
e->winnerGuid = winner;
|
||||
e->hasWinner = true;
|
||||
e->isDone = true;
|
||||
if (winner != 0)
|
||||
FindOrAddPlayer(e, winner); // so GetItem's winnerIdx resolves
|
||||
return IndexOf(e);
|
||||
}
|
||||
|
||||
int RecordAllPassed(const void *msg) {
|
||||
const uint64_t src = Read<uint64_t>(msg, Offsets::OFF_LOOTROLL_SOURCE_GUID);
|
||||
const uint32_t slot = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_SLOT);
|
||||
const uint32_t itemId = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_ITEM_ID);
|
||||
|
||||
HistItem *e = FindOrCreate(src, slot, itemId);
|
||||
e->isDone = true;
|
||||
return IndexOf(e);
|
||||
}
|
||||
|
||||
// SMSG_LOOT_START_ROLL carries the item's random suffix/seed; the result
|
||||
// packets don't. It's read from the freshly-built store-head node (rather
|
||||
// than the packet) right after the START_ROLL handler runs — the node has
|
||||
// the same field offsets as the message. This is the only place the random
|
||||
// fields are populated, so the result recorders leave them alone.
|
||||
void RecordStart() {
|
||||
const uint8_t *node = *reinterpret_cast<const uint8_t *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_LOOTROLL_STORE_HEAD));
|
||||
if (node == nullptr)
|
||||
return;
|
||||
const uint64_t src = Read<uint64_t>(node, Offsets::OFF_LOOTROLL_SOURCE_GUID);
|
||||
const uint32_t slot = Read<uint32_t>(node, Offsets::OFF_LOOTROLL_SLOT);
|
||||
const uint32_t itemId = Read<uint32_t>(node, Offsets::OFF_LOOTROLL_ITEM_ID);
|
||||
HistItem *e = FindOrCreate(src, slot, itemId);
|
||||
e->itemSuffix = Read<int32_t>(node, Offsets::OFF_LOOTROLL_ITEM_SUFFIX);
|
||||
e->itemSeed = Read<uint32_t>(node, Offsets::OFF_LOOTROLL_ITEM_SEED);
|
||||
}
|
||||
|
||||
// ---- Packet co-hooks -------------------------------------------------------
|
||||
// Signatures match the verified per-handler ABI above (note the differing
|
||||
// message register, stack arg, and RET). Read the message fields before the
|
||||
// original runs — it frees the message.
|
||||
|
||||
using RollFn_t = void(__fastcall *)(void *msg, void *ctx, int a3); // RET 4
|
||||
using WonFn_t = void(__fastcall *)(void *ctx, void *msg, int a3); // RET 4
|
||||
using PassedFn_t = void(__fastcall *)(void *ctx, void *msg); // RET 0
|
||||
using StartFn_t = void(__fastcall *)(void *packet); // RET 0
|
||||
|
||||
RollFn_t g_origRoll = nullptr;
|
||||
WonFn_t g_origWon = nullptr;
|
||||
PassedFn_t g_origPassed = nullptr;
|
||||
StartFn_t g_origStart = nullptr;
|
||||
|
||||
// Fire LOOT_HISTORY_FULL_UPDATE (no payload) — the "rebuild everything"
|
||||
// signal, matching the UI's full refresh. Called when the item set changes
|
||||
// structurally: a new item appears or the ring evicts an old one (shifting
|
||||
// every index).
|
||||
void FireFullUpdate() {
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kFullUpdate), "");
|
||||
}
|
||||
|
||||
// START_ROLL: let the original build the store node, then read the item's
|
||||
// random suffix off it. Adds the item to the history so a UI can show it as
|
||||
// soon as the roll opens (before any roll comes in) — hence the FULL_UPDATE.
|
||||
void __fastcall Start_h(void *packet) {
|
||||
g_origStart(packet);
|
||||
RecordStart();
|
||||
if (g_createdNew || g_evicted)
|
||||
FireFullUpdate();
|
||||
}
|
||||
|
||||
// Record fields (message is freed by the original), run the original, THEN
|
||||
// fire the event — firing after the original keeps the engine's own handler
|
||||
// off our re-entrancy (same order Item::Data uses from its packet hook). A
|
||||
// structural FULL_UPDATE (if any) precedes the granular event so a listener
|
||||
// that rebuilds on FULL_UPDATE sees the new row before the row-level refresh.
|
||||
void __fastcall Roll_h(void *msg, void *ctx, int a3) {
|
||||
int itemIdx = 0, playerIdx = 0;
|
||||
bool structural = false;
|
||||
if (msg != nullptr) {
|
||||
itemIdx = RecordRoll(msg, &playerIdx);
|
||||
structural = g_createdNew || g_evicted;
|
||||
}
|
||||
g_origRoll(msg, ctx, a3);
|
||||
if (structural)
|
||||
FireFullUpdate();
|
||||
if (itemIdx > 0)
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kRollChanged), "%d%d", itemIdx,
|
||||
playerIdx);
|
||||
}
|
||||
|
||||
void __fastcall Won_h(void *ctx, void *msg, int a3) {
|
||||
int itemIdx = 0;
|
||||
bool structural = false;
|
||||
if (msg != nullptr) {
|
||||
itemIdx = RecordWinner(msg);
|
||||
structural = g_createdNew || g_evicted;
|
||||
}
|
||||
g_origWon(ctx, msg, a3);
|
||||
if (structural)
|
||||
FireFullUpdate();
|
||||
if (itemIdx > 0)
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kRollComplete), "%d", itemIdx);
|
||||
}
|
||||
|
||||
void __fastcall Passed_h(void *ctx, void *msg) {
|
||||
int itemIdx = 0;
|
||||
bool structural = false;
|
||||
if (msg != nullptr) {
|
||||
itemIdx = RecordAllPassed(msg);
|
||||
structural = g_createdNew || g_evicted;
|
||||
}
|
||||
g_origPassed(ctx, msg);
|
||||
if (structural)
|
||||
FireFullUpdate();
|
||||
if (itemIdx > 0)
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kRollComplete), "%d", itemIdx);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookRoll{
|
||||
Offsets::FUN_LOOT_ROLL_HANDLER, reinterpret_cast<void *>(&Roll_h),
|
||||
reinterpret_cast<void **>(&g_origRoll)};
|
||||
const Game::HookAutoRegister _hookWon{
|
||||
Offsets::FUN_LOOT_ROLL_WON_HANDLER, reinterpret_cast<void *>(&Won_h),
|
||||
reinterpret_cast<void **>(&g_origWon)};
|
||||
const Game::HookAutoRegister _hookPassed{
|
||||
Offsets::FUN_LOOT_ALL_PASSED_HANDLER, reinterpret_cast<void *>(&Passed_h),
|
||||
reinterpret_cast<void **>(&g_origPassed)};
|
||||
const Game::HookAutoRegister _hookStart{
|
||||
Offsets::FUN_LOOT_START_ROLL_HANDLER, reinterpret_cast<void *>(&Start_h),
|
||||
reinterpret_cast<void **>(&g_origStart)};
|
||||
|
||||
// ---- Lua surface -----------------------------------------------------------
|
||||
|
||||
// `C_LootHistory.GetNumItems()` -> number of rolled items in the history.
|
||||
int __fastcall Script_GetNumItems(void *L) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(g_count));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_LootHistory.GetItem(itemIndex)` ->
|
||||
// rollID, itemLink, numPlayers, isDone, winnerIdx, timestamp
|
||||
// Matches the MoP signature: `rollID` is a stable ID (unlike itemIndex, which
|
||||
// shifts as the ring saturates — key expanded/highlighted rows on it);
|
||||
// `itemLink` is the `item:id:enchant:suffix:unique` form (valid for
|
||||
// GetItemInfo / SetHyperlink), carrying the roll's random suffix so "of the X"
|
||||
// items keep their affix; `winnerIdx` is the winner's 1-based playerIndex for
|
||||
// GetPlayerInfo(itemIndex, winnerIdx) (nil if undecided). `timestamp` is a
|
||||
// ClassicAPI extension: the `GetTime()`-comparable creation time in seconds.
|
||||
int __fastcall Script_GetItem(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: GetItem(itemIndex)");
|
||||
return 0;
|
||||
}
|
||||
HistItem *e = GetEntry(static_cast<int>(Game::Lua::ToNumber(L, 1)));
|
||||
if (e == nullptr)
|
||||
return 0;
|
||||
static char link[64];
|
||||
// Vanilla item link: item:id:enchant:suffix:unique. Carry the roll's
|
||||
// random suffix/seed so "of the Bear"-style items keep their affix.
|
||||
std::snprintf(link, sizeof(link), "item:%u:0:%d:%u", e->itemId,
|
||||
e->itemSuffix, e->itemSeed);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(e->rollID)); // 1 rollID
|
||||
Game::Lua::PushString(L, link); // 2 itemLink
|
||||
Game::Lua::PushNumber(L, static_cast<double>(e->playerCount)); // 3 numPlayers
|
||||
Game::Lua::PushBool(L, e->isDone); // 4 isDone
|
||||
const int winnerIdx = WinnerIndex(e);
|
||||
if (winnerIdx > 0)
|
||||
Game::Lua::PushNumber(L, static_cast<double>(winnerIdx)); // 5 winnerIdx
|
||||
else
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNumber(L, e->createdMs / 1000.0); // 6 timestamp
|
||||
return 6;
|
||||
}
|
||||
|
||||
// `C_LootHistory.GetPlayerInfo(itemIndex, playerIndex)` ->
|
||||
// name, classToken, rollType, roll, isWinner, isMe. classToken is the
|
||||
// ChrClasses filename ("WARRIOR", …) for RAID_CLASS_COLORS-style coloring;
|
||||
// both name and class come from the engine NameCache captured at roll time.
|
||||
// isMe compares the roller GUID to the local player's.
|
||||
int __fastcall Script_GetPlayerInfo(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
|
||||
Game::Lua::Error(L, "Usage: GetPlayerInfo(itemIndex, playerIndex)");
|
||||
return 0;
|
||||
}
|
||||
HistItem *e = GetEntry(static_cast<int>(Game::Lua::ToNumber(L, 1)));
|
||||
if (e == nullptr)
|
||||
return 0;
|
||||
const int p = static_cast<int>(Game::Lua::ToNumber(L, 2)) - 1;
|
||||
if (p < 0 || p >= e->playerCount)
|
||||
return 0;
|
||||
const PlayerRoll &r = e->players[p];
|
||||
if (r.name[0] != '\0')
|
||||
Game::Lua::PushString(L, r.name); // 1 name
|
||||
else
|
||||
Game::Lua::PushNil(L);
|
||||
const char *classToken = ClassTokenForId(r.classId); // 2 class token
|
||||
if (classToken != nullptr)
|
||||
Game::Lua::PushString(L, classToken);
|
||||
else
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(r.rollType)); // 3 rollType
|
||||
Game::Lua::PushNumber(L, static_cast<double>(r.roll)); // 4 roll
|
||||
Game::Lua::PushBool(L, e->hasWinner && e->winnerGuid == r.guid); // 5 isWinner
|
||||
Game::Lua::PushBool(L, r.guid == Unit::Identity::PlayerGuid()); // 6 isMe
|
||||
return 6;
|
||||
}
|
||||
|
||||
// `C_LootHistory.Clear()` -> wipe the accumulated history. A ClassicAPI
|
||||
// extension (no MoP equivalent) so a UI can offer a "clear history" button.
|
||||
// Fires LOOT_HISTORY_FULL_UPDATE so any open display rebuilds empty.
|
||||
int __fastcall Script_Clear(void *L) {
|
||||
(void)L;
|
||||
for (auto &e : g_items)
|
||||
e.used = false;
|
||||
g_write = 0;
|
||||
g_count = 0;
|
||||
FireFullUpdate();
|
||||
return 0;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_LootHistory", "GetNumItems",
|
||||
&Script_GetNumItems);
|
||||
Game::Lua::RegisterTableFunction("C_LootHistory", "GetItem",
|
||||
&Script_GetItem);
|
||||
Game::Lua::RegisterTableFunction("C_LootHistory", "GetPlayerInfo",
|
||||
&Script_GetPlayerInfo);
|
||||
Game::Lua::RegisterTableFunction("C_LootHistory", "Clear", &Script_Clear);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Loot::History
|
||||
@@ -0,0 +1,251 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#include "map/Area.h"
|
||||
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
|
||||
namespace Map::Area {
|
||||
|
||||
int RowForAreaID(uint32_t areaID) {
|
||||
const int count =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_AREA_COUNT);
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
|
||||
Offsets::VAR_WORLDMAP_AREA_COUNT,
|
||||
static_cast<uint32_t>(id));
|
||||
if (rec != nullptr &&
|
||||
*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_WMA_AREA_ID) ==
|
||||
areaID)
|
||||
return id;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
int CurrentViewRow() {
|
||||
const int continent =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_CONTINENT_INDEX);
|
||||
if (continent < 0)
|
||||
return *reinterpret_cast<const int *>(
|
||||
Offsets::VAR_WORLDMAP_DEFAULT_AREA_ROW);
|
||||
const uint8_t *blob = *reinterpret_cast<const uint8_t *const *>(
|
||||
Offsets::VAR_WORLDMAP_CONTINENT_DATA);
|
||||
if (blob == nullptr)
|
||||
return -1;
|
||||
const uint8_t *entry = blob + continent * Offsets::WORLDMAP_CONTINENT_STRIDE;
|
||||
const int zone =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_ZONE_INDEX);
|
||||
if (zone < 0)
|
||||
return *reinterpret_cast<const int *>(entry + 0x04);
|
||||
const int *zoneRows = *reinterpret_cast<const int *const *>(entry + 0x10);
|
||||
return (zoneRows != nullptr) ? zoneRows[zone] : -1;
|
||||
}
|
||||
|
||||
namespace {
|
||||
double FloatField(const uint8_t *rec, int off) {
|
||||
return *reinterpret_cast<const float *>(rec + off);
|
||||
}
|
||||
|
||||
// Vanilla zone world-map canvas size (WorldMapDetailFrame); WorldMapOverlay
|
||||
// hit rects live in this pixel space.
|
||||
constexpr double kZoneCanvasW = 1002.0;
|
||||
constexpr double kZoneCanvasH = 668.0;
|
||||
|
||||
// How deep inside its containing WorldMapOverlay hit rect the map-percent point
|
||||
// (0..100) sits on WMA row `wmaRow` — the max, over that zone's overlays whose
|
||||
// hit rect encloses the point, of the normalized margin to the nearest overlay
|
||||
// edge (0 at the edge .. 0.5 at center). Returns -1 when the point is on none
|
||||
// of the zone's overlays.
|
||||
//
|
||||
// This is the runtime analog of an ADT area-grid lookup. WorldMapArea rects are
|
||||
// loose overlapping boxes; the overlay hit rects trace the real landmass, so
|
||||
// "which zone is the point drawn on" disambiguates them (Un'Goro's crater vs
|
||||
// Thousand Needles' empty overlap). But hit rects are themselves rectangles
|
||||
// that overlap at borders, so the DEPTH matters: a border node deep inside its
|
||||
// own zone's overlay beats one merely clipping a neighbor's overlay edge
|
||||
// (Nijel's Point is deep in Desolace's "Nijel's Point" overlay but only nicks
|
||||
// the edge of Stonetalon's Charred Vale box).
|
||||
double OverlayMargin(int wmaRow, double mapXpct, double mapYpct) {
|
||||
const double cx = mapXpct / 100.0 * kZoneCanvasW;
|
||||
const double cy = mapYpct / 100.0 * kZoneCanvasH;
|
||||
const int count =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_OVERLAY_COUNT);
|
||||
double best = -1.0;
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_OVERLAY_RECORDS,
|
||||
Offsets::VAR_WORLDMAP_OVERLAY_COUNT,
|
||||
static_cast<uint32_t>(id));
|
||||
if (rec == nullptr ||
|
||||
*reinterpret_cast<const int *>(rec + Offsets::OFF_WMO_WORLDMAP_AREA) !=
|
||||
wmaRow)
|
||||
continue;
|
||||
const int hl = *reinterpret_cast<const int *>(rec + Offsets::OFF_WMO_HITRECT_LEFT);
|
||||
const int hr = *reinterpret_cast<const int *>(rec + Offsets::OFF_WMO_HITRECT_RIGHT);
|
||||
const int ht = *reinterpret_cast<const int *>(rec + Offsets::OFF_WMO_HITRECT_TOP);
|
||||
const int hb = *reinterpret_cast<const int *>(rec + Offsets::OFF_WMO_HITRECT_BOTTOM);
|
||||
const double sw = hr - hl, sh = hb - ht;
|
||||
if (sw <= 0.0 || sh <= 0.0)
|
||||
continue; // no / degenerate hit rect
|
||||
if (cx < hl || cx > hr || cy < ht || cy > hb)
|
||||
continue; // point outside this overlay
|
||||
const double mX = (cx - hl < hr - cx ? cx - hl : hr - cx) / sw;
|
||||
const double mY = (cy - ht < hb - cy ? cy - ht : hb - cy) / sh;
|
||||
const double m = mX < mY ? mX : mY;
|
||||
if (m > best)
|
||||
best = m;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool ZonePercent(int mapID, float x, float y, int *outAreaID, double *outMapX,
|
||||
double *outMapY) {
|
||||
const int count =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_AREA_COUNT);
|
||||
// Two candidate tracks over the zones whose rect contains the point:
|
||||
// land* — the point is on the zone's drawn landmass (an overlay hit rect
|
||||
// encloses it): authoritative, wins whenever any zone qualifies.
|
||||
// Scored by how DEEP inside the overlay the point sits, so a
|
||||
// border node deep in its own zone's overlay beats one nicking a
|
||||
// neighbor's overlay edge.
|
||||
// box* — bounding-box only: the fallback when the point is on no zone's
|
||||
// overlay (map gaps / overlay-less custom zones), scored by the
|
||||
// most-interior zone-rect margin.
|
||||
bool haveLand = false, haveBox = false;
|
||||
double landScore = -1.0, boxMargin = 0.0;
|
||||
int landArea = 0, boxArea = 0;
|
||||
double landX = 0.0, landY = 0.0, boxX = 0.0, boxY = 0.0;
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
|
||||
Offsets::VAR_WORLDMAP_AREA_COUNT,
|
||||
static_cast<uint32_t>(id));
|
||||
if (rec == nullptr)
|
||||
continue;
|
||||
if (*reinterpret_cast<const int *>(rec + Offsets::OFF_WMA_MAP_ID) != mapID)
|
||||
continue;
|
||||
const int areaID =
|
||||
*reinterpret_cast<const int *>(rec + Offsets::OFF_WMA_AREA_ID);
|
||||
if (areaID == 0)
|
||||
continue; // continent-spanning row, not a zone
|
||||
|
||||
const double left = FloatField(rec, Offsets::OFF_WMA_LOC_LEFT);
|
||||
const double right = FloatField(rec, Offsets::OFF_WMA_LOC_RIGHT);
|
||||
const double top = FloatField(rec, Offsets::OFF_WMA_LOC_TOP);
|
||||
const double bottom = FloatField(rec, Offsets::OFF_WMA_LOC_BOTTOM);
|
||||
const double spanX = top - bottom; // > 0
|
||||
const double spanY = left - right; // > 0
|
||||
if (spanX <= 0.0 || spanY <= 0.0)
|
||||
continue; // degenerate / unmapped zone
|
||||
|
||||
if (x < bottom || x > top || y < right || y > left)
|
||||
continue; // point outside this zone's rect
|
||||
|
||||
// mapX = horizontal (world Y), mapY = vertical (world X).
|
||||
const double mapX = (left - y) / spanY * 100.0;
|
||||
const double mapY = (top - x) / spanX * 100.0;
|
||||
const double marginX = (x - bottom < top - x ? x - bottom : top - x) / spanX;
|
||||
const double marginY = (y - right < left - y ? y - right : left - y) / spanY;
|
||||
const double margin = marginX < marginY ? marginX : marginY;
|
||||
|
||||
if (!haveBox || margin > boxMargin) {
|
||||
haveBox = true;
|
||||
boxMargin = margin;
|
||||
boxArea = areaID;
|
||||
boxX = mapX;
|
||||
boxY = mapY;
|
||||
}
|
||||
const double overlayMargin = OverlayMargin(id, mapX, mapY);
|
||||
if (overlayMargin >= 0.0 && (!haveLand || overlayMargin > landScore)) {
|
||||
haveLand = true;
|
||||
landScore = overlayMargin;
|
||||
landArea = areaID;
|
||||
landX = mapX;
|
||||
landY = mapY;
|
||||
}
|
||||
}
|
||||
|
||||
if (haveLand) {
|
||||
*outAreaID = landArea;
|
||||
*outMapX = landX;
|
||||
*outMapY = landY;
|
||||
return true;
|
||||
}
|
||||
if (haveBox) {
|
||||
*outAreaID = boxArea;
|
||||
*outMapX = boxX;
|
||||
*outMapY = boxY;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool PercentInZone(int areaID, float x, float y, double *outMapX, double *outMapY) {
|
||||
const int count =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_AREA_COUNT);
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
|
||||
Offsets::VAR_WORLDMAP_AREA_COUNT,
|
||||
static_cast<uint32_t>(id));
|
||||
if (rec == nullptr)
|
||||
continue;
|
||||
if (*reinterpret_cast<const int *>(rec + Offsets::OFF_WMA_AREA_ID) != areaID)
|
||||
continue;
|
||||
const double left = FloatField(rec, Offsets::OFF_WMA_LOC_LEFT);
|
||||
const double right = FloatField(rec, Offsets::OFF_WMA_LOC_RIGHT);
|
||||
const double top = FloatField(rec, Offsets::OFF_WMA_LOC_TOP);
|
||||
const double bottom = FloatField(rec, Offsets::OFF_WMA_LOC_BOTTOM);
|
||||
const double spanX = top - bottom;
|
||||
const double spanY = left - right;
|
||||
if (spanX <= 0.0 || spanY <= 0.0)
|
||||
continue; // degenerate row — keep scanning for a usable one
|
||||
if (x < bottom || x > top || y < right || y > left)
|
||||
return false; // point outside this zone's rect — not in this zone
|
||||
*outMapX = (left - y) / spanY * 100.0; // horizontal (world Y)
|
||||
*outMapY = (top - x) / spanX * 100.0; // vertical (world X)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ContinentPercent(int mapID, float x, float y, double *outPx, double *outPy) {
|
||||
const int count =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_AREA_COUNT);
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
|
||||
Offsets::VAR_WORLDMAP_AREA_COUNT,
|
||||
static_cast<uint32_t>(id));
|
||||
if (rec == nullptr)
|
||||
continue;
|
||||
if (*reinterpret_cast<const int *>(rec + Offsets::OFF_WMA_MAP_ID) != mapID)
|
||||
continue;
|
||||
if (*reinterpret_cast<const int *>(rec + Offsets::OFF_WMA_AREA_ID) != 0)
|
||||
continue; // want the continent-spanning row
|
||||
|
||||
const double left = FloatField(rec, Offsets::OFF_WMA_LOC_LEFT);
|
||||
const double right = FloatField(rec, Offsets::OFF_WMA_LOC_RIGHT);
|
||||
const double top = FloatField(rec, Offsets::OFF_WMA_LOC_TOP);
|
||||
const double bottom = FloatField(rec, Offsets::OFF_WMA_LOC_BOTTOM);
|
||||
const double spanX = top - bottom;
|
||||
const double spanY = left - right;
|
||||
if (spanX <= 0.0 || spanY <= 0.0)
|
||||
continue; // stray zero-rect continent row (e.g. "World") — skip
|
||||
|
||||
*outPx = (left - y) / spanY; // horizontal (world Y)
|
||||
*outPy = (top - x) / spanX; // vertical (world X)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace Map::Area
|
||||
@@ -0,0 +1,79 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// `Map::Area` — shared `WorldMapArea.dbc` row resolution. A WorldMapArea
|
||||
// row is the client's per-zone map descriptor (directory name + the
|
||||
// world-coordinate rect used for world↔map transforms); every C_Map.*
|
||||
// reader that keys off "which zone" resolves a row first. Both
|
||||
// `Map::Overlays` (GetMapOverlays) and `Map::WorldSize` (GetMapWorldSize)
|
||||
// go through here so the two ways of naming a zone — an AreaTable areaID
|
||||
// argument, or the world map's current continent/zone view — live in one
|
||||
// place.
|
||||
namespace Map::Area {
|
||||
|
||||
// WorldMapArea row for an AreaTable `areaID` (the stable zone identity
|
||||
// `C_Map.GetBestMapForUnit` returns). Linear walk of the ~175-row table.
|
||||
// Returns -1 when no row carries that areaID.
|
||||
int RowForAreaID(uint32_t areaID);
|
||||
|
||||
// WorldMapArea row for the world map's currently *viewed* zone — the same
|
||||
// continent/zone selection the engine's map-name resolver (FUN_004a6cf0,
|
||||
// behind GetMapInfo) performs: continent index → per-continent blob, zone
|
||||
// index (-1 = whole continent) → that continent's zone-row array; no
|
||||
// continent selected falls back to the default-row global. Returns -1 when
|
||||
// the continent blob isn't resident.
|
||||
int CurrentViewRow();
|
||||
|
||||
// Resolves world point (x, y) on continent `mapID` to a zone: its AreaTable
|
||||
// areaID and a 0..100 zone-relative percent (`mapX` horizontal off world Y,
|
||||
// `mapY` vertical off world X — the WoW/pfQuest convention).
|
||||
//
|
||||
// WorldMapArea rects are loose, overlapping bounding boxes, so a point near a
|
||||
// zone edge falls inside several. Resolution therefore prefers the zone whose
|
||||
// **drawn landmass** (WorldMapOverlay hit rect) the point actually sits on —
|
||||
// the runtime analog of an ADT area-grid lookup (Un'Goro's crater vs Thousand
|
||||
// Needles' empty overlap). Overlay hit rects are themselves rectangles that
|
||||
// overlap at borders, so among landmass matches the winner is the zone the
|
||||
// point is DEEPEST inside an overlay of (Nijel's Point is deep in Desolace's
|
||||
// own overlay but only clips the edge of Stonetalon's Charred Vale box). If the
|
||||
// point is on no zone's overlay, falls back to the most-interior containing
|
||||
// rect. Returns false when no zone rect encloses the point (open sea,
|
||||
// degenerate rect, an instance with no zone row). Shared by GetAreaTriggerInfo
|
||||
// and the taxi-node zone resolve.
|
||||
bool ZonePercent(int mapID, float x, float y, int *outAreaID, double *outMapX,
|
||||
double *outMapY);
|
||||
|
||||
// Projects world (x, y) into the WorldMapArea rect of a SPECIFIC AreaTable
|
||||
// zone (`areaID`), filling the 0..100 zone-relative percent (`mapX` horizontal
|
||||
// off world Y, `mapY` vertical off world X). Returns false when that zone has
|
||||
// no usable WorldMapArea row OR the point lies outside its rect. Used when the
|
||||
// zone is already known by other means (a taxi node's name) and only the
|
||||
// in-zone position is needed — bypassing the containment / landmass search of
|
||||
// ZonePercent. The containment check lets a caller test candidate zones (a
|
||||
// node's city vs its outdoor region) and take the first that actually holds
|
||||
// the point.
|
||||
bool PercentInZone(int areaID, float x, float y, double *outMapX, double *outMapY);
|
||||
|
||||
// Projects world (x, y) on `mapID` to 0..1 CONTINENT-map coordinates using
|
||||
// the continent's WorldMapArea row (the areaID == 0 row with a non-degenerate
|
||||
// rect — some maps carry a stray zero-rect continent row that is skipped).
|
||||
// `outPx` is horizontal (off world Y), `outPy` vertical (off world X).
|
||||
// Returns false when the map has no usable continent row. This is the
|
||||
// projection retail's TaxiNodeInfo.position uses.
|
||||
bool ContinentPercent(int mapID, float x, float y, double *outPx, double *outPy);
|
||||
|
||||
} // namespace Map::Area
|
||||
@@ -0,0 +1,169 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Map.GetAreaTriggerInfo(triggerID)` and `C_Map.GetAreaTriggers([mapID])`
|
||||
// — expose `AreaTrigger.dbc`, the client's static trigger-volume geometry
|
||||
// (subzone-entry / exploration / teleport triggers). Vanilla loads the DBC
|
||||
// but exposes NONE of its geometry to Lua, so map addons (pfQuest, …) ship
|
||||
// hand-scraped trigger tables. Same value proposition as `GetMapOverlays`:
|
||||
// live, correct client data, no per-server table drift.
|
||||
//
|
||||
// ## Return shape (one info table)
|
||||
//
|
||||
// id — AreaTrigger.dbc row id
|
||||
// mapID — Map.dbc id (continent 0/1, or an instance map)
|
||||
// x, y, z — continent-space world coordinates of the trigger center
|
||||
// radius — sphere-trigger radius (0 for a box trigger)
|
||||
// isBox — true when the trigger is an oriented box (see below)
|
||||
// boxLength / boxWidth / boxHeight / boxYaw — box dimensions + yaw
|
||||
// (all 0 for a pure sphere trigger)
|
||||
// areaID — AreaTable zone id the point falls in (via WorldMapArea);
|
||||
// absent when no zone rect on this map contains the point
|
||||
// mapX / mapY — the trigger's position as a 0..100 map-relative percent
|
||||
// within that zone (the coordinate form map addons draw
|
||||
// with); absent alongside areaID when unresolved
|
||||
//
|
||||
// The raw world geometry is authoritative (straight from the DBC); the
|
||||
// derived `areaID`/`mapX`/`mapY` is the consumable form. Both are provided
|
||||
// so a consumer can draw immediately or run its own world→map transform.
|
||||
//
|
||||
// ## World → map-percent transform
|
||||
//
|
||||
// `WorldMapArea.dbc` gives each zone a world-coordinate rect
|
||||
// (`locTop`/`locBottom` bound world X, `locLeft`/`locRight` bound world Y —
|
||||
// WoW's world axes: +X north, +Y west). In map space the HORIZONTAL axis
|
||||
// (mapX) runs off world Y and the VERTICAL axis (mapY) off world X — the
|
||||
// standard WoW / pfQuest / Astrolabe convention:
|
||||
// mapX% = (locLeft - y) / (locLeft - locRight) * 100 (horizontal)
|
||||
// mapY% = (locTop - x) / (locTop - locBottom) * 100 (vertical)
|
||||
// We pick the zone by containment: among WorldMapArea rows on the trigger's
|
||||
// map with a real (non-continent) areaID whose rect encloses the point, the
|
||||
// smallest-area rect wins (the tightest zone — a subzone beats its parent).
|
||||
// The engine's own map-name resolver reads the same rect fields; see
|
||||
// `Map::Overlays` for the shared WorldMapArea wiring.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "map/Area.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Map::AreaTriggers {
|
||||
|
||||
namespace {
|
||||
|
||||
float FloatField(const uint8_t *rec, int off) {
|
||||
return *reinterpret_cast<const float *>(rec + off);
|
||||
}
|
||||
|
||||
// Zone containment + map-percent projection lives in the shared
|
||||
// `Map::Area::ZonePercent` (taxi nodes use the same transform).
|
||||
|
||||
// Builds one trigger's info table and leaves it on the Lua stack top.
|
||||
// Returns false (pushing nothing) when `id` is out of range or the slot
|
||||
// is empty.
|
||||
bool PushTriggerInfo(void *L, int id) {
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_AREATRIGGER_RECORDS,
|
||||
Offsets::VAR_AREATRIGGER_COUNT,
|
||||
static_cast<uint32_t>(id));
|
||||
if (rec == nullptr)
|
||||
return false;
|
||||
|
||||
const int mapID = *reinterpret_cast<const int *>(rec + Offsets::OFF_AT_MAP_ID);
|
||||
const float x = FloatField(rec, Offsets::OFF_AT_X);
|
||||
const float y = FloatField(rec, Offsets::OFF_AT_Y);
|
||||
const float z = FloatField(rec, Offsets::OFF_AT_Z);
|
||||
const float radius = FloatField(rec, Offsets::OFF_AT_RADIUS);
|
||||
const float boxLength = FloatField(rec, Offsets::OFF_AT_BOX_LENGTH);
|
||||
const float boxWidth = FloatField(rec, Offsets::OFF_AT_BOX_WIDTH);
|
||||
const float boxHeight = FloatField(rec, Offsets::OFF_AT_BOX_HEIGHT);
|
||||
const float boxYaw = FloatField(rec, Offsets::OFF_AT_BOX_YAW);
|
||||
const bool isBox = (boxLength != 0.0f || boxWidth != 0.0f || boxHeight != 0.0f);
|
||||
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "id", id);
|
||||
Game::Lua::SetFieldNumber(L, "mapID", mapID);
|
||||
Game::Lua::SetFieldNumber(L, "x", x);
|
||||
Game::Lua::SetFieldNumber(L, "y", y);
|
||||
Game::Lua::SetFieldNumber(L, "z", z);
|
||||
Game::Lua::SetFieldNumber(L, "radius", radius);
|
||||
Game::Lua::SetFieldBool(L, "isBox", isBox);
|
||||
Game::Lua::SetFieldNumber(L, "boxLength", boxLength);
|
||||
Game::Lua::SetFieldNumber(L, "boxWidth", boxWidth);
|
||||
Game::Lua::SetFieldNumber(L, "boxHeight", boxHeight);
|
||||
Game::Lua::SetFieldNumber(L, "boxYaw", boxYaw);
|
||||
|
||||
int areaID = 0;
|
||||
double mapX = 0.0, mapY = 0.0;
|
||||
if (Map::Area::ZonePercent(mapID, x, y, &areaID, &mapX, &mapY)) {
|
||||
Game::Lua::SetFieldNumber(L, "areaID", areaID);
|
||||
Game::Lua::SetFieldNumber(L, "mapX", mapX);
|
||||
Game::Lua::SetFieldNumber(L, "mapY", mapY);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// `C_Map.GetAreaTriggerInfo(triggerID)` — one trigger's info table, or nil
|
||||
// for a missing / out-of-range id.
|
||||
int __fastcall Script_GetAreaTriggerInfo(void *L) {
|
||||
const bool hasID = Game::Lua::IsNumber(L, 1);
|
||||
const int id = hasID ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (!hasID || !PushTriggerInfo(L, id))
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.GetAreaTriggers([mapID])` — array of every trigger's info table,
|
||||
// optionally filtered to a single Map.dbc id.
|
||||
int __fastcall Script_GetAreaTriggers(void *L) {
|
||||
const bool filter = Game::Lua::IsNumber(L, 1);
|
||||
const int wantMap = filter ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
const int count =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_AREATRIGGER_COUNT);
|
||||
int outIdx = 0;
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_AREATRIGGER_RECORDS,
|
||||
Offsets::VAR_AREATRIGGER_COUNT,
|
||||
static_cast<uint32_t>(id));
|
||||
if (rec == nullptr)
|
||||
continue;
|
||||
if (filter &&
|
||||
*reinterpret_cast<const int *>(rec + Offsets::OFF_AT_MAP_ID) != wantMap)
|
||||
continue;
|
||||
|
||||
outIdx += 1;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(outIdx));
|
||||
PushTriggerInfo(L, id); // rec is valid, so this always pushes a table
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetAreaTriggerInfo",
|
||||
&Script_GetAreaTriggerInfo);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetAreaTriggers",
|
||||
&Script_GetAreaTriggers);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Map::AreaTriggers
|
||||
@@ -0,0 +1,133 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Map.GetAreaInfo(areaID)` and `C_Map.GetAreas()` — surface
|
||||
// `AreaTable.dbc`'s localized name column to Lua. `AreaTable.dbc` is a
|
||||
// client DBC (always loaded, synchronous, localized, ~1500 rows), so
|
||||
// unlike creature/item names there's no WDB cold-cache problem — the name
|
||||
// is always available for any id.
|
||||
//
|
||||
// - `GetAreaInfo(areaID)` is a retail backport: the localized name for one
|
||||
// area id (zone or subzone), matching retail's signature.
|
||||
// - `GetAreas()` is a ClassicAPI extension (retail uses the uiMapID tree,
|
||||
// not raw AreaTable enumeration): the full id→name map, the load-bearing
|
||||
// piece for name databases that need name→id / the full id set (reverse
|
||||
// search, name resolution). Same "surface the hidden DBC" pattern as
|
||||
// `GetAreaTriggers` / `GetMapOverlays`.
|
||||
//
|
||||
// Names are the client's active locale (same source as `GetRealZoneText` /
|
||||
// `Script_GetCharacterInfo`'s zone-name resolve), so they match what the
|
||||
// game shows everywhere — no locale/rename drift vs a shipped table.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Map::Areas {
|
||||
|
||||
namespace {
|
||||
|
||||
// `C_Map.GetAreaInfo(areaID)` — localized AreaTable name, or nil for
|
||||
// non-numeric / non-positive input or an id with no row / empty name.
|
||||
int __fastcall Script_GetAreaInfo(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
const int areaID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
const char *name =
|
||||
(areaID > 0) ? DBC::LocalizedField(Offsets::VAR_AREATABLE_RECORDS,
|
||||
Offsets::VAR_AREATABLE_COUNT,
|
||||
static_cast<uint32_t>(areaID),
|
||||
Offsets::OFF_AREATABLE_NAMES)
|
||||
: nullptr;
|
||||
if (name == nullptr) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
Game::Lua::PushString(L, name);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.GetAreas()` — { [areaID] = name } for every row with a non-empty
|
||||
// localized name. Empty-name rows (DBC::LocalizedField → nullptr) are
|
||||
// omitted, so the map round-trips with GetAreaInfo for every key.
|
||||
int __fastcall Script_GetAreas(void *L) {
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
const int count =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_AREATABLE_COUNT);
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const char *name = DBC::LocalizedField(Offsets::VAR_AREATABLE_RECORDS,
|
||||
Offsets::VAR_AREATABLE_COUNT,
|
||||
static_cast<uint32_t>(id),
|
||||
Offsets::OFF_AREATABLE_NAMES);
|
||||
if (name == nullptr)
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(id));
|
||||
Game::Lua::PushString(L, name);
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.GetMapAreaIDs()` — { [mapName] = areaID } for every
|
||||
// `WorldMapArea.dbc` row: the map's name (the dir the engine uses for its map
|
||||
// textures) → its `AreaTable` areaID. Vanilla has no `GetCurrentMapAreaID`;
|
||||
// this lets an addon resolve a *browsed* map to a zone id — including the
|
||||
// city / instance maps `GetMapZones()` doesn't enumerate (Orgrimmar,
|
||||
// Undercity, Alterac Valley), which otherwise can't be matched to zone content
|
||||
// (e.g. a flight master pinned by areaID). Continent-spanning rows (areaID 0)
|
||||
// and unnamed rows are omitted.
|
||||
//
|
||||
// Note: this is the WorldMapArea NAME, which matches `GetMapInfo()`'s returned
|
||||
// dir for almost all maps but not a few misspelled Blizzard folders (the
|
||||
// Orgrimmar map's texture folder is "Ogrimmar"), so `[GetMapInfo()]` can miss
|
||||
// for those — resolve them by zone name instead.
|
||||
int __fastcall Script_GetMapAreaIDs(void *L) {
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
const int count =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_AREA_COUNT);
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
|
||||
Offsets::VAR_WORLDMAP_AREA_COUNT,
|
||||
static_cast<uint32_t>(id));
|
||||
if (rec == nullptr)
|
||||
continue;
|
||||
const int areaID = *reinterpret_cast<const int *>(rec + Offsets::OFF_WMA_AREA_ID);
|
||||
if (areaID == 0)
|
||||
continue; // continent-spanning row, no zone id
|
||||
const char *dir = *reinterpret_cast<const char *const *>(rec + Offsets::OFF_WMA_NAME);
|
||||
if (dir == nullptr || dir[0] == '\0')
|
||||
continue;
|
||||
Game::Lua::SetFieldNumber(L, dir, static_cast<double>(areaID));
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetAreaInfo", &Script_GetAreaInfo);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetAreas", &Script_GetAreas);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetMapAreaIDs", &Script_GetMapAreaIDs);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Map::Areas
|
||||
@@ -0,0 +1,80 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_MapExplorationInfo.GetExploredMapTextures(areaID)` and
|
||||
// `GetUnexploredMapTextures(areaID)` — the exploration-filtered views of a
|
||||
// zone's `WorldMapOverlay.dbc` entries.
|
||||
//
|
||||
// - `GetExploredMapTextures` is the retail-named backport: only the overlays
|
||||
// the local player has **discovered**. Retail keys on `uiMapID` and returns
|
||||
// numeric `fileDataIDs`; here it's an `AreaTable` areaID and the tiles are
|
||||
// texture paths (`fileDataIDs` holds those paths, see Overlays.cpp).
|
||||
// - `GetUnexploredMapTextures` is the ClassicAPI-extension complement: the
|
||||
// overlays NOT yet discovered — what a map-reveal addon draws over the
|
||||
// fogged base map. No retail equivalent (retail only ships the explored
|
||||
// getter); its inverse, `C_Map.GetMapOverlays`, returns *all* overlays.
|
||||
//
|
||||
// All three share `Map::Overlays::PushZoneOverlays` — same per-overlay table
|
||||
// shape (texture, resolved tiles, hit rect, areaIDs, fileDataIDs); these two
|
||||
// just pass an exploration Filter. "Explored" is computed per-areaID from the
|
||||
// player's explored-areas bitfield (see Overlays.cpp `IsOverlayExplored`), so
|
||||
// the areaID argument works for any zone, not just the one on screen.
|
||||
//
|
||||
// With no argument, both default to the world map's currently viewed zone
|
||||
// (like `GetMapOverlays()`).
|
||||
|
||||
#include "Game.h"
|
||||
#include "map/Area.h"
|
||||
#include "map/Overlays.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Map::Exploration {
|
||||
|
||||
namespace {
|
||||
|
||||
int ResolveRow(void *L) {
|
||||
return Game::Lua::IsNumber(L, 1)
|
||||
? Map::Area::RowForAreaID(
|
||||
static_cast<uint32_t>(Game::Lua::ToNumber(L, 1)))
|
||||
: Map::Area::CurrentViewRow();
|
||||
}
|
||||
|
||||
int __fastcall Script_GetExploredMapTextures(void *L) {
|
||||
const int row = ResolveRow(L);
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Map::Overlays::PushZoneOverlays(L, row, Map::Overlays::Filter::Explored);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetUnexploredMapTextures(void *L) {
|
||||
const int row = ResolveRow(L);
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Map::Overlays::PushZoneOverlays(L, row, Map::Overlays::Filter::Unexplored);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_MapExplorationInfo",
|
||||
"GetExploredMapTextures",
|
||||
&Script_GetExploredMapTextures);
|
||||
Game::Lua::RegisterTableFunction("C_MapExplorationInfo",
|
||||
"GetUnexploredMapTextures",
|
||||
&Script_GetUnexploredMapTextures);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Map::Exploration
|
||||
+181
-71
@@ -80,23 +80,37 @@
|
||||
// Array of overlay tables:
|
||||
// textureName — bare DBC name ("GOLDSHIRE")
|
||||
// texturePath — "Interface\WorldMap\<dir>\GOLDSHIRE" (engine format)
|
||||
// areaID — primary AreaTable id revealed (first nonzero)
|
||||
// areaIDs — all AreaTable ids revealed (WorldMapOverlay.dbc
|
||||
// areaID[4]; usually one, some overlays span 2-3 subzones)
|
||||
// textureWidth / textureHeight — the DBC placement rect (reliable for
|
||||
// placement; its IMPLIED tile count is what lies)
|
||||
// offsetX / offsetY — placement on the zone canvas
|
||||
// mapPointX / mapPointY
|
||||
// hitRectTop/Left/Bottom/Right — hit rectangle in world-map canvas px
|
||||
// (~1002x668); the tight clickable bounds of the landmass,
|
||||
// the subzone-center source map addons scrape
|
||||
// tileCols / tileRows / upscaled — the resolved grid
|
||||
// tiles — array of ready-to-draw tiles:
|
||||
// { file — texture path incl. number (SetTexture-ready)
|
||||
// width, height — draw size in map pixels
|
||||
// texCoordX, texCoordY — right/bottom texcoords
|
||||
// offsetX, offsetY } — absolute canvas position
|
||||
// fileDataIDs — ordered tile texture paths (retail's field name; vanilla
|
||||
// has no numeric fileDataIDs, so these are paths). Redundant
|
||||
// with tiles[].file — for retail-ported exploration addons.
|
||||
//
|
||||
// A consumer draws an overlay with a dumb loop: per tile SetTexture,
|
||||
// SetWidth/Height, SetTexCoord(0, texCoordX, 0, texCoordY), SetPoint.
|
||||
// Texture-less rows (a subzone hit region with no distinct art) still appear,
|
||||
// with textureName == "", zero size, and an empty `tiles` table — so
|
||||
// geometry consumers see every subzone while `ipairs(ov.tiles)` draw loops
|
||||
// stay safe. A consumer draws a textured overlay with a dumb loop: per tile
|
||||
// SetTexture, SetWidth/Height, SetTexCoord(0, texCoordX, 0, texCoordY), SetPoint.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "map/Area.h"
|
||||
#include "map/Overlays.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
@@ -279,46 +293,8 @@ const ResolvedOverlay *CachedResolve(int overlayId, const char *basePath,
|
||||
return g_cache[overlayId];
|
||||
}
|
||||
|
||||
// WorldMapArea row id for the world map's current view — the same
|
||||
// resolution the engine's current-map-name getter (FUN_004a6cf0, behind
|
||||
// GetMapInfo) performs: continent/zone selection indexes into the
|
||||
// per-continent data blob; no continent selected falls back to the
|
||||
// default row global.
|
||||
int CurrentWorldMapAreaRow() {
|
||||
const int continent =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_CONTINENT_INDEX);
|
||||
if (continent < 0)
|
||||
return *reinterpret_cast<const int *>(
|
||||
Offsets::VAR_WORLDMAP_DEFAULT_AREA_ROW);
|
||||
const uint8_t *blob = *reinterpret_cast<const uint8_t *const *>(
|
||||
Offsets::VAR_WORLDMAP_CONTINENT_DATA);
|
||||
if (blob == nullptr)
|
||||
return -1;
|
||||
const uint8_t *entry = blob + continent * Offsets::WORLDMAP_CONTINENT_STRIDE;
|
||||
const int zone =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_ZONE_INDEX);
|
||||
if (zone < 0)
|
||||
return *reinterpret_cast<const int *>(entry + 0x04);
|
||||
const int *zoneRows = *reinterpret_cast<const int *const *>(entry + 0x10);
|
||||
return (zoneRows != nullptr) ? zoneRows[zone] : -1;
|
||||
}
|
||||
|
||||
// WorldMapArea row id for an AreaTable areaID. Linear walk — the table
|
||||
// has ~175 rows; called once per Lua call.
|
||||
int WorldMapAreaRowForAreaID(uint32_t areaID) {
|
||||
const int count =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_AREA_COUNT);
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
|
||||
Offsets::VAR_WORLDMAP_AREA_COUNT,
|
||||
static_cast<uint32_t>(id));
|
||||
if (rec != nullptr &&
|
||||
*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_WMA_AREA_ID) ==
|
||||
areaID)
|
||||
return id;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
// WorldMapArea row resolution (areaID → row, current view → row) lives in
|
||||
// the shared `Map::Area` helper — `GetMapWorldSize` keys off the same rows.
|
||||
|
||||
// Pushes the `tiles` array for one overlay (leaves it on the stack top).
|
||||
void PushTiles(void *L, const ResolvedOverlay *r, const char *basePath,
|
||||
@@ -401,20 +377,78 @@ void PushTiles(void *L, const ResolvedOverlay *r, const char *basePath,
|
||||
}
|
||||
}
|
||||
|
||||
// `C_Map.GetMapOverlays([areaID])` — see the module comment.
|
||||
int __fastcall Script_GetMapOverlays(void *L) {
|
||||
int wmaRow = -1;
|
||||
if (Game::Lua::IsNumber(L, 1)) {
|
||||
wmaRow = WorldMapAreaRowForAreaID(
|
||||
static_cast<uint32_t>(Game::Lua::ToNumber(L, 1)));
|
||||
} else {
|
||||
wmaRow = CurrentWorldMapAreaRow();
|
||||
}
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
|
||||
// The local player's explored-areas bitfield (`[player+0xE68] + 0xE6C`), or
|
||||
// null before the player is resident. Bit `AreaBit` set == that AreaTable
|
||||
// area is explored. Same storage the engine's discovered-overlay rebuild
|
||||
// (FUN_004a67a0) reads.
|
||||
const uint8_t *ExplorationBits() {
|
||||
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
auto *player = static_cast<const uint8_t *>(fn("player"));
|
||||
if (player == nullptr)
|
||||
return nullptr;
|
||||
auto *sub = *reinterpret_cast<const uint8_t *const *>(
|
||||
player + Offsets::OFF_CGPLAYER_INFO);
|
||||
if (sub == nullptr)
|
||||
return nullptr;
|
||||
return sub + Offsets::OFF_PLAYER_EXPLORED_BITS;
|
||||
}
|
||||
|
||||
// True if the local player has discovered `overlayRec` — mirrors the
|
||||
// per-areaID test in FUN_004a67a0's discovered-overlay rebuild: an overlay
|
||||
// is explored if ANY of its (up to 4) AreaTable ids is explored. An area
|
||||
// with gate (+0x28) < 0 needs no exploration; otherwise its AreaBit (+0x0C)
|
||||
// is looked up in `bits`. `bits == null` (no player) → not explored.
|
||||
bool IsOverlayExplored(const uint8_t *overlayRec, const uint8_t *bits) {
|
||||
if (bits == nullptr)
|
||||
return false;
|
||||
for (int k = 0; k < 4; ++k) {
|
||||
const int areaID = *reinterpret_cast<const int *>(
|
||||
overlayRec + Offsets::OFF_WMO_AREA_ID + k * 4);
|
||||
if (areaID <= 0)
|
||||
continue;
|
||||
const uint8_t *areaRec =
|
||||
DBC::Record(Offsets::VAR_AREATABLE_RECORDS,
|
||||
Offsets::VAR_AREATABLE_COUNT, static_cast<uint32_t>(areaID));
|
||||
if (areaRec == nullptr)
|
||||
continue;
|
||||
if (*reinterpret_cast<const int *>(areaRec + Offsets::OFF_AREATABLE_EXPLORE_GATE) < 0)
|
||||
return true; // area needs no exploration → always counts
|
||||
const int areaBit = *reinterpret_cast<const int *>(
|
||||
areaRec + Offsets::OFF_AREATABLE_AREA_BIT);
|
||||
if (areaBit < 0)
|
||||
continue;
|
||||
const int byteIdx = areaBit >> 3;
|
||||
if (byteIdx >= 0x100)
|
||||
return true; // out of bitfield range → engine treats as explored
|
||||
if ((bits[byteIdx] & (1u << (areaBit & 7))) != 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// `C_Map.GetMapOverlays([areaID])` — see the module comment. Thin wrapper
|
||||
// over the shared `PushZoneOverlays` with no exploration filter.
|
||||
int __fastcall Script_GetMapOverlays(void *L) {
|
||||
const int wmaRow = Game::Lua::IsNumber(L, 1)
|
||||
? Map::Area::RowForAreaID(static_cast<uint32_t>(
|
||||
Game::Lua::ToNumber(L, 1)))
|
||||
: Map::Area::CurrentViewRow();
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Map::Overlays::PushZoneOverlays(L, wmaRow, Map::Overlays::Filter::All);
|
||||
return 1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// Shared overlay walk — see Overlays.h. Filters by exploration and builds
|
||||
// the rich per-overlay table (texture + resolved tiles + hit rect + areaIDs
|
||||
// + fileDataIDs). Uses the anonymous-namespace helpers above (same TU).
|
||||
void PushZoneOverlays(void *L, int wmaRow, Filter filter) {
|
||||
Game::Lua::NewTable(L);
|
||||
if (wmaRow <= 0)
|
||||
return 1;
|
||||
return;
|
||||
|
||||
const uint8_t *wmaRec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
|
||||
Offsets::VAR_WORLDMAP_AREA_COUNT,
|
||||
@@ -425,6 +459,10 @@ int __fastcall Script_GetMapOverlays(void *L) {
|
||||
Offsets::OFF_WMA_NAME)
|
||||
: nullptr;
|
||||
|
||||
// Exploration bitfield only needed for the filtered variants.
|
||||
const uint8_t *bits =
|
||||
(filter == Filter::All) ? nullptr : ExplorationBits();
|
||||
|
||||
const int count =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_OVERLAY_COUNT);
|
||||
int outIdx = 0;
|
||||
@@ -436,35 +474,56 @@ int __fastcall Script_GetMapOverlays(void *L) {
|
||||
*reinterpret_cast<const int *>(rec + Offsets::OFF_WMO_WORLDMAP_AREA) !=
|
||||
wmaRow)
|
||||
continue;
|
||||
|
||||
if (filter != Filter::All) {
|
||||
const bool explored = IsOverlayExplored(rec, bits);
|
||||
if (filter == Filter::Explored && !explored)
|
||||
continue;
|
||||
if (filter == Filter::Unexplored && explored)
|
||||
continue;
|
||||
}
|
||||
|
||||
const char *texName = *reinterpret_cast<const char *const *>(
|
||||
rec + Offsets::OFF_WMO_TEXTURE_NAME);
|
||||
if (texName == nullptr || texName[0] == '\0')
|
||||
continue; // hit-rect-only rows have no drawable texture
|
||||
|
||||
// Full path, mirroring the engine's own GetMapOverlayInfo format
|
||||
// (unresolvable map dir falls back to the "World" folder).
|
||||
char basePath[0x104];
|
||||
if (mapDir != nullptr && mapDir[0] != '\0')
|
||||
std::snprintf(basePath, sizeof(basePath),
|
||||
"Interface\\WorldMap\\%s\\%s", mapDir, texName);
|
||||
else
|
||||
std::snprintf(basePath, sizeof(basePath),
|
||||
"Interface\\WorldMap\\World\\%s", texName);
|
||||
const bool hasTexture = (texName != nullptr && texName[0] != '\0');
|
||||
|
||||
const auto fieldInt = [rec](uintptr_t off) {
|
||||
return *reinterpret_cast<const int32_t *>(rec + off);
|
||||
};
|
||||
const int dbcW = fieldInt(Offsets::OFF_WMO_TEXTURE_WIDTH);
|
||||
const int dbcH = fieldInt(Offsets::OFF_WMO_TEXTURE_HEIGHT);
|
||||
const int offX = fieldInt(Offsets::OFF_WMO_OFFSET_X);
|
||||
const int offY = fieldInt(Offsets::OFF_WMO_OFFSET_Y);
|
||||
|
||||
const ResolvedOverlay *r = CachedResolve(id, basePath, dbcW, dbcH);
|
||||
// Texture placement + tile grid only exist for rows that carry art.
|
||||
// A hit-rect-only row (areaID + hit rect, no texture — a clickable
|
||||
// subzone region with no distinct BLP) still gets a result row: empty
|
||||
// textureName/path, zero size, and an empty `tiles` table (r == null →
|
||||
// PushTiles emits `{}`), so geometry consumers see it while art-draw
|
||||
// loops (`ipairs(ov.tiles)`) iterate zero times. Stock 1.12 and Octo
|
||||
// both happen to give every overlay a texture, but the DBC schema
|
||||
// allows texture-less rows, so we don't assume.
|
||||
char basePath[0x104];
|
||||
int dbcW = 0, dbcH = 0;
|
||||
const ResolvedOverlay *r = nullptr;
|
||||
if (hasTexture) {
|
||||
// Full path, mirroring the engine's own GetMapOverlayInfo format
|
||||
// (unresolvable map dir falls back to the "World" folder).
|
||||
if (mapDir != nullptr && mapDir[0] != '\0')
|
||||
std::snprintf(basePath, sizeof(basePath),
|
||||
"Interface\\WorldMap\\%s\\%s", mapDir, texName);
|
||||
else
|
||||
std::snprintf(basePath, sizeof(basePath),
|
||||
"Interface\\WorldMap\\World\\%s", texName);
|
||||
dbcW = fieldInt(Offsets::OFF_WMO_TEXTURE_WIDTH);
|
||||
dbcH = fieldInt(Offsets::OFF_WMO_TEXTURE_HEIGHT);
|
||||
r = CachedResolve(id, basePath, dbcW, dbcH);
|
||||
} else {
|
||||
basePath[0] = '\0';
|
||||
}
|
||||
|
||||
outIdx += 1;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(outIdx));
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldString(L, "textureName", texName);
|
||||
Game::Lua::SetFieldString(L, "textureName", hasTexture ? texName : "");
|
||||
Game::Lua::SetFieldString(L, "texturePath", basePath);
|
||||
Game::Lua::SetFieldNumber(L, "textureWidth", dbcW);
|
||||
Game::Lua::SetFieldNumber(L, "textureHeight", dbcH);
|
||||
@@ -474,6 +533,41 @@ int __fastcall Script_GetMapOverlays(void *L) {
|
||||
L, "mapPointX", fieldInt(Offsets::OFF_WMO_MAP_POINT_X));
|
||||
Game::Lua::SetFieldNumber(
|
||||
L, "mapPointY", fieldInt(Offsets::OFF_WMO_MAP_POINT_Y));
|
||||
|
||||
// Hit rectangle (world-map canvas px, ~1002x668) — the tight clickable
|
||||
// bounds of the landmass, present for every row (texture or not). The
|
||||
// subzone-center source: (left+right)/2 / 1002, (top+bottom)/2 / 668.
|
||||
Game::Lua::SetFieldNumber(
|
||||
L, "hitRectTop", fieldInt(Offsets::OFF_WMO_HITRECT_TOP));
|
||||
Game::Lua::SetFieldNumber(
|
||||
L, "hitRectLeft", fieldInt(Offsets::OFF_WMO_HITRECT_LEFT));
|
||||
Game::Lua::SetFieldNumber(
|
||||
L, "hitRectBottom", fieldInt(Offsets::OFF_WMO_HITRECT_BOTTOM));
|
||||
Game::Lua::SetFieldNumber(
|
||||
L, "hitRectRight", fieldInt(Offsets::OFF_WMO_HITRECT_RIGHT));
|
||||
|
||||
// AreaTable ids this overlay reveals (WorldMapOverlay.dbc areaID[4]).
|
||||
// `areaID` is the primary (first nonzero — every overlay has one);
|
||||
// `areaIDs` is the full nonzero list (52 of 707 overlays span 2-3
|
||||
// subzones, e.g. a lake straddling two areas). Same table shape as
|
||||
// `tiles` — build the array, then assign the scalar.
|
||||
int firstArea = 0, aCount = 0;
|
||||
Game::Lua::PushString(L, "areaIDs");
|
||||
Game::Lua::NewTable(L);
|
||||
for (int k = 0; k < 4; ++k) {
|
||||
const int aid = fieldInt(Offsets::OFF_WMO_AREA_ID + k * 4);
|
||||
if (aid == 0)
|
||||
continue;
|
||||
if (firstArea == 0)
|
||||
firstArea = aid;
|
||||
aCount += 1;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(aCount));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(aid));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
Game::Lua::SetTable(L, -3); // overlay.areaIDs = { ... }
|
||||
Game::Lua::SetFieldNumber(L, "areaID", firstArea);
|
||||
|
||||
if (r != nullptr) {
|
||||
Game::Lua::SetFieldNumber(L, "tileCols", r->cols);
|
||||
Game::Lua::SetFieldNumber(L, "tileRows", r->rows);
|
||||
@@ -483,18 +577,34 @@ int __fastcall Script_GetMapOverlays(void *L) {
|
||||
PushTiles(L, r, basePath, dbcW, dbcH, offX, offY);
|
||||
Game::Lua::SetTable(L, -3);
|
||||
|
||||
// fileDataIDs — ordered tile texture paths (retail's field name for
|
||||
// the tile list). Vanilla has no numeric fileDataIDs, so these are the
|
||||
// SetTexture-ready paths; redundant with tiles[].file, provided so
|
||||
// retail-ported exploration addons reading info.fileDataIDs work,
|
||||
// while tiles[] carries the per-tile draw geometry Octo needs.
|
||||
Game::Lua::PushString(L, "fileDataIDs");
|
||||
Game::Lua::NewTable(L);
|
||||
if (r != nullptr) {
|
||||
char file[0x120];
|
||||
for (int i = 0; i < r->count; ++i) {
|
||||
std::snprintf(file, sizeof(file), "%s%d", basePath, i + 1);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
|
||||
Game::Lua::PushString(L, file);
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
}
|
||||
Game::Lua::SetTable(L, -3); // overlay.fileDataIDs = { ... }
|
||||
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
namespace {
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetMapOverlays",
|
||||
&Script_GetMapOverlays);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Map::Overlays
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace Map::Overlays {
|
||||
|
||||
// Which overlays of a zone to include, by the local player's exploration.
|
||||
enum class Filter {
|
||||
All, // every WorldMapOverlay.dbc row for the zone (C_Map.GetMapOverlays)
|
||||
Explored, // only overlays the player has discovered
|
||||
Unexplored, // only overlays the player has NOT discovered
|
||||
};
|
||||
|
||||
// Pushes an array of overlay tables for `wmaRow` (a WorldMapArea.dbc row),
|
||||
// filtered by exploration, onto the Lua stack as a single value. Shared by
|
||||
// `C_Map.GetMapOverlays` (Filter::All) and `C_MapExplorationInfo`'s
|
||||
// explored / unexplored texture getters. The caller resolves the row (via
|
||||
// `Map::Area`) and clears the stack first; this leaves exactly one table.
|
||||
// The per-overlay table shape is documented in Overlays.cpp's header.
|
||||
//
|
||||
// Explored/Unexplored read the player's explored-areas bitfield; before the
|
||||
// player is resident (character select) every overlay reads as unexplored.
|
||||
void PushZoneOverlays(void *L, int wmaRow, Filter filter);
|
||||
|
||||
} // namespace Map::Overlays
|
||||
@@ -0,0 +1,82 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Map.GetMapWorldSize([areaID])` — the world size (in yards) of a zone,
|
||||
// as `width, height`. This is the physical extent the map represents: the
|
||||
// span of the `WorldMapArea.dbc` placement rect. Modern addons use it to
|
||||
// convert map-relative percents to yard distances (e.g. tracking-range
|
||||
// rings, "X yards away" readouts).
|
||||
//
|
||||
// With a numeric `areaID` (AreaTable id — the same identity
|
||||
// `C_Map.GetBestMapForUnit` returns and `C_Map.GetMapOverlays` accepts),
|
||||
// returns that zone's size. With no argument, returns the world map's
|
||||
// currently viewed zone (same resolution `GetMapOverlays()` uses with no
|
||||
// arg). Returns nothing (→ nil, nil) when the zone can't be resolved.
|
||||
//
|
||||
// WoW's world axes are +X north, +Y west; the WorldMapArea rect bounds
|
||||
// them as locTop/locBottom (world X) and locLeft/locRight (world Y). Map
|
||||
// width is the horizontal (east-west) extent and height the vertical
|
||||
// (north-south):
|
||||
// width = locLeft - locRight (world Y span)
|
||||
// height = locTop - locBottom (world X span)
|
||||
// The same spans are the denominators in the world→map percent transform
|
||||
// (see `Map::AreaTriggers`), so a point's `mapX% * width / 100` is its
|
||||
// offset in yards from the zone's east edge, and likewise for height.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "map/Area.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Map::WorldSize {
|
||||
|
||||
namespace {
|
||||
|
||||
int __fastcall Script_GetMapWorldSize(void *L) {
|
||||
const int row = Game::Lua::IsNumber(L, 1)
|
||||
? Map::Area::RowForAreaID(
|
||||
static_cast<uint32_t>(Game::Lua::ToNumber(L, 1)))
|
||||
: Map::Area::CurrentViewRow();
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (row <= 0)
|
||||
return 0; // no values -> nil, nil
|
||||
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
|
||||
Offsets::VAR_WORLDMAP_AREA_COUNT,
|
||||
static_cast<uint32_t>(row));
|
||||
if (rec == nullptr)
|
||||
return 0;
|
||||
|
||||
const float left = *reinterpret_cast<const float *>(rec + Offsets::OFF_WMA_LOC_LEFT);
|
||||
const float right = *reinterpret_cast<const float *>(rec + Offsets::OFF_WMA_LOC_RIGHT);
|
||||
const float top = *reinterpret_cast<const float *>(rec + Offsets::OFF_WMA_LOC_TOP);
|
||||
const float bottom = *reinterpret_cast<const float *>(rec + Offsets::OFF_WMA_LOC_BOTTOM);
|
||||
|
||||
Game::Lua::PushNumber(L, static_cast<double>(left) - right); // width (world Y span)
|
||||
Game::Lua::PushNumber(L, static_cast<double>(top) - bottom); // height (world X span)
|
||||
return 2;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetMapWorldSize",
|
||||
&Script_GetMapWorldSize);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Map::WorldSize
|
||||
+2
-11
@@ -33,6 +33,7 @@
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "item/Location.h"
|
||||
#include "unit/Identity.h"
|
||||
#include "tick/WorldTick.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -42,7 +43,6 @@ namespace Merchant::Frame {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
using GetItemRecord_t = const uint8_t *(__thiscall *)(
|
||||
void *cache, uint32_t itemID, const uint64_t *guid,
|
||||
void *callback, void *userData, bool requestIfMissing);
|
||||
@@ -108,15 +108,6 @@ int ItemQuality(uint32_t itemID) {
|
||||
|
||||
// Player invMgr lives inline at player + 0x1D38. First uint32 is the
|
||||
// max slot count; +0x04 is the GUID array pointer.
|
||||
const uint8_t *PlayerInvMgr() {
|
||||
auto resolve = reinterpret_cast<ResolveUnitToken_t>(
|
||||
Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
auto *player = static_cast<uint8_t *>(resolve("player"));
|
||||
if (player == nullptr)
|
||||
return nullptr;
|
||||
return player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
|
||||
}
|
||||
|
||||
// Walk a single bag, invoking `cb(cgItem)` for each non-empty slot.
|
||||
// `cb` returns true to continue, false to stop iteration. Stomps
|
||||
// the Lua stack — caller must own it (`ResolveBag` does the stomping).
|
||||
@@ -161,7 +152,7 @@ int __fastcall Script_GetBuybackItemID(void *L) {
|
||||
if (invSlot == 0)
|
||||
return 0;
|
||||
|
||||
auto *invMgr = PlayerInvMgr();
|
||||
auto *invMgr = Unit::Identity::PlayerInventoryManager();
|
||||
if (invMgr == nullptr)
|
||||
return 0;
|
||||
// First u32 of the invMgr struct is the max slot count.
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_PlayerInfo.CanUseItem(itemID)` — true iff the local player meets the
|
||||
// item's *use/equip* requirements. This is the "is the item red in the
|
||||
// tooltip" gate, and is distinct from its siblings:
|
||||
// - `C_Item.IsEquippableItem` — does the item fit *some* slot (static).
|
||||
// - `IsUsableItem` — is the item's on-use ability castable now.
|
||||
// - `C_PlayerInfo.CanUseItem` — may the player equip/use it at all.
|
||||
//
|
||||
// So this is the answer to "mail chest on a Mage" (proficiency), "level-40
|
||||
// item on a level-20" (RequiredLevel), and class/race-restricted gear.
|
||||
//
|
||||
// Not in vanilla 1.12 nor in our 3.3.5 reference client (the modern API
|
||||
// landed in 1.14.4 / 3.4.2 / 10.0.5), so there's no cross-binary source;
|
||||
// the logic mirrors the requirement checks the 1.12 item tooltip builder
|
||||
// (`FUN_0052B650`) inlines for its red/green line coloring — the same
|
||||
// `bVar23` "can't use this" flag, minus the display.
|
||||
//
|
||||
// Checks (all must pass):
|
||||
// 1. Proficiency — for weapon/armor the engine keeps a per-item-class
|
||||
// subclass-proficiency bitmask (VAR_PROFICIENCY_TABLE, fed by
|
||||
// SMSG_SET_PROFICIENCY). `mask[m_class] & (1 << m_subClass)` must be
|
||||
// set. Item classes with no proficiency concept store mask 0 and are
|
||||
// unrestricted (matches the tooltip's `mask != 0` gate).
|
||||
// 2. RequiredLevel <= player level.
|
||||
// 3. AllowableClass / AllowableRace masks include the player (0 or all-
|
||||
// bits = no restriction).
|
||||
// 4. RequiredSkill — the player must have the item's skill line at an
|
||||
// effective rank (current + temp bonus) >= RequiredSkillRank (e.g. a
|
||||
// mount "Requires Riding (150)"). Via FUN_SKILL_LINE_TO_SLOT.
|
||||
// 5. RequiredSpell — the player must know the item's prerequisite spell,
|
||||
// or a higher rank of it: a crafting specialization ("Requires
|
||||
// Armorsmith") or a proficiency spell. Via the spell-knowledge bitmap
|
||||
// plus the engine's rank-chain walk.
|
||||
// 6. RequiredHonorRank / RequiredCityRank — PvP-rank gates: the player's
|
||||
// honor rank (>= required) and earned PvP-medal bitmask.
|
||||
// 7. RequiredReputation — current standing with the item's faction must
|
||||
// reach the required reaction band.
|
||||
//
|
||||
// Cache-miss handling matches `C_Item.IsEquippableItem`: a synchronous
|
||||
// peek that returns false without firing a load.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "item/Arg.h"
|
||||
#include "spell/Lookup.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Player::CanUseItem {
|
||||
|
||||
namespace {
|
||||
|
||||
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
|
||||
const uint64_t *guid, void *callback,
|
||||
void *userData, int unused);
|
||||
// `__thiscall(player /*ecx*/, skillLineID) -> slot` (-1 if the player
|
||||
// doesn't have the skill line). See VAR docs on FUN_SKILL_LINE_TO_SLOT.
|
||||
using SkillLineToSlot_t = int(__thiscall *)(void *player, uint32_t skillLineID);
|
||||
// `__fastcall(factionID) -> current total reputation` (base + bonus), 0
|
||||
// for factions not in the player's list. See FUN_REPUTATION_GET_STANDING.
|
||||
using RepStanding_t = int(__fastcall *)(int factionID);
|
||||
// `__thiscall(player /*ecx*/, spellID) -> nonzero` if the player knows a
|
||||
// higher rank in spellID's chain. See FUN_SPELL_RANK_CHAIN_KNOWN.
|
||||
using SpellRankChainKnown_t = int(__thiscall *)(void *player, uint32_t spellID);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// True iff `mask` restricts a 1-based `index` (class/race) the player
|
||||
// isn't part of. Mask 0 and 0xFFFFFFFF both mean "no restriction".
|
||||
bool RestrictedOut(uint32_t mask, uint32_t index1Based) {
|
||||
if (mask == 0 || mask == 0xFFFFFFFF || index1Based == 0)
|
||||
return false;
|
||||
return (mask & (1u << ((index1Based - 1) & 31))) == 0;
|
||||
}
|
||||
|
||||
// Per-item-class proficiency bitmask lookup. Returns false only when the
|
||||
// item's class *has* a proficiency concept (mask != 0) and the item's
|
||||
// subclass bit is unset.
|
||||
bool ProficiencyMet(const uint8_t *record) {
|
||||
const uint32_t itemClass = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_CLASS);
|
||||
const uint32_t subClass = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SUBCLASS);
|
||||
if (itemClass > 16) // table is 17 entries (m_class 0..16)
|
||||
return true;
|
||||
auto *table = reinterpret_cast<const uint32_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_PROFICIENCY_TABLE));
|
||||
const uint32_t mask = table[itemClass];
|
||||
if (mask == 0)
|
||||
return true; // class has no proficiency concept (consumables, …)
|
||||
return (mask & (1u << (subClass & 31))) != 0;
|
||||
}
|
||||
|
||||
// RequiredSkill + RequiredSkillRank gate (e.g. a mount "Requires Riding
|
||||
// (150)", a profession tool "Requires Engineering (200)"). Maps the
|
||||
// item's SkillLine.dbc row to the player's skill slot; the player must
|
||||
// have the line at an effective rank (current + temp bonus) >= required.
|
||||
bool RequiredSkillMet(const uint8_t *player, const uint8_t *record) {
|
||||
const uint32_t reqSkill = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_REQUIRED_SKILL);
|
||||
if (reqSkill == 0)
|
||||
return true; // no skill requirement
|
||||
const uint32_t reqRank = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_REQUIRED_SKILL_RANK);
|
||||
|
||||
auto toSlot = reinterpret_cast<SkillLineToSlot_t>(Offsets::FUN_SKILL_LINE_TO_SLOT);
|
||||
const int slot = toSlot(const_cast<uint8_t *>(player), reqSkill);
|
||||
if (slot < 0)
|
||||
return false; // player doesn't have the skill line at all
|
||||
|
||||
auto *sub = *reinterpret_cast<const uint8_t *const *>(
|
||||
player + Offsets::OFF_CGPLAYER_INFO);
|
||||
if (sub == nullptr)
|
||||
return false;
|
||||
const uint8_t *rec = sub + Offsets::OFF_SKILL_INFO_TABLE +
|
||||
slot * Offsets::SKILL_INFO_STRIDE;
|
||||
uint32_t rank = *reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_CUR);
|
||||
if (rank != 0) // add the temp bonus only when there's a base value
|
||||
rank += *reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_BONUS);
|
||||
return rank >= reqRank;
|
||||
}
|
||||
|
||||
// True if the local player knows `spellID`, via the engine's spell-
|
||||
// knowledge bitmap (the active-player path of `FUN_0060c740`, read
|
||||
// directly — no player object needed). Covers trained abilities, talents,
|
||||
// racials, profession recipes, and crafting specializations (Armorsmith,
|
||||
// Weaponsmith, the Engineering branches, …), which are all single learned
|
||||
// spells that land in this bitmap.
|
||||
bool PlayerKnowsSpell(int spellID) {
|
||||
if (spellID <= 0)
|
||||
return false;
|
||||
auto *bitmap = *reinterpret_cast<const uint32_t *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELL_BITMAP));
|
||||
if (bitmap == nullptr)
|
||||
return false;
|
||||
const int spellCount = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_SPELL_RECORD_COUNT));
|
||||
if (spellID > spellCount)
|
||||
return false;
|
||||
return (bitmap[spellID >> 5] & (1u << (spellID & 31))) != 0;
|
||||
}
|
||||
|
||||
// RequiredSpell gate — the item's `RequiredSpell` (a Spell.dbc row) is the
|
||||
// prerequisite the "Requires <spell>" tooltip line reflects: a crafting
|
||||
// specialization like Armorsmith, or an armor/weapon proficiency spell.
|
||||
// The player must know it. An unset (0) or invalid (no Spell.dbc record)
|
||||
// requirement is no gate — matching the tooltip's validity guard, which
|
||||
// skips the line for a missing row.
|
||||
//
|
||||
// Mirrors the tooltip's `bitmap(reqSpell) OR rank-chain(reqSpell)`: met if
|
||||
// the player knows the exact RequiredSpell, or a higher rank of it (the
|
||||
// latter covers a ranked RequiredSpell — `FUN_SPELL_RANK_CHAIN_KNOWN`
|
||||
// walks the forward chain). Item RequiredSpell is a non-ranked
|
||||
// specialization/proficiency in practice, so the chain walk is
|
||||
// belt-and-suspenders, but it makes the check exact.
|
||||
bool RequiredSpellMet(const uint8_t *player, const uint8_t *record) {
|
||||
const int reqSpell = *reinterpret_cast<const int32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_REQUIRED_SPELL);
|
||||
if (reqSpell <= 0)
|
||||
return true; // no requirement
|
||||
if (::Spell::Lookup::RecordForID(reqSpell) == nullptr)
|
||||
return true; // invalid Spell.dbc row → tooltip skips the gate
|
||||
if (PlayerKnowsSpell(reqSpell))
|
||||
return true; // knows the exact spell
|
||||
auto chainKnown = reinterpret_cast<SpellRankChainKnown_t>(
|
||||
Offsets::FUN_SPELL_RANK_CHAIN_KNOWN);
|
||||
return chainKnown(const_cast<uint8_t *>(player),
|
||||
static_cast<uint32_t>(reqSpell)) != 0;
|
||||
}
|
||||
|
||||
// RequiredHonorRank — the player's PvP honor rank must be >= the item's.
|
||||
// `sub` is the CGPlayer +0xE68 sub-struct (may be null pre-world).
|
||||
bool RequiredHonorRankMet(const uint8_t *sub, const uint8_t *record) {
|
||||
const uint32_t req = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_REQUIRED_HONOR_RANK);
|
||||
if (req == 0)
|
||||
return true;
|
||||
if (sub == nullptr)
|
||||
return false;
|
||||
const uint8_t rank = *(sub + Offsets::OFF_CGPLAYER_HONOR_RANK);
|
||||
return rank >= req;
|
||||
}
|
||||
|
||||
// RequiredCityRank — a PvP-medal bitmask gate: the item's rank R passes
|
||||
// when the player has earned bit (R-1) in the medal mask.
|
||||
bool RequiredCityRankMet(const uint8_t *sub, const uint8_t *record) {
|
||||
const uint32_t req = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_REQUIRED_CITY_RANK);
|
||||
if (req == 0)
|
||||
return true;
|
||||
if (sub == nullptr)
|
||||
return false;
|
||||
const uint32_t medals = *reinterpret_cast<const uint32_t *>(
|
||||
sub + Offsets::OFF_CGPLAYER_PVP_MEDALS);
|
||||
return (medals & (1u << ((req - 1) & 31))) != 0;
|
||||
}
|
||||
|
||||
// RequiredReputation — current standing with the item's faction must
|
||||
// reach the required reaction band. The band index (0=Hated … 7=Exalted)
|
||||
// indexes the engine's reaction-min threshold table; the player's total
|
||||
// rep with the faction must be >= that threshold.
|
||||
bool RequiredReputationMet(const uint8_t *record) {
|
||||
const int faction = *reinterpret_cast<const int32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_REQUIRED_FACTION);
|
||||
if (faction == 0)
|
||||
return true;
|
||||
const uint32_t band = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_REQUIRED_FACTION_RANK);
|
||||
if (band > 7) // reaction-min table is 8 bands (0..7); guard OOB
|
||||
return true;
|
||||
auto getStanding = reinterpret_cast<RepStanding_t>(Offsets::FUN_REPUTATION_GET_STANDING);
|
||||
const int current = getStanding(faction);
|
||||
const int threshold = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_REACTION_MIN_TABLE) + band * 4);
|
||||
return current >= threshold;
|
||||
}
|
||||
|
||||
bool ComputeCanUse(int itemID) {
|
||||
if (itemID <= 0)
|
||||
return false;
|
||||
auto *record = PeekItemRecord(static_cast<uint32_t>(itemID));
|
||||
if (record == nullptr)
|
||||
return false; // not cached → sync false, no load fired
|
||||
auto *player = Unit::Identity::PlayerObject();
|
||||
if (player == nullptr)
|
||||
return false; // pre-login / no player
|
||||
auto *desc = *reinterpret_cast<const uint8_t *const *>(
|
||||
player + Offsets::OFF_UNIT_DESCRIPTOR);
|
||||
if (desc == nullptr)
|
||||
return false;
|
||||
|
||||
if (!ProficiencyMet(record))
|
||||
return false;
|
||||
|
||||
const int playerLevel = *reinterpret_cast<const int *>(
|
||||
desc + Offsets::OFF_UNIT_FIELD_LEVEL);
|
||||
const uint32_t reqLevel = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_REQUIRED_LEVEL);
|
||||
if (static_cast<int>(reqLevel) > playerLevel)
|
||||
return false;
|
||||
|
||||
const uint32_t allowClass = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_ALLOWABLE_CLASS);
|
||||
const uint32_t playerClass = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
|
||||
if (RestrictedOut(allowClass, playerClass))
|
||||
return false;
|
||||
|
||||
const uint32_t allowRace = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_ALLOWABLE_RACE);
|
||||
const uint32_t playerRace = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_RACE_BYTE);
|
||||
if (RestrictedOut(allowRace, playerRace))
|
||||
return false;
|
||||
|
||||
if (!RequiredSkillMet(player, record))
|
||||
return false;
|
||||
|
||||
if (!RequiredSpellMet(player, record))
|
||||
return false;
|
||||
|
||||
// PvP rank + reputation gates read the CGPlayer +0xE68 sub-struct.
|
||||
auto *sub = *reinterpret_cast<const uint8_t *const *>(
|
||||
player + Offsets::OFF_CGPLAYER_INFO);
|
||||
if (!RequiredHonorRankMet(sub, record))
|
||||
return false;
|
||||
if (!RequiredCityRankMet(sub, record))
|
||||
return false;
|
||||
if (!RequiredReputationMet(record))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
int __fastcall Script_CanUseItem(void *L) {
|
||||
const int itemID = Item::Arg::ResolveItemID(L, 1);
|
||||
Game::Lua::PushBool(L, ComputeCanUse(itemID));
|
||||
return 1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_PlayerInfo", "CanUseItem", &Script_CanUseItem);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace Player::CanUseItem
|
||||
@@ -52,6 +52,7 @@
|
||||
#include "Offsets.h"
|
||||
#include "equipmentset/Locations.h"
|
||||
#include "event/Custom.h"
|
||||
#include "player/StatSignal.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -112,6 +113,9 @@ int __fastcall EquipSlotChanged_cb(uint32_t fieldOffset, uint32_t /*size*/,
|
||||
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kEvtEquipmentChanged),
|
||||
slot0 + 1,
|
||||
SlotHasItem(guidLo, guidHi, slot0));
|
||||
// Gear swap changes item +healing and Spirit/Armor — invalidate the
|
||||
// GetSpellBonusHealing cache.
|
||||
Player::StatSignal::Notify();
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// The `PlayerLocation`-taking `C_PlayerInfo` accessors:
|
||||
//
|
||||
// C_PlayerInfo.GetName(playerLocation) -> name
|
||||
// C_PlayerInfo.GetClass(playerLocation) -> className, classFilename, classID
|
||||
// C_PlayerInfo.GetRace(playerLocation) -> raceID
|
||||
// C_PlayerInfo.GetSex(playerLocation) -> sex (1 neutral / 2 male / 3 female)
|
||||
// C_PlayerInfo.IsConnected([playerLocation]) -> isConnected
|
||||
//
|
||||
// A `PlayerLocation` (see Util/PlayerLocation.lua) is a plain table carrying
|
||||
// either a unit token (`.unit`) or a GUID string (`.guid`). We resolve it to
|
||||
// a 64-bit GUID — unit tokens through the engine's token→GUID table
|
||||
// (`FUN_TOKEN_TO_GUID`, which resolves group members in other zones too, not
|
||||
// just synced CGUnits), GUID strings by parsing — and then read everything
|
||||
// straight from the engine's own data structures. No `Script_*` Lua wrappers
|
||||
// are called; each value comes from the same engine internals the codebase
|
||||
// already uses elsewhere:
|
||||
//
|
||||
// - GetName -> resolve the GUID to a CGObject and call the polymorphic
|
||||
// name getter `FUN_OBJECT_GET_NAME` (same path as
|
||||
// `UnitNameFromGUID`).
|
||||
// - GetRace / -> UNIT_FIELD_BYTES_0: the login-time mirror globals when the
|
||||
// GetClass / location is the local player (valid before the descriptor
|
||||
// GetSex spawns, matching `Script_UnitClass`'s own player path),
|
||||
// otherwise the resolved unit's descriptor bytes. GetClass
|
||||
// maps classID -> className/classFile via `ChrClasses.dbc`
|
||||
// like `C_CreatureInfo.GetClassInfo`. GetSex reports the
|
||||
// API's 2/3 (raw gender byte 0/1 + 2), matching
|
||||
// `GetPlayerInfoByGUID`.
|
||||
// - IsConnected -> a resolvable CGObject means connected; otherwise the
|
||||
// group-member roster's online bit (then slot presence),
|
||||
// exactly what `Script_UnitIsConnected` checks.
|
||||
//
|
||||
// Only the `unit` and `guid` PlayerLocation kinds resolve on 1.12 — the chat
|
||||
// line / who / battlefield-score / community / voice kinds have no
|
||||
// client-side unit resolution. Unsupported or unresolvable locations return
|
||||
// nil.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "guid/Guid.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Player::LocationInfo {
|
||||
|
||||
namespace {
|
||||
|
||||
using TokenToGuid_t = uint64_t(__fastcall *)(const char *token);
|
||||
using ResolveObject_t = void *(__fastcall *)(uint32_t typeMask, const char *dbg,
|
||||
uint32_t guidLo, uint32_t guidHi, int line);
|
||||
using GetName_t = const char *(__thiscall *)(void *obj, int *outFlags);
|
||||
using GroupLookup_t = void *(__fastcall *)(const uint64_t *guid);
|
||||
|
||||
// Reads a string field `t[field]` at table stack index `locIdx` into `out`.
|
||||
// Returns true only for a non-empty string. Stack-neutral.
|
||||
bool ReadStringField(void *L, int locIdx, const char *field, char *out, int size) {
|
||||
Game::Lua::PushString(L, field);
|
||||
Game::Lua::GetTable(L, locIdx); // pops key, pushes value
|
||||
bool ok = false;
|
||||
if (Game::Lua::IsString(L, -1)) {
|
||||
const char *s = Game::Lua::ToString(L, -1);
|
||||
if (s != nullptr && s[0] != '\0') {
|
||||
int i = 0;
|
||||
for (; s[i] != '\0' && i < size - 1; ++i)
|
||||
out[i] = s[i];
|
||||
out[i] = '\0';
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
Game::Lua::SetTop(L, -2); // pop value (or nil)
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Resolves the PlayerLocation table at `locIdx` to a 64-bit GUID. Unit tokens
|
||||
// go through the engine token→GUID table (handles out-of-zone group members);
|
||||
// GUID strings are parsed. Returns false for unsupported kinds or a token
|
||||
// that maps to nothing.
|
||||
bool LocationGuid(void *L, int locIdx, uint64_t *out) {
|
||||
char token[64];
|
||||
if (ReadStringField(L, locIdx, "unit", token, sizeof(token))) {
|
||||
auto toGuid = reinterpret_cast<TokenToGuid_t>(Offsets::FUN_TOKEN_TO_GUID);
|
||||
*out = toGuid(token);
|
||||
return *out != 0;
|
||||
}
|
||||
char guidStr[40];
|
||||
if (ReadStringField(L, locIdx, "guid", guidStr, sizeof(guidStr)))
|
||||
return Guid::Parse(guidStr, out) && *out != 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolves a GUID to its currently-synced CGUnit, or null if not resident.
|
||||
const uint8_t *ResolveUnit(uint64_t guid) {
|
||||
auto resolve = reinterpret_cast<ResolveObject_t>(Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
|
||||
return static_cast<const uint8_t *>(
|
||||
resolve(Offsets::OBJ_TYPE_UNIT, "ClassicAPI", static_cast<uint32_t>(guid),
|
||||
static_cast<uint32_t>(guid >> 32), 0));
|
||||
}
|
||||
|
||||
// Reads a UNIT_FIELD_BYTES_0 byte: the login-time mirror global when `guid` is
|
||||
// the local player (works before the descriptor spawns), otherwise the
|
||||
// resolved unit's descriptor byte. Returns -1 when the unit isn't resident.
|
||||
int UnitByte(uint64_t guid, uintptr_t playerMirror, uint32_t descByteOffset) {
|
||||
if (guid == Unit::Identity::PlayerGuid())
|
||||
return *reinterpret_cast<const uint8_t *>(playerMirror);
|
||||
const uint8_t *unit = ResolveUnit(guid);
|
||||
if (unit == nullptr)
|
||||
return -1;
|
||||
auto *desc = *reinterpret_cast<const uint8_t *const *>(
|
||||
unit + Offsets::OFF_CGUNIT_OBJECT_FIELDS);
|
||||
if (desc == nullptr)
|
||||
return -1;
|
||||
return *reinterpret_cast<const uint8_t *>(desc + descByteOffset);
|
||||
}
|
||||
|
||||
int __fastcall Script_GetName(void *L) {
|
||||
uint64_t guid = 0;
|
||||
if (!LocationGuid(L, 1, &guid))
|
||||
return 0;
|
||||
const uint8_t *unit = ResolveUnit(guid);
|
||||
if (unit == nullptr)
|
||||
return 0;
|
||||
auto getName = reinterpret_cast<GetName_t>(Offsets::FUN_OBJECT_GET_NAME);
|
||||
const char *name = getName(const_cast<uint8_t *>(unit), nullptr);
|
||||
if (name == nullptr || name[0] == '\0')
|
||||
return 0;
|
||||
Game::Lua::PushString(L, name);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetRace(void *L) {
|
||||
uint64_t guid = 0;
|
||||
if (!LocationGuid(L, 1, &guid))
|
||||
return 0;
|
||||
const int raceID = UnitByte(guid, Offsets::VAR_PLAYER_RACE_BYTE,
|
||||
Offsets::OFF_UNIT_DESCRIPTOR_RACE_BYTE);
|
||||
if (raceID <= 0)
|
||||
return 0;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(raceID));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetClass(void *L) {
|
||||
uint64_t guid = 0;
|
||||
if (!LocationGuid(L, 1, &guid))
|
||||
return 0;
|
||||
const int classID = UnitByte(guid, Offsets::VAR_PLAYER_CLASS_BYTE,
|
||||
Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
|
||||
if (classID <= 0)
|
||||
return 0;
|
||||
// className / classFile from ChrClasses.dbc — same reads as
|
||||
// C_CreatureInfo.GetClassInfo(classID).
|
||||
const char *classFile = DBC::StringField(Offsets::VAR_CHRCLASSES_RECORDS,
|
||||
Offsets::VAR_CHRCLASSES_COUNT,
|
||||
static_cast<uint32_t>(classID),
|
||||
Offsets::OFF_CHRCLASSES_FILENAME);
|
||||
if (classFile == nullptr)
|
||||
return 0; // classID with no ChrClasses row
|
||||
const char *className = DBC::LocalizedField(Offsets::VAR_CHRCLASSES_RECORDS,
|
||||
Offsets::VAR_CHRCLASSES_COUNT,
|
||||
static_cast<uint32_t>(classID),
|
||||
Offsets::OFF_CHRCLASSES_NAMES);
|
||||
|
||||
Game::Lua::PushString(L, className); // NULL → nil via lua_pushstring
|
||||
Game::Lua::PushString(L, classFile);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(classID));
|
||||
return 3;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetSex(void *L) {
|
||||
uint64_t guid = 0;
|
||||
if (!LocationGuid(L, 1, &guid))
|
||||
return 0;
|
||||
const int gender = UnitByte(guid, Offsets::VAR_PLAYER_SEX_BYTE,
|
||||
Offsets::OFF_UNIT_DESCRIPTOR_SEX_BYTE);
|
||||
if (gender < 0)
|
||||
return 0;
|
||||
// UNIT_FIELD_BYTES_0 stores 0 = male / 1 = female; the API (and UnitSex)
|
||||
// reports 2 = male / 3 = female. Same +2 remap as GetPlayerInfoByGUID.
|
||||
Game::Lua::PushNumber(L, static_cast<double>(gender + 2));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_IsConnected(void *L) {
|
||||
// `playerLocation` is optional — with no table arg it defaults to the
|
||||
// local player (always connected while playing).
|
||||
uint64_t guid = 0;
|
||||
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_TABLE) {
|
||||
if (!LocationGuid(L, 1, &guid))
|
||||
return 0;
|
||||
} else {
|
||||
guid = Unit::Identity::PlayerGuid();
|
||||
}
|
||||
if (guid == 0)
|
||||
return 0;
|
||||
|
||||
bool connected = ResolveUnit(guid) != nullptr; // a synced object is online
|
||||
if (!connected) {
|
||||
// Not resident — consult the group-member roster, mirroring
|
||||
// Script_UnitIsConnected: the stats block's online bit, else slot
|
||||
// presence (a member with no stats block yet still counts as present).
|
||||
auto stats = reinterpret_cast<GroupLookup_t>(
|
||||
Offsets::FUN_GROUP_MEMBER_STATS_LOOKUP)(&guid);
|
||||
if (stats != nullptr) {
|
||||
connected = (*reinterpret_cast<const uint8_t *>(
|
||||
static_cast<const uint8_t *>(stats) +
|
||||
Offsets::OFF_GROUP_MEMBER_STATUS_FLAGS) &
|
||||
Offsets::GROUP_MEMBER_STATUS_ONLINE) != 0;
|
||||
} else {
|
||||
connected = reinterpret_cast<GroupLookup_t>(
|
||||
Offsets::FUN_GROUP_MEMBER_SLOT_LOOKUP)(&guid) != nullptr;
|
||||
}
|
||||
}
|
||||
Game::Lua::PushBool(L, connected);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_PlayerInfo", "GetName", &Script_GetName);
|
||||
Game::Lua::RegisterTableFunction("C_PlayerInfo", "GetClass", &Script_GetClass);
|
||||
Game::Lua::RegisterTableFunction("C_PlayerInfo", "GetRace", &Script_GetRace);
|
||||
Game::Lua::RegisterTableFunction("C_PlayerInfo", "GetSex", &Script_GetSex);
|
||||
Game::Lua::RegisterTableFunction("C_PlayerInfo", "IsConnected", &Script_IsConnected);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Player::LocationInfo
|
||||
+117
-22
@@ -23,13 +23,19 @@
|
||||
// parse callbacks, and the registry handles load/save so neither
|
||||
// this module nor `Charlist::Order` clobbers the other's lines.
|
||||
//
|
||||
// `WTF\Account\<acct>\<realm>\ClassicAPI_NameCache.txt`
|
||||
// Per-realm cache contents. v2 format. One entry per line:
|
||||
// `guidHi guidLo classID raceID sex name`
|
||||
// `WTF\ClassicAPI\<realmList>\<realm>\ClassicAPI_NameCache.txt`
|
||||
// Per-realm cache contents, SHARED across every account on the
|
||||
// same server. Keyed by realmlist address (unambiguous server
|
||||
// identity — can't collide across unrelated private servers) plus
|
||||
// the realm name, rather than by account, so alts on different
|
||||
// accounts pool into one file. Not under `Account\`, so we create
|
||||
// the directory tree ourselves. v4 format. One entry per line:
|
||||
// `name guid classID raceID sex`
|
||||
// (fields tab-separated). Loaded once the realm name is
|
||||
// available post-login. Saved when (a) a recent write hasn't
|
||||
// been flushed AND (b) at least 30 seconds have elapsed since
|
||||
// the prior save, OR (c) DLL teardown / `Flush()` is called.
|
||||
// available post-login (migrating a pre-existing per-account file
|
||||
// in on first load). Saved when (a) a recent write hasn't been
|
||||
// flushed AND (b) at least 30 seconds have elapsed since the prior
|
||||
// save, OR (c) DLL teardown / `Flush()` is called.
|
||||
//
|
||||
// Population sources:
|
||||
// 1. The engine's NameCache writer (`FUN_PLAYER_INFO_CACHE_WRITE`)
|
||||
@@ -69,6 +75,7 @@
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <istream>
|
||||
#include <ostream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
@@ -139,7 +146,57 @@ const char *ReadRealmName() {
|
||||
|
||||
// ----- path resolution -----
|
||||
|
||||
// realmList CVar value (the realmlist address, e.g.
|
||||
// "logon.turtle-wow.org") — the unambiguous server identity. Unlike the
|
||||
// friendly realm name it can't collide across unrelated private servers,
|
||||
// so it's the outer key for the shared cache path. Mirrors
|
||||
// `Security::CredStore`'s realm scoping.
|
||||
const char *ReadRealmList() {
|
||||
using GetRealmList_t = const char *(__fastcall *)(int forceRefresh);
|
||||
return reinterpret_cast<GetRealmList_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_CVAR_REALM_LIST))(0);
|
||||
}
|
||||
|
||||
// Filesystem-safe path component. We create these directories ourselves
|
||||
// (WoW doesn't), so any character Windows rejects in a path — or that
|
||||
// could surprise, like the space/apostrophe in "N'Zoth" — becomes '_'.
|
||||
// Deterministic, so a realm always maps to the same folder.
|
||||
std::string SanitizeComponent(const char *s) {
|
||||
std::string out;
|
||||
for (const char *p = s; p != nullptr && *p; ++p) {
|
||||
const char c = *p;
|
||||
const bool safe = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') || c == '.' || c == '-' ||
|
||||
c == '_';
|
||||
out.push_back(safe ? c : '_');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Shared, server-specific cache path:
|
||||
// WTF\ClassicAPI\<realmList>\<realmName>\ClassicAPI_NameCache.txt
|
||||
// Not under Account\, so alts on different accounts of the same server
|
||||
// share one pool. Empty (defer) until both realmlist + realm are known.
|
||||
std::string CachePath() {
|
||||
const char *realmList = ReadRealmList();
|
||||
if (realmList == nullptr || realmList[0] == '\0')
|
||||
return {};
|
||||
const char *realm = ReadRealmName();
|
||||
if (realm == nullptr || realm[0] == '\0')
|
||||
return {};
|
||||
std::string out = "WTF\\ClassicAPI\\";
|
||||
out += SanitizeComponent(realmList);
|
||||
out += '\\';
|
||||
out += SanitizeComponent(realm);
|
||||
out += "\\ClassicAPI_NameCache.txt";
|
||||
return out;
|
||||
}
|
||||
|
||||
// The pre-shared per-account path, used only to migrate an existing
|
||||
// cache into the shared location on first load. Built exactly as it was
|
||||
// written (raw account + realm, unsanitized) so it matches the file
|
||||
// sitting in WoW's own folders.
|
||||
std::string OldCachePath() {
|
||||
const char *account = ReadAccountName();
|
||||
if (account == nullptr || account[0] == '\0')
|
||||
return {};
|
||||
@@ -154,6 +211,21 @@ std::string CachePath() {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Creates every parent directory of `filePath` (mkdir -p). WoW makes
|
||||
// `WTF\` but not `WTF\ClassicAPI\...`, and ofstream won't create
|
||||
// directories, so a save into a fresh realm folder would silently fail
|
||||
// without this. CreateDirectory on an existing dir no-ops.
|
||||
void EnsureParentDirs(const std::string &filePath) {
|
||||
for (size_t pos = 0;;) {
|
||||
const size_t slash = filePath.find('\\', pos);
|
||||
if (slash == std::string::npos)
|
||||
break; // remainder is the filename
|
||||
if (slash > 0)
|
||||
CreateDirectoryA(filePath.substr(0, slash).c_str(), nullptr);
|
||||
pos = slash + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- account settings (registered with Settings::Account) -----
|
||||
|
||||
void SettingsReset() {
|
||||
@@ -259,21 +331,12 @@ bool Upsert(uint64_t guid, const std::string &name,
|
||||
return changed;
|
||||
}
|
||||
|
||||
void LoadCacheIfNeeded() {
|
||||
if (g_cacheLoaded)
|
||||
return;
|
||||
const std::string path = CachePath();
|
||||
if (path.empty())
|
||||
return; // pre-realm; retry on next call (g_cacheLoaded stays false)
|
||||
g_cacheLoaded = true;
|
||||
|
||||
std::ifstream file(path);
|
||||
if (!file.is_open())
|
||||
return; // first run for this realm
|
||||
|
||||
// v4 format: `name\tguid\tclass\trace\tsex`. Name first since
|
||||
// it's the primary key; GUID is a peripheral field carried for
|
||||
// GUID-keyed lookups. Variable-width hex GUID (no leading zeros).
|
||||
// Parses v4 cache lines (`name\tguid\tclass\trace\tsex`) from `file`
|
||||
// into the store. Name first since it's the primary key; GUID is a
|
||||
// peripheral field carried for GUID-keyed lookups. Variable-width hex
|
||||
// GUID (no leading zeros). Shared by the normal load and the one-time
|
||||
// migration from the old per-account file.
|
||||
void LoadFromStream(std::istream &file) {
|
||||
std::string line;
|
||||
while (std::getline(file, line)) {
|
||||
while (!line.empty() && (line.back() == '\r' || line.back() == '\n'))
|
||||
@@ -301,10 +364,42 @@ void LoadCacheIfNeeded() {
|
||||
}
|
||||
}
|
||||
|
||||
void LoadCacheIfNeeded() {
|
||||
if (g_cacheLoaded)
|
||||
return;
|
||||
const std::string path = CachePath();
|
||||
if (path.empty())
|
||||
return; // pre-realm; retry on next call (g_cacheLoaded stays false)
|
||||
g_cacheLoaded = true;
|
||||
|
||||
std::ifstream file(path);
|
||||
if (file.is_open()) {
|
||||
LoadFromStream(file);
|
||||
return;
|
||||
}
|
||||
|
||||
// No shared file yet. One-time migration: seed from the old
|
||||
// per-account file if it exists, then mark dirty so the next save
|
||||
// writes the merged data into the shared path. Only when the shared
|
||||
// file is absent — once it exists it's authoritative (a second
|
||||
// account's old-only entries just repopulate via scan / SMSG).
|
||||
const std::string oldPath = OldCachePath();
|
||||
if (oldPath.empty())
|
||||
return;
|
||||
std::ifstream oldFile(oldPath);
|
||||
if (oldFile.is_open()) {
|
||||
LoadFromStream(oldFile);
|
||||
g_dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
void SaveCache() {
|
||||
const std::string path = CachePath();
|
||||
if (path.empty())
|
||||
return;
|
||||
// `WTF\ClassicAPI\<realmList>\<realm>\` isn't created by WoW — make
|
||||
// the tree first, else the ofstream open below silently fails.
|
||||
EnsureParentDirs(path);
|
||||
const std::string tmp = path + ".tmp";
|
||||
{
|
||||
std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
|
||||
@@ -319,7 +414,7 @@ void SaveCache() {
|
||||
// sort/scan key when hand-editing. GUID is variable-width
|
||||
// hex — 1-8 digits for player GUIDs, up to 16 if a non-player
|
||||
// GUID ever lands here.
|
||||
out << "# ClassicAPI name cache v4 (per-realm)\n";
|
||||
out << "# ClassicAPI name cache v4 (per-realm, shared across accounts)\n";
|
||||
out << "# name\tguid\tclassID\traceID\tsex\n";
|
||||
for (const auto &kv : g_entries) {
|
||||
const std::string &name = kv.first;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#include "player/StatSignal.h"
|
||||
|
||||
namespace Player::StatSignal {
|
||||
|
||||
namespace {
|
||||
// Starts at 1 so a consumer whose cached epoch is default-0 always computes on
|
||||
// its first query.
|
||||
uint32_t g_epoch = 1;
|
||||
} // namespace
|
||||
|
||||
void Notify() { ++g_epoch; }
|
||||
|
||||
uint32_t Epoch() { return g_epoch; }
|
||||
|
||||
} // namespace Player::StatSignal
|
||||
@@ -0,0 +1,38 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// A pull-based "the local player's stat inputs changed" signal. `Notify()` is
|
||||
// called from the aura and equipment hooks whenever the player gains/loses an
|
||||
// aura or swaps gear — the state that feeds derived stats like
|
||||
// `GetSpellBonusHealing` (flat +healing, plus the Spirit/Armor talent
|
||||
// conversions that scale with those descriptor fields).
|
||||
//
|
||||
// Unlike Tick::WorldTick (push / per-frame callback), this is a monotonic
|
||||
// epoch that consumers POLL: a lazy cache recomputes only when `Epoch()`
|
||||
// differs from the value it last computed at, so a per-frame query is O(1)
|
||||
// between real changes and never does work on idle frames.
|
||||
namespace Player::StatSignal {
|
||||
|
||||
// Bump the epoch — called from the aura / equipment hooks on a
|
||||
// player-affecting change.
|
||||
void Notify();
|
||||
|
||||
// Current epoch; increments on each `Notify()`. A consumer caches the value it
|
||||
// last recomputed at and recomputes only when this differs.
|
||||
uint32_t Epoch();
|
||||
|
||||
} // namespace Player::StatSignal
|
||||
@@ -76,6 +76,87 @@ static int __fastcall Script_GetQuestIDForLogIndex(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_QuestLog.GetLogIndexForQuestID(questID)` — the inverse of
|
||||
// `GetQuestIDForLogIndex`: the 1-based quest-log index of `questID`, or nil
|
||||
// if the quest isn't in the log. The index spans the full entry array
|
||||
// (headers included), matching `GetQuestIDForLogIndex` /
|
||||
// `GetQuestLogTitle`. Walks `VAR_QUEST_LOG_ENTRIES`, skipping header rows via
|
||||
// the `+8` header-pointer gate, and returns the first matching entry's index.
|
||||
static int __fastcall Script_GetLogIndexForQuestID(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: GetLogIndexForQuestID(questID)");
|
||||
return 0;
|
||||
}
|
||||
const int target = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (target <= 0)
|
||||
return 0; // nil — no such quest
|
||||
|
||||
const int total = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_QUEST_LOG_ENTRY_COUNT));
|
||||
auto *base = reinterpret_cast<const uint8_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_QUEST_LOG_ENTRIES));
|
||||
for (int i = 0; i < total; ++i) {
|
||||
auto *entry = base + i * Offsets::OFF_QUEST_LOG_ENTRY_STRIDE;
|
||||
if (*reinterpret_cast<const void *const *>(
|
||||
entry + Offsets::OFF_QUEST_LOG_ENTRY_HEADER_PTR) != nullptr)
|
||||
continue; // header row
|
||||
const int questID = *reinterpret_cast<const int *>(
|
||||
entry + Offsets::OFF_QUEST_LOG_ENTRY_QUEST_ID);
|
||||
if (questID == target) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(i + 1)); // 1-based
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0; // nil — quest not in the log
|
||||
}
|
||||
|
||||
// `C_QuestLog.GetHeaderIndexForQuest(questID)` — the 1-based log index of the
|
||||
// collapsible category header (zone / "Dungeon" / class sort, …) the quest
|
||||
// sits under, or nil if the quest isn't in the log or has no header above it.
|
||||
// The log is laid out header-then-its-quests, so we locate the quest's entry
|
||||
// and walk backwards to the nearest preceding header row (`+8` non-NULL).
|
||||
static int __fastcall Script_GetHeaderIndexForQuest(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: GetHeaderIndexForQuest(questID)");
|
||||
return 0;
|
||||
}
|
||||
const int target = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (target <= 0)
|
||||
return 0; // nil — no such quest
|
||||
|
||||
const int total = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_QUEST_LOG_ENTRY_COUNT));
|
||||
auto *base = reinterpret_cast<const uint8_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_QUEST_LOG_ENTRIES));
|
||||
|
||||
// Locate the quest's entry.
|
||||
int questIndex = -1;
|
||||
for (int i = 0; i < total; ++i) {
|
||||
auto *entry = base + i * Offsets::OFF_QUEST_LOG_ENTRY_STRIDE;
|
||||
if (*reinterpret_cast<const void *const *>(
|
||||
entry + Offsets::OFF_QUEST_LOG_ENTRY_HEADER_PTR) != nullptr)
|
||||
continue; // header row
|
||||
if (*reinterpret_cast<const int *>(
|
||||
entry + Offsets::OFF_QUEST_LOG_ENTRY_QUEST_ID) == target) {
|
||||
questIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (questIndex < 0)
|
||||
return 0; // quest not in the log
|
||||
|
||||
// Walk back to the nearest preceding header.
|
||||
for (int i = questIndex - 1; i >= 0; --i) {
|
||||
auto *entry = base + i * Offsets::OFF_QUEST_LOG_ENTRY_STRIDE;
|
||||
if (*reinterpret_cast<const void *const *>(
|
||||
entry + Offsets::OFF_QUEST_LOG_ENTRY_HEADER_PTR) != nullptr) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(i + 1)); // 1-based
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0; // nil — quest sits above any header (shouldn't normally happen)
|
||||
}
|
||||
|
||||
// `C_QuestLog.IsOnQuest(questID)` — true iff `questID` is currently
|
||||
// in the player's quest log (incomplete OR ready-to-turn-in; the log
|
||||
// holds both). Walks `VAR_QUEST_LOG_ENTRIES` and matches against each
|
||||
@@ -148,6 +229,10 @@ static int __fastcall Script_IsUnitOnQuest(void *L) {
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_QuestLog", "GetQuestIDForLogIndex",
|
||||
&Script_GetQuestIDForLogIndex);
|
||||
Game::Lua::RegisterTableFunction("C_QuestLog", "GetLogIndexForQuestID",
|
||||
&Script_GetLogIndexForQuestID);
|
||||
Game::Lua::RegisterTableFunction("C_QuestLog", "GetHeaderIndexForQuest",
|
||||
&Script_GetHeaderIndexForQuest);
|
||||
Game::Lua::RegisterTableFunction("C_QuestLog", "IsOnQuest",
|
||||
&Script_IsOnQuest);
|
||||
Game::Lua::RegisterTableFunction("C_QuestLog", "IsUnitOnQuest",
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `GetSpellBonusDamage(school)` / `GetSpellBonusHealing()` — the local
|
||||
// player's flat spell-damage (per school) and healing bonus.
|
||||
//
|
||||
// **Spell damage is a direct field read.** The value lives in the CGPlayer
|
||||
// sub-struct at `[player + OFF_CGPLAYER_INFO]` (the +0xE68 blob), NOT the
|
||||
// broadcast UpdateField descriptor at `+0x110` — the descriptor copy of
|
||||
// this field is never sent to the client and reads zero (which sent an
|
||||
// earlier version of this module down a "derive it from gear" path). The
|
||||
// +0xE68 copy is the client's fully-computed value: `MOD_DAMAGE_DONE_POS[7]`
|
||||
// at `+0xFD4`, `MOD_DAMAGE_DONE_NEG[7]` at `+0xFF0`, one int32 per school;
|
||||
// net = POS − NEG. Because it's the finished value, gear + enchants + buffs
|
||||
// + talents + set bonuses are all already baked in.
|
||||
//
|
||||
// This mirrors nampower's `Script_GetSpellPower` (same base + offsets),
|
||||
// cross-verified in-game: a geared mage reads 613 generic across the magic
|
||||
// schools with Fire at 647 (a +34 fire-specific piece on top of the 613).
|
||||
//
|
||||
// **Healing has no such field** — vanilla 1.12 never had `MOD_HEALING_DONE`
|
||||
// (TBC addition; `PLAYER_FIELD_BYTES` follows `_PCT` directly), and nothing
|
||||
// client-side computes a healing total (verified in-game: toggling a pure
|
||||
// +44-healing item moved NO field in the +0xE68 struct). So
|
||||
// `GetSpellBonusHealing` is DERIVED in two parts, both read from `Spell.dbc`:
|
||||
//
|
||||
// 1. Flat healing — Σ `SPELL_AURA_MOD_HEALING_DONE` (aura 135) off gear
|
||||
// equip-effects, item enchants, random suffixes, and active buffs.
|
||||
// 2. Talent/buff stat→healing — the server's own `SpellBaseHealingBonusDone`
|
||||
// sums `SPELL_AURA_MOD_SPELL_HEALING_OF_STAT_PERCENT` (aura 175) ×
|
||||
// total Spirit / 100 over the unit's aura-175 auras. We mirror it: read
|
||||
// total Spirit from `UNIT_FIELD_STAT4` (descriptor +0x250), then sum
|
||||
// aura-175 percents over PASSIVE known spells (talents — Spiritual
|
||||
// Guidance, `Spell.dbc` ranks 14901/15028..15031 = 5/10/15/20/25%) plus
|
||||
// active buffs. Generic (any aura-175 talent/buff, incl. Turtle customs)
|
||||
// and robust — it reads the percent directly, no client-vs-server value
|
||||
// delta.
|
||||
//
|
||||
// An earlier attempt derived (2) as (spell_damage_field − derived_gear_damage)
|
||||
// — the part of the damage field gear doesn't explain. That was FRAGILE: the
|
||||
// field reflects values AFTER SpellMods, but our gear sum used raw DBC values.
|
||||
// Inner Fire (10951) is +60 in the DBC but +78 live (Improved Inner Fire, a
|
||||
// +15%/rank SpellMod), so an 18-point gap leaked into healing when that pure
|
||||
// +damage buff was gained. Reading aura 175 directly (above) sidesteps it.
|
||||
//
|
||||
// Residual: assumes talent ranks supersede (only the learned rank is known —
|
||||
// otherwise summed) and misses set-bonus healing granted via a set spell.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "item/ID.h"
|
||||
#include "item/Location.h"
|
||||
#include "item/StatAccum.h"
|
||||
#include "player/StatSignal.h"
|
||||
#include "tick/WorldTick.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Spell::BonusDamage {
|
||||
|
||||
namespace {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spell damage — direct read from the CGPlayer +0xE68 sub-struct.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const uint8_t *PlayerFields() {
|
||||
const uint8_t *player = Unit::Identity::PlayerObject();
|
||||
if (player == nullptr)
|
||||
return nullptr;
|
||||
return *reinterpret_cast<const uint8_t *const *>(
|
||||
player + Offsets::OFF_CGPLAYER_INFO);
|
||||
}
|
||||
|
||||
// Net spell damage for a 0-based school (0=physical .. 6=arcane). POS is the
|
||||
// full per-school value (generic "+spell damage" is already folded into every
|
||||
// magic school by the server), NEG the signed-magnitude negative accumulator.
|
||||
long SpellDamageForSchool(int school0) {
|
||||
const uint8_t *pf = PlayerFields();
|
||||
if (pf == nullptr)
|
||||
return 0;
|
||||
const int32_t pos = *reinterpret_cast<const int32_t *>(
|
||||
pf + Offsets::OFF_PLAYER_FIELD_MOD_DAMAGE_DONE_POS + school0 * 4);
|
||||
const int32_t neg = *reinterpret_cast<const int32_t *>(
|
||||
pf + Offsets::OFF_PLAYER_FIELD_MOD_DAMAGE_DONE_NEG + school0 * 4);
|
||||
return static_cast<long>(pos) - static_cast<long>(neg);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Healing — derived (no client field exists in vanilla).
|
||||
//
|
||||
// Item flat +healing goes through the shared Item::StatAccum engine (base
|
||||
// record + on-equip spells + random suffix + applied enchants) — exactly the
|
||||
// ITEM_MOD_SPELL_HEALING_DONE_SHORT (aura 135) value GetItemStats reports, so
|
||||
// the item spell/suffix/enchant walk isn't re-implemented here. Buff / talent
|
||||
// healing (flat + Spirit/Armor conversions) is decoded from Spell.dbc below.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Flat +healing contributed by one equipped item, via Item::StatAccum.
|
||||
long ItemFlatHealing(const uint8_t *cgItem) {
|
||||
if (cgItem == nullptr)
|
||||
return 0;
|
||||
Item::StatAccum::Accum acc[Item::StatAccum::kCount];
|
||||
Item::StatAccum::Init(acc);
|
||||
|
||||
const int itemID = Item::ID::FromCGItem(cgItem);
|
||||
if (itemID > 0)
|
||||
Item::StatAccum::AccumulateRecord(
|
||||
acc, Item::StatAccum::FetchRecord(static_cast<uint32_t>(itemID)), +1);
|
||||
|
||||
auto *desc = *reinterpret_cast<const uint8_t *const *>(
|
||||
cgItem + Offsets::OFF_ITEM_DESCRIPTOR);
|
||||
if (desc != nullptr) {
|
||||
// Random suffix ("… of Restoration") + applied permanent (slot 0) and
|
||||
// temporary (slot 1) enchants (Healing Power, Mana Oil, …).
|
||||
Item::StatAccum::ApplyRandomSuffix(
|
||||
acc, *reinterpret_cast<const int32_t *>(
|
||||
desc + Offsets::OFF_DESCRIPTOR_RANDOM_PROPERTY), +1);
|
||||
for (int slot = 0; slot <= 1; ++slot)
|
||||
Item::StatAccum::ApplyEnchant(
|
||||
acc, *reinterpret_cast<const uint32_t *>(
|
||||
desc + Offsets::OFF_DESCRIPTOR_ENCHANTMENT_ID +
|
||||
slot * Offsets::DESCRIPTOR_ENCHANTMENT_SLOT_STRIDE), +1);
|
||||
}
|
||||
return Item::StatAccum::Value(acc, "ITEM_MOD_SPELL_HEALING_DONE_SHORT");
|
||||
}
|
||||
|
||||
// --- Buff / talent healing auras (flat + Spirit/Armor conversions) ---
|
||||
// One decoder for every healing aura a buff or passive talent can carry,
|
||||
// mirroring the server's SpellBaseHealingBonusDone:
|
||||
// 135 MOD_HEALING_DONE → flat += amount
|
||||
// 175 MOD_SPELL_HEALING_OF_STAT_PERCENT → += Spirit × amount / 100 (Spiritual Guidance)
|
||||
// 199 MOD_SPELL_HEALING_OF_ARMOR_PERCENT → += Armor × amount / 100 (Turtle Ironclad)
|
||||
// Amounts/percents are read straight from Spell.dbc — generic (any talent/buff
|
||||
// using these auras, incl. Turtle customs) and robust (no client-vs-server
|
||||
// value delta). `requirePassive` gates the bitmap walk to always-on talents,
|
||||
// so a *known but not-currently-cast* buff (Divine Spirit) isn't counted as
|
||||
// on; active buffs pass `requirePassive == false`.
|
||||
constexpr int kAuraModHealingDone = 135;
|
||||
constexpr int kAuraModHealingOfStatPercent = 175; // × Spirit
|
||||
constexpr int kAuraModHealingOfArmorPercent = 199; // × Armor (Turtle Ironclad)
|
||||
|
||||
void AddSpellHealingAuras(long &total, int spellID, int32_t spirit,
|
||||
int32_t armor, bool requirePassive) {
|
||||
if (spellID <= 0)
|
||||
return;
|
||||
const uint8_t *sp = DBC::Record(Offsets::VAR_SPELL_RECORDS,
|
||||
Offsets::VAR_SPELL_RECORD_COUNT,
|
||||
static_cast<uint32_t>(spellID));
|
||||
if (sp == nullptr)
|
||||
return;
|
||||
if (requirePassive &&
|
||||
!(*reinterpret_cast<const uint32_t *>(sp + Offsets::OFF_SPELL_RECORD_ATTRIBUTES) &
|
||||
Offsets::SPELL_ATTR_PASSIVE))
|
||||
return;
|
||||
auto aura = reinterpret_cast<const int32_t *>(
|
||||
sp + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
|
||||
auto base = reinterpret_cast<const int32_t *>(
|
||||
sp + Offsets::OFF_SPELL_RECORD_EFFECT_BASE_POINTS);
|
||||
auto dice = reinterpret_cast<const int32_t *>(
|
||||
sp + Offsets::OFF_SPELL_RECORD_EFFECT_BASE_DICE);
|
||||
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
|
||||
const long amount = static_cast<long>(base[i]) + static_cast<long>(dice[i]);
|
||||
if (aura[i] == kAuraModHealingDone)
|
||||
total += amount;
|
||||
else if (aura[i] == kAuraModHealingOfStatPercent)
|
||||
total += static_cast<long>(spirit) * amount / 100;
|
||||
else if (aura[i] == kAuraModHealingOfArmorPercent)
|
||||
total += static_cast<long>(armor) * amount / 100;
|
||||
}
|
||||
}
|
||||
|
||||
long ComputeHealing() {
|
||||
long total = 0;
|
||||
|
||||
// Items — flat +healing via the shared StatAccum engine.
|
||||
for (int slot = 1; slot <= 19; ++slot)
|
||||
total += ItemFlatHealing(Item::Location::ResolveEquipmentSlot(slot));
|
||||
|
||||
// Player Spirit / Armor for the aura-175 / aura-199 conversions.
|
||||
const uint8_t *desc = Unit::Identity::PlayerDescriptor();
|
||||
const int32_t spirit = desc ? *reinterpret_cast<const int32_t *>(
|
||||
desc + Offsets::OFF_UNIT_FIELD_STAT_SPIRIT) : 0;
|
||||
const int32_t armor = desc ? *reinterpret_cast<const int32_t *>(
|
||||
desc + Offsets::OFF_UNIT_FIELD_RESISTANCE_ARMOR) : 0;
|
||||
|
||||
// Active buffs — flat healing + Spirit/Armor conversions, one pass.
|
||||
auto *table = reinterpret_cast<const uint8_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_PLAYER_BUFF_TABLE));
|
||||
for (int i = 0; i < Offsets::PLAYER_BUFF_TABLE_COUNT; ++i) {
|
||||
const uint8_t *entry = table + i * Offsets::PLAYER_BUFF_ENTRY_STRIDE;
|
||||
if (*reinterpret_cast<const int32_t *>(
|
||||
entry + Offsets::OFF_PLAYER_BUFF_SLOT_CODE) < 0)
|
||||
continue;
|
||||
AddSpellHealingAuras(total, *reinterpret_cast<const int32_t *>(
|
||||
entry + Offsets::OFF_PLAYER_BUFF_SPELL_ID),
|
||||
spirit, armor, /*requirePassive=*/false);
|
||||
}
|
||||
|
||||
// Passive talents — Spirit/Armor conversions (Spiritual Guidance, Ironclad)
|
||||
// and any passive flat healing, via the known-spell bitmap (bounded by
|
||||
// spellCount). The passive gate assumes ranks supersede (only the learned
|
||||
// rank is known), so no per-rank dedup is needed.
|
||||
auto *bitmap = *reinterpret_cast<const uint32_t *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELL_BITMAP));
|
||||
const int spellCount = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_SPELL_RECORD_COUNT));
|
||||
if (bitmap != nullptr && spellCount > 0) {
|
||||
for (int id = 1; id <= spellCount; ++id)
|
||||
if (bitmap[id >> 5] & (1u << (id & 31)))
|
||||
AddSpellHealingAuras(total, id, spirit, armor, /*requirePassive=*/true);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
// Event-driven cache for the derived healing sum. The walk (StatAccum over 19
|
||||
// items + the buff table + the known-spell bitmap) is expensive, so we
|
||||
// recompute it ONLY when the player's stat inputs change — Player::StatSignal
|
||||
// is bumped from the aura and equipment hooks, so a per-frame
|
||||
// GetSpellBonusHealing poll is O(1) between real gear/buff changes.
|
||||
//
|
||||
// Every input to the sum is now event-driven: the aura, equipment, and
|
||||
// spell-learn/unlearn hooks all bump Player::StatSignal (buffs, gear, and
|
||||
// talent respecs respectively). This coarse world-tick recompute (at most once
|
||||
// per kFallbackTicks frames, ~4s) is a pure backstop against a missed signal —
|
||||
// e.g. a player aura the object-pointer guard in aura/Source didn't match —
|
||||
// bounding staleness rather than being the primary mechanism.
|
||||
constexpr int kFallbackTicks = 256;
|
||||
uint32_t g_cachedEpoch = 0; // 0 = never computed (epoch starts at 1)
|
||||
int g_ticksSinceCompute = 0;
|
||||
long g_cachedHealing = 0;
|
||||
|
||||
void OnTick() { ++g_ticksSinceCompute; }
|
||||
const Tick::WorldTick::AutoSubscribe _tick{&OnTick};
|
||||
|
||||
long CurrentHealing() {
|
||||
const uint32_t epoch = Player::StatSignal::Epoch();
|
||||
if (epoch != g_cachedEpoch || g_ticksSinceCompute >= kFallbackTicks) {
|
||||
g_cachedHealing = ComputeHealing();
|
||||
g_cachedEpoch = epoch;
|
||||
g_ticksSinceCompute = 0;
|
||||
}
|
||||
return g_cachedHealing;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lua bindings.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// `GetSpellBonusDamage(school)` → number. `school` is 1-based (1=Physical
|
||||
// through 7=Arcane). Raises the usage error for a missing / out-of-range
|
||||
// school, matching 3.3.5's Script_GetSpellBonusDamage (0x0060E310).
|
||||
int __fastcall Script_GetSpellBonusDamage(void *L) {
|
||||
const int school =
|
||||
Game::Lua::IsNumber(L, 1) ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
const unsigned idx = static_cast<unsigned>(school - 1);
|
||||
if (idx >= static_cast<unsigned>(Offsets::SPELL_SCHOOL_COUNT)) {
|
||||
Game::Lua::Error(L, "Usage: GetSpellBonusDamage(school)");
|
||||
return 0;
|
||||
}
|
||||
Game::Lua::PushNumber(L, static_cast<double>(SpellDamageForSchool(static_cast<int>(idx))));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `GetSpellBonusHealing()` → number. The player's flat healing bonus (derived).
|
||||
int __fastcall Script_GetSpellBonusHealing(void *L) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(CurrentHealing()));
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("GetSpellBonusDamage", &Script_GetSpellBonusDamage);
|
||||
Game::Lua::RegisterGlobalFunction("GetSpellBonusHealing", &Script_GetSpellBonusHealing);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Spell::BonusDamage
|
||||
@@ -29,6 +29,7 @@
|
||||
#include "Offsets.h"
|
||||
#include "aura/Data.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
@@ -37,15 +38,8 @@ namespace Spell::Cancel {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
using CancelAuraSend_t = void(__fastcall *)(int spellID);
|
||||
|
||||
const uint8_t *ResolvePlayer() {
|
||||
auto fn = reinterpret_cast<ResolveUnitToken_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN));
|
||||
return static_cast<const uint8_t *>(fn("player"));
|
||||
}
|
||||
|
||||
// Pre-validates `spellID` against Spell.dbc and ships the cancel
|
||||
// packet. The engine's sender unconditionally derefs `[record+0x1C]`
|
||||
// after a NULL-on-OOR record lookup, so skipping the
|
||||
@@ -71,7 +65,7 @@ int __fastcall Script_CancelSpellByName(void *L) {
|
||||
const char *targetName = Game::Lua::ToString(L, 1);
|
||||
if (targetName == nullptr || *targetName == '\0')
|
||||
return 0;
|
||||
const uint8_t *player = ResolvePlayer();
|
||||
const uint8_t *player = Unit::Identity::PlayerObject();
|
||||
if (player == nullptr)
|
||||
return 0;
|
||||
// Walk the player's buff range only (slots 0..31). Modern
|
||||
|
||||
+3
-43
@@ -23,57 +23,17 @@
|
||||
|
||||
#include "Arg.h"
|
||||
#include "Lookup.h"
|
||||
#include "Range.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace Spell::HasRange {
|
||||
|
||||
namespace {
|
||||
|
||||
// Spell.dbc RangeIndex field — mirrors `Script_GetSpellInfo`'s
|
||||
// local constant. Future refactor: promote to `Offsets.h`.
|
||||
constexpr int OFF_SPELL_RANGE_INDEX = 0x90;
|
||||
|
||||
// SpellRange.dbc record layout (verified, documented in CLAUDE.md
|
||||
// under "Sub-DBC record layouts"):
|
||||
// +0x00 id, +0x04 minRange (float), +0x08 maxRange (float), ...
|
||||
constexpr int OFF_SPELLRANGE_MIN = 0x04;
|
||||
constexpr int OFF_SPELLRANGE_MAX = 0x08;
|
||||
|
||||
bool SpellIDHasRange(int spellID) {
|
||||
if (spellID <= 0)
|
||||
return false;
|
||||
const uint8_t *spellRec = DBC::Record(Offsets::VAR_SPELL_RECORDS,
|
||||
Offsets::VAR_SPELL_RECORD_COUNT,
|
||||
static_cast<uint32_t>(spellID));
|
||||
if (spellRec == nullptr)
|
||||
return false;
|
||||
const uint32_t rangeIdx = *reinterpret_cast<const uint32_t *>(
|
||||
spellRec + OFF_SPELL_RANGE_INDEX);
|
||||
if (rangeIdx == 0)
|
||||
return false;
|
||||
const uint8_t *rangeRec = DBC::Record(Offsets::VAR_SPELL_RANGE_RECORDS,
|
||||
Offsets::VAR_SPELL_RANGE_COUNT,
|
||||
rangeIdx);
|
||||
if (rangeRec == nullptr)
|
||||
return false;
|
||||
const float minRange = *reinterpret_cast<const float *>(
|
||||
rangeRec + OFF_SPELLRANGE_MIN);
|
||||
const float maxRange = *reinterpret_cast<const float *>(
|
||||
rangeRec + OFF_SPELLRANGE_MAX);
|
||||
return minRange > 0.0f || maxRange > 0.0f;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static int __fastcall Script_C_Spell_SpellHasRange(void *L) {
|
||||
const int spellID = Spell::Arg::ResolveSpellID(L, 1);
|
||||
Game::Lua::PushBool(L, SpellIDHasRange(spellID));
|
||||
Game::Lua::PushBool(L, Spell::Range::SpellIDHasRange(spellID));
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -94,7 +54,7 @@ static int __fastcall Script_SpellHasRange(void *L) {
|
||||
bookType = 1;
|
||||
}
|
||||
const int spellID = Spell::Lookup::SpellbookSlotToID(slot, bookType);
|
||||
Game::Lua::PushBool(L, SpellIDHasRange(spellID));
|
||||
Game::Lua::PushBool(L, Spell::Range::SpellIDHasRange(spellID));
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Spell.IsSpellInRange(spellIdentifier, targetUnit)` — true if the
|
||||
// spell is in range of the unit, false if out of range, nil when the
|
||||
// range check doesn't apply (rangeless spell, or an unresolvable
|
||||
// spell / unit). `spellIdentifier` is a spellID, spell link, or the
|
||||
// name of a spell in the player's spellbook; `targetUnit` is a unit
|
||||
// token ("target", "mouseover", "party1", …).
|
||||
//
|
||||
// Thin Lua wrapper over the shared `Spell::Range::PlayerVsUnit` core
|
||||
// (see spell/Range.h) — the same range test behind `C_Item.IsItemInRange`
|
||||
// (via the item's on-use spell). Range-only, ignoring line of sight and
|
||||
// target faction, matching retail IsSpellInRange.
|
||||
|
||||
#include "Arg.h"
|
||||
#include "Range.h"
|
||||
|
||||
#include "Game.h"
|
||||
|
||||
namespace Spell::InRange {
|
||||
|
||||
namespace {
|
||||
|
||||
int __fastcall Script_C_Spell_IsSpellInRange(void *L) {
|
||||
const int spellID = Spell::Arg::ResolveSpellID(L, 1);
|
||||
const char *unit =
|
||||
Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : nullptr;
|
||||
|
||||
const int result = Spell::Range::PlayerVsUnit(spellID, unit);
|
||||
if (result < 0) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
Game::Lua::PushBool(L, result == 1);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Spell", "IsSpellInRange",
|
||||
&Script_C_Spell_IsSpellInRange);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Spell::InRange
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "player/StatSignal.h"
|
||||
#include "spell/Arg.h"
|
||||
#include "spell/Lookup.h"
|
||||
|
||||
@@ -586,6 +587,35 @@ static int __fastcall Script_C_Spell_IsSpellHelpful(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// --- Known-spell-bitmap change signal ------------------------------------
|
||||
// Learning/unlearning a spell rewrites the bitmap GetSpellBonusHealing scans
|
||||
// for talent conversions (Spiritual Guidance / Ironclad). Co-hook the engine's
|
||||
// learn + unlearn writers and bump Player::StatSignal, so a talent respec
|
||||
// invalidates the healing cache immediately — the same event-driven path as
|
||||
// aura/equipment changes, no polling. Both fire only at login and on
|
||||
// learn/unlearn (never per-frame), so they're safe co-hook targets.
|
||||
using LearnSpell_t = void(__fastcall *)(uint32_t spellID, int notify,
|
||||
uint32_t replacedSpellID);
|
||||
static LearnSpell_t g_origLearnSpell = nullptr;
|
||||
static void __fastcall LearnSpell_h(uint32_t spellID, int notify,
|
||||
uint32_t replacedSpellID) {
|
||||
g_origLearnSpell(spellID, notify, replacedSpellID);
|
||||
Player::StatSignal::Notify();
|
||||
}
|
||||
static const Game::HookAutoRegister _hookLearnSpell{
|
||||
Offsets::FUN_LEARN_SPELL, reinterpret_cast<void *>(&LearnSpell_h),
|
||||
reinterpret_cast<void **>(&g_origLearnSpell)};
|
||||
|
||||
using UnlearnSpell_t = void(__fastcall *)(uint32_t spellID, int param2);
|
||||
static UnlearnSpell_t g_origUnlearnSpell = nullptr;
|
||||
static void __fastcall UnlearnSpell_h(uint32_t spellID, int param2) {
|
||||
g_origUnlearnSpell(spellID, param2);
|
||||
Player::StatSignal::Notify();
|
||||
}
|
||||
static const Game::HookAutoRegister _hookUnlearnSpell{
|
||||
Offsets::FUN_UNLEARN_SPELL, reinterpret_cast<void *>(&UnlearnSpell_h),
|
||||
reinterpret_cast<void **>(&g_origUnlearnSpell)};
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("GetSpellInfo", &Script_GetSpellInfo);
|
||||
Game::Lua::RegisterGlobalFunction("GetSpellLink", &Script_GetSpellLink);
|
||||
|
||||
@@ -117,6 +117,139 @@ bool SpellHiddenFromAutoLearn(int spellID) {
|
||||
return (attrEx2 & SPELL_ATTR_EX2_HIDE_FROM_AUTOLEARN) != 0;
|
||||
}
|
||||
|
||||
// ---- Target-level gate for ranked beneficial auras ----------------------
|
||||
//
|
||||
// A ranked beneficial aura can't be applied to a target too low for the
|
||||
// rank — e.g. Divine Spirit Rank 1 (14752) requires the target to be level
|
||||
// >= 20. Server-enforced via `SpellMgr::SelectAuraRankForLevel`, whose rule
|
||||
// is: a ranked positive aura is castable when `targetLevel + 10 >=
|
||||
// spellLevel`, i.e. the minimum target level is `spellLevel - 10`. Below
|
||||
// it the lowest rank fails (SPELL_FAILED_LOWLEVEL); higher ranks auto-
|
||||
// downrank. The gate only applies to *ranked* *positive* *auras* — see
|
||||
// RequiredTargetLevel. Fully derivable from the client's Spell.dbc.
|
||||
//
|
||||
// (The explicit MinTargetLevel / MaxTargetLevel fields the server also
|
||||
// checks are NOT in the 1.12 client's Spell.dbc — server-only columns — so
|
||||
// the spellLevel rule is the only client-readable target-level mechanism.)
|
||||
|
||||
constexpr int OFF_SPELL_MAX_LEVEL = 0x6C; // uint32 (scaling cap level)
|
||||
constexpr int OFF_SPELL_SPELL_LEVEL = 0x74; // uint32 (rank's effective level)
|
||||
constexpr int OFF_SPELL_EFFECT = 0xF4; // uint32[3] effect type
|
||||
constexpr int OFF_SPELL_EFFECT_TARGET_A = 0x148; // uint32[3] implicit target A
|
||||
constexpr int OFF_SPELL_RANK = 0x204; // char*[9] localized rank text
|
||||
constexpr uint32_t SPELL_ATTR_PASSIVE = 0x40; // Attributes bit
|
||||
constexpr uint32_t SPELL_EFFECT_APPLY_AURA = 6;
|
||||
constexpr uint32_t SPELL_EFFECT_APPLY_AREA_AURA_PARTY = 35;
|
||||
|
||||
int LocaleIndex() {
|
||||
return *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
|
||||
}
|
||||
|
||||
// The friendly-target IDs the server's SelectAuraRankForLevel treats as
|
||||
// "positive" for rank selection: IsExplicitPositiveTarget (21/35/45/57/61)
|
||||
// plus IsAreaEffectPossitiveTarget (20/30/31/33/34/37/56/61). Narrower than
|
||||
// a general "is helpful" set — this is the exact gate the rank rule uses
|
||||
// (self-cast / pet targets don't count).
|
||||
bool IsPositiveRankTarget(uint32_t t) {
|
||||
switch (t) {
|
||||
case 21: // TARGET_UNIT_FRIEND
|
||||
case 35: // TARGET_UNIT_PARTY
|
||||
case 45: // TARGET_UNIT_FRIEND_CHAIN_HEAL
|
||||
case 57: // TARGET_UNIT_RAID
|
||||
case 61: // TARGET_UNIT_RAID_AND_CLASS
|
||||
case 20: // TARGET_ENUM_UNITS_PARTY_WITHIN_CASTER_RANGE
|
||||
case 30: // TARGET_ENUM_UNITS_FRIEND_AOE_AT_SRC_LOC
|
||||
case 31: // TARGET_ENUM_UNITS_FRIEND_AOE_AT_DEST_LOC
|
||||
case 33: // TARGET_ENUM_UNITS_PARTY_AOE_AT_SRC_LOC
|
||||
case 34: // TARGET_ENUM_UNITS_PARTY_AOE_AT_DEST_LOC
|
||||
case 37: // TARGET_UNIT_FRIEND_AND_PARTY
|
||||
case 56: // TARGET_ENUM_UNITS_RAID_WITHIN_CASTER_RANGE
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// True if the record carries a localized "Rank N" string — the client's
|
||||
// marker for a spell in a ranked line (single-rank spells leave it empty).
|
||||
// Proxy for the server's `GetSpellRank(id) != 0`, verified against
|
||||
// single-rank buffs (Power Infusion / Innervate / Blessing of Kings, all
|
||||
// empty Rank and un-gated) vs ranked ones (Divine Spirit / Fortitude).
|
||||
bool HasRankString(const uint8_t *record) {
|
||||
const char *rank = *reinterpret_cast<const char *const *>(
|
||||
record + OFF_SPELL_RANK + LocaleIndex() * 4);
|
||||
return rank != nullptr && *rank != '\0';
|
||||
}
|
||||
|
||||
// Minimum target level required to apply this exact spell (rank), or 0 if
|
||||
// the spell isn't subject to the ranked-positive-aura target-level rule.
|
||||
int RequiredTargetLevel(const uint8_t *record) {
|
||||
const int spellLevel =
|
||||
*reinterpret_cast<const int *>(record + OFF_SPELL_SPELL_LEVEL);
|
||||
if (spellLevel <= 10) // targetLevel + 10 >= spellLevel holds for any level
|
||||
return 0;
|
||||
const uint32_t attr =
|
||||
*reinterpret_cast<const uint32_t *>(record + OFF_SPELL_RECORD_ATTRIBUTES);
|
||||
if (attr & SPELL_ATTR_PASSIVE)
|
||||
return 0;
|
||||
if (!HasRankString(record)) // single-rank spell → not gated
|
||||
return 0;
|
||||
|
||||
auto *effect = reinterpret_cast<const uint32_t *>(record + OFF_SPELL_EFFECT);
|
||||
auto *targetA =
|
||||
reinterpret_cast<const uint32_t *>(record + OFF_SPELL_EFFECT_TARGET_A);
|
||||
bool positiveAura = false;
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
if ((effect[i] == SPELL_EFFECT_APPLY_AURA &&
|
||||
IsPositiveRankTarget(targetA[i])) ||
|
||||
effect[i] == SPELL_EFFECT_APPLY_AREA_AURA_PARTY) {
|
||||
positiveAura = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!positiveAura)
|
||||
return 0;
|
||||
return spellLevel - 10;
|
||||
}
|
||||
|
||||
int ArgSpellID(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1))
|
||||
return 0;
|
||||
return static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
}
|
||||
|
||||
// `C_Spell.GetSpellLevelInfo(spellID)` → spellLevel, baseLevel, maxLevel.
|
||||
// Raw Spell.dbc level fields: spellLevel (this rank's effective level),
|
||||
// baseLevel, maxLevel (level at which scaling caps). nil for an unknown
|
||||
// spellID.
|
||||
int __fastcall Script_GetSpellLevelInfo(void *L) {
|
||||
const uint8_t *record = Spell::Lookup::RecordForID(ArgSpellID(L));
|
||||
if (record == nullptr)
|
||||
return 0;
|
||||
Game::Lua::PushNumber(
|
||||
L, static_cast<double>(
|
||||
*reinterpret_cast<const int *>(record + OFF_SPELL_SPELL_LEVEL)));
|
||||
Game::Lua::PushNumber(
|
||||
L, static_cast<double>(
|
||||
*reinterpret_cast<const int *>(record + Offsets::OFF_SPELL_RECORD_BASE_LEVEL)));
|
||||
Game::Lua::PushNumber(
|
||||
L, static_cast<double>(
|
||||
*reinterpret_cast<const int *>(record + OFF_SPELL_MAX_LEVEL)));
|
||||
return 3;
|
||||
}
|
||||
|
||||
// `GetSpellRequiredTargetLevel(spellID)` → the minimum level a target must
|
||||
// be for this spell (rank) to apply (0 = no restriction). nil for an
|
||||
// unknown spellID. Ranked beneficial auras only; see RequiredTargetLevel.
|
||||
int __fastcall Script_GetSpellRequiredTargetLevel(void *L) {
|
||||
const uint8_t *record = Spell::Lookup::RecordForID(ArgSpellID(L));
|
||||
if (record == nullptr)
|
||||
return 0;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(RequiredTargetLevel(record)));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_SpellBook.GetSpellLevelLearned(spellID)` — returns the level
|
||||
// at which a spell becomes available (the `BaseLevel` field in
|
||||
// Spell.dbc). Direct read off the record — no class/race filtering,
|
||||
@@ -315,6 +448,12 @@ void RegisterLuaFunctions() {
|
||||
&Script_GetSpellLevelLearned);
|
||||
Game::Lua::RegisterTableFunction("C_SpellBook", "GetCurrentLevelSpells",
|
||||
&Script_GetCurrentLevelSpells);
|
||||
Game::Lua::RegisterGlobalFunction("GetSpellRequiredTargetLevel",
|
||||
&Script_GetSpellRequiredTargetLevel);
|
||||
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellLevelInfo",
|
||||
&Script_GetSpellLevelInfo);
|
||||
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellRequiredTargetLevel",
|
||||
&Script_GetSpellRequiredTargetLevel);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#include "Range.h"
|
||||
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "unit/Position.h"
|
||||
|
||||
namespace Spell::Range {
|
||||
|
||||
namespace {
|
||||
|
||||
// Spell.dbc RangeIndex + SpellRange.dbc min/max offsets (verified,
|
||||
// documented in CLAUDE.md under "Sub-DBC record layouts").
|
||||
constexpr int OFF_SPELL_RANGE_INDEX = 0x90;
|
||||
constexpr int OFF_SPELLRANGE_MIN = 0x04;
|
||||
constexpr int OFF_SPELLRANGE_MAX = 0x08;
|
||||
|
||||
// FUN_SPELL_RANGE_CHECK: char __fastcall(caster, target, spellID, outFlag).
|
||||
using SpellRangeCheck_t = char(__fastcall *)(void *caster, void *target,
|
||||
int spellID, char *outFlag);
|
||||
|
||||
} // namespace
|
||||
|
||||
bool SpellIDHasRange(int spellID) {
|
||||
if (spellID <= 0)
|
||||
return false;
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_SPELL_RECORDS,
|
||||
Offsets::VAR_SPELL_RECORD_COUNT,
|
||||
static_cast<uint32_t>(spellID));
|
||||
if (rec == nullptr)
|
||||
return false;
|
||||
const uint32_t rangeIdx =
|
||||
*reinterpret_cast<const uint32_t *>(rec + OFF_SPELL_RANGE_INDEX);
|
||||
if (rangeIdx == 0)
|
||||
return false;
|
||||
const uint8_t *rangeRec = DBC::Record(Offsets::VAR_SPELL_RANGE_RECORDS,
|
||||
Offsets::VAR_SPELL_RANGE_COUNT, rangeIdx);
|
||||
if (rangeRec == nullptr)
|
||||
return false;
|
||||
const float minRange =
|
||||
*reinterpret_cast<const float *>(rangeRec + OFF_SPELLRANGE_MIN);
|
||||
const float maxRange =
|
||||
*reinterpret_cast<const float *>(rangeRec + OFF_SPELLRANGE_MAX);
|
||||
return minRange > 0.0f || maxRange > 0.0f;
|
||||
}
|
||||
|
||||
int PlayerVsUnit(int spellID, const char *unitToken) {
|
||||
// No answer when the range check can't apply: unknown/rangeless spell,
|
||||
// or no unit token given.
|
||||
if (spellID <= 0 || unitToken == nullptr || !SpellIDHasRange(spellID))
|
||||
return -1;
|
||||
|
||||
void *caster = Unit::Position::ResolveToken("player");
|
||||
void *target = Unit::Position::ResolveToken(unitToken);
|
||||
|
||||
// Guard positions before handing the objects to the engine core:
|
||||
// FUN_SPELL_RANGE_CHECK dereferences each GetPosition result without a
|
||||
// null check, and a resolved-but-position-less object (or an absent
|
||||
// token → null) would otherwise crash. A failed read → no answer.
|
||||
float casterPos[3];
|
||||
float targetPos[3];
|
||||
if (!Unit::Position::Read(caster, casterPos) ||
|
||||
!Unit::Position::Read(target, targetPos))
|
||||
return -1;
|
||||
|
||||
char minMaxFlag = 0;
|
||||
auto check =
|
||||
reinterpret_cast<SpellRangeCheck_t>(Offsets::FUN_SPELL_RANGE_CHECK);
|
||||
return check(caster, target, spellID, &minMaxFlag) != 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
} // namespace Spell::Range
|
||||
@@ -0,0 +1,43 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// Shared spell-range core. One place for "does this spell have a range"
|
||||
// and "is the player's cast of this spell in range of a unit", used by
|
||||
// `C_Spell.SpellHasRange`, `C_Spell.IsSpellInRange`, and (via the item's
|
||||
// on-use spell) `C_Item.IsItemInRange`.
|
||||
namespace Spell::Range {
|
||||
|
||||
// True iff the spell's `SpellRange.dbc` row has a non-zero min or max
|
||||
// range. Rangeless spells (self buffs, etc.) have no meaningful "in
|
||||
// range" answer.
|
||||
bool SpellIDHasRange(int spellID);
|
||||
|
||||
// Range check of the local player's cast of `spellID` against
|
||||
// `unitToken` (`"target"`, `"mouseover"`, `"party1"`, …). Returns:
|
||||
// 1 — in range
|
||||
// 0 — out of range
|
||||
// -1 — no answer: `spellID <= 0`, a rangeless spell, a null/absent
|
||||
// unit token, or either unit's position can't be read.
|
||||
// Backed by the engine's geometric range core `FUN_SPELL_RANGE_CHECK`
|
||||
// (the same test the action-button range coloring uses), which folds in
|
||||
// the target's bounding radius so the boundary matches the client's own
|
||||
// "in range" exactly — melee and min-range (too-close) spells included.
|
||||
// Ignores line of sight and target faction, matching retail's range-only
|
||||
// `Is*InRange` semantics.
|
||||
int PlayerVsUnit(int spellID, const char *unitToken);
|
||||
|
||||
} // namespace Spell::Range
|
||||
@@ -22,6 +22,7 @@ namespace Spell::SkillLine {
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
using SkillLineToSlot_t = int(__thiscall *)(void *player, uint32_t skillLineID);
|
||||
|
||||
// Reads UNIT_FIELD_BYTES_0 from the local player's descriptor. Same
|
||||
// pattern Spell::Level::PlayerDescriptor uses. Returns (0, 0) when the
|
||||
@@ -171,9 +172,109 @@ int __fastcall Script_GetSpellSkillLine(void *L) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
// `C_SpellBook.GetSkillLineName(skillLineID) → name`
|
||||
//
|
||||
// Localized `SkillLine.dbc::Name` for a skill-line id — the id → name
|
||||
// half of the bridge addons build by hand. Pure DBC read: returns the
|
||||
// name for ANY valid skill line (Herbalism = 182, Mining = 186, Swords,
|
||||
// Defense, …) whether or not the player has learned it. `nil` for
|
||||
// non-numeric / non-positive input or an id with no SkillLine.dbc row.
|
||||
//
|
||||
// Pairs with `GetSkillLineRank` (the learned-rank lookup): name comes
|
||||
// from the DBC, rank from the player's live skill list.
|
||||
int __fastcall Script_GetSkillLineName(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
const int id = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
const char *name =
|
||||
(id > 0) ? DBC::LocalizedField(Offsets::VAR_SKILL_LINE_RECORDS,
|
||||
Offsets::VAR_SKILL_LINE_COUNT,
|
||||
static_cast<uint32_t>(id),
|
||||
Offsets::OFF_SKILL_LINE_NAME)
|
||||
: nullptr;
|
||||
if (name == nullptr) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
Game::Lua::PushString(L, name);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_SpellBook.GetSkillLineRank(skillLineID) → curRank, maxRank, modifier`
|
||||
//
|
||||
// The local player's rank in a skill line, keyed by `SkillLine.dbc` id.
|
||||
// Vanilla's `GetSkillLineInfo(index)` only walks the skill window by
|
||||
// position and returns skills by *name*, so an addon starting from a
|
||||
// SkillLine.dbc id (a quest's `requiredSkill`, "Herbalism" = 182, …) has
|
||||
// to map id → localized name and string-match every skill line to recover
|
||||
// the rank. This reads the player's live skill list keyed by id directly.
|
||||
//
|
||||
// Returns the same values `GetSkillLineInfo` reports for the line:
|
||||
// curRank — current skill value (base, no temp bonus)
|
||||
// maxRank — the line's cap at the player's level
|
||||
// modifier — temporary bonus (weapon oils, buffs; 0 if none)
|
||||
//
|
||||
// Returns `nil` (nothing) when the player hasn't learned the line
|
||||
// (`FUN_SKILL_LINE_TO_SLOT` reports no slot), for bad input, or before
|
||||
// the player object / skill sub-struct is resident (character select).
|
||||
// Reads the same CGPlayer `+0xE68` skill table `Player::CanUseItem`'s
|
||||
// RequiredSkill gate uses; ranks verified there (Tailoring 261/300, …).
|
||||
int __fastcall Script_GetSkillLineRank(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
const int skillLineID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (skillLineID <= 0) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
auto *player = static_cast<uint8_t *>(resolve("player"));
|
||||
if (player == nullptr) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto toSlot = reinterpret_cast<SkillLineToSlot_t>(Offsets::FUN_SKILL_LINE_TO_SLOT);
|
||||
const int slot = toSlot(player, static_cast<uint32_t>(skillLineID));
|
||||
if (slot < 0) { // player doesn't have the skill line
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto *sub = *reinterpret_cast<const uint8_t *const *>(
|
||||
player + Offsets::OFF_CGPLAYER_INFO);
|
||||
if (sub == nullptr) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const uint8_t *rec = sub + Offsets::OFF_SKILL_INFO_TABLE +
|
||||
slot * Offsets::SKILL_INFO_STRIDE;
|
||||
const uint32_t cur =
|
||||
*reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_CUR);
|
||||
const uint32_t max =
|
||||
*reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_MAX);
|
||||
const uint32_t modifier =
|
||||
*reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_BONUS);
|
||||
|
||||
Game::Lua::PushNumber(L, static_cast<double>(cur));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(max));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(modifier));
|
||||
return 3;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_SpellBook", "GetSpellSkillLine",
|
||||
&Script_GetSpellSkillLine);
|
||||
Game::Lua::RegisterTableFunction("C_SpellBook", "GetSkillLineName",
|
||||
&Script_GetSkillLineName);
|
||||
Game::Lua::RegisterTableFunction("C_SpellBook", "GetSkillLineRank",
|
||||
&Script_GetSkillLineRank);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
@@ -29,6 +29,11 @@ namespace Table::Wipe {
|
||||
// guarantee holds in 1.12's Lua 5.0 (`luaH_next` at `0x006FA2A0`),
|
||||
// so the pattern ports verbatim.
|
||||
//
|
||||
// One divergence from the 3.3.5 source: 1.12's Lua 5.0 keeps a table's
|
||||
// `table.insert`/`getn` length out of band from its keys (see the
|
||||
// `luaL_setn` note below), so after the key-clear loop we must also
|
||||
// reset that counter — the 5.1 twipe had no such legacy to clear.
|
||||
//
|
||||
// Stack discipline matches 3.3.5 to the call:
|
||||
// 1. `lua_pushnil(L)` — initial "no key" sentinel for next
|
||||
// 2. `lua_next(L, 1)` — pops key, pushes (newKey, value)
|
||||
@@ -51,6 +56,17 @@ static int __fastcall Script_table_wipe(void *L) {
|
||||
Game::Lua::RawSet(L, 1); // t[key] = nil
|
||||
}
|
||||
|
||||
// Reset the Lua 5.0 length counter. In 5.0 a table's `table.insert`/
|
||||
// `getn` length is stored OUT of band from its keys (in a `t.n`
|
||||
// field or a weak `sizes[t]` registry entry), so the rawset(nil)
|
||||
// loop above — which only clears keys — leaves it at its old value.
|
||||
// Without this, `table.insert` after a wipe appends at
|
||||
// `getn(t)+1`, past the now-nil slots, and `t[1]` stays nil. 5.1's
|
||||
// wipe doesn't need this (no getn legacy), which is why the ported
|
||||
// logic looked complete. `luaL_setn(L, 1, 0)` puts it back to 0 so
|
||||
// inserts resume at `t[1]`.
|
||||
Game::Lua::SetN(L, 1, 0);
|
||||
|
||||
// The table is still at stack[1] and is the result we return.
|
||||
Game::Lua::SetTop(L, 1);
|
||||
return 1;
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_TaxiMap.GetTaxiNodesForMap(mapID)` (static) and
|
||||
// `C_TaxiMap.GetAllTaxiNodes([uiMapID])` (live) — backports of the retail
|
||||
// taxi-map node enumerators, plus the `Enum.FlightPathFaction` /
|
||||
// `Enum.FlightPathState` tables so retail code ports unchanged.
|
||||
//
|
||||
// - **GetTaxiNodesForMap** reads `TaxiNodes.dbc` directly: every flight point
|
||||
// on a continent (or all continents with no arg), faction-tagged,
|
||||
// regardless of discovery — the static DB a zone-map addon (pfQuest's
|
||||
// `pfDB.meta.flight`) wants. No flight master needed. Non-flight rows are filtered out — the DBC also holds transports/
|
||||
// boats/zeppelins (no flight mount) and non-selectable markers like
|
||||
// Northshire Abbey (no connecting flight path); a real flight master has a
|
||||
// mount AND is a TaxiPath endpoint.
|
||||
// - **GetAllTaxiNodes** wraps the open flight master's live reachable-node
|
||||
// list (the legacy NumTaxiNodes/TaxiNode* globals' backing state): the
|
||||
// nodes you can actually fly to right now, with per-node reachability and
|
||||
// the slot index `TakeTaxiNode` takes. Empty when no taxi map is open.
|
||||
//
|
||||
// Ship both: static/DBC for databases + overlays, live/session for a custom
|
||||
// flight-map UI. See docs/API.md for the field-by-field contract and which
|
||||
// fields are retail vs ClassicAPI extensions.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "map/Area.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace Taxi::Map {
|
||||
|
||||
namespace {
|
||||
|
||||
// Enum.FlightPathFaction (retail values).
|
||||
enum { FACTION_NEUTRAL = 0, FACTION_HORDE = 1, FACTION_ALLIANCE = 2 };
|
||||
// Enum.FlightPathState (retail values).
|
||||
enum { STATE_CURRENT = 0, STATE_REACHABLE = 1, STATE_UNREACHABLE = 2 };
|
||||
|
||||
float FloatField(const uint8_t *rec, int off) {
|
||||
return *reinterpret_cast<const float *>(rec + off);
|
||||
}
|
||||
int IntField(const uint8_t *rec, int off) {
|
||||
return *reinterpret_cast<const int *>(rec + off);
|
||||
}
|
||||
|
||||
// --- name-based zone resolution -------------------------------------------
|
||||
//
|
||||
// A taxi node's name is "Location, Zone" (e.g. "Chillwind Camp, Western
|
||||
// Plaguelands"), so the zone is authoritative in the data itself — which
|
||||
// beats any geometric world->zone guess at genuine border interleaving that
|
||||
// the WorldMapArea/overlay boxes can't separate (Chillwind sits by Strahnbrad,
|
||||
// deep in Alterac's overlay box, but is a WPL node). We resolve the name
|
||||
// suffix against AreaTable and fall back to the geometric resolver only when a
|
||||
// node has no ", Zone" suffix or the zone name doesn't match.
|
||||
|
||||
char AsciiLower(char c) { return (c >= 'A' && c <= 'Z') ? char(c - 'A' + 'a') : c; }
|
||||
|
||||
bool IEqual(const char *a, const char *b) {
|
||||
while (*a && *b) {
|
||||
if (AsciiLower(*a) != AsciiLower(*b))
|
||||
return false;
|
||||
++a;
|
||||
++b;
|
||||
}
|
||||
return *a == *b;
|
||||
}
|
||||
|
||||
// True if `name` begins with `prefix` followed by a space — a word-prefix, so
|
||||
// the abbreviated taxi suffixes ("Redridge", "Arathi") match the full
|
||||
// AreaTable names ("Redridge Mountains", "Arathi Highlands").
|
||||
bool IStartsWord(const char *name, const char *prefix) {
|
||||
while (*prefix) {
|
||||
if (!*name || AsciiLower(*name) != AsciiLower(*prefix))
|
||||
return false;
|
||||
++name;
|
||||
++prefix;
|
||||
}
|
||||
return *name == ' ';
|
||||
}
|
||||
|
||||
// Pointer into `name` at the zone part (after the last ", "), or null when the
|
||||
// name has no ", " separator.
|
||||
const char *ZoneSuffix(const char *name) {
|
||||
const char *best = nullptr;
|
||||
if (name != nullptr)
|
||||
for (const char *p = name; p[0] != '\0'; ++p)
|
||||
if (p[0] == ',' && p[1] == ' ')
|
||||
best = p + 2;
|
||||
return best;
|
||||
}
|
||||
|
||||
// Copies the location part (before the FIRST ", ") of `name` into `out`,
|
||||
// null-terminated. Returns `out`, or null when `name` has no ", " separator.
|
||||
// This is the "Orgrimmar" in "Orgrimmar, Durotar" — a capital's own city map
|
||||
// when it's a mapped area, distinct from its outdoor region.
|
||||
const char *LocationPrefix(const char *name, char *out, int outSize) {
|
||||
if (name == nullptr)
|
||||
return nullptr;
|
||||
for (int i = 0; name[i] != '\0'; ++i) {
|
||||
if (name[i] == ',' && name[i + 1] == ' ') {
|
||||
int n = (i < outSize - 1) ? i : outSize - 1;
|
||||
for (int j = 0; j < n; ++j)
|
||||
out[j] = name[j];
|
||||
out[n] = '\0';
|
||||
return out;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// AreaTable areaID for a zone name: exact (case-insensitive) match preferred,
|
||||
// else the first word-prefix match. 0 when nothing matches.
|
||||
int ResolveZoneByName(const char *zone) {
|
||||
if (zone == nullptr || *zone == '\0')
|
||||
return 0;
|
||||
const int count =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_AREATABLE_COUNT);
|
||||
int prefixHit = 0;
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const char *nm = DBC::LocalizedField(Offsets::VAR_AREATABLE_RECORDS,
|
||||
Offsets::VAR_AREATABLE_COUNT,
|
||||
static_cast<uint32_t>(id),
|
||||
Offsets::OFF_AREATABLE_NAMES);
|
||||
if (nm == nullptr)
|
||||
continue;
|
||||
if (IEqual(nm, zone))
|
||||
return id; // exact wins outright
|
||||
if (prefixHit == 0 && IStartsWord(nm, zone))
|
||||
prefixHit = id;
|
||||
}
|
||||
return prefixHit;
|
||||
}
|
||||
|
||||
// Pushes { x = px, y = py } as a subtable field `key` of the table on top.
|
||||
void PushPosition(void *L, const char *key, double px, double py) {
|
||||
Game::Lua::PushString(L, key);
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "x", px);
|
||||
Game::Lua::SetFieldNumber(L, "y", py);
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
|
||||
// Walks TaxiPath.dbc into two per-node-id flags:
|
||||
// endpoint[] — the node is either end of some flight path. A real flight
|
||||
// master always is; the fake rows (Northshire Abbey, spurious
|
||||
// duplicate hub rows, standalone markers like "Eastern
|
||||
// Plaguelands"/"Naxxramas") connect to nothing.
|
||||
// dest[] — the node is a path DESTINATION (in-degree > 0), i.e. you can
|
||||
// fly TO it. In-degree 0 marks a departure-only node reachable
|
||||
// only by other means: druid Teleport (Nighthaven, Moonglade),
|
||||
// a boat dock (Stormwind Harbor), etc.
|
||||
// ids >= `size` are ignored (the vanilla table maxes ~122, so the fixed buffer
|
||||
// is ample). Returns false when the path table isn't loaded.
|
||||
constexpr int kMaxTaxiNodeID = 2048;
|
||||
bool BuildTaxiGraph(bool *endpoint, bool *dest, int size) {
|
||||
const int count = *reinterpret_cast<const int *>(Offsets::VAR_TAXIPATH_COUNT);
|
||||
if (count <= 0)
|
||||
return false;
|
||||
for (int i = 1; i <= count; ++i) {
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_TAXIPATH_RECORDS,
|
||||
Offsets::VAR_TAXIPATH_COUNT,
|
||||
static_cast<uint32_t>(i));
|
||||
if (rec == nullptr)
|
||||
continue;
|
||||
const int from = IntField(rec, Offsets::OFF_TAXIPATH_FROM);
|
||||
const int to = IntField(rec, Offsets::OFF_TAXIPATH_TO);
|
||||
if (from >= 0 && from < size)
|
||||
endpoint[from] = true;
|
||||
if (to >= 0 && to < size) {
|
||||
endpoint[to] = true;
|
||||
dest[to] = true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// `C_TaxiMap.GetTaxiNodesForMap([mapID])` — see module comment. A numeric
|
||||
// `mapID` filters to that continent; omitted / non-number returns every
|
||||
// flight master on every continent.
|
||||
int __fastcall Script_GetTaxiNodesForMap(void *L) {
|
||||
const bool filterMap = Game::Lua::IsNumber(L, 1);
|
||||
const int wantMap =
|
||||
filterMap ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : -1;
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
// Real flight masters are TaxiPath endpoints; `dest` marks the ones a
|
||||
// path leads to (in-degree > 0) for the `reachable` field.
|
||||
bool endpoint[kMaxTaxiNodeID] = {};
|
||||
bool dest[kMaxTaxiNodeID] = {};
|
||||
BuildTaxiGraph(endpoint, dest, kMaxTaxiNodeID);
|
||||
|
||||
const int count =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_TAXINODES_COUNT);
|
||||
int outIdx = 0;
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_TAXINODES_RECORDS,
|
||||
Offsets::VAR_TAXINODES_COUNT,
|
||||
static_cast<uint32_t>(id));
|
||||
if (rec == nullptr)
|
||||
continue;
|
||||
const int mapID = IntField(rec, Offsets::OFF_TAXINODE_MAP_ID);
|
||||
if (filterMap && mapID != wantMap)
|
||||
continue;
|
||||
|
||||
// Two filters isolate real flight masters from the DBC's non-flight
|
||||
// rows: (1) at least one flight mount — drops transports/boats/
|
||||
// zeppelins/instance markers (both mounts 0); (2) the node is a
|
||||
// TaxiPath endpoint — drops Northshire Abbey, spurious duplicate hub
|
||||
// rows, and standalone markers that connect to no flight path.
|
||||
const int horde = IntField(rec, Offsets::OFF_TAXINODE_HORDE_MOUNT);
|
||||
const int alliance = IntField(rec, Offsets::OFF_TAXINODE_ALLIANCE_MOUNT);
|
||||
if (horde == 0 && alliance == 0)
|
||||
continue;
|
||||
if (id >= kMaxTaxiNodeID || !endpoint[id])
|
||||
continue;
|
||||
|
||||
const char *name = DBC::LocalizedField(Offsets::VAR_TAXINODES_RECORDS,
|
||||
Offsets::VAR_TAXINODES_COUNT,
|
||||
static_cast<uint32_t>(id),
|
||||
Offsets::OFF_TAXINODE_NAME);
|
||||
// The one path-connected, mounted row that still isn't a flight
|
||||
// master: Blizzard's internal "Generic, World target" zeppelin-route
|
||||
// marker. It's the sole such placeholder across all continents.
|
||||
if (name != nullptr && std::strncmp(name, "Generic", 7) == 0)
|
||||
continue;
|
||||
|
||||
const int faction = (horde != 0 && alliance != 0) ? FACTION_NEUTRAL
|
||||
: (alliance != 0) ? FACTION_ALLIANCE
|
||||
: FACTION_HORDE;
|
||||
|
||||
const float x = FloatField(rec, Offsets::OFF_TAXINODE_X);
|
||||
const float y = FloatField(rec, Offsets::OFF_TAXINODE_Y);
|
||||
const float z = FloatField(rec, Offsets::OFF_TAXINODE_Z);
|
||||
|
||||
outIdx += 1;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(outIdx));
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "nodeID", id);
|
||||
Game::Lua::SetFieldString(L, "name", name ? name : "");
|
||||
Game::Lua::SetFieldNumber(L, "faction", faction);
|
||||
// reachable: a flight path leads to this node (in-degree > 0). False
|
||||
// marks a departure-only node reachable only by other means — druid
|
||||
// Teleport (Nighthaven, Moonglade) or a boat dock (Stormwind Harbor);
|
||||
// hide those from a non-class-specific "where can I fly" map.
|
||||
Game::Lua::SetFieldBool(L, "reachable", id < kMaxTaxiNodeID && dest[id]);
|
||||
Game::Lua::SetFieldNumber(L, "mapID", mapID);
|
||||
Game::Lua::SetFieldNumber(L, "x", x);
|
||||
Game::Lua::SetFieldNumber(L, "y", y);
|
||||
Game::Lua::SetFieldNumber(L, "z", z);
|
||||
|
||||
// position: 0-1 on the continent map (retail-accurate). Falls back to
|
||||
// {0,0} when the continent has no usable WorldMapArea row (instance
|
||||
// maps); zone-map addons use areaID + mapX/mapY below instead.
|
||||
double px = 0.0, py = 0.0;
|
||||
::Map::Area::ContinentPercent(mapID, x, y, &px, &py);
|
||||
PushPosition(L, "position", px, py);
|
||||
|
||||
// Derived zone anchor (areaID + 0-100 zone-relative percent), from the
|
||||
// node's own name — authoritative, and right at borders geometry can't
|
||||
// separate. Two candidate zones from the name, each accepted only if
|
||||
// its WorldMapArea rect actually contains the point:
|
||||
// 1. the LOCATION ("Orgrimmar" in "Orgrimmar, Durotar") — a capital's
|
||||
// own city map, so the flight master pins on the city, not the
|
||||
// surrounding region;
|
||||
// 2. else the ZONE suffix ("Western Plaguelands") — the outdoor zone
|
||||
// for a normal node (whose location is an unmapped subzone).
|
||||
// Falls back to the geometric landmass resolver when neither name part
|
||||
// resolves to a containing zone (no ", Zone" suffix, unknown name).
|
||||
int areaID = 0;
|
||||
double mapX = 0.0, mapY = 0.0;
|
||||
bool haveZone = false;
|
||||
char locBuf[128];
|
||||
const int locArea = ResolveZoneByName(LocationPrefix(name, locBuf, sizeof(locBuf)));
|
||||
const int zoneArea = ResolveZoneByName(ZoneSuffix(name));
|
||||
if (locArea > 0 && ::Map::Area::PercentInZone(locArea, x, y, &mapX, &mapY)) {
|
||||
areaID = locArea; // capital / mapped location -> its own city map
|
||||
haveZone = true;
|
||||
} else if (zoneArea > 0 &&
|
||||
::Map::Area::PercentInZone(zoneArea, x, y, &mapX, &mapY)) {
|
||||
areaID = zoneArea; // outdoor zone from the name suffix
|
||||
haveZone = true;
|
||||
} else {
|
||||
haveZone = ::Map::Area::ZonePercent(mapID, x, y, &areaID, &mapX, &mapY);
|
||||
}
|
||||
if (haveZone) {
|
||||
Game::Lua::SetFieldNumber(L, "areaID", areaID);
|
||||
Game::Lua::SetFieldNumber(L, "mapX", mapX);
|
||||
Game::Lua::SetFieldNumber(L, "mapY", mapY);
|
||||
}
|
||||
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
using TaxiNodeType_t = const char *(__fastcall *)(uint32_t slot0Based);
|
||||
|
||||
int StateFromType(const char *type) {
|
||||
if (type == nullptr)
|
||||
return STATE_UNREACHABLE;
|
||||
if (std::strcmp(type, "CURRENT") == 0)
|
||||
return STATE_CURRENT;
|
||||
if (std::strcmp(type, "REACHABLE") == 0)
|
||||
return STATE_REACHABLE;
|
||||
return STATE_UNREACHABLE; // DISTANT / NONE / other
|
||||
}
|
||||
|
||||
// `C_TaxiMap.GetAllTaxiNodes([uiMapID])` — see module comment. The uiMapID
|
||||
// arg is accepted for retail signature parity but ignored (vanilla has a
|
||||
// single active taxi map — the open flight master's).
|
||||
int __fastcall Script_GetAllTaxiNodes(void *L) {
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
const int count =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_TAXI_NODE_COUNT);
|
||||
if (count <= 0)
|
||||
return 1; // no taxi map open
|
||||
|
||||
auto typeFn = reinterpret_cast<TaxiNodeType_t>(Offsets::FUN_TAXI_NODE_TYPE);
|
||||
const auto *arrayBase =
|
||||
reinterpret_cast<const uint8_t *>(Offsets::VAR_TAXI_NODE_ARRAY);
|
||||
|
||||
for (int slot = 0; slot < count; ++slot) {
|
||||
const uint8_t *slotRec = arrayBase + slot * Offsets::TAXI_NODE_STRIDE;
|
||||
const uint8_t *dbcRec = *reinterpret_cast<const uint8_t *const *>(
|
||||
slotRec + Offsets::OFF_TAXI_SLOT_RECORD);
|
||||
const float x = FloatField(slotRec, Offsets::OFF_TAXI_SLOT_X);
|
||||
const float y = FloatField(slotRec, Offsets::OFF_TAXI_SLOT_Y);
|
||||
|
||||
int nodeID = 0;
|
||||
const char *name = nullptr;
|
||||
if (dbcRec != nullptr) {
|
||||
nodeID = IntField(dbcRec, 0); // TaxiNodes.dbc id @ +0
|
||||
name = DBC::LocalizedField(Offsets::VAR_TAXINODES_RECORDS,
|
||||
Offsets::VAR_TAXINODES_COUNT,
|
||||
static_cast<uint32_t>(nodeID),
|
||||
Offsets::OFF_TAXINODE_NAME);
|
||||
}
|
||||
const int state = StateFromType(typeFn(static_cast<uint32_t>(slot)));
|
||||
|
||||
Game::Lua::PushNumber(L, static_cast<double>(slot + 1));
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "slotIndex", slot + 1); // TakeTaxiNode arg
|
||||
Game::Lua::SetFieldString(L, "name", name ? name : "");
|
||||
Game::Lua::SetFieldNumber(L, "nodeID", nodeID);
|
||||
Game::Lua::SetFieldNumber(L, "state", state);
|
||||
PushPosition(L, "position", x, y); // flight-map 0-1, matches retail
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
static const Game::Lua::EnumIntegerEntry kFaction[] = {
|
||||
{"Neutral", FACTION_NEUTRAL},
|
||||
{"Horde", FACTION_HORDE},
|
||||
{"Alliance", FACTION_ALLIANCE},
|
||||
};
|
||||
Game::Lua::RegisterIntegerEnum("Enum", "FlightPathFaction", kFaction, 3);
|
||||
|
||||
static const Game::Lua::EnumIntegerEntry kState[] = {
|
||||
{"Current", STATE_CURRENT},
|
||||
{"Reachable", STATE_REACHABLE},
|
||||
{"Unreachable", STATE_UNREACHABLE},
|
||||
};
|
||||
Game::Lua::RegisterIntegerEnum("Enum", "FlightPathState", kState, 3);
|
||||
|
||||
Game::Lua::RegisterTableFunction("C_TaxiMap", "GetTaxiNodesForMap",
|
||||
&Script_GetTaxiNodesForMap);
|
||||
Game::Lua::RegisterTableFunction("C_TaxiMap", "GetAllTaxiNodes",
|
||||
&Script_GetAllTaxiNodes);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Taxi::Map
|
||||
+139
-21
@@ -126,8 +126,9 @@ int SlotsForInvType(int invType, int *out) {
|
||||
// English fallback labels, used when the client doesn't define the
|
||||
// modern `_G[key]` global-string (vanilla 1.12 largely doesn't). Phrasing
|
||||
// mirrors pfUI's eqcompare so the deltas read consistently on this
|
||||
// client. Percent stats keep a "%" suffix since the values are native
|
||||
// vanilla percentages.
|
||||
// client. Percent stats carry NO "%" here — `IsPercentStat` appends the
|
||||
// "%" to the value ("-5% Block"), so the unit is correct regardless of
|
||||
// which label (Blizzard `_SHORT` or this fallback) is used.
|
||||
struct KeyLabel {
|
||||
const char *key;
|
||||
const char *label;
|
||||
@@ -149,25 +150,46 @@ constexpr KeyLabel kFallbackLabels[] = {
|
||||
{"RESISTANCE6_NAME", "Arcane Resistance"},
|
||||
{"ITEM_MOD_ATTACK_POWER_SHORT", "Attack Power"},
|
||||
{"ITEM_MOD_RANGED_ATTACK_POWER_SHORT", "Ranged Attack Power"},
|
||||
{"ITEM_MOD_CRIT_MELEE_RATING", "Melee Crit %"},
|
||||
{"ITEM_MOD_CRIT_RANGED_RATING", "Ranged Crit %"},
|
||||
{"ITEM_MOD_HIT_MELEE_RATING", "Melee Hit %"},
|
||||
{"ITEM_MOD_HIT_RANGED_RATING", "Ranged Hit %"},
|
||||
{"ITEM_MOD_HIT_SPELL_RATING", "Spell Hit %"},
|
||||
{"ITEM_MOD_CRIT_SPELL_RATING", "Spell Crit %"},
|
||||
{"ITEM_MOD_CRIT_MELEE_RATING", "Melee Crit"},
|
||||
{"ITEM_MOD_CRIT_RANGED_RATING", "Ranged Crit"},
|
||||
{"ITEM_MOD_HIT_MELEE_RATING", "Melee Hit"},
|
||||
{"ITEM_MOD_HIT_RANGED_RATING", "Ranged Hit"},
|
||||
{"ITEM_MOD_HIT_SPELL_RATING", "Spell Hit"},
|
||||
{"ITEM_MOD_CRIT_SPELL_RATING", "Spell Crit"},
|
||||
{"ITEM_MOD_SPELL_DAMAGE_DONE_SHORT", "Spell Damage"},
|
||||
{"ITEM_MOD_SPELL_HEALING_DONE_SHORT", "Spell Healing"},
|
||||
{"ITEM_MOD_MANA_REGENERATION", "Mana Regen"},
|
||||
{"ITEM_MOD_DEFENSE_SKILL_RATING", "Defense"},
|
||||
{"ITEM_MOD_DODGE_RATING", "Dodge %"},
|
||||
{"ITEM_MOD_PARRY_RATING", "Parry %"},
|
||||
{"ITEM_MOD_BLOCK_RATING", "Block %"},
|
||||
{"ITEM_MOD_DODGE_RATING", "Dodge"},
|
||||
{"ITEM_MOD_PARRY_RATING", "Parry"},
|
||||
{"ITEM_MOD_BLOCK_RATING", "Block"},
|
||||
{"ITEM_MOD_BLOCK_VALUE", "Block Value"},
|
||||
{"ITEM_MOD_DAMAGE_PER_SECOND_SHORT", "Damage Per Second"},
|
||||
{"ITEM_DELTA_DESCRIPTION",
|
||||
"If you replace this item, the following stat changes will occur:"},
|
||||
};
|
||||
|
||||
// Percent-valued stat keys: the block/dodge/parry *chances* and the
|
||||
// crit/hit percentages. Vanilla stores these as native percentages (a
|
||||
// value of 5 means 5%), so the delta must read "-5% Block", not "-5
|
||||
// Block". The "%" is attached to the NUMBER rather than the label, so it
|
||||
// stays correct in every locale and requires no `_SHORT` change.
|
||||
// NB: ITEM_MOD_DEFENSE_SKILL_RATING (skill points) and
|
||||
// ITEM_MOD_BLOCK_VALUE (flat block amount) are NOT percentages.
|
||||
bool IsPercentStat(const char *key) {
|
||||
static const char *const kPercent[] = {
|
||||
"ITEM_MOD_CRIT_MELEE_RATING", "ITEM_MOD_CRIT_RANGED_RATING",
|
||||
"ITEM_MOD_CRIT_SPELL_RATING", "ITEM_MOD_HIT_MELEE_RATING",
|
||||
"ITEM_MOD_HIT_RANGED_RATING", "ITEM_MOD_HIT_SPELL_RATING",
|
||||
"ITEM_MOD_DODGE_RATING", "ITEM_MOD_PARRY_RATING",
|
||||
"ITEM_MOD_BLOCK_RATING",
|
||||
};
|
||||
for (const char *k : kPercent)
|
||||
if (std::strcmp(k, key) == 0)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const char *FallbackLabel(const char *key) {
|
||||
for (const auto &e : kFallbackLabels)
|
||||
if (std::strcmp(e.key, key) == 0)
|
||||
@@ -175,23 +197,112 @@ const char *FallbackLabel(const char *key) {
|
||||
return key; // last resort — raw key beats crashing
|
||||
}
|
||||
|
||||
// Resolve a display label for `key` into `out`. Prefers the client's
|
||||
// `_G[key]` when it's a plain, non-empty string (respects localization /
|
||||
// server-defined labels), read via a direct globals-table access — no
|
||||
// pcall. Ignores format strings (a "%"-bearing global like "+%d Strength"
|
||||
// would render garbage), falling back to the built-in English label.
|
||||
void LabelFor(void *L, const char *key, char *out, int outSize) {
|
||||
// True if `s` contains a printf conversion (`%d`, `%s`, `%1$s`, …) as
|
||||
// opposed to a literal `%` used as a unit. A `%` at end-of-string, before
|
||||
// a space, or escaped as `%%` is treated as literal. Lets locale labels
|
||||
// like "Dodge %" through while still rejecting a format-string global
|
||||
// such as "+%d Strength" (which would render garbage via snprintf "%s").
|
||||
bool HasFormatSpecifier(const char *s) {
|
||||
for (const char *p = std::strchr(s, '%'); p != nullptr;
|
||||
p = std::strchr(p + 1, '%')) {
|
||||
const char n = p[1];
|
||||
if (n == '%') { // escaped "%%" — skip both, keep scanning
|
||||
++p;
|
||||
continue;
|
||||
}
|
||||
if (n != '\0' && n != ' ')
|
||||
return true; // "%d", "%s", "%1$..." → real conversion
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool EndsWithShort(const char *key) {
|
||||
const size_t n = std::strlen(key);
|
||||
return n >= 6 && std::strcmp(key + n - 6, "_SHORT") == 0;
|
||||
}
|
||||
|
||||
// Raw `_G[key]` string fetch — no format-specifier filtering. Direct
|
||||
// globals-table access, no pcall. Returns whether a non-empty string was
|
||||
// copied into `out`.
|
||||
bool TryGlobalRaw(void *L, const char *key, char *out, int outSize) {
|
||||
const int saved = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushString(L, key);
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
|
||||
const char *g = (Game::Lua::Type(L, -1) == Game::Lua::TYPE_STRING)
|
||||
? Game::Lua::ToString(L, -1)
|
||||
: nullptr;
|
||||
if (g != nullptr && *g != '\0' && std::strchr(g, '%') == nullptr)
|
||||
const bool ok = (g != nullptr && *g != '\0');
|
||||
if (ok)
|
||||
std::snprintf(out, outSize, "%s", g);
|
||||
else
|
||||
std::snprintf(out, outSize, "%s", FallbackLabel(key));
|
||||
Game::Lua::SetTop(L, saved);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Copies `_G[key]` into `out` iff it's a plain, non-empty, non-format
|
||||
// string. Returns whether it took.
|
||||
bool TryGlobalLabel(void *L, const char *key, char *out, int outSize) {
|
||||
char tmp[192];
|
||||
if (!TryGlobalRaw(L, key, tmp, sizeof(tmp)) || HasFormatSpecifier(tmp))
|
||||
return false;
|
||||
std::snprintf(out, outSize, "%s", tmp);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Formats a signed value token (e.g. "-5") as a localized percentage via
|
||||
// the PERCENTAGE_STRING global (typically "%s%%" → "-5%"). The locale
|
||||
// format is NEVER passed to snprintf — we substitute `num` into its first
|
||||
// conversion slot (`%s`/`%d`) by hand and collapse `%%` → `%`, so a
|
||||
// hostile or unexpected format can't misread the argument list. Falls back
|
||||
// to a plain "%" suffix when the global is absent.
|
||||
void FormatPercent(void *L, const char *num, char *out, int outSize) {
|
||||
char fmt[64];
|
||||
if (!TryGlobalRaw(L, "PERCENTAGE_STRING", fmt, sizeof(fmt))) {
|
||||
std::snprintf(out, outSize, "%s%%", num);
|
||||
return;
|
||||
}
|
||||
int o = 0;
|
||||
bool inserted = false;
|
||||
for (const char *p = fmt; *p != '\0' && o < outSize - 1; ++p) {
|
||||
if (p[0] == '%' && p[1] == '%') { // literal percent
|
||||
out[o++] = '%';
|
||||
++p;
|
||||
} else if (p[0] == '%' && p[1] != '\0' && !inserted) { // value slot
|
||||
for (const char *q = num; *q != '\0' && o < outSize - 1; ++q)
|
||||
out[o++] = *q;
|
||||
++p; // consume the conversion letter (s / d)
|
||||
inserted = true;
|
||||
} else {
|
||||
out[o++] = *p;
|
||||
}
|
||||
}
|
||||
out[o] = '\0';
|
||||
}
|
||||
|
||||
// Resolve a display label for a stat `key` into `out`, respecting
|
||||
// localization. The rating/regen tokens GetItemStats keys on
|
||||
// (`ITEM_MOD_CRIT_MELEE_RATING`, …) carry a "%s" *description* as their
|
||||
// own global; their `_SHORT` sibling is the compact label a delta wants.
|
||||
// So for a key that isn't already `_SHORT`, prefer `_G[key.."_SHORT"]`:
|
||||
// 1. `_G[key.."_SHORT"]` — the compact label (skipped if `key` already
|
||||
// ends in `_SHORT`, e.g. the primary stats, or has no `_SHORT`
|
||||
// variant like `RESISTANCE*_NAME` / `CURRENTLY_EQUIPPED`).
|
||||
// 2. `_G[key]` — the key itself (primary stats resolve here; rating
|
||||
// tokens only land here if no `_SHORT` global exists).
|
||||
// 3. Built-in English `FallbackLabel(key)`.
|
||||
// Preferring `_SHORT` first (rather than only when `_G[key]` fails) means
|
||||
// a server/addon defining the base rating token as a plain string can't
|
||||
// shadow the compact `_SHORT` label. StatAccum keys stay the canonical
|
||||
// GetItemStats tokens; this `_SHORT` mapping is display-only.
|
||||
void LabelFor(void *L, const char *key, char *out, int outSize) {
|
||||
if (!EndsWithShort(key)) {
|
||||
char shortKey[160];
|
||||
std::snprintf(shortKey, sizeof(shortKey), "%s_SHORT", key);
|
||||
if (TryGlobalLabel(L, shortKey, out, outSize))
|
||||
return;
|
||||
}
|
||||
if (TryGlobalLabel(L, key, out, outSize))
|
||||
return;
|
||||
std::snprintf(out, outSize, "%s", FallbackLabel(key));
|
||||
}
|
||||
|
||||
// Delta colors — the canonical GlobalColor.dbc tags modern WoW uses for
|
||||
@@ -237,7 +348,14 @@ void RenderDeltas(void *self, void *L, const Accum *acc, double dpsDelta) {
|
||||
if (v == 0)
|
||||
continue;
|
||||
LabelFor(L, acc[i].key, label, sizeof(label));
|
||||
std::snprintf(line, sizeof(line), "%s%ld %s", v > 0 ? "+" : "", v, label);
|
||||
char num[48];
|
||||
std::snprintf(num, sizeof(num), "%s%ld", v > 0 ? "+" : "", v);
|
||||
if (IsPercentStat(acc[i].key)) {
|
||||
char pct[64];
|
||||
FormatPercent(L, num, pct, sizeof(pct));
|
||||
std::snprintf(num, sizeof(num), "%s", pct);
|
||||
}
|
||||
std::snprintf(line, sizeof(line), "%s %s", num, label);
|
||||
AddColoredLine(self, line, v > 0 ? kGain : kLoss);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `UnitCreatureFamilyID(unit)` — the numeric CreatureFamily id (the
|
||||
// `CreatureFamily.dbc` row: 1 = Wolf, 3 = Cat, 4 = Bear, …) for a unit,
|
||||
// or `nil` when the unit has no family.
|
||||
//
|
||||
// The stock `UnitCreatureFamily(unit)` returns only the *localized name*
|
||||
// ("Wolf", "Chat"), which is awkward to compare and locale-dependent. This
|
||||
// extension exposes the raw id addons actually want for stable checks
|
||||
// (tameable-pet logic, family-specific ability tables, etc.) — the same
|
||||
// value that indexes `CreatureFamily.dbc`.
|
||||
//
|
||||
// This is exactly the id the engine's `Script_UnitCreatureFamily`
|
||||
// (`0x0051A310`) reads before its DBC name lookup: its family helper
|
||||
// `FUN_006055e0` returns `[unit + 0xB30] ? [row + 0x1C] : 0`, then the id is
|
||||
// bounds-checked against `CreatureFamily.dbc` and used to push the localized
|
||||
// name. We stop at the id. `nil` for:
|
||||
// - Unresolvable unit token (`target` with nothing targeted, empty
|
||||
// `partyN` slot, etc.).
|
||||
// - Players and non-beast NPCs — family id is 0 (`UnitCreatureFamily`
|
||||
// returns nil for these too).
|
||||
// - NPCs whose creature data hasn't synced yet (no +0xB30 row).
|
||||
//
|
||||
// Raises a Lua error on a missing / non-string `unit` argument — same
|
||||
// semantics as `UnitCreatureFamily` / `UnitSubName`.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Unit::CreatureFamily {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
|
||||
int __fastcall Script_UnitCreatureFamilyID(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: UnitCreatureFamilyID(\"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; // unsynced NPC / player unit
|
||||
|
||||
const int familyID = *reinterpret_cast<const int *>(
|
||||
cacheRow + Offsets::OFF_CREATURE_CACHE_FAMILY);
|
||||
if (familyID <= 0)
|
||||
return 0; // no family (players, non-beast NPCs)
|
||||
|
||||
Game::Lua::PushNumber(L, static_cast<double>(familyID));
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("UnitCreatureFamilyID",
|
||||
&Script_UnitCreatureFamilyID);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Unit::CreatureFamily
|
||||
@@ -0,0 +1,81 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `UnitCreatureTypeID(unit)` — the numeric CreatureType id (the
|
||||
// `CreatureType.dbc` row: 1 = Beast, 3 = Demon, 7 = Humanoid, …) for a
|
||||
// unit, or `nil` when it has none.
|
||||
//
|
||||
// The stock `UnitCreatureType(unit)` returns only the *localized name*
|
||||
// ("Humanoid", "Humanoïde"), which is awkward to compare and locale-
|
||||
// dependent. This extension exposes the raw id — the twin of
|
||||
// `UnitCreatureFamilyID`.
|
||||
//
|
||||
// It's exactly the value the engine's `Script_UnitCreatureType`
|
||||
// (`0x0051A280`) resolves before its DBC name lookup, so we just call that
|
||||
// same inner helper (`FUN_UNIT_CREATURE_TYPE`). The helper handles all three
|
||||
// engine paths itself — a display-override, the creature-cache type, and the
|
||||
// player-race type (players resolve to Humanoid via their race) — so a
|
||||
// straight call matches `UnitCreatureType` for every unit kind. `nil` for:
|
||||
// - Unresolvable unit token (`target` with nothing targeted, etc.).
|
||||
// - A unit with no resolvable type (helper returns 0).
|
||||
//
|
||||
// Raises a Lua error on a missing / non-string `unit` argument — same
|
||||
// semantics as `UnitCreatureType` / `UnitCreatureFamilyID`.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Unit::CreatureType {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
using CreatureType_t = int(__fastcall *)(void *unit);
|
||||
|
||||
int __fastcall Script_UnitCreatureTypeID(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: UnitCreatureTypeID(\"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);
|
||||
void *unit = resolve(token);
|
||||
if (unit == nullptr)
|
||||
return 0;
|
||||
|
||||
auto creatureType = reinterpret_cast<CreatureType_t>(
|
||||
Offsets::FUN_UNIT_CREATURE_TYPE);
|
||||
const int typeID = creatureType(unit);
|
||||
if (typeID <= 0)
|
||||
return 0; // no resolvable type
|
||||
|
||||
Game::Lua::PushNumber(L, static_cast<double>(typeID));
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("UnitCreatureTypeID",
|
||||
&Script_UnitCreatureTypeID);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Unit::CreatureType
|
||||
+57
-71
@@ -149,64 +149,44 @@ using PlayerInfoLookup_t = const uint8_t *(__thiscall *)(
|
||||
void *cache, uint32_t guidLo, uint32_t guidHi, uint64_t *cookie,
|
||||
void *callback, void *userData, int retryFlag);
|
||||
|
||||
bool AllDigits(const char *s) {
|
||||
if (s == nullptr || *s == '\0')
|
||||
return false;
|
||||
for (; *s != '\0'; ++s) {
|
||||
if (*s < '0' || *s > '9')
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
using ResolveObjectByGuid_t = void *(__fastcall *)(int typeMask,
|
||||
const char *debugName,
|
||||
uint32_t guidLo,
|
||||
uint32_t guidHi,
|
||||
int priority);
|
||||
|
||||
// Resolve a GUID to its live CGUnit / CGPlayer object, or nullptr if
|
||||
// the object isn't currently loaded. Unlike a unit-*token* resolve
|
||||
// (`FUN_RESOLVE_UNIT_TOKEN`), this takes the GUID directly, so it works
|
||||
// for any GUID `FUN_TOKEN_TO_GUID` produced — including `focus` /
|
||||
// `nameplateN` — and never raises. The type arg is a bitmask of
|
||||
// `(1 << objectType)`; `FUN_OBJECT_RESOLVE_BY_GUID` keeps the object
|
||||
// only if its type flags intersect the mask, so unit|player matches a
|
||||
// player, a pet, or an MC'd creature in one call.
|
||||
const uint8_t *ResolveUnitOrPlayerByGuid(uint64_t guid) {
|
||||
if (guid == 0)
|
||||
return nullptr;
|
||||
constexpr int kUnitOrPlayerMask =
|
||||
(1 << Offsets::OBJECT_TYPE_UNIT) | (1 << Offsets::OBJECT_TYPE_PLAYER);
|
||||
auto fn = reinterpret_cast<ResolveObjectByGuid_t>(
|
||||
Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
|
||||
return static_cast<const uint8_t *>(
|
||||
fn(kUnitOrPlayerMask, "UnitIsInMyGuild",
|
||||
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32), 0));
|
||||
}
|
||||
|
||||
bool HasPrefix(const char *s, const char *prefix) {
|
||||
return std::strncmp(s, prefix, std::strlen(prefix)) == 0;
|
||||
}
|
||||
|
||||
// Inline mirror of the engine's token-prefix tree inside
|
||||
// `FUN_TOKEN_TO_GUID` (`0x00515970`). The engine raises a Lua error
|
||||
// on unknown input via `"Unknown unit name: %s"`; we want a
|
||||
// non-erroring "is this a token?" predicate so we can dispatch only
|
||||
// recognized tokens to the engine and treat everything else as a
|
||||
// literal character name.
|
||||
//
|
||||
// Order matters: `partypet`/`raidpet` must be checked before
|
||||
// `party`/`raid` because they share the shorter prefix.
|
||||
bool LooksLikeKnownToken(const char *s) {
|
||||
if (s == nullptr)
|
||||
return false;
|
||||
if (std::strcmp(s, "player") == 0) return true;
|
||||
if (std::strcmp(s, "pet") == 0) return true;
|
||||
if (std::strcmp(s, "target") == 0) return true;
|
||||
if (std::strcmp(s, "mouseover") == 0) return true;
|
||||
if (std::strcmp(s, "npc") == 0) return true;
|
||||
if (HasPrefix(s, "partypet")) return AllDigits(s + 8);
|
||||
if (HasPrefix(s, "raidpet")) return AllDigits(s + 7);
|
||||
if (HasPrefix(s, "party")) return AllDigits(s + 5);
|
||||
if (HasPrefix(s, "raid")) return AllDigits(s + 4);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolves a known unit token to a player name via:
|
||||
// 1. `FUN_TOKEN_TO_GUID` — OOR-safe (reads the SMSG_GROUP_LIST GUID
|
||||
// tables, not CGObject lookups). Returns 0 for empty slots like
|
||||
// `"party6"` when not in a 6-person group.
|
||||
// 2. `FUN_PLAYER_INFO_LOOKUP` with a NULL callback — a pure
|
||||
// NameCache read; returns the entry block for any GUID the
|
||||
// engine has seen a `SMSG_NAME_QUERY_RESPONSE` for. Same path
|
||||
// `GetPlayerInfoByGUID` uses.
|
||||
// Resolves a player GUID to its character name via
|
||||
// `FUN_PLAYER_INFO_LOOKUP` with a NULL callback — a pure NameCache
|
||||
// read that returns the entry block for any GUID the engine has seen a
|
||||
// `SMSG_NAME_QUERY_RESPONSE` for (same path `GetPlayerInfoByGUID`
|
||||
// uses). This is the OOR-safe fallback: it works for a raid member in
|
||||
// another zone whose CGUnit isn't loaded.
|
||||
//
|
||||
// Returns `nameBuf` on success (copied out — the entry's name pointer
|
||||
// isn't guaranteed stable across reentrant cache writes), or nullptr
|
||||
// if either step fails (empty token slot, NPC/creature GUID not in
|
||||
// the player cache, or player not yet name-queried).
|
||||
//
|
||||
// Caller must have already prefix-gated the input via
|
||||
// `LooksLikeKnownToken` — `FUN_TOKEN_TO_GUID` raises a Lua error for
|
||||
// unrecognized tokens.
|
||||
const char *TokenNameViaNameCache(const char *token, char (&nameBuf)[64]) {
|
||||
auto toGuid = reinterpret_cast<TokenToGUID_t>(Offsets::FUN_TOKEN_TO_GUID);
|
||||
const uint64_t guid = toGuid(token);
|
||||
// when the GUID isn't a player the engine has name-queried (NPC GUID,
|
||||
// or not yet queried).
|
||||
const char *NameFromGuid(uint64_t guid, char (&nameBuf)[64]) {
|
||||
if (guid == 0)
|
||||
return nullptr;
|
||||
|
||||
@@ -288,14 +268,25 @@ int __fastcall Script_UnitIsInMyGuild(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const bool inputIsToken = LooksLikeKnownToken(input);
|
||||
// Resolve the input to a GUID via the non-throwing token resolver
|
||||
// (`FUN_TOKEN_TO_GUID`, which our `Unit::TokenExtensions` hook also
|
||||
// teaches `focus` / `nameplateN`). A recognized unit token yields
|
||||
// its GUID; a literal character name — or an empty token slot like
|
||||
// `"party6"` — yields 0, in which case the input is treated as a
|
||||
// name for the roster strcmp. This replaces a hand-rolled mirror of
|
||||
// the engine's token grammar: the engine already knows the grammar,
|
||||
// and unlike its throwing sibling `FUN_RESOLVE_UNIT_TOKEN` this one
|
||||
// just returns 0 on anything it doesn't recognize. (It's also what
|
||||
// 3.3.5's `Script_UnitIsInMyGuild` does — resolve to a GUID first.)
|
||||
auto toGuid = reinterpret_cast<TokenToGUID_t>(Offsets::FUN_TOKEN_TO_GUID);
|
||||
const uint64_t guid = toGuid(input);
|
||||
|
||||
if (inputIsToken) {
|
||||
// Fast path: direct guild-key comparison for visible/loaded
|
||||
// units. `resolve()` is safe to call now that we've prefix-
|
||||
// gated — the engine raises a Lua error only on unrecognized
|
||||
// tokens.
|
||||
auto *unit = static_cast<const uint8_t *>(resolve(input));
|
||||
const char *nameToFind;
|
||||
char nameBuf[64];
|
||||
if (guid != 0) {
|
||||
// Fast path: GUID → live object → direct guild-key comparison.
|
||||
// Covers visible/loaded units without touching the roster.
|
||||
const uint8_t *unit = ResolveUnitOrPlayerByGuid(guid);
|
||||
if (unit != nullptr) {
|
||||
const uint32_t unitKey = ReadGuildKey(unit);
|
||||
if (unitKey == playerKey) {
|
||||
@@ -309,22 +300,17 @@ int __fastcall Script_UnitIsInMyGuild(void *L) {
|
||||
}
|
||||
// unitKey == 0: data not synced. Fall through to roster.
|
||||
}
|
||||
}
|
||||
|
||||
// Slow path: derive a name and strcmp against the roster.
|
||||
const char *nameToFind;
|
||||
char nameBuf[64];
|
||||
if (inputIsToken) {
|
||||
nameToFind = TokenNameViaNameCache(input, nameBuf);
|
||||
// Slow path: GUID → name via the player NameCache → roster
|
||||
// strcmp (handles OOR raid members in another zone).
|
||||
nameToFind = NameFromGuid(guid, nameBuf);
|
||||
if (nameToFind == nullptr) {
|
||||
// Token resolved to an empty slot, an NPC GUID, or a
|
||||
// player the engine hasn't name-queried yet. Either way,
|
||||
// not a guildmate we can identify.
|
||||
// Empty slot, an NPC GUID, or a player not yet name-queried
|
||||
// — not a guildmate we can identify.
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
// Literal name — used directly for the roster strcmp.
|
||||
// Not a recognized token — treat as a literal character name.
|
||||
nameToFind = input;
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,13 @@ const uint8_t *PlayerObject() {
|
||||
/*line*/ 0));
|
||||
}
|
||||
|
||||
const uint8_t *PlayerInventoryManager() {
|
||||
const uint8_t *player = PlayerObject();
|
||||
if (player == nullptr)
|
||||
return nullptr;
|
||||
return player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
|
||||
}
|
||||
|
||||
const uint8_t *PlayerDescriptor() {
|
||||
const uint8_t *player = PlayerObject();
|
||||
if (player == nullptr)
|
||||
|
||||
@@ -44,6 +44,17 @@ const uint8_t *PlayerObject();
|
||||
// re-deriving the player object + descriptor offset in each module.
|
||||
const uint8_t *PlayerDescriptor();
|
||||
|
||||
// The local player's inventory manager (`PlayerObject() +
|
||||
// OFF_PLAYER_INVENTORY_MANAGER`), or null. Layout: slot count at `+0x00`,
|
||||
// flat GUID array at `+OFF_INVMGR_GUID_ARRAY`; it's the `this` that
|
||||
// `GetItemBySlot` expects. This is the SAME pointer the throwing token
|
||||
// resolver (`FUN_RESOLVE_UNIT_TOKEN("player")`) yields — both resolve the
|
||||
// local player's GUID through the object manager (verified: the token
|
||||
// resolver is `TokenToGUID → FUN_OBJECT_RESOLVE_BY_GUID`, differing only in
|
||||
// the type-mask, which both the player object passes) — but non-throwing
|
||||
// (null pre-world). Prefer this over re-deriving `player + 0x1D38` per module.
|
||||
const uint8_t *PlayerInventoryManager();
|
||||
|
||||
// The 64-bit GUID of a resolved CGUnit/CGObject pointer, read through the
|
||||
// pointer at `+0x08` to an instance block whose first 8 bytes are the GUID
|
||||
// (verified in `Script_GetInventoryItemLink`; see `OFF_UNIT_GUID_PTR`).
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user