mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-11 20:37:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aae4eb8a2b | ||
|
|
0a31ee8a89 | ||
|
|
969273f5f7 | ||
|
|
a4760386ed | ||
|
|
f9ef12bc9b | ||
|
|
89d86bffb7 | ||
|
|
a0d8d6d723 | ||
|
|
e0e4fbcf84 | ||
|
|
1da06d4a73 | ||
|
|
960b37d7e6 | ||
|
|
0c27d61839 | ||
|
|
f02f576e70 | ||
|
|
0bd85dd4f3 | ||
|
|
29e3dab24a | ||
|
|
555ea02447 | ||
|
|
e3f5318747 | ||
|
|
cd674aa115 | ||
|
|
f91ffd3189 | ||
|
|
8361ae01eb | ||
|
|
5d785ea0d3 | ||
|
|
e84673dde5 | ||
|
|
a2c5db7b76 | ||
|
|
46597f6587 | ||
|
|
4e1c1863e9 | ||
|
|
a4c57614bc | ||
|
|
e1a9d9b5c4 | ||
|
|
cd280ac307 | ||
|
|
564495248c | ||
|
|
1f3f3cb9f6 | ||
|
|
7ae9b83ad7 | ||
|
|
26e5fdc052 | ||
|
|
4281cd8253 | ||
|
|
9239756153 | ||
|
|
f13aa1943d | ||
|
|
438ad97f8f | ||
|
|
7f4b20c6ec | ||
|
|
3f11f942bd | ||
|
|
9622ea7742 | ||
|
|
659dd7e159 | ||
|
|
1ae102b554 | ||
|
|
a5b6cf367d | ||
|
|
b496568670 | ||
|
|
a37c590a72 | ||
|
|
60479677c1 | ||
|
|
b5038dc3fd | ||
|
|
0bbf802316 | ||
|
|
0fcaa2e064 | ||
|
|
6724ddd2bd | ||
|
|
50d001a71f | ||
|
|
3aad341a91 | ||
|
|
3141acf547 | ||
|
|
05e7bda5da | ||
|
|
c4b48f7f42 | ||
|
|
4d97b1c449 | ||
|
|
e447c70ca3 | ||
|
|
72fa36956e | ||
|
|
40813a715c | ||
|
|
a14ccfacbb | ||
|
|
4afe0b9eae | ||
|
|
c01dceabb8 | ||
|
|
365df6b882 | ||
|
|
72de6c0960 | ||
|
|
eeba4ada9d | ||
|
|
5f13c63c99 | ||
|
|
afa1f62720 | ||
|
|
c369373efe | ||
|
|
1c86d0dead | ||
|
|
cedbd04553 | ||
|
|
3b1bba2131 | ||
|
|
786428c9fb | ||
|
|
f5ecdc69c5 | ||
|
|
7531223b9a | ||
|
|
cb4e428592 | ||
|
|
e7e2fe05dc | ||
|
|
d7078827fd | ||
|
|
c571c1a852 | ||
|
|
a7b205cd56 | ||
|
|
a3f224876f | ||
|
|
a40236eb84 | ||
|
|
538beda499 | ||
|
|
af01a4b408 | ||
|
|
33c8c3e37c | ||
|
|
c5d5415afe | ||
|
|
39dcfc73de | ||
|
|
6e5f7c3e43 | ||
|
|
2e05e86362 | ||
|
|
bcc631c4ed | ||
|
|
f9fb32186d | ||
|
|
60adbba7c3 | ||
|
|
c35d883af0 | ||
|
|
c202c1000f | ||
|
|
98b4ae14e7 | ||
|
|
8d957eb424 | ||
|
|
78618ea2cc | ||
|
|
1a4583e16e | ||
|
|
a20e1206fb | ||
|
|
9f67cade87 | ||
|
|
8629e13e1f | ||
|
|
ca9bfbab5e | ||
|
|
8fdc767cc7 | ||
|
|
aa31c25be5 | ||
|
|
29aafe88f8 | ||
|
|
461f15a4a6 | ||
|
|
6245d72694 | ||
|
|
bd14228ad7 | ||
|
|
5b15172894 | ||
|
|
b5470e8d3d | ||
|
|
c601967fdb | ||
|
|
5c063ade73 | ||
|
|
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 |
@@ -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
|
||||
@@ -38,3 +38,10 @@ Util\LinkUtil.lua
|
||||
Util\TradeSkillLink.lua
|
||||
Util\IconDataProvider.lua
|
||||
Util\GameTooltip.lua
|
||||
Util\Rectangle.lua
|
||||
Util\Vector2D.lua
|
||||
Util\Vector3D.lua
|
||||
Util\Vector4D.lua
|
||||
Util\UIParent.lua
|
||||
Util\AuraDurationModifiers.lua
|
||||
Util\SecureUnitMenu.lua
|
||||
|
||||
@@ -8,5 +8,53 @@ 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
|
||||
end
|
||||
|
||||
if C_AddOns.DoesAddOnExist("SpecialTalentUI") then
|
||||
EventUtil.ContinueOnAddOnLoaded("SpecialTalentUI", CAPI_ApplyStandardColorGlobals)
|
||||
end
|
||||
|
||||
-- ShaguTweaks libpredict register's TBC events that ClassicAPI backports.
|
||||
if C_AddOns.DoesAddOnExist("ShaguTweaks") then
|
||||
EventUtil.ContinueOnAddOnLoaded("ShaguTweaks", function()
|
||||
local libp = ShaguTweaks.libpredict
|
||||
if libp then
|
||||
libp.sender:UnregisterEvent("COMBAT_LOG_EVENT_UNFILTERED")
|
||||
libp.sender:UnregisterEvent("UNIT_SPELLCAST_START")
|
||||
libp.sender:UnregisterEvent("UNIT_SPELLCAST_STOP")
|
||||
libp.sender:UnregisterEvent("UNIT_SPELLCAST_FAILED")
|
||||
libp.sender:UnregisterEvent("UNIT_SPELLCAST_INTERRUPTED")
|
||||
libp.sender:UnregisterEvent("UNIT_SPELLCAST_SENT")
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
-- Server-side DoT-duration adjustments the 1.12 client is never told about.
|
||||
--
|
||||
-- ClassicAPI derives C_UnitAuras expirationTime for a debuff on another unit
|
||||
-- from the cast packet (SMSG_SPELL_GO). The vanilla server never retransmits a
|
||||
-- LATER server-side duration change on a non-self unit -- verified in the
|
||||
-- server source: the change runs through SetAuraDuration / RefreshHolder, and
|
||||
-- the only duration packet (SMSG_UPDATE_AURA_DURATION) is self-scoped to the
|
||||
-- aura-bearer, so an observing caster hears nothing (and on a mob the packet
|
||||
-- isn't even built). We can't hear the change, but we DO see the *triggering*
|
||||
-- cast, so C_UnitAuras.RegisterAuraDurationModifier mirrors the server's edit
|
||||
-- off that trigger.
|
||||
--
|
||||
-- A trigger is matched by exact spellID (stable across ranks -- these are the
|
||||
-- IDs the server binds its spell script to); the affected aura by
|
||||
-- SpellFamilyName + a family-flag overlap (rank-proof, exactly how the server
|
||||
-- scripts locate it). Flags below are from the client's Spell.dbc. When the
|
||||
-- trigger is a whole class of spells rather than a named ability,
|
||||
-- RegisterAuraDurationModifierByTrigger matches it by SpellFamilyName + school
|
||||
-- index instead (see Shadow Weaving below).
|
||||
--
|
||||
-- NOT included on purpose: Carnage's Rip/Rake refresh fires behind a
|
||||
-- server-side probability roll on Ferocious Bite. The client can't know the
|
||||
-- roll's outcome, so inferring it would show wrong timers. If you accept that,
|
||||
-- register it yourself, e.g. (DRUID = 7):
|
||||
-- C_UnitAuras.RegisterAuraDurationModifier(<ferociousBiteID>, 7, tonumber("800000",16), 108, "refresh") -- Rip
|
||||
-- C_UnitAuras.RegisterAuraDurationModifier(<ferociousBiteID>, 7, tonumber("1000",16), 494, "refresh") -- Rake
|
||||
-- Conflagrate's *full*-consume variant (other servers) removes Immolate
|
||||
-- outright; that clears the aura slot and ClassicAPI evicts it via the normal
|
||||
-- removal path, so the reduce rule below is harmless there and correct on
|
||||
-- Turtle (where Immolate keeps ticking with 3s shaved off).
|
||||
|
||||
if TURTLE_WOW_VERSION then
|
||||
local WARLOCK, SHAMAN = 5, 11
|
||||
-- Lua 5.0 has no 0x hex literals; tonumber(hex, 16) keeps the flags readable.
|
||||
local IMMOLATE_FLAG = tonumber("4", 16) -- CF_WARLOCK_IMMOLATE (bit 2)
|
||||
local FLAMESHOCK_FLAG = tonumber("10000000", 16) -- CF_SHAMAN_FLAME_SHOCK (bit 28)
|
||||
|
||||
-- Conflagrate (all ranks) -> shave 3s off the caster's Immolate.
|
||||
local CONFLAGRATE = { 17962, 18930, 18931, 18932 }
|
||||
for i = 1, table.getn(CONFLAGRATE) do
|
||||
C_UnitAuras.RegisterAuraDurationModifier(CONFLAGRATE[i], WARLOCK, IMMOLATE_FLAG, 0, "reduce", 3)
|
||||
end
|
||||
|
||||
-- Molten Blast (all ranks) -> refresh the caster's Flame Shock to full.
|
||||
local MOLTEN_BLAST = { 36916, 36917, 36918, 36919, 36920, 36921 }
|
||||
for i = 1, table.getn(MOLTEN_BLAST) do
|
||||
C_UnitAuras.RegisterAuraDurationModifier(MOLTEN_BLAST[i], SHAMAN, FLAMESHOCK_FLAG, 0, "refresh")
|
||||
end
|
||||
end
|
||||
-- Shadow Weaving (priest): any shadow-school priest cast refreshes the caster's
|
||||
-- Shadow Vulnerability (15258, 15s, stacks to 5). The 1->5 stack changes fire
|
||||
-- OnAuraStacksChanged (already handled); the 5->5 refresh is the silent blind
|
||||
-- spot this covers. Matched by family(6) + school(5) so it spans every priest
|
||||
-- shadow spell/rank incl. Turtle additions, excluding holy/discipline casts
|
||||
-- (Smite, heals) which are priest-family but not shadow-school.
|
||||
--
|
||||
-- Gated on the 5/5 talent (15334), where the proc is 100% -> deterministic;
|
||||
-- below 5/5 it's chance-based and inferring on every cast would over-refresh.
|
||||
-- Deferred to SPELLS_CHANGED because talent/spell state isn't ready at load,
|
||||
-- and re-checked there so a respec INTO 5/5 registers without a reload.
|
||||
-- Caveat: DoT *ticks* (SW:P / Devouring Plague) refresh it server-side too but
|
||||
-- emit no cast packet, so a pure DoT-only phase (no direct casts) under-counts.
|
||||
local PRIEST, SHADOW = 6, 5
|
||||
local SW_FLAG, SW_ICON = tonumber("4000000", 16), 9 -- 15258 SpellFamilyFlags + icon
|
||||
|
||||
EventUtil.RegisterOnceFrameEventAndCallback("SPELLS_CHANGED", function()
|
||||
if IsPlayerSpell(15334) then
|
||||
C_UnitAuras.RegisterAuraDurationModifierByTrigger(PRIEST, SHADOW, PRIEST, SW_FLAG, SW_ICON, "refresh")
|
||||
end
|
||||
end)
|
||||
@@ -26,10 +26,17 @@ local function GenerateClosure(func, ...)
|
||||
for i = 1, boundN do bound[i] = arg[i] end
|
||||
return function(...)
|
||||
local callN = arg.n
|
||||
local merged = {}
|
||||
for i = 1, boundN do merged[i] = bound[i] end
|
||||
for i = 1, callN do merged[boundN + i] = arg[i] end
|
||||
return func(unpack(merged, 1, boundN + callN))
|
||||
-- Forward bound + call args faithfully, embedded/leading nils and all.
|
||||
-- The ONLY thing that makes unpack span holes on this Lua 5.0 build is
|
||||
-- a `.n` field: unpack takes its count from getn (which returns t.n)
|
||||
-- and ignores any explicit i/j, so a `.n`-less table stops dead at the
|
||||
-- first hole (verified: unpack({1, 1, nil, 1}, 1, 4) yields just 1, 1).
|
||||
local all = {}
|
||||
local n = 0
|
||||
for i = 1, boundN do n = n + 1; all[n] = bound[i] end
|
||||
for i = 1, callN do n = n + 1; all[n] = arg[i] end
|
||||
all.n = n
|
||||
return func(unpack(all))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -145,11 +152,14 @@ function CallbackRegistryMixin:TriggerEvent(event, ...)
|
||||
if funcs then
|
||||
for owner, func in pairs(funcs) do
|
||||
-- Stock 3.3.5 passes the owner as the first arg so callers can
|
||||
-- distinguish multiple registrations of the same function.
|
||||
local n = arg.n
|
||||
local merged = { owner }
|
||||
for i = 1, n do merged[i + 1] = arg[i] end
|
||||
func(unpack(merged, 1, n + 1))
|
||||
-- distinguish multiple registrations of the same function. `arg`
|
||||
-- carries its own `.n`, so unpack(arg) forwards the payload with
|
||||
-- leading/embedded nils intact. Do NOT instead merge into a
|
||||
-- `{ owner, ... }` table and unpack that WITHOUT setting `.n`: this
|
||||
-- build's unpack counts via getn, so a `.n`-less table collapses at
|
||||
-- the first hole (verified in-game: reload's PLAYER_ENTERING_WORLD,
|
||||
-- arg1=nil arg2=1, dropped the payload).
|
||||
func(owner, unpack(arg))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -24,8 +24,14 @@ function ColorMixin:GetRGB()
|
||||
return self.r, self.g, self.b
|
||||
end
|
||||
|
||||
function ColorMixin:GetHSL()
|
||||
local r, g, b, a = self.r, self.g, self.b, self.a
|
||||
local h, s, l = C_ColorUtil.ConvertHSVToHSL(C_ColorUtil.ConvertRGBToHSV(r, g, b))
|
||||
return h, s, l, a or 1
|
||||
end
|
||||
|
||||
function ColorMixin:GetRGBAsBytes()
|
||||
return self.r * 255, self.g * 255, self.b * 255
|
||||
return Round(self.r * 255), Round(self.g * 255), Round(self.b * 255)
|
||||
end
|
||||
|
||||
function ColorMixin:GetRGBA()
|
||||
@@ -33,14 +39,14 @@ function ColorMixin:GetRGBA()
|
||||
end
|
||||
|
||||
function ColorMixin:GetRGBAAsBytes()
|
||||
return self.r * 255, self.g * 255, self.b * 255, (self.a or 1) * 255
|
||||
return Round(self.r * 255), Round(self.g * 255), Round(self.b * 255), Round((self.a or 1) * 255)
|
||||
end
|
||||
|
||||
function ColorMixin:SetRGBA(r, g, b, a)
|
||||
self.r = r
|
||||
self.g = g
|
||||
self.b = b
|
||||
self.a = a
|
||||
self.a = a or 1
|
||||
end
|
||||
|
||||
function ColorMixin:SetRGB(r, g, b)
|
||||
@@ -48,56 +54,27 @@ function ColorMixin:SetRGB(r, g, b)
|
||||
end
|
||||
|
||||
function ColorMixin:GenerateHexColor()
|
||||
return string.format("ff%.2x%.2x%.2x", self:GetRGBAsBytes())
|
||||
return C_ColorUtil.GenerateTextColorCode(self)
|
||||
end
|
||||
|
||||
function ColorMixin:GenerateHexColorNoAlpha()
|
||||
return string.format("%.2X%.2X%.2X", self:GetRGBAsBytes())
|
||||
end
|
||||
|
||||
function ColorMixin:GenerateHexColorMarkup()
|
||||
return "|c"..self:GenerateHexColor()
|
||||
end
|
||||
|
||||
function ColorMixin:GenerateHexColorNoAlpha()
|
||||
return string.format("%.2x%.2x%.2x", self:GetRGBAsBytes())
|
||||
end
|
||||
|
||||
function ColorMixin:GetHSL()
|
||||
local r, g, b, a = self.r, self.g, self.b, self.a
|
||||
local max, min = math.max(r, g, b), math.min(r, g, b)
|
||||
local h, s, l
|
||||
l = (max + min) / 2
|
||||
if max == min then
|
||||
h, s = 0, 0 -- achromatic
|
||||
else
|
||||
local d = max - min
|
||||
if l > 0.5 then
|
||||
s = d / (2 - max - min)
|
||||
else
|
||||
s = d / (max + min)
|
||||
end
|
||||
if max == r then
|
||||
h = (g - b) / d
|
||||
if g < b then
|
||||
h = h + 6
|
||||
end
|
||||
elseif max == g then
|
||||
h = (b - r) / d + 2
|
||||
elseif max == b then
|
||||
h = (r - g) / d + 4
|
||||
end
|
||||
h = h / 6
|
||||
end
|
||||
return h, s, l, a or 1
|
||||
end
|
||||
|
||||
function ColorMixin:WrapTextInColorCode(text)
|
||||
return WrapTextInColorCode(text, self:GenerateHexColor())
|
||||
return C_ColorUtil.WrapTextInColor(text, self)
|
||||
end
|
||||
|
||||
function WrapTextInColorCode(text, colorHexString)
|
||||
return string.format("|c%s%s|r", colorHexString, text)
|
||||
return C_ColorUtil.WrapTextInColorCode(text, colorHexString)
|
||||
end
|
||||
|
||||
function WrapTextInColor(text, color)
|
||||
return WrapTextInColorCode(text, color:GenerateHexColor())
|
||||
return C_ColorUtil.WrapTextInColor(text, color)
|
||||
end
|
||||
|
||||
function ColorMixin:WrapTextInColorTableCode(text)
|
||||
@@ -108,11 +85,14 @@ for _, v in pairs(ITEM_QUALITY_COLORS) do
|
||||
v.color = CreateColor(v.r, v.g, v.b, 1)
|
||||
end
|
||||
|
||||
do
|
||||
function CAPI_ApplyStandardColorGlobals()
|
||||
local envTbl = _G
|
||||
for _, dbColor in ipairs(C_UIColor.GetColors()) do
|
||||
local color = CreateColor(dbColor.color.r, dbColor.color.g, dbColor.color.b, dbColor.color.a)
|
||||
envTbl[dbColor.baseTag] = color
|
||||
envTbl[dbColor.baseTag.."_CODE"] = color:GenerateHexColorMarkup()
|
||||
if not envTbl[dbColor.baseTag] or not envTbl[dbColor.baseTag].GetRGB then
|
||||
local color = CreateColor(dbColor.color.r, dbColor.color.g, dbColor.color.b, dbColor.color.a)
|
||||
envTbl[dbColor.baseTag] = color
|
||||
envTbl[dbColor.baseTag.."_CODE"] = color:GenerateHexColorMarkup()
|
||||
end
|
||||
end
|
||||
end
|
||||
CAPI_ApplyStandardColorGlobals()
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
COLOR_FORMAT_RGBA = "RRGGBBAA"
|
||||
COLOR_FORMAT_RGB = "RRGGBB"
|
||||
COLOR_FORMAT_ARGB = "AARRGGBB"
|
||||
FONT_COLOR_CODE_CLOSE = "|r"
|
||||
|
||||
do
|
||||
local ColorMixin = ColorMixin
|
||||
for _, classColor in pairs(RAID_CLASS_COLORS) do
|
||||
@@ -12,30 +17,40 @@ function ExtractColorValueFromHex(str, index)
|
||||
end
|
||||
|
||||
function CreateColorFromHexString(hexColor)
|
||||
if string.len(hexColor) == 8 then
|
||||
if string.len(hexColor) == string.len(COLOR_FORMAT_ARGB) then
|
||||
local a, r, g, b = ExtractColorValueFromHex(hexColor, 1), ExtractColorValueFromHex(hexColor, 3), ExtractColorValueFromHex(hexColor, 5), ExtractColorValueFromHex(hexColor, 7);
|
||||
return CreateColor(r, g, b, a);
|
||||
else
|
||||
error("CreateColorFromHexString input must be hexadecimal digits in this format: AARRGGBB.", 2);
|
||||
end
|
||||
error("CreateColorFromHexString input must be hexadecimal digits in this format: "..COLOR_FORMAT_ARGB..".", 2);
|
||||
return nil;
|
||||
end
|
||||
|
||||
function CreateColorFromRGBAHexString(hexColor)
|
||||
if string.len(hexColor) == 8 then
|
||||
if string.len(hexColor) == string.len(COLOR_FORMAT_RGBA) then
|
||||
local r, g, b, a = ExtractColorValueFromHex(hexColor, 1), ExtractColorValueFromHex(hexColor, 3), ExtractColorValueFromHex(hexColor, 5), ExtractColorValueFromHex(hexColor, 7);
|
||||
return CreateColor(r, g, b, a);
|
||||
else
|
||||
error("CreateColorFromRGBAHexString input must be hexadecimal digits in this format: RRGGBBAA.", 2);
|
||||
end
|
||||
error("CreateColorFromRGBAHexString input must be hexadecimal digits in this format: "..COLOR_FORMAT_RGBA..".", 2);
|
||||
return nil;
|
||||
end
|
||||
|
||||
function CreateColorFromRGBHexString(hexColor)
|
||||
if string.len(hexColor) == 6 then
|
||||
if string.len(hexColor) == string.len(COLOR_FORMAT_RGB) then
|
||||
local r, g, b = ExtractColorValueFromHex(hexColor, 1), ExtractColorValueFromHex(hexColor, 3), ExtractColorValueFromHex(hexColor, 5);
|
||||
return CreateColor(r, g, b, 1);
|
||||
else
|
||||
error("CreateColorFromRGBHexString input must be hexadecimal digits in this format: RRGGBB.", 2);
|
||||
end
|
||||
error("CreateColorFromRGBHexString input must be hexadecimal digits in this format: "..COLOR_FORMAT_RGB..".", 2);
|
||||
return nil;
|
||||
end
|
||||
|
||||
function CreateColorFromBestRGBHexString(hexColor)
|
||||
if string.len(hexColor) == string.len(COLOR_FORMAT_RGBA) then
|
||||
return CreateColorFromRGBAHexString(hexColor);
|
||||
elseif string.len(hexColor) == string.len(COLOR_FORMAT_RGB) then
|
||||
return CreateColorFromRGBHexString(hexColor);
|
||||
end
|
||||
error("CreateColorFromBestRGBHexString input must be hexadecimal digits in either of these formats: "..COLOR_FORMAT_RGBA.." / "..COLOR_FORMAT_RGB..".", 2);
|
||||
return nil;
|
||||
end
|
||||
|
||||
function CreateColorFromBytes(r, g, b, a)
|
||||
@@ -68,9 +83,7 @@ end
|
||||
function GetClassColoredTextForUnit(unit, text)
|
||||
local _, classFilename = UnitClass(unit);
|
||||
local color = GetClassColorObj(classFilename);
|
||||
if (color) then
|
||||
return color:WrapTextInColorCode(text);
|
||||
end
|
||||
return color and color:WrapTextInColorCode(text) or text;
|
||||
end
|
||||
|
||||
function GetFactionColor(factionGroupTag)
|
||||
@@ -82,7 +95,7 @@ function GetFactionColor(factionGroupTag)
|
||||
end
|
||||
|
||||
function RGBToColorCode(r, g, b)
|
||||
return string.format("|cff%.2x%.2x%.2x", r * 255, g * 255, b * 255);
|
||||
return string.format("|cff%02x%02x%02x", r * 255, g * 255, b * 255);
|
||||
end
|
||||
|
||||
function RGBTableToColorCode(rgbTable)
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
--
|
||||
-- Implementation differences from the modern source:
|
||||
-- - Lua 5.0 has no select() and no `...` as expression; use the
|
||||
-- implicit `arg` table (arg.n + arg[i]) and unpack(arg, 1, arg.n).
|
||||
-- implicit `arg` table (arg.n + arg[i]). unpack(arg) already spans
|
||||
-- arg[1..arg.n] via getn — this build's unpack ignores explicit i/j
|
||||
-- args, so the count must live in the table's `.n`.
|
||||
-- - Lua 5.0 has no `#` operator; use table.getn().
|
||||
-- - 5.0's xpcall is strictly (f, handler) — no trailing args. We wrap
|
||||
-- the script call in a local closure that captures frame + arg.
|
||||
@@ -32,7 +34,7 @@ function ExecuteFrameScript(frame, scriptName, ...)
|
||||
if script then
|
||||
local args = arg;
|
||||
local function invoke()
|
||||
return script(frame, unpack(args, 1, args.n));
|
||||
return script(frame, unpack(args));
|
||||
end
|
||||
xpcall(invoke, CallErrorHandler);
|
||||
end
|
||||
@@ -45,7 +47,7 @@ function CallMethodOnNearestAncestor(self, methodName, ...)
|
||||
end
|
||||
|
||||
if ancestor then
|
||||
return true, ancestor[methodName](ancestor, unpack(arg, 1, arg.n));
|
||||
return true, ancestor[methodName](ancestor, unpack(arg));
|
||||
end
|
||||
|
||||
return false;
|
||||
@@ -58,7 +60,7 @@ function GetValueOrCallFunction(tbl, key, ...)
|
||||
|
||||
local value = tbl[key];
|
||||
if type(value) == "function" then
|
||||
return value(unpack(arg, 1, arg.n));
|
||||
return value(unpack(arg));
|
||||
else
|
||||
return value;
|
||||
end
|
||||
@@ -117,12 +119,12 @@ function IterateTables(iteratorFunction, ...)
|
||||
end
|
||||
|
||||
local s_passThroughClosureGenerators = {
|
||||
function(f) return function(...) return f(unpack(arg, 1, arg.n)); end; end,
|
||||
function(f, a) return function(...) return f(a, unpack(arg, 1, arg.n)); end; end,
|
||||
function(f, a, b) return function(...) return f(a, b, unpack(arg, 1, arg.n)); end; end,
|
||||
function(f, a, b, c) return function(...) return f(a, b, c, unpack(arg, 1, arg.n)); end; end,
|
||||
function(f, a, b, c, d) return function(...) return f(a, b, c, d, unpack(arg, 1, arg.n)); end; end,
|
||||
function(f, a, b, c, d, e) return function(...) return f(a, b, c, d, e, unpack(arg, 1, arg.n)); end; end,
|
||||
function(f) return function(...) return f(unpack(arg)); end; end,
|
||||
function(f, a) return function(...) return f(a, unpack(arg)); end; end,
|
||||
function(f, a, b) return function(...) return f(a, b, unpack(arg)); end; end,
|
||||
function(f, a, b, c) return function(...) return f(a, b, c, unpack(arg)); end; end,
|
||||
function(f, a, b, c, d) return function(...) return f(a, b, c, d, unpack(arg)); end; end,
|
||||
function(f, a, b, c, d, e) return function(...) return f(a, b, c, d, e, unpack(arg)); end; end,
|
||||
};
|
||||
|
||||
local s_flatClosureGenerators = {
|
||||
@@ -138,7 +140,7 @@ local function GenerateClosureInternal(generatorArray, f, ...)
|
||||
local count = arg.n;
|
||||
local generator = generatorArray[count + 1];
|
||||
if generator then
|
||||
return generator(f, unpack(arg, 1, count));
|
||||
return generator(f, unpack(arg));
|
||||
end
|
||||
|
||||
assertsafe(false, "Closure generation does not support more than " .. (table.getn(generatorArray) - 1) .. " parameters");
|
||||
@@ -147,14 +149,14 @@ end
|
||||
|
||||
-- Syntactic sugar for function(...) return f(a, b, c, ...); end
|
||||
function GenerateClosure(f, ...)
|
||||
return GenerateClosureInternal(s_passThroughClosureGenerators, f, unpack(arg, 1, arg.n));
|
||||
return GenerateClosureInternal(s_passThroughClosureGenerators, f, unpack(arg));
|
||||
end
|
||||
|
||||
-- Generates a closure with the specified arguments that will ignore extra arguments when called later. Useful for passing
|
||||
-- through callbacks to APIs where we don't want extra arguments to be passed through, i.e. simple OnClick scripts.
|
||||
-- This is equivalent to: function() return f(a, b, c); end INSTEAD OF function(...) return f(a, b, c, ...); end
|
||||
function GenerateFlatClosure(f, ...)
|
||||
return GenerateClosureInternal(s_flatClosureGenerators, f, unpack(arg, 1, arg.n));
|
||||
return GenerateClosureInternal(s_flatClosureGenerators, f, unpack(arg));
|
||||
end
|
||||
|
||||
function RunNextFrame(callback)
|
||||
@@ -166,7 +168,7 @@ FunctionUtil = {};
|
||||
function FunctionUtil.SafeInvokeMethod(table, methodName, ...)
|
||||
local method = table[methodName];
|
||||
if method then
|
||||
return method(table, unpack(arg, 1, arg.n));
|
||||
return method(table, unpack(arg));
|
||||
end
|
||||
|
||||
return nil;
|
||||
|
||||
@@ -90,3 +90,31 @@ function GameTooltip_ShowCompareItem(self, shift)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Guard the stock GameTooltip UPDATE_MOUSEOVER_UNIT handler (issue #12).
|
||||
--
|
||||
-- ClassicAPI's DLL now fires UPDATE_MOUSEOVER_UNIT on mouseover LOSS too (retail
|
||||
-- parity), not just on gain. The stock inline handler in FrameXML/GameTooltip.xml
|
||||
-- recolors the name unconditionally:
|
||||
-- _G[this:GetName().."TextLeft1"]:SetTextColor(GameTooltip_UnitColor("mouseover"))
|
||||
-- and GameTooltip_UnitColor returns white (1,1,1) when "mouseover" doesn't exist.
|
||||
-- So the loss fire (and the transient clear that happens when clicking a unit)
|
||||
-- repaints the still-visible/fading name white before it disappears -- reported
|
||||
-- as NPC and PvP-flagged-player names flashing white.
|
||||
--
|
||||
-- We wrap the stock handler instead of replacing its body: swallow the event
|
||||
-- only when there is no mouseover unit to color, otherwise delegate to the
|
||||
-- original untouched. During the loss window the name simply keeps its last
|
||||
-- color instead of going white; when a mouseover resolves again it recolors
|
||||
-- normally. (1.12 OnEvent handlers read the `event`/`this` globals the engine
|
||||
-- sets before invoking us, so calling the original as a plain function works.)
|
||||
local originalOnEvent = GameTooltip:GetScript("OnEvent")
|
||||
GameTooltip:SetScript("OnEvent", function()
|
||||
if ( event == "UPDATE_MOUSEOVER_UNIT" and not UnitExists("mouseover") ) then
|
||||
return;
|
||||
end
|
||||
|
||||
if ( originalOnEvent ) then
|
||||
originalOnEvent();
|
||||
end
|
||||
end)
|
||||
|
||||
@@ -22,6 +22,18 @@ function EventRegistry:OnLoad()
|
||||
-- Lua-5.0 varargs, so we forward through a small wrapper.
|
||||
self:DispatchGameEvent(event, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9)
|
||||
end)
|
||||
|
||||
-- Stop delivering events once logout begins: a callback firing during the
|
||||
-- engine's teardown can touch state that's already gone. A dedicated frame
|
||||
-- owns PLAYER_LOGOUT so this fires regardless of what consumers registered
|
||||
-- — folding the check into frameEventFrame's OnEvent would depend on the
|
||||
-- ref-counted event set still holding PLAYER_LOGOUT at logout time.
|
||||
self.logoutFrame = CreateFrame("Frame")
|
||||
self.logoutFrame:RegisterEvent("PLAYER_LOGOUT")
|
||||
self.logoutFrame:SetScript("OnEvent", function()
|
||||
self.frameEventFrame:UnregisterAllEvents()
|
||||
self.frameEventFrame:SetScript("OnEvent", nil)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Helper so we can forward up to nine event args (the most any vanilla
|
||||
|
||||
@@ -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 = {}
|
||||
@@ -218,8 +216,7 @@ function ItemMixin:GetItemLink() -- requires item data to be loaded
|
||||
end
|
||||
|
||||
if self.itemID then
|
||||
local _, link = GetItemInfo(self.itemID)
|
||||
return link
|
||||
return (select(2, C_Item.GetItemInfo(self.itemID)));
|
||||
end
|
||||
|
||||
if not self:IsItemEmpty() then
|
||||
@@ -301,9 +298,7 @@ end
|
||||
|
||||
function ItemMixin:GetInventoryTypeName()
|
||||
if not self:IsItemEmpty() then
|
||||
-- GetItemInfoInstant returns: itemID, itemType, itemSubType, equipLoc, ...
|
||||
local _, _, _, equipLoc = C_Item.GetItemInfoInstant(self:GetItemID())
|
||||
return equipLoc
|
||||
return select(4, C_Item.GetItemInfoInstant(self:GetItemID()));
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -11,12 +11,19 @@ local random = math.random
|
||||
local atan2 = math.atan2
|
||||
local sqrt = math.sqrt
|
||||
|
||||
math.huge = 1 / 0
|
||||
MathUtil =
|
||||
{
|
||||
Epsilon = .000001,
|
||||
};
|
||||
|
||||
function CreateCounter(initialCount)
|
||||
MathUtil.ApproxZero = MathUtil.Epsilon;
|
||||
MathUtil.ApproxOne = 1.0 - MathUtil.Epsilon;
|
||||
|
||||
function CreateCounter(initialCount, step)
|
||||
local count = initialCount or 0
|
||||
step = step or 1
|
||||
return function()
|
||||
count = count + 1
|
||||
count = count + step
|
||||
return count
|
||||
end
|
||||
end
|
||||
@@ -130,3 +137,31 @@ end
|
||||
function CalculateAngleBetween(x1, y1, x2, y2)
|
||||
return atan2(y2 - y1, x2 - x1)
|
||||
end
|
||||
|
||||
AccumulatorMixin = {};
|
||||
|
||||
function AccumulatorMixin:Init(initialCount)
|
||||
self.count = initialCount or 0;
|
||||
end
|
||||
|
||||
function AccumulatorMixin:Add(count)
|
||||
self.count = self.count + count;
|
||||
return self.count;
|
||||
end
|
||||
|
||||
function AccumulatorMixin:Subtract(count)
|
||||
self.count = self.count - count;
|
||||
return self.count;
|
||||
end
|
||||
|
||||
function AccumulatorMixin:Count()
|
||||
return self.count;
|
||||
end
|
||||
|
||||
function AccumulatorMixin:Reset(resetCount)
|
||||
self.count = resetCount or 0;
|
||||
end
|
||||
|
||||
function CreateAccumulator(initialCount)
|
||||
return CreateAndInitFromMixin(AccumulatorMixin, initialCount);
|
||||
end
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
-- Backport of 3.3.5 FrameXML/Mixin.lua to vanilla 1.12 / Lua 5.0.
|
||||
-- `Mixin` and `CreateFromMixins` are engine (DLL) natives — see
|
||||
-- src/baselib/Mixin.cpp — so they exist whenever ClassicAPI is injected,
|
||||
-- even with this addon disabled, mirroring retail where they live in
|
||||
-- TableUtil as C functions. Only the composite helper (which calls a Lua
|
||||
-- `:Init` method) stays here, like retail's Mixin.lua.
|
||||
--
|
||||
-- Implementation differences from the 3.3.5 source:
|
||||
-- - Lua 5.0 has no select(); inside a vararg function the args are
|
||||
-- gathered into an implicit `arg` table (`arg.n` for the count).
|
||||
|
||||
function Mixin(object, ...)
|
||||
for i = 1, arg.n do
|
||||
local mixin = arg[i]
|
||||
if mixin then
|
||||
for k, v in pairs(mixin) do
|
||||
object[k] = v
|
||||
end
|
||||
end
|
||||
end
|
||||
return object
|
||||
end
|
||||
|
||||
function CreateFromMixins(...)
|
||||
return Mixin({}, unpack(arg))
|
||||
end
|
||||
-- Lua 5.0 note: varargs arrive in the implicit `arg` table (`arg.n` count),
|
||||
-- so we forward them with unpack(arg) rather than 5.1's `...`.
|
||||
|
||||
function CreateAndInitFromMixin(mixin, ...)
|
||||
local object = CreateFromMixins(mixin)
|
||||
|
||||
@@ -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
|
||||
@@ -28,7 +28,7 @@ function PlayerUtil.GetClassColor()
|
||||
end
|
||||
|
||||
function PlayerUtil.GetCurrentSpeed()
|
||||
return GetUnitSpeed("player") / 7 * 100
|
||||
return GetUnitSpeed("player") / BASE_MOVEMENT_SPEED * 100
|
||||
end
|
||||
|
||||
function GetPlayerGuid()
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
-- consumer addons doesn't fail; they'll error at Acquire() time
|
||||
-- if anything actually invokes them.
|
||||
-- - Lua 5.0 has no `...` as expression; the converter mixins use
|
||||
-- `unpack(arg, 1, arg.n)` to forward varargs.
|
||||
-- `unpack(arg)` to forward varargs (spans arg[1..arg.n] via getn;
|
||||
-- this build's unpack ignores explicit i/j — the count lives in `.n`).
|
||||
-- - assertsafe / nop aren't globals in stock 1.12; defined as local
|
||||
-- fallbacks (same pattern as FunctionUtil.lua).
|
||||
|
||||
@@ -381,12 +382,12 @@ function FramePoolCollectionConverterMixin:CreatePoolKeyFromPoolArgs(args)
|
||||
end
|
||||
|
||||
function FramePoolCollectionConverterMixin:GetOrCreatePool(...)
|
||||
local args = FramePoolCollection_ArgsToTable(unpack(arg, 1, arg.n));
|
||||
local args = FramePoolCollection_ArgsToTable(unpack(arg));
|
||||
return self:GetOrCreatePoolWithArgs(args);
|
||||
end
|
||||
|
||||
function FramePoolCollectionConverterMixin:CreatePool(...)
|
||||
local args = FramePoolCollection_ArgsToTable(unpack(arg, 1, arg.n));
|
||||
local args = FramePoolCollection_ArgsToTable(unpack(arg));
|
||||
return self:CreatePoolWithArgs(args);
|
||||
end
|
||||
|
||||
@@ -432,12 +433,12 @@ local function FontStringPoolCollection_ArgsToTable(parent, layer, subLayer, tem
|
||||
end
|
||||
|
||||
function FontStringPoolCollectionMixin:GetOrCreatePool(...)
|
||||
local args = FontStringPoolCollection_ArgsToTable(unpack(arg, 1, arg.n));
|
||||
local args = FontStringPoolCollection_ArgsToTable(unpack(arg));
|
||||
return self:GetOrCreatePoolWithArgs(args);
|
||||
end
|
||||
|
||||
function FontStringPoolCollectionMixin:CreatePool(...)
|
||||
local args = FontStringPoolCollection_ArgsToTable(unpack(arg, 1, arg.n));
|
||||
local args = FontStringPoolCollection_ArgsToTable(unpack(arg));
|
||||
return self:CreatePoolWithArgs(args);
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
RectangleMixin = {};
|
||||
|
||||
function CreateRectangle(left, right, top, bottom)
|
||||
local rectangle = CreateFromMixins(RectangleMixin);
|
||||
rectangle:OnLoad(left, right, top, bottom);
|
||||
return rectangle;
|
||||
end
|
||||
|
||||
function RectangleMixin:OnLoad(left, right, top, bottom)
|
||||
self:SetSides(left or 0.0, right or 0.0, top or 0.0, bottom or 0.0);
|
||||
end
|
||||
|
||||
function RectangleMixin:SetSides(left, right, top, bottom)
|
||||
self.left = left;
|
||||
self.right = right;
|
||||
self.top = top;
|
||||
self.bottom = bottom;
|
||||
end
|
||||
|
||||
function RectangleMixin:Reset()
|
||||
self.left = 0.0;
|
||||
self.right = 0.0;
|
||||
self.top = 0.0;
|
||||
self.bottom = 0.0;
|
||||
end
|
||||
|
||||
function RectangleMixin:Stretch(x, y)
|
||||
self:Adjust(-x, x, -y, y);
|
||||
end
|
||||
|
||||
function RectangleMixin:Move(x, y)
|
||||
self:Adjust(x, x, y, y);
|
||||
end
|
||||
|
||||
function RectangleMixin:Adjust(left, right, top, bottom)
|
||||
self.left = self.left + left;
|
||||
self.right = self.right + right;
|
||||
self.top = self.top + top;
|
||||
self.bottom = self.bottom + bottom;
|
||||
end
|
||||
|
||||
function RectangleMixin:IsEmpty()
|
||||
return self.left == self.right or self.top == self.bottom;
|
||||
end
|
||||
|
||||
function RectangleMixin:IsInsideOut()
|
||||
return self.left > self.right or self.top > self.bottom;
|
||||
end
|
||||
|
||||
function RectangleMixin:EnclosesPoint(x, y)
|
||||
return x >= self.left and x <= self.right and y >= self.top and y <= self.bottom;
|
||||
end
|
||||
|
||||
function RectangleMixin:EnclosesRect(otherRect)
|
||||
return self:EnclosesPoint(otherRect:GetLeft(), otherRect:GetTop()) and self:EnclosesPoint(otherRect:GetRight(), otherRect:GetBottom());
|
||||
end
|
||||
|
||||
function RectangleMixin:IntersectsRect(otherRect)
|
||||
return not (
|
||||
self.left > otherRect.right or
|
||||
self.right < otherRect.left or
|
||||
self.top > otherRect.bottom or
|
||||
self.bottom < otherRect.top
|
||||
);
|
||||
end
|
||||
|
||||
function RectangleMixin:GetTop()
|
||||
return self.top;
|
||||
end
|
||||
|
||||
function RectangleMixin:GetBottom()
|
||||
return self.bottom;
|
||||
end
|
||||
|
||||
function RectangleMixin:GetLeft()
|
||||
return self.left;
|
||||
end
|
||||
|
||||
function RectangleMixin:GetRight()
|
||||
return self.right;
|
||||
end
|
||||
|
||||
function RectangleMixin:GetWidth()
|
||||
return self.right - self.left;
|
||||
end
|
||||
|
||||
function RectangleMixin:GetHeight()
|
||||
return self.bottom - self.top;
|
||||
end
|
||||
|
||||
function RectangleMixin:GetCenter()
|
||||
return Lerp(self.left, self.right, .5), Lerp(self.top, self.bottom, .5);
|
||||
end
|
||||
|
||||
function RectangleMixin:GetCenterH()
|
||||
return Lerp(self.left, self.right, .5);
|
||||
end
|
||||
|
||||
function RectangleMixin:GetCenterV()
|
||||
return Lerp(self.top, self.bottom, .5);
|
||||
end
|
||||
|
||||
function RectangleMixin:SetTop(top)
|
||||
self.top = top;
|
||||
end
|
||||
|
||||
function RectangleMixin:SetBottom(bottom)
|
||||
self.bottom = bottom;
|
||||
end
|
||||
|
||||
function RectangleMixin:SetLeft(left)
|
||||
self.left = left;
|
||||
end
|
||||
|
||||
function RectangleMixin:SetRight(right)
|
||||
self.right = right;
|
||||
end
|
||||
|
||||
function RectangleMixin:SetWidth(width)
|
||||
self.right = self.left + width;
|
||||
end
|
||||
|
||||
function RectangleMixin:SetHeight(height)
|
||||
self.bottom = self.top + height;
|
||||
end
|
||||
|
||||
function RectangleMixin:SetSize(width, height)
|
||||
self:SetWidth(width);
|
||||
self:SetHeight(height);
|
||||
end
|
||||
|
||||
function RectangleMixin:SetCenter(x, y)
|
||||
local width = self:GetWidth();
|
||||
local height = self:GetHeight();
|
||||
|
||||
self.left = x - width * .5;
|
||||
self.right = x + width * .5;
|
||||
|
||||
self.top = y - height * .5;
|
||||
self.bottom = y + height * .5;
|
||||
end
|
||||
@@ -0,0 +1,59 @@
|
||||
-- Unit right-click menu for attribute-driven unit frames.
|
||||
--
|
||||
-- Backs the `menu` / `togglemenu` click verb of the Frame:SetAttribute
|
||||
-- backport (see src/frame/Attributes.cpp). When a frame with a `unit`
|
||||
-- attribute resolves a click to the `menu` verb, the DLL calls
|
||||
-- ClassicAPI_ToggleUnitMenu(unit), which pops the standard unit dropdown at
|
||||
-- the cursor -- the same UnitPopup menu Blizzard's PlayerFrame / TargetFrame /
|
||||
-- PartyMemberFrame show on right-click. Lets a unit frame express its
|
||||
-- right-click menu purely as an attribute (e.g. type2 = "menu") instead of a
|
||||
-- hand-rolled OnClick handler.
|
||||
--
|
||||
-- The unit -> menu-type resolution mirrors Blizzard's own generic resolver
|
||||
-- (TargetFrameDropDown_Initialize in FrameXML): self -> SELF, pet -> PET, a
|
||||
-- grouped player -> PARTY (whisper / inspect / trade / follow / promote / ...),
|
||||
-- any other player -> PLAYER (adds INVITE), anything else (NPC) ->
|
||||
-- RAID_TARGET_ICON. Vanilla's stock UI never wires a unit to the "RAID" menu
|
||||
-- (there are no clickable raid unit frames in 1.12), and PARTY carries the
|
||||
-- options players actually want on a grouped member, so grouped players (party
|
||||
-- or raid) use PARTY -- matching how pfUI drives its raid menu.
|
||||
|
||||
local dropdown;
|
||||
|
||||
local function EnsureDropdown()
|
||||
if not dropdown then
|
||||
dropdown = CreateFrame("Frame", "ClassicAPIUnitMenuDropDown", UIParent, "UIDropDownMenuTemplate");
|
||||
dropdown.displayMode = "MENU";
|
||||
end
|
||||
return dropdown;
|
||||
end
|
||||
|
||||
local function ResolveMenu(unit)
|
||||
if UnitIsUnit(unit, "player") then
|
||||
return "SELF";
|
||||
elseif UnitIsUnit(unit, "pet") then
|
||||
return "PET";
|
||||
elseif UnitIsPlayer(unit) then
|
||||
if UnitInParty(unit) or UnitInRaid(unit) then
|
||||
return "PARTY";
|
||||
end
|
||||
return "PLAYER";
|
||||
end
|
||||
return "RAID_TARGET_ICON";
|
||||
end
|
||||
|
||||
function ClassicAPI_ToggleUnitMenu(unit)
|
||||
if not unit or not UnitExists(unit) then
|
||||
return;
|
||||
end
|
||||
local which = ResolveMenu(unit);
|
||||
local name;
|
||||
if which == "RAID_TARGET_ICON" then
|
||||
name = RAID_TARGET_ICON;
|
||||
end
|
||||
local dd = EnsureDropdown();
|
||||
dd.initialize = function()
|
||||
UnitPopup_ShowMenu(dd, which, unit, name);
|
||||
end
|
||||
ToggleDropDownMenu(1, nil, dd, "cursor");
|
||||
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
|
||||
@@ -431,7 +431,7 @@ function SafePack(...)
|
||||
end
|
||||
|
||||
function SafeUnpack(tbl)
|
||||
return unpack(tbl, 1, tbl.n)
|
||||
return unpack(tbl)
|
||||
end
|
||||
|
||||
-- Returns the length of a table, accounting for the possibility of a table constructed using SafePack.
|
||||
|
||||
@@ -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",
|
||||
@@ -224,7 +248,7 @@ end
|
||||
local function CreateFrame_TradeSkillLink()
|
||||
local f = CreateFrame("Frame", "ClassicAPITradeSkillLinkFrame", UIParent);
|
||||
f:SetSize(FRAME_W, FRAME_H);
|
||||
f:SetPoint("CENTER", 0, 0);
|
||||
f:SetPoint("CENTER");
|
||||
f:SetMovable(true);
|
||||
f:EnableMouse(true);
|
||||
f:SetHitRectInsets(LO.hit[1], LO.hit[2], LO.hit[3], LO.hit[4]);
|
||||
@@ -235,6 +259,9 @@ local function CreateFrame_TradeSkillLink()
|
||||
f:Hide();
|
||||
tinsert(UISpecialFrames, f:GetName())
|
||||
|
||||
f:SetScript("OnShow", function() PlaySound("igCharacterInfoOpen"); end);
|
||||
f:SetScript("OnHide", function() PlaySound("igCharacterInfoClose"); end);
|
||||
|
||||
local portrait = f:CreateTexture(nil, "BACKGROUND");
|
||||
portrait:SetSize(60, 60);
|
||||
portrait:SetPoint("TOPLEFT", 7, -6);
|
||||
@@ -248,6 +275,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;
|
||||
@@ -258,7 +299,7 @@ local function CreateFrame_TradeSkillLink()
|
||||
local exit = CreateFrame("Button", nil, f, "UIPanelButtonTemplate");
|
||||
exit:SetSize(80, 22);
|
||||
exit:SetPoint(LO.exit[1], f, LO.exit[2], LO.exit[3], LO.exit[4]);
|
||||
exit:SetText(EXIT or "Exit");
|
||||
exit:SetText(EXIT);
|
||||
exit:SetScript("OnClick", function() f:Hide(); end);
|
||||
|
||||
local bar = CreateFrame("StatusBar", nil, f);
|
||||
@@ -300,7 +341,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()
|
||||
@@ -346,17 +387,38 @@ 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]);
|
||||
-- Bar visibility + wheel are driven per-render by SetDetailBarShown (from
|
||||
-- the computed overflow). `scrollBarHideable` still lets the template's
|
||||
-- ScrollFrame_OnScrollRangeChanged manage the slider on range changes.
|
||||
-- Start with the wheel off; the first RenderDetail/ClearDetail sets it.
|
||||
detailScroll.scrollBarHideable = 1;
|
||||
detailScroll:EnableMouseWheel(false);
|
||||
f.detailScroll = detailScroll;
|
||||
|
||||
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);
|
||||
@@ -365,57 +427,99 @@ 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.itemID then
|
||||
GameTooltip:SetHyperlink("item:"..this.itemID);
|
||||
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;
|
||||
btn:SetScript("OnEnter", function()
|
||||
if this.itemLink then
|
||||
if this.itemID then
|
||||
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
|
||||
GameTooltip:SetHyperlink(this.itemLink);
|
||||
GameTooltip:SetHyperlink("item:"..this.itemID);
|
||||
GameTooltip:Show();
|
||||
end
|
||||
end);
|
||||
btn:SetScript("OnClick", function()
|
||||
if this.itemLink and IsShiftKeyDown() and ChatFrameEditBox:IsVisible() then
|
||||
ChatFrameEditBox:Insert(this.itemLink);
|
||||
end
|
||||
end);
|
||||
btn:SetScript("OnLeave", GameTooltip_Hide);
|
||||
btn:Hide();
|
||||
f.reagents[i] = btn;
|
||||
return btn;
|
||||
end
|
||||
|
||||
-- Show the detail bar/borders only when the content overflows the pane.
|
||||
-- Driven by our own computed overflow, NOT the engine: scrollBarHideable's
|
||||
-- auto-hide only fires on a range CHANGE (so content that fits from the
|
||||
-- start never hides), and GetVerticalScrollRange isn't reliably readable
|
||||
-- the same tick as UpdateScrollChildRect.
|
||||
function f:SetDetailBarShown(show)
|
||||
self.detailScroll:EnableMouseWheel(show);
|
||||
local base = "ClassicAPITradeSkillLinkDetailScroll";
|
||||
local sb = getglobal(base .. "ScrollBar");
|
||||
if sb then sb:SetShown(show) end
|
||||
local top = getglobal(base .. "Top");
|
||||
if top then top:SetShown(show) end
|
||||
local bottom = getglobal(base .. "Bottom");
|
||||
if bottom then bottom:SetShown(show) end
|
||||
end
|
||||
|
||||
function f:RenderDetail(entry)
|
||||
@@ -442,11 +546,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 "");
|
||||
@@ -457,50 +559,73 @@ 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 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 total = table.getn(reagents);
|
||||
for i = 1, total do
|
||||
local btn = AcquireReagent(i);
|
||||
local reagent = reagents[i];
|
||||
local name, tex, link = itemInfo(reagent.itemID);
|
||||
btn:SetNormalTexture(tex or "Interface\\Icons\\INV_Misc_QuestionMark");
|
||||
btn.itemLink = link;
|
||||
btn.itemID = reagent.itemID
|
||||
btn.nameText:SetText(name or ("item:" .. reagent.itemID));
|
||||
local need = reagent.count or 1;
|
||||
local have = C_Item.GetItemCount(reagent.itemID) or 0;
|
||||
if have >= need then
|
||||
local r, g, b = HIGHLIGHT_FONT_COLOR:GetRGB()
|
||||
btn:GetNormalTexture():SetVertexColor(r, g, b);
|
||||
btn.nameText:SetTextColor(r, g, b);
|
||||
else
|
||||
btn.itemLink = nil;
|
||||
btn:Hide();
|
||||
local r, g, b = GRAY_FONT_COLOR:GetRGB()
|
||||
btn:GetNormalTexture():SetVertexColor(r, g, b);
|
||||
btn.nameText:SetTextColor(r, g, b);
|
||||
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].itemID = 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);
|
||||
local function applyContentHeight()
|
||||
local contentH = LO.detail[4];
|
||||
if total > 0 then
|
||||
local childTop = self.detailChild:GetTop();
|
||||
local lastBtn = self.reagents[total];
|
||||
local lastBottom = lastBtn and lastBtn:GetBottom();
|
||||
if childTop and lastBottom then
|
||||
-- +22: ~12px name-plate overhang below the icon + ~10px pad.
|
||||
contentH = (childTop - lastBottom) + 22;
|
||||
end
|
||||
end
|
||||
self.detailChild:SetHeight(math.max(contentH, LO.detail[4]));
|
||||
-- Recompute the scroll range from the child's new height, then
|
||||
-- show the bar only if the content actually overflows the pane.
|
||||
self.detailScroll:UpdateScrollChildRect();
|
||||
self:SetDetailBarShown(contentH > LO.detail[4] + 0.5);
|
||||
end
|
||||
GameTooltip:Show();
|
||||
end);
|
||||
prodIcon:SetScript("OnLeave", GameTooltip_Hide);
|
||||
applyContentHeight();
|
||||
local sb = getglobal("ClassicAPITradeSkillLinkDetailScrollScrollBar");
|
||||
if sb then
|
||||
sb:SetValue(0);
|
||||
end
|
||||
self.detailScroll:SetVerticalScroll(0);
|
||||
RunNextFrame(function()
|
||||
if self:IsShown() and self.selectedSpellID == entry.spellID then
|
||||
applyContentHeight();
|
||||
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
|
||||
|
||||
@@ -510,9 +635,15 @@ 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].itemID = nil;
|
||||
self.reagents[i]:Hide();
|
||||
end
|
||||
self.detailChild:SetHeight(LO.detail[4]);
|
||||
self.detailScroll:UpdateScrollChildRect();
|
||||
self:SetDetailBarShown(false);
|
||||
self.detailScroll:SetVerticalScroll(0);
|
||||
end
|
||||
|
||||
function f:Refresh()
|
||||
@@ -603,7 +734,7 @@ local function ShowTradeSkillLink(link, text)
|
||||
local r = recipes[i];
|
||||
if r.isKnown then
|
||||
r.band = DifficultyBand(cur or 0, r.trivialLevel, r.greenLevel);
|
||||
r.name = GetSpellInfo(r.spellID) or ("spell:" .. r.spellID);
|
||||
r.name = C_Spell.GetSpellName(r.spellID) or ("spell:" .. r.spellID);
|
||||
table.insert(knownList, r);
|
||||
end
|
||||
end
|
||||
@@ -664,9 +795,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);
|
||||
@@ -692,8 +838,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)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
function MouseIsOver(region, topOffset, bottomOffset, leftOffset, rightOffset)
|
||||
return region:IsMouseOver(topOffset, bottomOffset, leftOffset, rightOffset);
|
||||
end
|
||||
@@ -0,0 +1,142 @@
|
||||
-- Verbatim backport of retail's Blizzard_SharedXML/Vector2D.lua. Pure vector
|
||||
-- math on CreateFromMixins (a ClassicAPI C native) + Lua 5.0 math.*; no 1.12
|
||||
-- adaptation needed. (Retail's "used in secure code" note doesn't apply here —
|
||||
-- vanilla has no taint system.)
|
||||
local cos = math.cos;
|
||||
local sin = math.sin;
|
||||
local atan2 = math.atan2;
|
||||
local sqrt = math.sqrt;
|
||||
|
||||
function Vector2D_ScaleBy(scalar, x, y)
|
||||
return x * scalar, y * scalar;
|
||||
end
|
||||
|
||||
function Vector2D_DivideBy(divisor, x, y)
|
||||
return x / divisor, y / divisor;
|
||||
end
|
||||
|
||||
function Vector2D_Add(leftX, leftY, rightX, rightY)
|
||||
return leftX + rightX, leftY + rightY;
|
||||
end
|
||||
|
||||
function Vector2D_Subtract(leftX, leftY, rightX, rightY)
|
||||
return leftX - rightX, leftY - rightY;
|
||||
end
|
||||
|
||||
function Vector2D_Cross(leftX, leftY, rightX, rightY)
|
||||
return leftX * rightY - leftY * rightX;
|
||||
end
|
||||
|
||||
function Vector2D_Dot(leftX, leftY, rightX, rightY)
|
||||
return leftX * rightX + leftY * rightY;
|
||||
end
|
||||
|
||||
function Vector2D_GetLengthSquared(x, y)
|
||||
return Vector2D_Dot(x, y, x, y);
|
||||
end
|
||||
|
||||
function Vector2D_GetLength(x, y)
|
||||
return sqrt(Vector2D_GetLengthSquared(x, y));
|
||||
end
|
||||
|
||||
function Vector2D_Normalize(x, y)
|
||||
return Vector2D_DivideBy(Vector2D_GetLength(x, y), x, y);
|
||||
end
|
||||
|
||||
function Vector2D_CalculateAngleBetween(leftX, leftY, rightX, rightY)
|
||||
return atan2(Vector2D_Cross(leftX, leftY, rightX, rightY), Vector2D_Dot(leftX, leftY, rightX, rightY));
|
||||
end
|
||||
|
||||
function Vector2D_RotateDirection(rotationRadians, x, y)
|
||||
local cosValue = cos(rotationRadians);
|
||||
local sinValue = sin(rotationRadians);
|
||||
return x * cosValue - y * sinValue, x * sinValue + y * cosValue;
|
||||
end
|
||||
|
||||
Vector2DMixin = {};
|
||||
|
||||
function CreateVector2D(x, y)
|
||||
local vector = CreateFromMixins(Vector2DMixin);
|
||||
vector:OnLoad(x, y);
|
||||
return vector;
|
||||
end
|
||||
|
||||
function AreVector2DEqual(left, right)
|
||||
if left and right then
|
||||
return left:IsEqualTo(right);
|
||||
end
|
||||
return left == right;
|
||||
end
|
||||
|
||||
function Vector2DMixin:OnLoad(x, y)
|
||||
self:SetXY(x, y);
|
||||
end
|
||||
|
||||
function Vector2DMixin:IsEqualTo(otherVector)
|
||||
return self.x == otherVector.x
|
||||
and self.y == otherVector.y;
|
||||
end
|
||||
|
||||
function Vector2DMixin:GetXY()
|
||||
return self.x, self.y;
|
||||
end
|
||||
|
||||
function Vector2DMixin:SetXY(x, y)
|
||||
self.x = x;
|
||||
self.y = y;
|
||||
end
|
||||
|
||||
function Vector2DMixin:ScaleBy(scalar)
|
||||
self:SetXY(Vector2D_ScaleBy(scalar, self:GetXY()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector2DMixin:DivideBy(scalar)
|
||||
self:SetXY(Vector2D_DivideBy(scalar, self:GetXY()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector2DMixin:Add(other)
|
||||
self:SetXY(Vector2D_Add(self.x, self.y, other:GetXY()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector2DMixin:Subtract(other)
|
||||
self:SetXY(Vector2D_Subtract(self.x, self.y, other:GetXY()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector2DMixin:Cross(other)
|
||||
self:SetXY(Vector2D_Cross(self.x, self.y, other:GetXY()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector2DMixin:Dot(other)
|
||||
return Vector2D_Dot(self.x, self.y, other:GetXY());
|
||||
end
|
||||
|
||||
function Vector2DMixin:IsZero()
|
||||
return self.x == 0 and self.y == 0;
|
||||
end
|
||||
|
||||
function Vector2DMixin:GetLengthSquared()
|
||||
return Vector2D_GetLengthSquared(self:GetXY());
|
||||
end
|
||||
|
||||
function Vector2DMixin:GetLength()
|
||||
return Vector2D_GetLength(self:GetXY());
|
||||
end
|
||||
|
||||
function Vector2DMixin:Normalize()
|
||||
self:SetXY(Vector2D_Normalize(self:GetXY()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector2DMixin:RotateDirection(rotationRadians)
|
||||
self:SetXY(Vector2D_RotateDirection(rotationRadians, self:GetXY()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector2DMixin:Clone()
|
||||
return CreateVector2D(self:GetXY());
|
||||
end
|
||||
@@ -0,0 +1,173 @@
|
||||
-- Verbatim backport of retail's Blizzard_SharedXML/Vector3D.lua. Pure vector
|
||||
-- math on CreateFromMixins (a ClassicAPI C native) + Lua 5.0 math.*; no 1.12
|
||||
-- adaptation needed. (Retail's "used in secure code" note doesn't apply here —
|
||||
-- vanilla has no taint system.)
|
||||
local cos = math.cos;
|
||||
local sin = math.sin;
|
||||
local atan2 = math.atan2;
|
||||
local asin = math.asin;
|
||||
local sqrt = math.sqrt;
|
||||
|
||||
function Vector3D_ScaleBy(scalar, x, y, z)
|
||||
return x * scalar, y * scalar, z * scalar;
|
||||
end
|
||||
|
||||
function Vector3D_DivideBy(divisor, x, y, z)
|
||||
return x / divisor, y / divisor, z / divisor;
|
||||
end
|
||||
|
||||
function Vector3D_Add(leftX, leftY, leftZ, rightX, rightY, rightZ)
|
||||
return leftX + rightX, leftY + rightY, leftZ + rightZ;
|
||||
end
|
||||
|
||||
function Vector3D_Subtract(leftX, leftY, leftZ, rightX, rightY, rightZ)
|
||||
return leftX - rightX, leftY - rightY, leftZ - rightZ;
|
||||
end
|
||||
|
||||
function Vector3D_Cross(leftX, leftY, leftZ, rightX, rightY, rightZ)
|
||||
return leftY * rightZ - leftZ * rightY, leftZ * rightX - leftX * rightZ, leftX * rightY - leftY * rightX;
|
||||
end
|
||||
|
||||
function Vector3D_Dot(leftX, leftY, leftZ, rightX, rightY, rightZ)
|
||||
return leftX * rightX + leftY * rightY + leftZ * rightZ;
|
||||
end
|
||||
|
||||
function Vector3D_GetLengthSquared(x, y, z)
|
||||
return Vector3D_Dot(x, y, z, x, y, z);
|
||||
end
|
||||
|
||||
function Vector3D_GetLength(x, y, z)
|
||||
return sqrt(Vector3D_GetLengthSquared(x, y, z));
|
||||
end
|
||||
|
||||
function Vector3D_Normalize(x, y, z)
|
||||
return Vector3D_DivideBy(Vector3D_GetLength(x, y, z), x, y, z);
|
||||
end
|
||||
|
||||
function Vector3D_AddVector(left, right)
|
||||
local clone = left:Clone();
|
||||
clone:Add(right);
|
||||
return clone;
|
||||
end
|
||||
|
||||
function Vector3D_SubtractVector(left, right)
|
||||
local clone = left:Clone();
|
||||
clone:Subtract(right);
|
||||
return clone;
|
||||
end
|
||||
|
||||
function Vector3D_NormalizeVector(vector)
|
||||
local clone = vector:Clone();
|
||||
clone:Normalize();
|
||||
return clone;
|
||||
end
|
||||
|
||||
function Vector3D_ScaleVector(scalar, vector)
|
||||
local clone = vector:Clone();
|
||||
clone:ScaleBy(scalar);
|
||||
return clone;
|
||||
end
|
||||
|
||||
function Vector3D_CalculateNormalFromYawPitch(yaw, pitch)
|
||||
return cos(-pitch) * cos(yaw),
|
||||
cos(-pitch) * sin(yaw),
|
||||
sin(-pitch);
|
||||
end
|
||||
|
||||
function Vector3D_CalculateYawPitchFromNormal(x, y, z)
|
||||
if x ~= 0 or y ~= 0 then
|
||||
return atan2(y, x), asin(-z);
|
||||
end
|
||||
|
||||
return 0, asin(-z);
|
||||
end
|
||||
|
||||
function Vector3D_CalculateYawPitchFromNormalVector(vector)
|
||||
return Vector3D_CalculateYawPitchFromNormal(vector:GetXYZ());
|
||||
end
|
||||
|
||||
|
||||
function Vector3D_CreateNormalVectorFromYawPitch(yawRadians, pitchRadians)
|
||||
return CreateVector3D(Vector3D_CalculateNormalFromYawPitch(yawRadians, pitchRadians));
|
||||
end
|
||||
|
||||
Vector3DMixin = {};
|
||||
|
||||
function CreateVector3D(x, y, z)
|
||||
local vector = CreateFromMixins(Vector3DMixin);
|
||||
vector:OnLoad(x, y, z);
|
||||
return vector;
|
||||
end
|
||||
|
||||
function AreVector3DEqual(left, right)
|
||||
if left and right then
|
||||
return left:IsEqualTo(right);
|
||||
end
|
||||
return left == right;
|
||||
end
|
||||
|
||||
function Vector3DMixin:OnLoad(x, y, z)
|
||||
self:SetXYZ(x, y, z);
|
||||
end
|
||||
|
||||
function Vector3DMixin:IsEqualTo(otherVector)
|
||||
return self.x == otherVector.x
|
||||
and self.y == otherVector.y
|
||||
and self.z == otherVector.z;
|
||||
end
|
||||
|
||||
function Vector3DMixin:GetXYZ()
|
||||
return self.x, self.y, self.z;
|
||||
end
|
||||
|
||||
function Vector3DMixin:SetXYZ(x, y, z)
|
||||
self.x = x;
|
||||
self.y = y;
|
||||
self.z = z;
|
||||
end
|
||||
|
||||
function Vector3DMixin:ScaleBy(scalar)
|
||||
self:SetXYZ(Vector3D_ScaleBy(scalar, self:GetXYZ()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector3DMixin:DivideBy(scalar)
|
||||
self:SetXYZ(Vector3D_DivideBy(scalar, self:GetXYZ()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector3DMixin:Add(other)
|
||||
self:SetXYZ(Vector3D_Add(self.x, self.y, self.z, other:GetXYZ()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector3DMixin:Subtract(other)
|
||||
self:SetXYZ(Vector3D_Subtract(self.x, self.y, self.z, other:GetXYZ()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector3DMixin:Cross(other)
|
||||
self:SetXYZ(Vector3D_Cross(self.x, self.y, self.z, other:GetXYZ()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector3DMixin:Dot(other)
|
||||
return Vector3D_Dot(self.x, self.y, self.z, other:GetXYZ());
|
||||
end
|
||||
|
||||
function Vector3DMixin:GetLengthSquared()
|
||||
return Vector3D_GetLengthSquared(self:GetXYZ());
|
||||
end
|
||||
|
||||
function Vector3DMixin:GetLength()
|
||||
return Vector3D_GetLength(self:GetXYZ());
|
||||
end
|
||||
|
||||
function Vector3DMixin:Normalize()
|
||||
self:SetXYZ(Vector3D_Normalize(self:GetXYZ()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector3DMixin:Clone()
|
||||
return CreateVector3D(self:GetXYZ());
|
||||
end
|
||||
@@ -0,0 +1,143 @@
|
||||
-- Verbatim backport of retail's Blizzard_SharedXML/Vector4D.lua. Pure vector
|
||||
-- math on CreateFromMixins (a ClassicAPI C native) + Lua 5.0 math.*; no 1.12
|
||||
-- adaptation needed. (Retail's "used in secure code" note doesn't apply here —
|
||||
-- vanilla has no taint system.)
|
||||
local cos = math.cos;
|
||||
local sin = math.sin;
|
||||
local atan2 = math.atan2;
|
||||
local asin = math.asin;
|
||||
local sqrt = math.sqrt;
|
||||
|
||||
function Vector4D_ScaleBy(scalar, x, y, z, w)
|
||||
return x * scalar, y * scalar, z * scalar, w * scalar;
|
||||
end
|
||||
|
||||
function Vector4D_DivideBy(divisor, x, y, z, w)
|
||||
return x / divisor, y / divisor, z / divisor, w / divisor;
|
||||
end
|
||||
|
||||
function Vector4D_Add(leftX, leftY, leftZ, leftW, rightX, rightY, rightZ, rightW)
|
||||
return leftX + rightX, leftY + rightY, leftZ + rightZ, leftW + rightW;
|
||||
end
|
||||
|
||||
function Vector4D_Subtract(leftX, leftY, leftZ, leftW, rightX, rightY, rightZ, rightW)
|
||||
return leftX - rightX, leftY - rightY, leftZ - rightZ, leftW - rightW;
|
||||
end
|
||||
|
||||
function Vector4D_Dot(leftX, leftY, leftZ, leftW, rightX, rightY, rightZ, rightW)
|
||||
return leftX * rightX + leftY * rightY + leftZ * rightZ + leftW * rightW;
|
||||
end
|
||||
|
||||
function Vector4D_GetLengthSquared(x, y, z, w)
|
||||
return Vector4D_Dot(x, y, z, w, x, y, z, w);
|
||||
end
|
||||
|
||||
function Vector4D_GetLength(x, y, z, w)
|
||||
return sqrt(Vector4D_GetLengthSquared(x, y, z, w));
|
||||
end
|
||||
|
||||
function Vector4D_Normalize(x, y, z, w)
|
||||
return Vector4D_DivideBy(Vector4D_GetLength(x, y, z, w), x, y, z, w);
|
||||
end
|
||||
|
||||
function Vector4D_AddVector(left, right)
|
||||
local clone = left:Clone();
|
||||
clone:Add(right);
|
||||
return clone;
|
||||
end
|
||||
|
||||
function Vector4D_SubtractVector(left, right)
|
||||
local clone = left:Clone();
|
||||
clone:Subtract(right);
|
||||
return clone;
|
||||
end
|
||||
|
||||
function Vector4D_NormalizeVector(vector)
|
||||
local clone = vector:Clone();
|
||||
clone:Normalize();
|
||||
return clone;
|
||||
end
|
||||
|
||||
function Vector4D_ScaleVector(scalar, vector)
|
||||
local clone = vector:Clone();
|
||||
clone:ScaleBy(scalar);
|
||||
return clone;
|
||||
end
|
||||
|
||||
Vector4DMixin = {};
|
||||
|
||||
function CreateVector4D(x, y, z, w)
|
||||
local vector = CreateFromMixins(Vector4DMixin);
|
||||
vector:OnLoad(x, y, z, w);
|
||||
return vector;
|
||||
end
|
||||
|
||||
function AreVector4DEqual(left, right)
|
||||
if left and right then
|
||||
return left:IsEqualTo(right);
|
||||
end
|
||||
return left == right;
|
||||
end
|
||||
|
||||
function Vector4DMixin:OnLoad(x, y, z, w)
|
||||
self:SetXYZW(x, y, z, w);
|
||||
end
|
||||
|
||||
function Vector4DMixin:IsEqualTo(otherVector)
|
||||
return self.x == otherVector.x
|
||||
and self.y == otherVector.y
|
||||
and self.z == otherVector.z
|
||||
and self.w == otherVector.w;
|
||||
end
|
||||
|
||||
function Vector4DMixin:GetXYZW()
|
||||
return self.x, self.y, self.z, self.w;
|
||||
end
|
||||
|
||||
function Vector4DMixin:SetXYZW(x, y, z, w)
|
||||
self.x = x;
|
||||
self.y = y;
|
||||
self.z = z;
|
||||
self.w = w;
|
||||
end
|
||||
|
||||
function Vector4DMixin:ScaleBy(scalar)
|
||||
self:SetXYZW(Vector4D_ScaleBy(scalar, self:GetXYZW()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector4DMixin:DivideBy(scalar)
|
||||
self:SetXYZW(Vector4D_DivideBy(scalar, self:GetXYZW()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector4DMixin:Add(other)
|
||||
self:SetXYZW(Vector4D_Add(self.x, self.y, self.z, self.w, other:GetXYZW()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector4DMixin:Subtract(other)
|
||||
self:SetXYZW(Vector4D_Subtract(self.x, self.y, self.z, self.w, other:GetXYZW()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector4DMixin:Dot(other)
|
||||
return Vector4D_Dot(self.x, self.y, self.z, self.w, other:GetXYZW());
|
||||
end
|
||||
|
||||
function Vector4DMixin:GetLengthSquared()
|
||||
return Vector4D_GetLengthSquared(self:GetXYZW());
|
||||
end
|
||||
|
||||
function Vector4DMixin:GetLength()
|
||||
return Vector4D_GetLength(self:GetXYZW());
|
||||
end
|
||||
|
||||
function Vector4DMixin:Normalize()
|
||||
self:SetXYZW(Vector4D_Normalize(self:GetXYZW()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector4DMixin:Clone()
|
||||
return CreateVector4D(self:GetXYZW());
|
||||
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 = "點擊這裡以製造一個你專業技能的連結。";
|
||||
|
||||
@@ -155,25 +155,6 @@ _initFrame:SetScript("OnEvent", function()
|
||||
end
|
||||
end);
|
||||
|
||||
-- Vanilla has no Frame:IsMouseOver(); do it ourselves from cursor + bounds.
|
||||
local function DebugTools_IsMouseOver(frame)
|
||||
if (not frame) then return false; end
|
||||
local visOK, visible = pcall(frame.IsVisible, frame);
|
||||
if (not visOK or not visible) then return false; end
|
||||
local sOK, scale = pcall(frame.GetEffectiveScale, frame);
|
||||
if (not sOK or not scale or scale == 0) then return false; end
|
||||
local cx, cy = GetCursorPosition();
|
||||
cx = cx / scale;
|
||||
cy = cy / scale;
|
||||
local lOK, left = pcall(frame.GetLeft, frame);
|
||||
local rOK, right = pcall(frame.GetRight, frame);
|
||||
local tOK, top = pcall(frame.GetTop, frame);
|
||||
local bOK, bottom = pcall(frame.GetBottom, frame);
|
||||
if (not (lOK and rOK and tOK and bOK)) then return false; end
|
||||
if (not (left and right and top and bottom)) then return false; end
|
||||
return cx >= left and cx <= right and cy >= bottom and cy <= top;
|
||||
end
|
||||
|
||||
local _EventTraceFrame;
|
||||
|
||||
_framesSinceLast = 0;
|
||||
@@ -566,7 +547,7 @@ function EventTraceFrame_Update()
|
||||
button:GetHighlightTexture():SetVertexColor(.8, .8, 1, .15);
|
||||
button:UnlockHighlight();
|
||||
end
|
||||
if (DebugTools_IsMouseOver(button)) then
|
||||
if MouseIsOver(button) then
|
||||
EventTraceFrameEvent_OnEnter(button);
|
||||
end
|
||||
end
|
||||
@@ -1016,8 +997,8 @@ local function _CollectFramesUnderMouse(showHidden)
|
||||
local frame = EnumerateFrames(nil);
|
||||
while (frame) do
|
||||
local visOK, visible = pcall(frame.IsVisible, frame);
|
||||
local mouseOver = DebugTools_IsMouseOver(frame);
|
||||
if (mouseOver and (showHidden or (visOK and visible))) then
|
||||
local overOK, mouseOver = pcall(frame.IsMouseOver, frame);
|
||||
if (overOK and mouseOver and (showHidden or (visOK and visible))) then
|
||||
local nameOK, name = pcall(frame.GetName, frame);
|
||||
if (not nameOK or not name) then name = "<anonymous>"; end
|
||||
table.insert(matches, { name = name, frame = frame });
|
||||
|
||||
@@ -456,14 +456,11 @@
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
</FontString>
|
||||
<!-- Breadcrumb showing the active navigation path,
|
||||
e.g. "_G > MyFrame > backdrop". Updated each
|
||||
`TableInspectorFrame_Update` tick. -->
|
||||
<FontString name="$parentBreadcrumb" inherits="GameFontHighlightSmall" justifyH="LEFT">
|
||||
<Size x="380" y="16"/>
|
||||
<Size x="300" y="16"/>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT">
|
||||
<Offset x="80" y="-34"/>
|
||||
<Offset x="156" y="-34"/>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
</FontString>
|
||||
@@ -510,6 +507,17 @@
|
||||
<OnClick>TableInspector_Back();</OnClick>
|
||||
</Scripts>
|
||||
</Button>
|
||||
<Button name="$parentRefreshButton" inherits="UIPanelButtonTemplate" text="Refresh">
|
||||
<Size x="70" y="20"/>
|
||||
<Anchors>
|
||||
<Anchor point="LEFT" relativeTo="$parentBackButton" relativePoint="RIGHT">
|
||||
<Offset x="4" y="0"/>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<Scripts>
|
||||
<OnClick>TableInspector_Refresh();</OnClick>
|
||||
</Scripts>
|
||||
</Button>
|
||||
<Slider name="$parentScroll" orientation="VERTICAL">
|
||||
<Size x="16" y="0"/>
|
||||
<Anchors>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
-- TableInspector_Show(tbl, label) -- open with `tbl` as root, `label` shown in breadcrumb
|
||||
-- TableInspector_Push(tbl, label) -- drill in (called by row click)
|
||||
-- TableInspector_Back() -- pop one level
|
||||
-- TableInspector_Refresh() -- re-read the current level's live table (scroll kept)
|
||||
-- TableInspector_Close() -- hide the window
|
||||
-- /tinspect <luaexpr> -- evaluate `<luaexpr>` as Lua and open if result is a table
|
||||
------------------------------------------------------------------------------
|
||||
@@ -184,6 +185,13 @@ function TableInspector_Back()
|
||||
TableInspectorFrame_Update();
|
||||
end
|
||||
|
||||
function TableInspector_Refresh()
|
||||
local top = _navStack[table.getn(_navStack)];
|
||||
if (not top or type(top.tbl) ~= "table") then return; end
|
||||
_RebuildEntries();
|
||||
TableInspectorFrame_Update();
|
||||
end
|
||||
|
||||
function TableInspector_Close()
|
||||
if (_TableInspectorFrame) then _TableInspectorFrame:Hide(); end
|
||||
_navStack = {};
|
||||
|
||||
+24
-6
@@ -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,10 +102,25 @@ 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.
|
||||
target_link_libraries(${PROJECT_NAME} PRIVATE minhook advapi32 ole32)
|
||||
# user32: Win32 clipboard API (OpenClipboard / SetClipboardData) for
|
||||
# CopyToClipboard in src/clipboard/Copy.cpp.
|
||||
target_link_libraries(${PROJECT_NAME} PRIVATE minhook advapi32 ole32 user32)
|
||||
|
||||
target_include_directories(
|
||||
${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||
|
||||
@@ -18,50 +18,60 @@ 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` |
|
||||
| [ChatBubbles](docs/API.md#chatbubbles) | `C_ChatBubbles.GetAllChatBubbles` |
|
||||
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
|
||||
| [ColorUtil](docs/API.md#colorutil) | `C_ColorUtil.ConvertRGBToHSV`, `C_ColorUtil.ConvertHSVToRGB`, `C_ColorUtil.ConvertHSVToHSL`, `C_ColorUtil.ConvertHSLToHSV`, `C_ColorUtil.ConvertHSLToRGB`, `C_ColorUtil.GenerateTextColorCode`, `C_ColorUtil.WrapTextInColor`, `C_ColorUtil.WrapTextInColorCode` |
|
||||
| [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` |
|
||||
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid`, `GetFramesRegisteredForEvent` |
|
||||
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
|
||||
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
|
||||
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
|
||||
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `frame:SetShown`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered` |
|
||||
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `frame:SetShown`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs` |
|
||||
| [FriendList](docs/API.md#friendlist) | `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName` |
|
||||
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID` |
|
||||
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID`, `ClosestGameObjectPosition` |
|
||||
| [Glue](docs/API.md#glue) | `C_Glue.IsOnGlueScreen` |
|
||||
| [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) |
|
||||
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:AddSpellByID`, `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:SetTotem`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem`, `OnTooltipSetSpell`, `OnTooltipSetUnit`, `OnTooltipSetGameObject` (scripts) |
|
||||
| [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` |
|
||||
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
|
||||
| [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.GetItemClassInfo`, `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`, `GetItemClassInfo`, `GetItemIcon`, `GetItemSubClassInfo`, `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.LootAllCorpses`, `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`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `string.gmatch`, `string.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `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` |
|
||||
| [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` |
|
||||
| [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` |
|
||||
| [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` |
|
||||
| [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.GetSpellEffectMechanics`, `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`, `CanDualWield`, `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`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsOutdoors`, `IsStealthed`, `IsSwimming` |
|
||||
| [System](docs/API.md#system) | `CopyToClipboard`, `GetPhysicalScreenSize` |
|
||||
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
|
||||
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime` |
|
||||
| [Targeting](docs/API.md#targeting) | `GetPlayerFacing`, `TargetDirectionEnemy`, `TargetDirectionFriend`, `TargetNearest`, `TargetNearestEnemyPlayer`, `TargetNearestFriendPlayer` |
|
||||
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes`, `C_TaxiMap.GetTaxiPaths`, `C_TaxiMap.GetTaxiPathWaypoints`, `C_TaxiMap.GetTaxiRoute` |
|
||||
| [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`, `GetTimeCached` |
|
||||
| [Totem](docs/API.md#totem) | `GetTotemInfo`, `GetTotemTimeLeft`, `GetTotemDuration`, `TargetTotem` |
|
||||
| [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` |
|
||||
| [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` |
|
||||
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `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.RegisterAuraDurationModifier`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `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` |
|
||||
|
||||
@@ -81,6 +91,7 @@ the glue state because GlueXML had no way to reach them otherwise.
|
||||
| [Account](docs/API.md#account) | `SaveAccount`, `DeleteAccount`, `GetSavedAccounts`, `LoginWithSavedAccount` (passwords encrypted in Windows Credential Manager, scoped per realmlist; plaintext never returned to Lua) |
|
||||
| [CharacterList](docs/API.md#characterlist) | `GetSavedCharacterOrder`, `SetSavedCharacterOrder` (persist to `WTF\Account\...\ClassicAPI.txt`) |
|
||||
| CVar | `GetCVar`, `SetCVar`, `RegisterCVar`, `GetCVarDefault`, `C_CVar.GetCVarBool` (storage is process-global — writes from glue are visible in-world and vice versa) |
|
||||
| [Glue](docs/API.md#glue) | `C_Glue.IsFirstLoadThisSession`, `C_Glue.IsOnGlueScreen` |
|
||||
| Script | `RunScript` (compile and run a Lua chunk in the glue state's globals — useful for slash-command-style helpers in GlueXML) |
|
||||
| State | `IsLoggedIn` |
|
||||
|
||||
@@ -112,6 +123,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 +133,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"`) |
|
||||
@@ -136,10 +150,24 @@ when launching with `-console`), not as Lua functions. See the
|
||||
| `PLAYER_STOPPED_MOVING` | *(none)* |
|
||||
| `PLAYER_STARTED_TURNING` | *(none)* |
|
||||
| `PLAYER_STOPPED_TURNING` | *(none)* |
|
||||
| `PLAYER_TOTEM_UPDATE` | `totemSlot` |
|
||||
| `QUEST_ACCEPTED` | `questLogIndex, questID` |
|
||||
| `QUEST_DATA_LOAD_RESULT` | `questID, success` |
|
||||
| `QUEST_REMOVED` | `questID` |
|
||||
| `QUEST_TURNED_IN` | `questID, xpReward, moneyReward` |
|
||||
| `UNIT_SPELLCAST_SENT` | `"player", target, castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_START` | `unit, castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_STOP` | `unit, castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_DELAYED` | `"player", castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_SUCCEEDED` | `unit, castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_INTERRUPTED` | `unit, castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_FAILED` | `"player", castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_FAILED_QUIET` | `"player", castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_CHANNEL_START` | `unit, castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_CHANNEL_UPDATE` | `"player", castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_CHANNEL_STOP` | `unit, castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_RETICLE_TARGET` | `"player", "", spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_RETICLE_CLEAR` | `"player", "", spellID, spellName, rank` |
|
||||
| `UPDATE_INVENTORY_DURABILITY` | *(none)* |
|
||||
| `UPDATE_SHAPESHIFT_FORM` | *(none)* |
|
||||
| `VOICE_CHAT_TTS_PLAYBACK_STARTED` | `numConsumers, utteranceID, durationMS, destination` |
|
||||
@@ -158,6 +186,8 @@ when launching with `-console`), not as Lua functions. See the
|
||||
| Addon security | `Enum.AddOnSecurityStatus.{Secure,Insecure,Banned,NotAvailable}` |
|
||||
| Power type | `Enum.PowerType.{HealthCost,None,Mana,Rage,Focus,Energy,Happiness}` |
|
||||
| Inventory type | `Enum.InventoryType.Index*Type` (0–34, e.g. `IndexHeadType`=1 … `IndexRelicType`=28) |
|
||||
| Item class | `Enum.ItemClass.{Consumable,Container,Weapon,Gem,Armor,Reagent,Projectile,Tradegoods,ItemEnhancement,Recipe,Quiver,Questitem,Key,Miscellaneous,…}` (0–19) |
|
||||
| Item quality | `Enum.ItemQuality.{Poor,Common,Uncommon,Rare,Epic,Legendary,Artifact}` (0–6) |
|
||||
|
||||
### Unit tokens
|
||||
|
||||
@@ -165,6 +195,7 @@ when launching with `-console`), not as Lua functions. See the
|
||||
|-------|-------------|
|
||||
| `nameplate1`..`nameplateN` | Unit behind the Nth visible nameplate, in creation-order. Works with every `UnitX` function — `UnitName`, `UnitGUID`, `UnitClass`, `UnitHealth`, etc. Suffix chains (`nameplate1target`, `nameplate1targettarget`) compose. See [NamePlate / Unit tokens](docs/API.md#unit-tokens-nameplaten). |
|
||||
| `focus` / `focustarget` | Sticky target set via [`FocusUnit`](docs/API.md#focusunitunit), cleared via [`ClearFocus`](docs/API.md#clearfocus). Same `UnitX` coverage as `nameplateN`. Fires [`PLAYER_FOCUS_CHANGED`](docs/API.md#player_focus_changed-event) on transition. See [Focus](docs/API.md#focus). |
|
||||
| `mark1`..`mark8` | Unit currently wearing the Nth raid-target marker (`mark1` = star … `mark8` = skull). Same `UnitX` coverage as `nameplateN`, and fires unit events (`UNIT_HEALTH`, `UNIT_AURA`, …) with `arg1 == "markN"`. `UnitExists("markN")` is `false` when the marker is unset, out of range, or on a non-unit. Suffix chains (`mark1target`) compose. See [Unit tokens (`markN`)](docs/API.md#unit-tokens-markn). |
|
||||
|
||||
### Bindings
|
||||
|
||||
@@ -208,36 +239,38 @@ The Lua-side companion library lives in
|
||||
[`AddOns/!!!ClassicAPI/`](AddOns/!!!ClassicAPI/). It's a 1.12.1 /
|
||||
Lua 5.0 backport of the modern Blizzard helpers that aren't engine
|
||||
functions but that consumer code still expects to find as globals —
|
||||
`Mixin` / `CreateFromMixins`, `CallbackRegistryMixin`, `EventRegistry`,
|
||||
`ColorMixin` + `CreateColor`, `Item` / `ItemLocation`, `MathUtil`
|
||||
(`Lerp` / `Clamp` / `CreateCounter`), `TableUtil` (`tCompare`,
|
||||
`MergeTable`, `SafePack`, etc.), and `EventUtil`
|
||||
`CallbackRegistryMixin`, `EventRegistry`, `ColorMixin` + `CreateColor`,
|
||||
`Item` / `ItemLocation`, `MathUtil`(`Lerp` / `Clamp` / `CreateCounter`),
|
||||
`TableUtil` (`tCompare`, `MergeTable`, `SafePack`, etc.), and `EventUtil`
|
||||
(`ContinueOnAddOnLoaded` etc.).
|
||||
|
||||
Some of the addon's surface depends on engine functions ClassicAPI
|
||||
provides (`C_UIColor.GetColors` populates the modern color globals,
|
||||
`C_Item.GetItemInfoInstant` powers `ItemMixin`, the
|
||||
`ITEM_DATA_LOAD_RESULT` event drives `ContinueOnItemLoad`); the rest
|
||||
is pure Lua and would work standalone. Without the DLL the addon
|
||||
still loads, but those features no-op.
|
||||
|
||||
The triple-`!` prefix is **load-order-significant** — WoW loads
|
||||
addons alphabetically, so `!!!ClassicAPI` is guaranteed to run before
|
||||
any consumer that depends on its globals. Don't rename the folder.
|
||||
|
||||
**You don't have to install the addon manually** — the DLL embeds
|
||||
the contents of [`AddOns/!!!ClassicAPI/`](AddOns/!!!ClassicAPI/) and
|
||||
registers them with the engine as a synthetic addon on startup, so
|
||||
the library is always available when the DLL is loaded. The engine
|
||||
sees it as a normal addon: it appears in `GetAddOnInfo`, fires
|
||||
`ADDON_LOADED`, supports `SavedVariables`, the works.
|
||||
the library is always available when the DLL is loaded. It fires
|
||||
`ADDON_LOADED`, supports `SavedVariables`, and is resolvable by name —
|
||||
`IsAddOnLoaded("!!!ClassicAPI")`, `GetAddOnInfo("!!!ClassicAPI")`, and
|
||||
`## Dependencies: !!!ClassicAPI` all work as usual.
|
||||
|
||||
If you do drop the folder into your `Interface/AddOns/` directory,
|
||||
**that copy wins** — the DLL's embedded version is only registered
|
||||
when the engine's normal scan doesn't already have an entry under
|
||||
that name. Useful for editing the Lua locally without rebuilding the
|
||||
DLL. The dispatch is transparent: addons consuming `Mixin`,
|
||||
`ColorMixin`, `TableUtil`, etc. behave identically in both cases.
|
||||
**It is intentionally always-on and cannot be disabled.** The library
|
||||
provides FrameXML-compat fixes that other addons and the DLL's own
|
||||
features rely on, so leaving it toggleable would let a user (or a
|
||||
one-off character-select uncheck) silently break them. The DLL
|
||||
therefore hides it from the character-select AddOns list and
|
||||
force-enables it every login — so it never appears as a checkbox and
|
||||
can't be switched off. Being hidden means it's omitted from the
|
||||
index-based `GetNumAddOns()` / `GetAddOnInfo(i)` enumeration, but every
|
||||
by-name lookup and dependency reference still resolves normally.
|
||||
|
||||
If you drop the folder into your `Interface/AddOns/` directory, **that
|
||||
copy's files win** — the DLL's embedded version only serves files when
|
||||
the engine's normal scan doesn't already have a newer entry under that
|
||||
name. Useful for editing the Lua locally without rebuilding the DLL.
|
||||
The hidden + force-enabled treatment applies either way (it's keyed to
|
||||
the addon name, not to which copy serves the files), so the local copy
|
||||
is also absent from the AddOns list. The dispatch is transparent:
|
||||
addons consuming `Mixin`, `ColorMixin`, `TableUtil`, etc. behave
|
||||
identically in both cases.
|
||||
|
||||
## Bundled addon: DebugTools
|
||||
|
||||
|
||||
@@ -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
|
||||
+3913
-369
File diff suppressed because it is too large
Load Diff
@@ -435,6 +435,17 @@ and other DLLs claim for custom events.
|
||||
|
||||
## Notes on specific entries
|
||||
|
||||
- **`[336] UPDATE_MOUSEOVER_UNIT` now fires on loss too (ClassicAPI).**
|
||||
Stock 1.12 fires this only when a mouseover unit is *gained*; moving
|
||||
the cursor off a unit clears the mouseover and signals nothing.
|
||||
ClassicAPI co-hooks the engine's mouseover set/clear chokepoint
|
||||
(`0x00492890`, the sole writer of the mouseover GUID) and fires
|
||||
`UPDATE_MOUSEOVER_UNIT` on the unit→non-unit transition, matching
|
||||
retail. Gated on the *old* mouseover being a unit and the *new* one
|
||||
not, so moving off a gameobject / item (which never fired a gain)
|
||||
doesn't spuriously fire. No payload either way — handlers read
|
||||
`UnitExists("mouseover")`, false at fire time on loss. See
|
||||
[API.md](API.md#update_mouseover_unit-event-loss-fire-fix).
|
||||
- **Repeated `UNIT_*` entries at low indices** (e.g. four slots named
|
||||
`UNIT_DAMAGE` at 128–131, five `UNIT_STATS` at 144–148, seven
|
||||
`UNIT_RESISTANCES` at 149–155) — these are the per-stat /
|
||||
|
||||
+30
-1
@@ -56,7 +56,9 @@ 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) \
|
||||
F(CheckStack, lua_checkstack, LUA_CHECK_STACK)
|
||||
|
||||
#define CLASSICAPI_BIND_LUA(Name, Typedef, Offset) \
|
||||
const Typedef##_t Name = reinterpret_cast<Typedef##_t>(Offsets::Offset);
|
||||
@@ -183,6 +185,33 @@ void SetGlobalNumber(void *L, const char *name, double value) {
|
||||
RawSet(L, GLOBALS_INDEX);
|
||||
}
|
||||
|
||||
bool PushGlobalFunction(void *L, const char *name) {
|
||||
PushString(L, name);
|
||||
GetTable(L, GLOBALS_INDEX);
|
||||
if (Type(L, -1) == TYPE_FUNCTION)
|
||||
return true;
|
||||
SetTop(L, -2); // pop the non-function
|
||||
return false;
|
||||
}
|
||||
|
||||
void CallGlobal(void *L, const char *name) {
|
||||
const int top = GetTop(L);
|
||||
if (PushGlobalFunction(L, name))
|
||||
Call(L, 0, 0);
|
||||
SetTop(L, top);
|
||||
}
|
||||
|
||||
bool CallGlobalString(void *L, const char *name, const char *arg) {
|
||||
const int top = GetTop(L);
|
||||
const bool called = PushGlobalFunction(L, name);
|
||||
if (called) {
|
||||
PushString(L, arg);
|
||||
Call(L, 1, 0);
|
||||
}
|
||||
SetTop(L, top);
|
||||
return called;
|
||||
}
|
||||
|
||||
void PushLocalizedString(void *L, const char *globalName, const char *fallback) {
|
||||
PushString(L, globalName);
|
||||
GetTable(L, GLOBALS_INDEX);
|
||||
|
||||
+20
@@ -99,6 +99,8 @@ 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);
|
||||
using lua_checkstack_t = int(__fastcall *)(void *L, int size);
|
||||
|
||||
extern const lua_isnumber_t IsNumber;
|
||||
extern const lua_isstring_t IsString;
|
||||
@@ -140,6 +142,8 @@ 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;
|
||||
extern const lua_checkstack_t CheckStack;
|
||||
|
||||
// Returns the global `lua_State *` (read on demand from the engine's global).
|
||||
// Callable outside a Lua callback, e.g. during LoadScriptFunctions setup.
|
||||
@@ -240,6 +244,22 @@ void PushLocalizedString(void *L, const char *globalName, const char *fallback);
|
||||
// using `PushLocalizedString` + manual `string.format` invocation.
|
||||
void PushLocalizedFormatInt(void *L, const char *globalName,
|
||||
const char *fallback, int n);
|
||||
|
||||
// Pushes `_G[name]` and returns true only if it resolved to a function
|
||||
// (leaving it on the stack, ready to be called); otherwise pops it and
|
||||
// returns false. The building block for the CallGlobal* helpers below and for
|
||||
// any C module that dispatches to a Lua/FrameXML global by name (a pattern
|
||||
// several modules otherwise open-code).
|
||||
bool PushGlobalFunction(void *L, const char *name);
|
||||
|
||||
// `_G[name]()` — no args, no results; a no-op if the global isn't a function.
|
||||
// Stack-neutral.
|
||||
void CallGlobal(void *L, const char *name);
|
||||
|
||||
// `_G[name](arg)` — one string arg (NULL → nil), no results. Returns true iff
|
||||
// the global was a function and got called, so callers can fall back to a
|
||||
// native path when the FrameXML/addon global is absent. Stack-neutral.
|
||||
bool CallGlobalString(void *L, const char *name, const char *arg);
|
||||
} // namespace Lua
|
||||
|
||||
// Developer-console command system — the `~` console available when the
|
||||
|
||||
+1043
-8
File diff suppressed because it is too large
Load Diff
+54
-2
@@ -336,7 +336,32 @@ using ListInsert_t = void(__thiscall *)(void *listCtrl, void *entry,
|
||||
constexpr uintptr_t VAR_ADDON_LIST_HEAD = 0x00BE1B6C;
|
||||
constexpr int OFF_ADDON_ENTRY_NAME_PTR = 0x14;
|
||||
|
||||
void MoveEmbeddedToListHead() {
|
||||
// `entry+0x29` — the "filter-out" byte. The flat display-array builder
|
||||
// `FUN_0051da70` walks the linked list and copies an entry into
|
||||
// `GetNumAddOns`/`GetAddOnInfo`'s array ONLY when this byte is 0, so
|
||||
// setting it hides `!!!ClassicAPI` from the character-select AddOns
|
||||
// list — there's no checkbox, so it can't be toggled off. The builder
|
||||
// only ever *writes* this byte on the secure/SMSG path (`entry+0x28 !=
|
||||
// 0`); our entry is non-secure, so a manual `1` here is stable across
|
||||
// rebuilds. Hiding does NOT stop loading (the load pass `FUN_0051f600`
|
||||
// never reads `+0x29`) and does NOT break dependency resolution or
|
||||
// by-name queries (those use the addon hash table, which keeps the
|
||||
// entry regardless).
|
||||
constexpr int OFF_ADDON_ENTRY_FILTER_OUT = 0x29;
|
||||
|
||||
// `entry+0x2b` — DefaultState (`## DefaultState: enabled/disabled`).
|
||||
// The enable resolver `FUN_ADDON_ENABLE_RESOLVE` falls back to this
|
||||
// when the player has no per-character override. Belt-and-suspenders
|
||||
// alongside the resolver co-hook below (which forces enabled anyway).
|
||||
constexpr int OFF_ADDON_ENTRY_DEFAULT_STATE = 0x2b;
|
||||
|
||||
// Post-registration finalize for the embedded entry: move it to the
|
||||
// head of the load-order list (so consumers that read our globals from
|
||||
// their main chunk see them — see the block comment above the offsets),
|
||||
// hide it from the addon list, and mark it default-enabled. Combined
|
||||
// with the `FUN_ADDON_ENABLE_RESOLVE` co-hook, `!!!ClassicAPI` always
|
||||
// loads and never appears as a toggleable entry.
|
||||
void FinalizeEmbeddedEntry() {
|
||||
uintptr_t entry = *reinterpret_cast<uintptr_t *>(VAR_ADDON_LIST_HEAD);
|
||||
const int linkOffset = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL));
|
||||
@@ -344,6 +369,11 @@ void MoveEmbeddedToListHead() {
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
entry + OFF_ADDON_ENTRY_NAME_PTR);
|
||||
if (name != nullptr && std::strcmp(name, kAddonName) == 0) {
|
||||
// Hide from the character-select AddOns list and default it
|
||||
// to enabled.
|
||||
*reinterpret_cast<uint8_t *>(entry + OFF_ADDON_ENTRY_FILTER_OUT) = 1;
|
||||
*reinterpret_cast<uint8_t *>(entry + OFF_ADDON_ENTRY_DEFAULT_STATE) = 1;
|
||||
|
||||
auto fn = reinterpret_cast<ListInsert_t>(
|
||||
Offsets::FUN_INTRUSIVE_LIST_INSERT);
|
||||
fn(reinterpret_cast<void *>(
|
||||
@@ -361,7 +391,24 @@ void __fastcall AddonInit_h(const char *accountName) {
|
||||
AddonInit_o(accountName);
|
||||
auto TocParser = reinterpret_cast<TocParser_t>(Offsets::FUN_TOC_PARSER);
|
||||
TocParser(kAddonName);
|
||||
MoveEmbeddedToListHead();
|
||||
FinalizeEmbeddedEntry();
|
||||
}
|
||||
|
||||
// `FUN_ADDON_ENABLE_RESOLVE` — per-character enable resolver. We
|
||||
// co-hook it to force-enable `!!!ClassicAPI` regardless of DefaultState
|
||||
// or any stale WTF disable-override, so hiding it from the addon list
|
||||
// can never leave it stuck off. Returns 2 ("enabled for all"); the
|
||||
// load-pass gate only tests `!= 0`. Every other addon falls through to
|
||||
// the original untouched. See the Offsets.h note for the ABI.
|
||||
using AddonEnableResolve_t =
|
||||
uint32_t(__fastcall *)(const char *name, const char *account, char resolveAll);
|
||||
AddonEnableResolve_t AddonEnableResolve_o = nullptr;
|
||||
|
||||
uint32_t __fastcall AddonEnableResolve_h(const char *name, const char *account,
|
||||
char resolveAll) {
|
||||
if (name != nullptr && PathEqualsCI(name, kAddonName))
|
||||
return 2;
|
||||
return AddonEnableResolve_o(name, account, resolveAll);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookFileRead{
|
||||
@@ -374,6 +421,11 @@ const Game::HookAutoRegister _hookAddonInit{
|
||||
reinterpret_cast<void *>(&AddonInit_h),
|
||||
reinterpret_cast<void **>(&AddonInit_o)};
|
||||
|
||||
const Game::HookAutoRegister _hookEnableResolve{
|
||||
Offsets::FUN_ADDON_ENABLE_RESOLVE,
|
||||
reinterpret_cast<void *>(&AddonEnableResolve_h),
|
||||
reinterpret_cast<void **>(&AddonEnableResolve_o)};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Addons::Embedded
|
||||
|
||||
@@ -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
|
||||
+70
-25
@@ -29,6 +29,7 @@
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "ui/ColorData.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
@@ -47,6 +48,18 @@ const uint8_t *ResolveUnit(const char *token) {
|
||||
return static_cast<const uint8_t *>(fn(token));
|
||||
}
|
||||
|
||||
// Resolves a token to its GUID for the out-of-range (no live CGUnit) path.
|
||||
// Only valid to call AFTER `ResolveUnit(token)` returned null *without raising*:
|
||||
// `ResolveUnit` runs the engine's token→GUID resolver first, so reaching a null
|
||||
// return means the token was well-formed (the resolver never raised). This runs
|
||||
// the same non-raising resolver and returns 0 for a token that maps to no
|
||||
// rostered member — which the group-array push paths treat as "no auras".
|
||||
uint64_t GuidForOutOfRange(const char *token) {
|
||||
if (token == nullptr)
|
||||
return 0;
|
||||
return Unit::Identity::GuidForToken(token);
|
||||
}
|
||||
|
||||
// Parses the filter string. Returns Helpful by default; Harmful if
|
||||
// the string contains the substring "HARMFUL". Case-sensitive — the
|
||||
// modern API documents the filter tokens as upper-case constants
|
||||
@@ -91,16 +104,25 @@ const char *ArgOptString(void *L, int idx) {
|
||||
int PushAuraByIndex(void *L, const char *unitToken, int index,
|
||||
Data::Filter filter, bool playerOnly = false) {
|
||||
const uint8_t *unit = ResolveUnit(unitToken);
|
||||
const int slot = Data::FindNthSlot(unit, index, filter, playerOnly);
|
||||
if (slot >= 0) {
|
||||
Data::Push(L, unit, slot);
|
||||
return 1;
|
||||
if (unit != nullptr) {
|
||||
const int slot = Data::FindNthSlot(unit, index, filter, playerOnly);
|
||||
if (slot >= 0) {
|
||||
Data::Push(L, unit, slot);
|
||||
return 1;
|
||||
}
|
||||
// Descriptor exhausted — an aura the engine dropped from the slot array
|
||||
// (rogue stealth, nearby party range fluctuation) may still be live in
|
||||
// the Aura::Source cache. Surface it after the descriptor entries.
|
||||
if (Data::PushNthCacheFallback(L, unit, index, filter, playerOnly))
|
||||
return 1;
|
||||
} else {
|
||||
// No live CGUnit — an out-of-range / cross-map groupmate. The server
|
||||
// still sends their aura spell IDs via SMSG_PARTY_MEMBER_STATS; read
|
||||
// them the same way the built-in UnitBuff/UnitDebuff do.
|
||||
if (Data::PushNthGroupAura(L, GuidForOutOfRange(unitToken), index,
|
||||
filter, playerOnly))
|
||||
return 1;
|
||||
}
|
||||
// Descriptor exhausted — an aura the engine dropped from the slot array
|
||||
// (rogue stealth, party range fluctuation) may still be live in the
|
||||
// Aura::Source cache. Surface it after the descriptor entries.
|
||||
if (Data::PushNthCacheFallback(L, unit, index, filter, playerOnly))
|
||||
return 1;
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
@@ -152,13 +174,21 @@ int __fastcall Script_GetUnitAuraBySpellID(void *L) {
|
||||
f = ParseFilter(filterStr);
|
||||
fp = &f;
|
||||
}
|
||||
const int slot = Data::FindSlotBySpellID(unit, static_cast<uint32_t>(spellID),
|
||||
fp, HasPlayerFilter(filterStr));
|
||||
if (slot < 0) {
|
||||
Game::Lua::PushNil(L);
|
||||
const bool playerOnly = HasPlayerFilter(filterStr);
|
||||
if (unit != nullptr) {
|
||||
const int slot = Data::FindSlotBySpellID(
|
||||
unit, static_cast<uint32_t>(spellID), fp, playerOnly);
|
||||
if (slot >= 0) {
|
||||
Data::Push(L, unit, slot);
|
||||
return 1;
|
||||
}
|
||||
} else if (Data::PushGroupAuraBySpellID(L, GuidForOutOfRange(unitToken),
|
||||
static_cast<uint32_t>(spellID), fp,
|
||||
playerOnly)) {
|
||||
// Out-of-range groupmate — read from the group-member aura array.
|
||||
return 1;
|
||||
}
|
||||
Data::Push(L, unit, slot);
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -177,13 +207,19 @@ int __fastcall Script_GetAuraDataBySpellName(void *L) {
|
||||
f = ParseFilter(filterStr);
|
||||
fp = &f;
|
||||
}
|
||||
const int slot = Data::FindSlotBySpellName(unit, spellName, fp,
|
||||
HasPlayerFilter(filterStr));
|
||||
if (slot < 0) {
|
||||
Game::Lua::PushNil(L);
|
||||
const bool playerOnly = HasPlayerFilter(filterStr);
|
||||
if (unit != nullptr) {
|
||||
const int slot = Data::FindSlotBySpellName(unit, spellName, fp, playerOnly);
|
||||
if (slot >= 0) {
|
||||
Data::Push(L, unit, slot);
|
||||
return 1;
|
||||
}
|
||||
} else if (Data::PushGroupAuraBySpellName(L, GuidForOutOfRange(unitToken),
|
||||
spellName, fp, playerOnly)) {
|
||||
// Out-of-range groupmate — read from the group-member aura array.
|
||||
return 1;
|
||||
}
|
||||
Data::Push(L, unit, slot);
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -234,8 +270,6 @@ int __fastcall Script_GetUnitAuras(void *L) {
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
if (unit == nullptr)
|
||||
return 1;
|
||||
|
||||
// Range tokens are independent of PLAYER: an explicit HELPFUL/HARMFUL
|
||||
// selects that range, neither selects both. So `"PLAYER"` alone returns
|
||||
@@ -246,10 +280,21 @@ int __fastcall Script_GetUnitAuras(void *L) {
|
||||
const bool both = !hasHelpful && !hasHarmful;
|
||||
|
||||
int nextKey = 1;
|
||||
if (hasHelpful || both)
|
||||
AppendRangeToArray(L, unit, 1, Data::Filter::Helpful, nextKey, playerOnly);
|
||||
if (hasHarmful || both)
|
||||
AppendRangeToArray(L, unit, 1, Data::Filter::Harmful, nextKey, playerOnly);
|
||||
if (unit != nullptr) {
|
||||
if (hasHelpful || both)
|
||||
AppendRangeToArray(L, unit, 1, Data::Filter::Helpful, nextKey, playerOnly);
|
||||
if (hasHarmful || both)
|
||||
AppendRangeToArray(L, unit, 1, Data::Filter::Harmful, nextKey, playerOnly);
|
||||
} else {
|
||||
// No live CGUnit — out-of-range / cross-map groupmate. Enumerate the
|
||||
// group-member aura array (spell IDs the server still transmits), the
|
||||
// same source the built-in UnitBuff/UnitDebuff read out of range.
|
||||
const uint64_t guid = GuidForOutOfRange(unitToken);
|
||||
if (hasHelpful || both)
|
||||
Data::AppendGroupAuras(L, guid, Data::Filter::Helpful, playerOnly, 1, nextKey);
|
||||
if (hasHarmful || both)
|
||||
Data::AppendGroupAuras(L, guid, Data::Filter::Harmful, playerOnly, 1, nextKey);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
+31
-48
@@ -21,6 +21,7 @@
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "net/PacketReader.h"
|
||||
#include "net/SendObserver.h"
|
||||
#include "spell/Mod.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
@@ -86,28 +87,17 @@ int ConsumeCapturedCP(uint32_t spellId) {
|
||||
// re-entry, but still build and send CMSG_CAST_SPELL through the engine).
|
||||
// Snapshot the combo points at the moment the cast request leaves: the
|
||||
// server consumes them before SMSG_SPELL_GO arrives, so reading at
|
||||
// SpellGo time is too late.
|
||||
using NetSend_t = void(__fastcall *)(void *conn, void *edx,
|
||||
CDataStore *packet);
|
||||
NetSend_t g_origNetSend = nullptr;
|
||||
|
||||
void __fastcall NetSend_h(void *conn, void *edx, CDataStore *packet) {
|
||||
if (packet != nullptr) {
|
||||
const uint32_t saved = packet->m_read;
|
||||
const uint32_t opcode = Net::Read<uint32_t>(packet);
|
||||
if (opcode == Offsets::OP_CMSG_CAST_SPELL) {
|
||||
const uint32_t spellId = Net::Read<uint32_t>(packet);
|
||||
if (spellId != 0)
|
||||
g_capture = {spellId, CurrentComboPoints(), NowMs()};
|
||||
}
|
||||
packet->m_read = saved;
|
||||
}
|
||||
g_origNetSend(conn, edx, packet);
|
||||
// SpellGo time is too late. We watch sends through the shared
|
||||
// `Net::SendObserver` rather than owning the funnel hook ourselves.
|
||||
void OnSend(uint32_t opcode, Net::CDataStore *packet) {
|
||||
if (opcode != Offsets::OP_CMSG_CAST_SPELL)
|
||||
return;
|
||||
const uint32_t spellId = Net::Read<uint32_t>(packet);
|
||||
if (spellId != 0)
|
||||
g_capture = {spellId, CurrentComboPoints(), NowMs()};
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _sendHook{
|
||||
Offsets::FUN_NET_SEND, reinterpret_cast<void *>(&NetSend_h),
|
||||
reinterpret_cast<void **>(&g_origNetSend)};
|
||||
const Net::SendObserver::AutoSubscribe _sendSub{&OnSend};
|
||||
|
||||
// ---- Duration data (SpellDuration.dbc + Lua overrides) --------------------
|
||||
|
||||
@@ -132,30 +122,19 @@ const Override *FindOverride(uint32_t spellId) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Known values for spells whose duration row DANGLES — a non-zero
|
||||
// DurationIndex pointing at a SpellDuration row the client doesn't have.
|
||||
// Consulted ONLY when that exact condition holds, so the gate is the bug
|
||||
// itself, not a server fingerprint: on a stock client every one of these
|
||||
// resolves to a valid row and this table is never reached, and if the
|
||||
// broken client data is ever fixed the DBC row takes over automatically.
|
||||
//
|
||||
// Turtle's reworked Rip is the known case: all six ranks point at
|
||||
// SpellDuration row 87, which Turtle added on the SERVER only
|
||||
// ({8000, 0, 18000} — live server data shows 10/12/14/16/18s at 1..5 CP,
|
||||
// i.e. 8s + 2s per combo point); their client patch never shipped the
|
||||
// row (the client table jumps 86 -> 105). Verified the only dangling
|
||||
// duration index in the entire client Spell.dbc.
|
||||
constexpr Override kDanglingRowKnown[] = {
|
||||
{1079, 8000, 18000}, {9492, 8000, 18000}, {9493, 8000, 18000},
|
||||
{9752, 8000, 18000}, {9894, 8000, 18000}, {9896, 8000, 18000},
|
||||
};
|
||||
// Resolver chain for dangling-duration-row spells (client DBC missing the
|
||||
// row). The KNOWN VALUES themselves are server-custom data and live in
|
||||
// their own module (e.g. `src/turtle/ComboDuration.cpp` for Turtle's
|
||||
// reworked Rip); this stock module only owns the mechanism and the
|
||||
// dangling-condition gate. Chained at static-init by `AutoDanglingResolver`.
|
||||
AutoDanglingResolver *g_danglingResolvers = nullptr;
|
||||
|
||||
const Override *FindDanglingRowKnown(uint32_t spellId) {
|
||||
for (const auto &k : kDanglingRowKnown) {
|
||||
if (k.spellId == spellId)
|
||||
return &k;
|
||||
}
|
||||
return nullptr;
|
||||
bool ResolveDangling(uint32_t spellId, int32_t *baseMs, int32_t *maxMs) {
|
||||
for (AutoDanglingResolver *r = g_danglingResolvers; r != nullptr;
|
||||
r = r->next)
|
||||
if (r->fn(spellId, baseMs, maxMs))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// `C_UnitAuras.RegisterComboDuration(spellID, baseSeconds, maxSeconds)`
|
||||
@@ -203,6 +182,11 @@ const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
AutoDanglingResolver::AutoDanglingResolver(DanglingResolver f)
|
||||
: fn(f), next(g_danglingResolvers) {
|
||||
g_danglingResolvers = this;
|
||||
}
|
||||
|
||||
uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId) {
|
||||
if (spellRecord == nullptr || spellId == 0)
|
||||
return 0;
|
||||
@@ -227,12 +211,11 @@ uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId) {
|
||||
maxMs = *reinterpret_cast<const int32_t *>(row + 0xC);
|
||||
} else if (idx != 0) {
|
||||
// Dangling duration index — the client data is broken for this
|
||||
// spell. Fall back to the built-in known values (Turtle Rip).
|
||||
const Override *k = FindDanglingRowKnown(spellId);
|
||||
if (k == nullptr)
|
||||
// spell. Defer to a registered resolver (the known values are
|
||||
// server-custom data owned by a platform module, e.g.
|
||||
// src/turtle for Turtle's reworked Rip).
|
||||
if (!ResolveDangling(spellId, &baseMs, &maxMs))
|
||||
return 0;
|
||||
baseMs = k->baseMs;
|
||||
maxMs = k->maxMs;
|
||||
} else {
|
||||
return 0; // no duration row at all
|
||||
}
|
||||
|
||||
@@ -47,4 +47,22 @@ namespace Aura::ComboDuration {
|
||||
// duration path".
|
||||
uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId);
|
||||
|
||||
// Extension point for finishers whose SpellDuration row DANGLES — a
|
||||
// non-zero DurationIndex pointing at a row the client DBC doesn't have.
|
||||
// A resolver returns true and fills *baseMs/*maxMs if it knows the spell,
|
||||
// false otherwise. Consulted by `TryComboScaledMs` ONLY in that dangling
|
||||
// condition (after any Lua-registered override), so the activation gate is
|
||||
// the client-data bug itself, not a server fingerprint — the deliberate
|
||||
// design choice (a realm-sniffing approach was rejected). Server-custom
|
||||
// data (e.g. Turtle's reworked Rip) registers its values here instead of
|
||||
// living in this stock module: declare a file-scope
|
||||
// `static const Aura::ComboDuration::AutoDanglingResolver`.
|
||||
using DanglingResolver = bool (*)(uint32_t spellId, int32_t *baseMs,
|
||||
int32_t *maxMs);
|
||||
struct AutoDanglingResolver {
|
||||
explicit AutoDanglingResolver(DanglingResolver fn);
|
||||
DanglingResolver fn;
|
||||
AutoDanglingResolver *next;
|
||||
};
|
||||
|
||||
} // namespace Aura::ComboDuration
|
||||
|
||||
+356
-41
@@ -17,6 +17,7 @@
|
||||
#include "Offsets.h"
|
||||
#include "aura/Source.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "group/MemberStats.h"
|
||||
#include "guid/Guid.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
@@ -139,6 +140,22 @@ double PlayerBuffExpirationSeconds(const uint8_t *entry) {
|
||||
const uint32_t expirationMs = expirationTable[slotCode];
|
||||
if (expirationMs == 0)
|
||||
return 0.0;
|
||||
// Report "no timer" once the stored expiration has elapsed — this is not a
|
||||
// heuristic, it's exactly what the engine's own GetPlayerBuffTimeLeft does.
|
||||
// Script_GetPlayerBuffTimeLeft (0x004e4930) → FUN_004e4450 reads this same
|
||||
// expiration table and returns:
|
||||
// if ((int)(now - expiration[slot]) >= 0) return 0; // elapsed -> 0
|
||||
// return expiration[slot] - now; // else remaining
|
||||
// Permanent auras (paladin auras, passives, racials) carry an infinite
|
||||
// Spell.dbc duration (SpellDuration base -1); the server stores the fixed
|
||||
// cast-time in this slot for them, so it's always "already elapsed" and the
|
||||
// engine shows no countdown. The signed `(int)` compare also absorbs the
|
||||
// ~24.86-day GetTickCount wrap (both operands wrap together).
|
||||
using TickCount_t = uint32_t(__fastcall *)();
|
||||
const uint32_t nowMs = reinterpret_cast<TickCount_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
|
||||
if (static_cast<int32_t>(nowMs - expirationMs) >= 0)
|
||||
return 0.0; // expiration reached/passed → permanent or no timer
|
||||
return static_cast<double>(expirationMs) * 0.001;
|
||||
}
|
||||
|
||||
@@ -419,46 +436,39 @@ static void BuildTable(void *L, uint32_t spellID, int applications,
|
||||
// yields nil, so the table doesn't need an explicit entry.
|
||||
}
|
||||
|
||||
void Push(void *L, const uint8_t *unit, int slot) {
|
||||
const uint32_t spellID = ReadSpellID(unit, slot);
|
||||
const bool isHelpful = slot < Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
|
||||
// Timing fields. Vanilla doesn't broadcast cast time / duration for ANY
|
||||
// unit's auras except the local player's — for everyone else (target,
|
||||
// party, raid, mouseover) `expirationTime` stays 0 unless the
|
||||
// `Aura::Source` cache observed the cast. For the player we read the
|
||||
// engine's `VAR_PLAYER_BUFF_TABLE` (same data `GetPlayerBuffTimeLeft`
|
||||
// returns), which gives a real expiration timestamp.
|
||||
//
|
||||
// `duration` comes from Spell.dbc → SpellDuration.dbc with level scaling
|
||||
// (the *base* value); talent / glyph duration-extension modifiers are
|
||||
// already baked into the expiration timestamp on the caster's side, so
|
||||
// `expirationTime - GetTime()` reflects true remaining time even when the
|
||||
// base `duration` doesn't include the talent boost.
|
||||
// Enriches a bare `(guid, spellID)` into a full AuraData table — the shared
|
||||
// core behind every push path (descriptor, cache fallback, group-array). No
|
||||
// dependence on a live descriptor: the descriptor path passes what it read
|
||||
// (stacks, level, is-this-the-player), the others pass their known-or-default
|
||||
// values.
|
||||
//
|
||||
// Timing rules (identical across paths): `duration` is the Spell.dbc →
|
||||
// SpellDuration.dbc base scaled by `unitLevel` (0 skips scaling); talent/glyph
|
||||
// duration modifiers are baked into the caster-side expiration, so
|
||||
// `expirationTime - GetTime()` is the true remaining time even when `duration`
|
||||
// omits the boost. `expirationTime` for the local player comes from the engine
|
||||
// buff table (gated on `isPlayer`); for everyone else it, the caster, and the
|
||||
// applied (caster-modified) duration come from the `Aura::Source` SMSG_SPELL_GO
|
||||
// cache when it observed the cast — a miss leaves the modern-truthful defaults
|
||||
// (expiration 0, no sourceUnit/GUID).
|
||||
static void PushEnriched(void *L, uint64_t guid, uint32_t spellID,
|
||||
bool isHelpful, int applications, int unitLevel,
|
||||
bool isPlayer) {
|
||||
double duration = 0.0;
|
||||
double expirationTime = 0.0;
|
||||
uint64_t casterGuid = 0;
|
||||
const uint8_t *player = LocalPlayer();
|
||||
if (spellID != 0) {
|
||||
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
|
||||
if (spellID != 0)
|
||||
duration = SpellBaseDurationSeconds(spellID, unitLevel);
|
||||
}
|
||||
if (unit != nullptr && unit == player) {
|
||||
if (isPlayer && spellID != 0) {
|
||||
const uint8_t *entry = FindPlayerBuffEntry(spellID);
|
||||
if (entry != nullptr)
|
||||
expirationTime = PlayerBuffExpirationSeconds(entry);
|
||||
}
|
||||
// Caster + non-player expiration from the SMSG_SPELL_GO cache, captured at
|
||||
// cast time (the descriptor has neither). The player-buff table above is
|
||||
// authoritative for the player's own expiration; the cache fills it for
|
||||
// everyone else. Prefer the applied (caster-modified) duration so it stays
|
||||
// consistent with `expirationTime` — otherwise a talented DoT (Improved
|
||||
// Shadow Word: Pain) would show remaining time exceeding the base.
|
||||
if (spellID != 0) {
|
||||
if (spellID != 0 && guid != 0) {
|
||||
uint64_t c = 0;
|
||||
uint32_t expMs = 0;
|
||||
uint32_t durMs = 0;
|
||||
if (Aura::Source::Get(UnitGuid(unit), spellID, &c, &expMs, &durMs)) {
|
||||
if (Aura::Source::Get(guid, spellID, &c, &expMs, &durMs)) {
|
||||
if (expirationTime == 0.0 && expMs != 0)
|
||||
expirationTime = static_cast<double>(expMs) * 0.001;
|
||||
if (durMs != 0)
|
||||
@@ -466,9 +476,16 @@ void Push(void *L, const uint8_t *unit, int slot) {
|
||||
casterGuid = c;
|
||||
}
|
||||
}
|
||||
BuildTable(L, spellID, applications, isHelpful, duration, expirationTime,
|
||||
casterGuid);
|
||||
}
|
||||
|
||||
BuildTable(L, spellID, ReadStacks(unit, slot), isHelpful, duration,
|
||||
expirationTime, casterGuid);
|
||||
void Push(void *L, const uint8_t *unit, int slot) {
|
||||
const uint32_t spellID = ReadSpellID(unit, slot);
|
||||
const bool isHelpful = slot < Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
|
||||
PushEnriched(L, UnitGuid(unit), spellID, isHelpful, ReadStacks(unit, slot),
|
||||
unitLevel, unit != nullptr && unit == LocalPlayer());
|
||||
}
|
||||
|
||||
namespace {
|
||||
@@ -479,17 +496,37 @@ namespace {
|
||||
// applied (caster-modified) ms when known, else the Spell.dbc base.
|
||||
void PushFromCache(void *L, const uint8_t *unit,
|
||||
const Aura::Source::CachedAura &c, bool isHelpful) {
|
||||
double duration = 0.0;
|
||||
if (c.durationMs != 0) {
|
||||
duration = static_cast<double>(c.durationMs) * 0.001;
|
||||
} else {
|
||||
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
|
||||
duration = SpellBaseDurationSeconds(c.spellId, unitLevel);
|
||||
// `PushEnriched` re-reads the same cache entry `c` came from (by
|
||||
// guid+spellID) to fill caster / expiration / applied duration, so the
|
||||
// result is identical to reading `c`'s fields directly. Stacks aren't in
|
||||
// SMSG_SPELL_GO, so `applications` is 1.
|
||||
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
|
||||
PushEnriched(L, UnitGuid(unit), c.spellId, isHelpful, 1, unitLevel, false);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
const double expirationTime =
|
||||
(c.expirationMs != 0) ? static_cast<double>(c.expirationMs) * 0.001 : 0.0;
|
||||
BuildTable(L, c.spellId, 1, isHelpful, duration, expirationTime,
|
||||
c.casterGuid);
|
||||
if (n == 0)
|
||||
return;
|
||||
Aura::Source::EvictAbsent(UnitGuid(unit), present, n);
|
||||
}
|
||||
|
||||
// True if `unit`'s descriptor currently exposes any visible aura in either
|
||||
@@ -559,6 +596,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 +627,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);
|
||||
@@ -606,4 +645,280 @@ void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Out-of-range groupmate path ────────────────────────────────────────────
|
||||
|
||||
namespace {
|
||||
|
||||
// Absolute slot range [start, end) for a filter, matching the descriptor
|
||||
// convention (helpful 0..31, harmful 32..47).
|
||||
void GroupRange(Filter filter, int &start, int &end) {
|
||||
start = (filter == Filter::Harmful) ? Offsets::UNIT_AURA_BUFF_COUNT : 0;
|
||||
end = (filter == Filter::Harmful) ? Offsets::UNIT_AURA_TOTAL
|
||||
: Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
}
|
||||
|
||||
// Spell ID at group-array slot (0..47), 0 if `arr` null / OOB / empty.
|
||||
uint32_t GroupSpellID(const uint16_t *arr, int slot) {
|
||||
if (arr == nullptr || slot < 0 || slot >= Offsets::UNIT_AURA_TOTAL)
|
||||
return 0;
|
||||
return arr[slot];
|
||||
}
|
||||
|
||||
// Whether the group-array slot holds a surfacable aura for this query. Mirrors
|
||||
// the engine's per-slot test in Script_UnitBuff/UnitDebuff — a non-zero spell
|
||||
// ID with a real Spell.dbc record that passes the visibility predicate (the
|
||||
// server only transmits *applied* auras, so there is no flag nibble to check).
|
||||
// `playerOnly` additionally requires the `Aura::Source` cache to attribute the
|
||||
// cast to the local player (a miss excludes it — we can't confirm the caster
|
||||
// from the group array alone).
|
||||
bool GroupSlotEligible(uint64_t guid, const uint16_t *arr, int slot,
|
||||
bool playerOnly) {
|
||||
const uint32_t id = GroupSpellID(arr, slot);
|
||||
if (id == 0 || !IsVisible(SpellRecord(id)))
|
||||
return false;
|
||||
if (playerOnly) {
|
||||
uint64_t c = 0;
|
||||
uint32_t e = 0;
|
||||
uint32_t d = 0;
|
||||
if (!Aura::Source::Get(guid, id, &c, &e, &d) ||
|
||||
c != Unit::Identity::PlayerGuid())
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// The groupmate's level, for Spell.dbc duration scaling (0 = skip). Cheap
|
||||
// roster lookup; only hit on the out-of-range path.
|
||||
int GroupMemberLevel(uint64_t guid) {
|
||||
return Group::MemberStats::Lookup(guid).level;
|
||||
}
|
||||
|
||||
// True if the group array exposes ANY visible aura (either kind). When it
|
||||
// does, the server's out-of-range aura data is present and authoritative, so
|
||||
// we do NOT supplement from the Aura::Source cache — mirroring how the
|
||||
// descriptor path treats a populated descriptor (DescriptorHasVisibleAura).
|
||||
// Only when the array is entirely empty do we fall back to the cache: the
|
||||
// server resends out-of-range stats as deltas only, so a member seen in range
|
||||
// and then moved away again leaves an empty party-stats aura array (the array
|
||||
// is cleared when they become visible and never re-sent), yet their auras
|
||||
// still sit in the cache from when we observed them in range.
|
||||
bool GroupArrayHasVisibleAura(const uint16_t *arr) {
|
||||
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
|
||||
const uint32_t id = GroupSpellID(arr, slot);
|
||||
if (id != 0 && IsVisible(SpellRecord(id)))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Whether cache entry `c` may supplement the (empty) group array: a visible
|
||||
// aura, not already present in the array, and — when player-filtered — cast by
|
||||
// us. See GroupArrayHasVisibleAura for when this path runs.
|
||||
bool GroupFallbackEligible(const uint16_t *arr, const Aura::Source::CachedAura &c,
|
||||
bool playerOnly) {
|
||||
if (!IsVisible(SpellRecord(c.spellId)))
|
||||
return false;
|
||||
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot)
|
||||
if (GroupSpellID(arr, slot) == c.spellId)
|
||||
return false;
|
||||
if (playerOnly && c.casterGuid != Unit::Identity::PlayerGuid())
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Nth (1-based) cache-fallback aura of `filter`'s kind for an out-of-range
|
||||
// member whose group array is empty. Pushes the AuraData table on a hit.
|
||||
bool PushNthGroupCacheFallback(void *L, uint64_t guid, const uint16_t *arr,
|
||||
int oneBasedIndex, Filter filter, bool playerOnly) {
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
const int n =
|
||||
Aura::Source::Enumerate(guid, filter == Filter::Harmful, buf, kFallbackMax);
|
||||
int seen = 0;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (!GroupFallbackEligible(arr, buf[i], playerOnly))
|
||||
continue;
|
||||
if (++seen == oneBasedIndex) {
|
||||
PushEnriched(L, guid, buf[i].spellId, filter == Filter::Helpful, 1,
|
||||
GroupMemberLevel(guid), false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Append every cache-fallback aura of `filter`'s kind into the array table.
|
||||
void AppendGroupCacheFallbacks(void *L, uint64_t guid, const uint16_t *arr,
|
||||
Filter filter, bool playerOnly, int outerIdx,
|
||||
int &nextKey) {
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
const int n =
|
||||
Aura::Source::Enumerate(guid, filter == Filter::Harmful, buf, kFallbackMax);
|
||||
const int level = GroupMemberLevel(guid);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (!GroupFallbackEligible(arr, buf[i], playerOnly))
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
|
||||
PushEnriched(L, guid, buf[i].spellId, filter == Filter::Helpful, 1, level,
|
||||
false);
|
||||
Game::Lua::SetTable(L, outerIdx);
|
||||
}
|
||||
}
|
||||
|
||||
// Single cache-fallback aura matching `spellID` (when nonzero) or `spellName`
|
||||
// (when non-null), of the given kind. Backs the by-ID / by-name group queries.
|
||||
bool PushGroupCacheFallbackMatch(void *L, uint64_t guid, const uint16_t *arr,
|
||||
bool harmful, uint32_t spellID,
|
||||
const char *spellName, bool playerOnly) {
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
const int n = Aura::Source::Enumerate(guid, harmful, buf, kFallbackMax);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (spellID != 0 && buf[i].spellId != spellID)
|
||||
continue;
|
||||
if (spellName != nullptr) {
|
||||
const char *nm = LocalizedSpellName(SpellRecord(buf[i].spellId));
|
||||
if (nm == nullptr || std::strcmp(nm, spellName) != 0)
|
||||
continue;
|
||||
}
|
||||
if (!GroupFallbackEligible(arr, buf[i], playerOnly))
|
||||
continue;
|
||||
PushEnriched(L, guid, buf[i].spellId, !harmful, 1, GroupMemberLevel(guid),
|
||||
false);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
|
||||
bool playerOnly) {
|
||||
if (guid == 0 || oneBasedIndex < 1)
|
||||
return false;
|
||||
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
|
||||
if (arr == nullptr)
|
||||
return false;
|
||||
Aura::Source::ObserveGroupAuras(guid, arr);
|
||||
int start, end;
|
||||
GroupRange(filter, start, end);
|
||||
int matches = 0;
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
if (!GroupSlotEligible(guid, arr, slot, playerOnly))
|
||||
continue;
|
||||
if (++matches == oneBasedIndex) {
|
||||
PushEnriched(L, guid, GroupSpellID(arr, slot),
|
||||
filter == Filter::Helpful, 1, GroupMemberLevel(guid),
|
||||
false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Empty array (server delta-resend gap): supplement from the cache.
|
||||
if (!GroupArrayHasVisibleAura(arr))
|
||||
return PushNthGroupCacheFallback(L, guid, arr, oneBasedIndex, filter,
|
||||
playerOnly);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
|
||||
const Filter *filter, bool playerOnly) {
|
||||
if (guid == 0 || spellID == 0)
|
||||
return false;
|
||||
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
|
||||
if (arr == nullptr)
|
||||
return false;
|
||||
Aura::Source::ObserveGroupAuras(guid, arr);
|
||||
const int start = (filter != nullptr && *filter == Filter::Harmful)
|
||||
? Offsets::UNIT_AURA_BUFF_COUNT
|
||||
: 0;
|
||||
const int end = (filter != nullptr && *filter == Filter::Helpful)
|
||||
? Offsets::UNIT_AURA_BUFF_COUNT
|
||||
: Offsets::UNIT_AURA_TOTAL;
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
if (GroupSpellID(arr, slot) != spellID)
|
||||
continue;
|
||||
if (!GroupSlotEligible(guid, arr, slot, playerOnly))
|
||||
continue;
|
||||
PushEnriched(L, guid, spellID, slot < Offsets::UNIT_AURA_BUFF_COUNT, 1,
|
||||
GroupMemberLevel(guid), false);
|
||||
return true;
|
||||
}
|
||||
if (!GroupArrayHasVisibleAura(arr)) {
|
||||
const bool both = (filter == nullptr);
|
||||
if ((both || *filter == Filter::Helpful) &&
|
||||
PushGroupCacheFallbackMatch(L, guid, arr, false, spellID, nullptr,
|
||||
playerOnly))
|
||||
return true;
|
||||
if ((both || *filter == Filter::Harmful) &&
|
||||
PushGroupCacheFallbackMatch(L, guid, arr, true, spellID, nullptr,
|
||||
playerOnly))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
|
||||
const Filter *filter, bool playerOnly) {
|
||||
if (guid == 0 || spellName == nullptr || *spellName == '\0')
|
||||
return false;
|
||||
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
|
||||
if (arr == nullptr)
|
||||
return false;
|
||||
Aura::Source::ObserveGroupAuras(guid, arr);
|
||||
const int start = (filter != nullptr && *filter == Filter::Harmful)
|
||||
? Offsets::UNIT_AURA_BUFF_COUNT
|
||||
: 0;
|
||||
const int end = (filter != nullptr && *filter == Filter::Helpful)
|
||||
? Offsets::UNIT_AURA_BUFF_COUNT
|
||||
: Offsets::UNIT_AURA_TOTAL;
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
const uint32_t id = GroupSpellID(arr, slot);
|
||||
if (id == 0)
|
||||
continue;
|
||||
const char *name = LocalizedSpellName(SpellRecord(id));
|
||||
if (name == nullptr || std::strcmp(name, spellName) != 0)
|
||||
continue;
|
||||
if (!GroupSlotEligible(guid, arr, slot, playerOnly))
|
||||
continue;
|
||||
PushEnriched(L, guid, id, slot < Offsets::UNIT_AURA_BUFF_COUNT, 1,
|
||||
GroupMemberLevel(guid), false);
|
||||
return true;
|
||||
}
|
||||
if (!GroupArrayHasVisibleAura(arr)) {
|
||||
const bool both = (filter == nullptr);
|
||||
if ((both || *filter == Filter::Helpful) &&
|
||||
PushGroupCacheFallbackMatch(L, guid, arr, false, 0, spellName,
|
||||
playerOnly))
|
||||
return true;
|
||||
if ((both || *filter == Filter::Harmful) &&
|
||||
PushGroupCacheFallbackMatch(L, guid, arr, true, 0, spellName,
|
||||
playerOnly))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void AppendGroupAuras(void *L, uint64_t guid, Filter filter, bool playerOnly,
|
||||
int outerIdx, int &nextKey) {
|
||||
if (guid == 0)
|
||||
return;
|
||||
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
|
||||
if (arr == nullptr)
|
||||
return;
|
||||
Aura::Source::ObserveGroupAuras(guid, arr);
|
||||
int start, end;
|
||||
GroupRange(filter, start, end);
|
||||
const int level = GroupMemberLevel(guid);
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
if (!GroupSlotEligible(guid, arr, slot, playerOnly))
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
|
||||
PushEnriched(L, guid, GroupSpellID(arr, slot),
|
||||
filter == Filter::Helpful, 1, level, false);
|
||||
Game::Lua::SetTable(L, outerIdx);
|
||||
}
|
||||
// Empty array (server delta-resend gap): supplement from the cache.
|
||||
if (!GroupArrayHasVisibleAura(arr))
|
||||
AppendGroupCacheFallbacks(L, guid, arr, filter, playerOnly, outerIdx,
|
||||
nextKey);
|
||||
}
|
||||
|
||||
} // namespace Aura::Data
|
||||
|
||||
@@ -132,4 +132,45 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
|
||||
void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
|
||||
bool playerOnly, int outerIdx, int &nextKey);
|
||||
|
||||
// ── Out-of-range groupmate path ────────────────────────────────────────────
|
||||
//
|
||||
// When a party/raid member has no live CGUnit at all (different map, far out
|
||||
// of range) the token resolves to a GUID but no object, so there is no
|
||||
// descriptor to read. The server still transmits that member's current aura
|
||||
// spell IDs via SMSG_PARTY_MEMBER_STATS, which the client keeps in the
|
||||
// group-member stats structs (`Group::MemberStats::AuraArray`). These functions
|
||||
// enumerate that array — exactly what the built-in `UnitBuff`/`UnitDebuff` do
|
||||
// out of range — and build the same AuraData table.
|
||||
//
|
||||
// Spell IDs only come off the wire: `applications` is 1 (stacks aren't sent)
|
||||
// and IDs are u16-truncated (custom IDs > 65535 are wrong). Any caster / real
|
||||
// expirationTime we observed for the member via SMSG_SPELL_GO is still merged
|
||||
// in from the `Aura::Source` cache. `guid` of 0 (not a rostered member) yields
|
||||
// no results. These are only meaningful when the unit has NO descriptor —
|
||||
// callers use them in the `unit == nullptr` branch, so there is no descriptor
|
||||
// to dedup against.
|
||||
|
||||
// Pushes AuraData for the `oneBasedIndex`-th group-array aura on `guid`
|
||||
// matching `filter`. On a hit pushes the table and returns true; otherwise
|
||||
// pushes nothing and returns false (caller pushes nil).
|
||||
bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
|
||||
bool playerOnly = false);
|
||||
|
||||
// Pushes AuraData for the group-array aura with `spellID` on `guid`, optionally
|
||||
// restricted to one filter range (nullptr = both, helpful first). Returns true
|
||||
// on a hit (table pushed), false otherwise (nothing pushed).
|
||||
bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
|
||||
const Filter *filter, bool playerOnly = false);
|
||||
|
||||
// As `PushGroupAuraBySpellID` but matched by locale-resolved spell name
|
||||
// (case-sensitive exact match), mirroring `FindSlotBySpellName`.
|
||||
bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
|
||||
const Filter *filter, bool playerOnly = false);
|
||||
|
||||
// Appends every group-array aura on `guid` matching `filter` into the array
|
||||
// table at `outerIdx`, continuing from `nextKey` (updated in place). The
|
||||
// bulk-enumeration analog of `PushNthGroupAura`, for `GetUnitAuras`.
|
||||
void AppendGroupAuras(void *L, uint64_t guid, Filter filter, bool playerOnly,
|
||||
int outerIdx, int &nextKey);
|
||||
|
||||
} // namespace Aura::Data
|
||||
|
||||
+512
-33
@@ -19,12 +19,17 @@
|
||||
#include "Data.h"
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "net/PacketDispatch.h"
|
||||
#include "net/PacketReader.h"
|
||||
#include "player/StatSignal.h"
|
||||
#include "spell/CastEvents.h"
|
||||
#include "spell/Lookup.h"
|
||||
#include "tick/WorldTick.h"
|
||||
#include "totem/Tracker.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace Aura::Source {
|
||||
|
||||
@@ -68,6 +73,49 @@ uint32_t SpellDurationMs(const uint8_t *spellRecord, bool casterIsPlayer) {
|
||||
return ms > 0 ? static_cast<uint32_t>(ms) : 0;
|
||||
}
|
||||
|
||||
// ---- Recent local-player aura casts --------------------------------------
|
||||
|
||||
// The aura-application hooks (OnAuraAdded / OnAuraStacksChanged) get no caster,
|
||||
// so they compute the UNMODIFIED base duration (`skipMod = 1`). That's correct
|
||||
// for other units' auras, but wrong for one the LOCAL PLAYER just cast: it
|
||||
// should carry the player-talented duration (Improved Shadow Word: Pain → 24s
|
||||
// vs the 18s base). SpellGo normally owns that entry with the talented value,
|
||||
// but when it can't attribute the cast to this target — an empty SMSG_SPELL_GO
|
||||
// hit list files it under the caster instead, or a slot reshuffle evicts and
|
||||
// refills the entry after SpellGo ran — the base value is what's left, so the
|
||||
// duration flickers 24 → 18. SpellGo (which knows caster == player) records the
|
||||
// spellId here so the application hooks can recover the player-modified
|
||||
// duration for it.
|
||||
constexpr int kRecentCastCount = 16;
|
||||
constexpr uint32_t kRecentCastTtlMs = 1500;
|
||||
struct RecentCast {
|
||||
uint32_t spellId;
|
||||
uint32_t tMs; // 0 = empty
|
||||
};
|
||||
RecentCast g_recentCasts[kRecentCastCount];
|
||||
int g_recentCastCursor = 0;
|
||||
|
||||
void RememberPlayerCast(uint32_t spellId) {
|
||||
const uint32_t now = NowMs();
|
||||
for (auto &r : g_recentCasts)
|
||||
if (r.spellId == spellId) {
|
||||
r.tMs = now;
|
||||
return;
|
||||
}
|
||||
g_recentCasts[g_recentCastCursor] = {spellId, now};
|
||||
g_recentCastCursor = (g_recentCastCursor + 1) % kRecentCastCount;
|
||||
}
|
||||
|
||||
bool WasRecentPlayerCast(uint32_t spellId) {
|
||||
if (spellId == 0)
|
||||
return false;
|
||||
const uint32_t now = NowMs();
|
||||
for (const auto &r : g_recentCasts)
|
||||
if (r.spellId == spellId && r.tMs != 0 && now - r.tMs <= kRecentCastTtlMs)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---- Cache ---------------------------------------------------------------
|
||||
|
||||
// Helpful/harmful classification, recorded from the aura's descriptor slot
|
||||
@@ -133,6 +181,45 @@ void Store(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
|
||||
true};
|
||||
}
|
||||
|
||||
// ---- Out-of-range group-member aura snapshots ---------------------------
|
||||
//
|
||||
// Transition state for `ObserveGroupAuras`: the last aura spell-ID set we saw
|
||||
// for each out-of-range group member, so we can stamp a duration guess only
|
||||
// when an aura genuinely appears (not for auras already present when we first
|
||||
// started watching that member). See the header for the rationale.
|
||||
|
||||
struct GroupSnapshot {
|
||||
uint64_t guid;
|
||||
uint32_t touchMs;
|
||||
bool used;
|
||||
uint16_t ids[Offsets::UNIT_AURA_TOTAL];
|
||||
};
|
||||
constexpr int kGroupSnapshots = 44; // MAX_RAID (40) + slack
|
||||
constexpr uint32_t kGroupSnapshotTtlMs = 30000; // forget members not polled for 30s
|
||||
GroupSnapshot g_groupSnaps[kGroupSnapshots];
|
||||
|
||||
bool SnapshotHasId(const GroupSnapshot &s, uint16_t id) {
|
||||
for (uint16_t v : s.ids)
|
||||
if (v == id)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stamp a base-duration expiration guess for a group aura that just appeared.
|
||||
// casterGuid stays 0 (unknown), so `Store`'s guard preserves any real SpellGo
|
||||
// timing we already hold. Skips non-aura spells and auras with no finite base
|
||||
// duration (nothing meaningful to guess — leave expiration unknown).
|
||||
void StampGroupGuess(uint64_t guid, uint16_t spellId, int8_t kind, uint32_t now) {
|
||||
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellId));
|
||||
if (!SpellAppliesAura(rec))
|
||||
return;
|
||||
const uint32_t base = SpellDurationMs(rec, /*casterIsPlayer*/ false);
|
||||
if (base == 0)
|
||||
return;
|
||||
Store(guid, spellId, /*casterGuid*/ 0, now + base, base, /*fromCast*/ false,
|
||||
kind);
|
||||
}
|
||||
|
||||
// Evict the entry for an aura the engine reports gone. Keyed by (target,
|
||||
// spell) like the rest of the cache. Without this, the GetAuraDataByIndex
|
||||
// fallback would keep surfacing a dropped aura until its computed expiry —
|
||||
@@ -150,10 +237,271 @@ void Evict(uint64_t targetGuid, uint32_t spellId) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Server-side duration modifiers (trigger-driven inference) -----------
|
||||
//
|
||||
// Some server mechanics change a DoT's remaining time on another unit with
|
||||
// NO client-visible signal — verified in tortoise-wow: the change goes
|
||||
// through SetAuraDuration / RefreshHolder, and UpdateAuraDuration only
|
||||
// notifies the aura's target-if-a-player (self-scoped), never the observing
|
||||
// caster; on a mob the packet isn't even built. We can't hear the *change*,
|
||||
// but we DO see the TRIGGERING cast's SMSG_SPELL_GO, so we mirror the
|
||||
// server's edit on the cached entry. Rules come from Lua
|
||||
// (`C_UnitAuras.RegisterAuraDurationModifier`), so the server-specific set
|
||||
// lives in the addon, not the DLL. Turtle examples:
|
||||
// Conflagrate -> Immolate: reduce 3s (Immolate keeps ticking, -3s)
|
||||
// Molten Blast -> Flame Shock: refresh (RefreshHolder → reset to max)
|
||||
// A trigger is matched by exact spellID (from the server's script binding,
|
||||
// stable across ranks); the affected aura by SpellFamilyName + a family-flag
|
||||
// overlap (+ optional icon) — rank-proof, exactly how the server's scripts
|
||||
// find it. Conflagrate's *full*-consume path removes Immolate, which clears
|
||||
// the descriptor slot → OnAuraRemoved already handles it; the reduce rule
|
||||
// covers the keep-ticking case. Probabilistic refreshes (Carnage's roll) are
|
||||
// deliberately NOT shipped as defaults — the client can't know the server's
|
||||
// roll outcome, so inferring them would show wrong timers.
|
||||
|
||||
enum ModOp { MOD_REFRESH = 0, MOD_REDUCE = 1, MOD_SET = 2, MOD_REMOVE = 3 };
|
||||
|
||||
struct DurationMod {
|
||||
uint32_t triggerSpellId; // exact trigger; 0 = match by family+school below
|
||||
uint32_t triggerFamily; // (triggerSpellId==0) trigger's SpellFamilyName
|
||||
int32_t triggerSchool; // (triggerSpellId==0) school index; -1 = any school
|
||||
uint32_t affectedFamily; // SpellFamilyName of the affected aura
|
||||
uint64_t affectedMask; // must overlap the affected aura's SpellFamilyFlags
|
||||
uint32_t affectedIcon; // 0 = any; else affected aura's SpellIconID must equal
|
||||
int32_t op;
|
||||
int32_t valueMs; // REDUCE/SET amount; ignored by REFRESH/REMOVE
|
||||
};
|
||||
constexpr int kMaxMods = 128;
|
||||
DurationMod g_mods[kMaxMods];
|
||||
int g_modCount = 0;
|
||||
|
||||
bool AffectedMatches(const uint8_t *rec, const DurationMod &m) {
|
||||
if (rec == nullptr)
|
||||
return false;
|
||||
if (*reinterpret_cast<const uint32_t *>(
|
||||
rec + Offsets::OFF_SPELL_RECORD_FAMILY_NAME) != m.affectedFamily)
|
||||
return false;
|
||||
const uint64_t flags = *reinterpret_cast<const uint64_t *>(
|
||||
rec + Offsets::OFF_SPELL_RECORD_FAMILY_FLAGS);
|
||||
if ((flags & m.affectedMask) == 0)
|
||||
return false;
|
||||
if (m.affectedIcon != 0 &&
|
||||
*reinterpret_cast<const uint32_t *>(
|
||||
rec + Offsets::OFF_SPELL_RECORD_ICON_ID) != m.affectedIcon)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// A rule's trigger matches either by exact spellID, or (triggerSpellId == 0)
|
||||
// by the cast spell's SpellFamilyName + school index — one rule then covers
|
||||
// every rank / server-added spell of a class's school (e.g. any priest
|
||||
// shadow-school cast, for Shadow Weaving). triggerSchool < 0 = any school.
|
||||
bool TriggerMatches(const DurationMod &m, uint32_t triggerSpellId,
|
||||
const uint8_t *triggerRec) {
|
||||
if (m.triggerSpellId != 0)
|
||||
return m.triggerSpellId == triggerSpellId;
|
||||
if (triggerRec == nullptr)
|
||||
return false;
|
||||
if (*reinterpret_cast<const uint32_t *>(
|
||||
triggerRec + Offsets::OFF_SPELL_RECORD_FAMILY_NAME) != m.triggerFamily)
|
||||
return false;
|
||||
if (m.triggerSchool >= 0 &&
|
||||
static_cast<int32_t>(*reinterpret_cast<const uint32_t *>(
|
||||
triggerRec + Offsets::OFF_SPELL_SCHOOL)) != m.triggerSchool)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void ApplyMod(Entry &e, const DurationMod &m, uint32_t now) {
|
||||
switch (m.op) {
|
||||
case MOD_REFRESH:
|
||||
if (e.durationMs > 0)
|
||||
e.expirationMs = now + e.durationMs; // RefreshHolder → reset to max
|
||||
break;
|
||||
case MOD_SET:
|
||||
e.durationMs = static_cast<uint32_t>(m.valueMs);
|
||||
e.expirationMs = now + static_cast<uint32_t>(m.valueMs);
|
||||
break;
|
||||
case MOD_REDUCE:
|
||||
if (e.expirationMs != 0) {
|
||||
if (e.expirationMs > now + static_cast<uint32_t>(m.valueMs))
|
||||
e.expirationMs -= static_cast<uint32_t>(m.valueMs);
|
||||
else
|
||||
e.used = false; // shaved to/past now → server removes it
|
||||
}
|
||||
break;
|
||||
case MOD_REMOVE:
|
||||
e.used = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// On a trigger cast landing on its hit targets, mirror the server's duration
|
||||
// edit on the caster's own matching cached aura. Called from SpellGo_h before
|
||||
// the aura gate — triggers (Conflagrate, Molten Blast) apply no aura of their
|
||||
// own, so they'd otherwise be dropped.
|
||||
void ApplyDurationModifiers(uint32_t triggerSpellId, uint64_t caster,
|
||||
const uint64_t *targets, int numTargets) {
|
||||
if (triggerSpellId == 0 || caster == 0 || numTargets <= 0)
|
||||
return;
|
||||
const uint8_t *triggerRec =
|
||||
Spell::Lookup::RecordForID(static_cast<int>(triggerSpellId));
|
||||
const uint32_t now = NowMs();
|
||||
for (int r = 0; r < g_modCount; ++r) {
|
||||
const DurationMod &m = g_mods[r];
|
||||
if (!TriggerMatches(m, triggerSpellId, triggerRec))
|
||||
continue;
|
||||
for (int t = 0; t < numTargets; ++t) {
|
||||
for (auto &e : g_cache) {
|
||||
if (!e.used || e.targetGuid != targets[t])
|
||||
continue;
|
||||
// The mechanic acts on the trigger-caster's own aura. Cast-applied
|
||||
// auras (Immolate, Flame Shock) carry caster == player from SpellGo;
|
||||
// but PROC-applied ones (Shadow Weaving) enter the cache via the
|
||||
// application hooks with casterGuid 0 (no SMSG_SPELL_GO ever named a
|
||||
// caster), so accept those too and attribute them to this trigger
|
||||
// caster (also gives them a sourceUnit).
|
||||
if (e.casterGuid != 0 && e.casterGuid != caster)
|
||||
continue;
|
||||
if (!AffectedMatches(
|
||||
Spell::Lookup::RecordForID(static_cast<int>(e.spellId)), m))
|
||||
continue;
|
||||
if (e.casterGuid == 0)
|
||||
e.casterGuid = caster;
|
||||
ApplyMod(e, m, now);
|
||||
break; // one matching aura per (rule, target), like the server
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool RegisterDurationMod(uint32_t triggerSpellId, uint32_t triggerFamily,
|
||||
int32_t triggerSchool, uint32_t affectedFamily,
|
||||
uint64_t affectedMask, uint32_t affectedIcon, int op,
|
||||
int32_t valueMs) {
|
||||
// Trigger must be identified one way or the other.
|
||||
if ((triggerSpellId == 0 && triggerFamily == 0) || affectedMask == 0 ||
|
||||
op < MOD_REFRESH || op > MOD_REMOVE)
|
||||
return false;
|
||||
for (int i = 0; i < g_modCount; ++i) { // replace an identical rule
|
||||
DurationMod &m = g_mods[i];
|
||||
if (m.triggerSpellId == triggerSpellId && m.triggerFamily == triggerFamily &&
|
||||
m.triggerSchool == triggerSchool && m.affectedFamily == affectedFamily &&
|
||||
m.affectedMask == affectedMask && m.affectedIcon == affectedIcon) {
|
||||
m.op = op;
|
||||
m.valueMs = valueMs;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (g_modCount >= kMaxMods)
|
||||
return false;
|
||||
g_mods[g_modCount++] = {triggerSpellId, triggerFamily, triggerSchool,
|
||||
affectedFamily, affectedMask, affectedIcon, op, valueMs};
|
||||
return true;
|
||||
}
|
||||
|
||||
int OpFromString(const char *s) {
|
||||
if (s == nullptr)
|
||||
return -1;
|
||||
if (_stricmp(s, "refresh") == 0)
|
||||
return MOD_REFRESH;
|
||||
if (_stricmp(s, "reduce") == 0)
|
||||
return MOD_REDUCE;
|
||||
if (_stricmp(s, "set") == 0)
|
||||
return MOD_SET;
|
||||
if (_stricmp(s, "remove") == 0)
|
||||
return MOD_REMOVE;
|
||||
return -1;
|
||||
}
|
||||
|
||||
// `C_UnitAuras.RegisterAuraDurationModifier(triggerSpellID, affectedFamily,
|
||||
// affectedFamilyFlags, affectedIcon, op [, valueSeconds])` -> bool.
|
||||
// op: "refresh" | "reduce" | "set" | "remove". valueSeconds used by
|
||||
// reduce/set. affectedIcon 0 = match any icon. Re-registering an identical
|
||||
// (trigger, family, flags, icon) tuple replaces its op/value.
|
||||
int __fastcall Script_RegisterAuraDurationModifier(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2) ||
|
||||
!Game::Lua::IsNumber(L, 3) || !Game::Lua::IsNumber(L, 4) ||
|
||||
!Game::Lua::IsString(L, 5)) {
|
||||
Game::Lua::Error(
|
||||
L, "Usage: C_UnitAuras.RegisterAuraDurationModifier(triggerSpellID, "
|
||||
"affectedFamily, affectedFamilyFlags, affectedIcon, op[, "
|
||||
"valueSeconds])");
|
||||
return 0;
|
||||
}
|
||||
const auto trigger = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
|
||||
const auto family = static_cast<uint32_t>(Game::Lua::ToNumber(L, 2));
|
||||
const auto mask = static_cast<uint64_t>(Game::Lua::ToNumber(L, 3));
|
||||
const auto icon = static_cast<uint32_t>(Game::Lua::ToNumber(L, 4));
|
||||
const int op = OpFromString(Game::Lua::ToString(L, 5));
|
||||
const int32_t valueMs =
|
||||
Game::Lua::IsNumber(L, 6)
|
||||
? static_cast<int32_t>(Game::Lua::ToNumber(L, 6) * 1000.0)
|
||||
: 0;
|
||||
if (op < 0) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
Game::Lua::PushBool(L, RegisterDurationMod(trigger, /*triggerFamily*/ 0,
|
||||
/*triggerSchool*/ -1, family, mask,
|
||||
icon, op, valueMs));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_UnitAuras.RegisterAuraDurationModifierByTrigger(triggerFamily,
|
||||
// triggerSchool, affectedFamily, affectedFamilyFlags, affectedIcon, op
|
||||
// [, valueSeconds])` -> bool. Like the above, but the trigger is matched
|
||||
// by SpellFamilyName + school index instead of an exact spellID — one rule
|
||||
// covers every rank / server-added spell of a class's school. triggerSchool
|
||||
// < 0 = any school. E.g. Shadow Weaving: priest (6) shadow-school (5) casts
|
||||
// refresh the target's Shadow Vulnerability.
|
||||
int __fastcall Script_RegisterAuraDurationModifierByTrigger(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2) ||
|
||||
!Game::Lua::IsNumber(L, 3) || !Game::Lua::IsNumber(L, 4) ||
|
||||
!Game::Lua::IsNumber(L, 5) || !Game::Lua::IsString(L, 6)) {
|
||||
Game::Lua::Error(
|
||||
L, "Usage: C_UnitAuras.RegisterAuraDurationModifierByTrigger("
|
||||
"triggerFamily, triggerSchool, affectedFamily, affectedFamilyFlags, "
|
||||
"affectedIcon, op[, valueSeconds])");
|
||||
return 0;
|
||||
}
|
||||
const auto tfamily = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
|
||||
const auto tschool = static_cast<int32_t>(Game::Lua::ToNumber(L, 2));
|
||||
const auto family = static_cast<uint32_t>(Game::Lua::ToNumber(L, 3));
|
||||
const auto mask = static_cast<uint64_t>(Game::Lua::ToNumber(L, 4));
|
||||
const auto icon = static_cast<uint32_t>(Game::Lua::ToNumber(L, 5));
|
||||
const int op = OpFromString(Game::Lua::ToString(L, 6));
|
||||
const int32_t valueMs =
|
||||
Game::Lua::IsNumber(L, 7)
|
||||
? static_cast<int32_t>(Game::Lua::ToNumber(L, 7) * 1000.0)
|
||||
: 0;
|
||||
if (op < 0 || tfamily == 0) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
Game::Lua::PushBool(L, RegisterDurationMod(/*triggerSpellId*/ 0, tfamily, tschool,
|
||||
family, mask, icon, op, valueMs));
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterDurationModLua() {
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "RegisterAuraDurationModifier",
|
||||
&Script_RegisterAuraDurationModifier);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras",
|
||||
"RegisterAuraDurationModifierByTrigger",
|
||||
&Script_RegisterAuraDurationModifierByTrigger);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoregDurationMod{&RegisterDurationModLua};
|
||||
|
||||
// Wipe the whole cache. Used on a map transition (see OnWorldTick).
|
||||
void FlushAll() {
|
||||
for (auto &e : g_cache)
|
||||
e.used = false;
|
||||
// Reset transition baselines too: post-transition group auras re-sync and
|
||||
// should be treated as first-sight (unknown age), not diffed as new.
|
||||
for (auto &s : g_groupSnaps)
|
||||
s.used = false;
|
||||
}
|
||||
|
||||
// -1 = no map seen yet (never a valid Map.dbc id), so the first tick just
|
||||
@@ -189,6 +537,12 @@ void OnWorldTick() {
|
||||
if (e.used && e.expirationMs != 0 && now >= e.expirationMs)
|
||||
e.used = false;
|
||||
}
|
||||
// Forget snapshots for members we haven't polled recently (left the group,
|
||||
// or no longer displayed) so a later re-appearance re-baselines cleanly.
|
||||
for (auto &s : g_groupSnaps) {
|
||||
if (s.used && now - s.touchMs > kGroupSnapshotTtlMs)
|
||||
s.used = false;
|
||||
}
|
||||
}
|
||||
|
||||
const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
|
||||
@@ -202,33 +556,34 @@ const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
|
||||
// keeps the parse short and robust.
|
||||
constexpr int kMaxTargets = 16;
|
||||
|
||||
using SpellGo_t = void(__fastcall *)(uint64_t *itemGUID, uint64_t *casterGUID,
|
||||
uint32_t spellId, CDataStore *packet);
|
||||
SpellGo_t g_origSpellGo = nullptr;
|
||||
|
||||
void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
|
||||
uint32_t spellId, CDataStore *packet) {
|
||||
uint64_t targets[kMaxTargets];
|
||||
int numTargets = 0;
|
||||
|
||||
if (packet != nullptr) {
|
||||
const uint32_t savedRead = packet->m_read;
|
||||
Net::Read<int16_t>(packet); // castFlags (unused)
|
||||
const uint8_t numHit = Net::Read<uint8_t>(packet);
|
||||
for (int i = 0; i < numHit; ++i) {
|
||||
const uint64_t guid = Net::Read<uint64_t>(packet);
|
||||
if (numTargets < kMaxTargets)
|
||||
targets[numTargets++] = guid;
|
||||
}
|
||||
packet->m_read = savedRead; // restore so the engine re-parses cleanly
|
||||
}
|
||||
|
||||
g_origSpellGo(itemGUID, casterGUID, spellId, packet);
|
||||
|
||||
const uint64_t caster = (casterGUID != nullptr) ? *casterGUID : 0;
|
||||
// The self-contained SPELL_GO processing (succeeded events, server-side
|
||||
// duration edits, aura-source caching). None of it reads engine descriptor
|
||||
// state, so it's order-independent of the engine's own SPELL_GO handler —
|
||||
// which is why it can run from the dispatch funnel (before the leaf handler)
|
||||
// rather than after a co-hook's original call. The aura *application* hooks
|
||||
// fire off a separate SMSG_UPDATE_OBJECT, so RememberPlayerCast's handoff to
|
||||
// them is unaffected by the move.
|
||||
void HandleSpellGo(uint64_t caster, uint32_t spellId, const uint64_t *targets,
|
||||
int numTargets) {
|
||||
if (caster == 0 || spellId == 0)
|
||||
return;
|
||||
|
||||
// Feed the totem tracker + fire UNIT_SPELLCAST_SUCCEEDED BEFORE the aura
|
||||
// gate below — a totem summon (and any non-aura spell) applies no aura,
|
||||
// so it would otherwise be dropped. SPELL_GO is "the spell went off", so
|
||||
// this is the succeeded signal for instants too. Player casts only.
|
||||
if (caster == Unit::Identity::PlayerGuid()) {
|
||||
Totem::Tracker::OnPlayerSpellGo(spellId);
|
||||
Spell::CastEvents::OnPlayerSucceeded(static_cast<int>(spellId));
|
||||
} else {
|
||||
Spell::CastEvents::OnRemoteSucceeded(caster, static_cast<int>(spellId));
|
||||
}
|
||||
|
||||
// Mirror server-side duration edits the client is never told about
|
||||
// (Conflagrate -3s Immolate, Molten Blast refresh Flame Shock, …). Runs
|
||||
// before the aura gate below: the trigger spell applies no aura itself.
|
||||
ApplyDurationModifiers(spellId, caster, targets, numTargets);
|
||||
|
||||
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellId));
|
||||
if (!SpellAppliesAura(rec))
|
||||
return;
|
||||
@@ -245,6 +600,12 @@ void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
|
||||
durationMs = SpellDurationMs(rec, casterIsPlayer);
|
||||
const uint32_t expirationMs = durationMs > 0 ? NowMs() + durationMs : 0;
|
||||
|
||||
// Let the application hooks recover the player-talented duration for this
|
||||
// spell if they end up owning the target's entry (empty hit list / refill
|
||||
// race) — see WasRecentPlayerCast.
|
||||
if (casterIsPlayer)
|
||||
RememberPlayerCast(spellId);
|
||||
|
||||
if (numTargets == 0) {
|
||||
// No explicit hit list (self-cast with caster-implicit target).
|
||||
Store(caster, spellId, caster, expirationMs, durationMs, true,
|
||||
@@ -256,9 +617,33 @@ void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
|
||||
KIND_UNKNOWN);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hook{Offsets::FUN_SPELL_GO,
|
||||
reinterpret_cast<void *>(&SpellGo_h),
|
||||
reinterpret_cast<void **>(&g_origSpellGo)};
|
||||
// SMSG_SPELL_GO parse (funnel subscriber). At the leaf handler the engine has
|
||||
// pre-decoded itemGuid/casterGuid/spellId; here we're at the raw body, so we
|
||||
// decode them ourselves before the hit list:
|
||||
// itemGuid(packed), casterGuid(packed), spellId(u32), castFlags(i16),
|
||||
// numHit(u8), hitGuids(u64 × numHit).
|
||||
void ParseSpellGo(CDataStore *packet) {
|
||||
Net::ReadPackedGuid(packet); // itemGuid (unused)
|
||||
const uint64_t caster = Net::ReadPackedGuid(packet);
|
||||
const uint32_t spellId = Net::Read<uint32_t>(packet);
|
||||
Net::Read<int16_t>(packet); // castFlags (unused)
|
||||
const uint8_t numHit = Net::Read<uint8_t>(packet);
|
||||
uint64_t targets[kMaxTargets];
|
||||
int numTargets = 0;
|
||||
for (int i = 0; i < numHit; ++i) {
|
||||
const uint64_t guid = Net::Read<uint64_t>(packet);
|
||||
if (numTargets < kMaxTargets)
|
||||
targets[numTargets++] = guid;
|
||||
}
|
||||
HandleSpellGo(caster, spellId, targets, numTargets);
|
||||
}
|
||||
|
||||
void SpellGoSub(uint32_t opcode, CDataStore *packet) {
|
||||
if (opcode == Offsets::SMSG_SPELL_GO && packet != nullptr)
|
||||
ParseSpellGo(packet);
|
||||
}
|
||||
|
||||
const Net::PacketDispatch::AutoSubscribe _spellGoSub{&SpellGoSub};
|
||||
|
||||
// ---- Aura-application co-hooks (timing for proc / triggered auras) -------
|
||||
|
||||
@@ -276,9 +661,15 @@ void StampApplication(void *unit, uint32_t spellId, int8_t kind) {
|
||||
const uint64_t unitGuid = Unit::Identity::GuidForObject(unit);
|
||||
if (unitGuid == 0)
|
||||
return;
|
||||
const uint32_t durationMs = SpellDurationMs(rec, /*casterIsPlayer*/ false);
|
||||
// If the local player just cast this aura, use its player-talented duration
|
||||
// (and attribute the caster) — SpellGo couldn't always land the talented
|
||||
// value on this target's entry. Otherwise base (we lack other casters'
|
||||
// mods). See WasRecentPlayerCast.
|
||||
const bool byPlayer = WasRecentPlayerCast(spellId);
|
||||
const uint32_t durationMs = SpellDurationMs(rec, byPlayer);
|
||||
const uint64_t caster = byPlayer ? Unit::Identity::PlayerGuid() : 0;
|
||||
const uint32_t expirationMs = durationMs > 0 ? NowMs() + durationMs : 0;
|
||||
Store(unitGuid, spellId, /*casterGuid*/ 0, expirationMs, durationMs,
|
||||
Store(unitGuid, spellId, caster, expirationMs, durationMs,
|
||||
/*fromCast*/ false, kind);
|
||||
}
|
||||
|
||||
@@ -288,6 +679,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);
|
||||
@@ -297,6 +697,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{
|
||||
@@ -317,6 +718,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{
|
||||
@@ -325,10 +727,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;
|
||||
@@ -338,6 +748,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{
|
||||
@@ -361,6 +772,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;
|
||||
@@ -379,4 +807,55 @@ int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut) {
|
||||
return n;
|
||||
}
|
||||
|
||||
void ObserveGroupAuras(uint64_t guid, const uint16_t *auraArray) {
|
||||
if (guid == 0 || auraArray == nullptr)
|
||||
return;
|
||||
const int total = Offsets::UNIT_AURA_TOTAL;
|
||||
const int buffCount = Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
const uint32_t now = NowMs();
|
||||
|
||||
GroupSnapshot *snap = nullptr;
|
||||
for (auto &s : g_groupSnaps)
|
||||
if (s.used && s.guid == guid) {
|
||||
snap = &s;
|
||||
break;
|
||||
}
|
||||
|
||||
if (snap == nullptr) {
|
||||
// First sight of this member — record a baseline without stamping;
|
||||
// their current auras have unknown age.
|
||||
GroupSnapshot *dst = nullptr;
|
||||
for (auto &s : g_groupSnaps)
|
||||
if (!s.used) {
|
||||
dst = &s;
|
||||
break;
|
||||
}
|
||||
if (dst == nullptr) { // full — evict the least-recently-polled
|
||||
dst = &g_groupSnaps[0];
|
||||
for (auto &s : g_groupSnaps)
|
||||
if (s.touchMs < dst->touchMs)
|
||||
dst = &s;
|
||||
}
|
||||
dst->guid = guid;
|
||||
dst->used = true;
|
||||
dst->touchMs = now;
|
||||
for (int i = 0; i < total; ++i)
|
||||
dst->ids[i] = auraArray[i];
|
||||
return;
|
||||
}
|
||||
|
||||
// Stamp a guess for every spell ID present now but absent from the prior
|
||||
// snapshot (a genuine appearance), then refresh the snapshot.
|
||||
for (int slot = 0; slot < total; ++slot) {
|
||||
const uint16_t id = auraArray[slot];
|
||||
if (id == 0 || SnapshotHasId(*snap, id))
|
||||
continue;
|
||||
StampGroupGuess(guid, id, slot < buffCount ? KIND_HELPFUL : KIND_HARMFUL,
|
||||
now);
|
||||
}
|
||||
for (int i = 0; i < total; ++i)
|
||||
snap->ids[i] = auraArray[i];
|
||||
snap->touchMs = now;
|
||||
}
|
||||
|
||||
} // namespace Aura::Source
|
||||
|
||||
@@ -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
|
||||
@@ -62,4 +74,19 @@ struct CachedAura {
|
||||
// the aura is still active server-side: the cache survives descriptor clears.
|
||||
int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut);
|
||||
|
||||
// Feeds the cache from an out-of-range group member's aura spell-ID array
|
||||
// (`Group::MemberStats::AuraArray` — 48 slots, buffs 0..BUFF_COUNT-1, debuffs
|
||||
// after). Such a member has no CGUnit and never yields SMSG_SPELL_GO, so the
|
||||
// SpellGo path never learns their auras' timing — but SMSG_PARTY_MEMBER_STATS
|
||||
// carries the spell IDs and the server sends it promptly when an aura is
|
||||
// added/removed. So a spell ID that newly *appears* here did so ~one server
|
||||
// tick + latency after the real application: we stamp a best-effort
|
||||
// `now + base duration` expiration (base only — no remote caster mods, no
|
||||
// caster recorded). Auras already present the first time we see a member have
|
||||
// unknown age and are NOT stamped; only genuine absent->present transitions
|
||||
// are. Idempotent within a frame; call it before enumerating the array so a
|
||||
// just-appeared aura carries its guess on the same poll. The Store guard keeps
|
||||
// any real SpellGo timing we already hold, so this never downgrades better data.
|
||||
void ObserveGroupAuras(uint64_t guid, const uint16_t *auraArray);
|
||||
|
||||
} // namespace Aura::Source
|
||||
|
||||
@@ -56,9 +56,12 @@
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "bag/UpdateDelayed.h"
|
||||
#include "event/Custom.h"
|
||||
#include "tick/WorldTick.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Bag::UpdateDelayed {
|
||||
|
||||
namespace {
|
||||
@@ -71,8 +74,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 *)();
|
||||
@@ -119,10 +129,26 @@ unsigned __fastcall KeyringDescChange_h(void *bagDesc, void *edx,
|
||||
void OnWorldTick() {
|
||||
if (!g_pending)
|
||||
return;
|
||||
// Bag changes aren't meaningful until the player is actually in the world.
|
||||
// During any loading screen (initial login, zone/instance transition) the
|
||||
// engine re-populates bag slots as objects stream in (setting g_pending)
|
||||
// while still behind the screen; the per-frame world tick runs there too,
|
||||
// so firing BAG_UPDATE_DELAYED would make addons rescan bags repeatedly
|
||||
// mid-load for no real change. Hold the pending flag — do NOT clear it —
|
||||
// until the engine's in-world flag is set; the first in-world frame then
|
||||
// emits a single DELAYED covering the settled inventory (matching retail's
|
||||
// one BAG_UPDATE_DELAYED per load). VAR_IN_WORLD clears on every transition,
|
||||
// so this re-suppresses on later zone changes too, not just initial login.
|
||||
if (*reinterpret_cast<const volatile uint8_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_IN_WORLD)) == 0)
|
||||
return;
|
||||
g_pending = false;
|
||||
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,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/>.
|
||||
|
||||
// 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`.
|
||||
// - `math.modf(x)` — split into integral + fractional parts.
|
||||
// - `math.huge` — positive infinity constant.
|
||||
//
|
||||
// `math.fmod` 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 it as a direct alias of the engine's `math.mod` C function
|
||||
// (`FUN_LUA_MATH_FMOD`) — no wrapper. `math.mod` stays available too, so code
|
||||
// written for either name works. `modf` and `huge` are genuinely new in 5.1
|
||||
// (not in 1.12's mathlib table), so we implement/define them here.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
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);
|
||||
|
||||
// `math.modf(x)` — returns `(integral, fractional)`, the integral part
|
||||
// truncated toward zero and the fractional part carrying `x`'s sign
|
||||
// (`modf(3.7) → 3, 0.7`; `modf(-3.7) → -3, -0.7`). Matches C `modf` / Lua 5.1.
|
||||
int __fastcall Script_math_modf(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: math.modf(x)");
|
||||
return 0; // unreachable
|
||||
}
|
||||
double integral = 0.0;
|
||||
const double fractional = std::modf(Game::Lua::ToNumber(L, 1), &integral);
|
||||
Game::Lua::PushNumber(L, integral);
|
||||
Game::Lua::PushNumber(L, fractional);
|
||||
return 2;
|
||||
}
|
||||
|
||||
void RegisterFns() {
|
||||
Game::Lua::RegisterTableFunction("math", "fmod", Script_math_fmod);
|
||||
Game::Lua::RegisterTableFunction("math", "modf", &Script_math_modf);
|
||||
|
||||
// `math.huge` — a numeric constant, not a function. Fetch the (now
|
||||
// guaranteed-present) `math` table and set the field directly.
|
||||
void *L = Game::Lua::State();
|
||||
if (L != nullptr) {
|
||||
Game::Lua::PushString(L, "math");
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // [math]
|
||||
if (Game::Lua::Type(L, -1) == 5 /* LUA_TTABLE */) {
|
||||
Game::Lua::SetFieldNumber(L, "huge", HUGE_VAL); // math.huge = inf; [math]
|
||||
Game::Lua::SetTop(L, -2); // pop [math]
|
||||
} else {
|
||||
Game::Lua::SetTop(L, -2); // pop whatever GetTable returned
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,83 @@
|
||||
// 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/>.
|
||||
|
||||
// `Mixin(object, ...)` and `CreateFromMixins(...)` — the FrameXML table-mixin
|
||||
// primitives. Modern WoW provides these as engine C functions (in TableUtil);
|
||||
// we do the same rather than leaving them in the !!!ClassicAPI addon so they
|
||||
// exist whenever the DLL is injected, even if the addon is disabled. The
|
||||
// composite `CreateAndInitFromMixin` (which calls a Lua `:Init` method) stays
|
||||
// Lua-side in the addon, mirroring retail's Mixin.lua.
|
||||
//
|
||||
// Behaviour matches the canonical Lua implementation exactly:
|
||||
// Mixin(object, ...) -- shallow-copy every key/value from each table
|
||||
// argument onto `object`; returns `object`.
|
||||
// CreateFromMixins(...) -- Mixin({}, ...); returns the new table.
|
||||
// Non-table extra arguments are skipped (the Lua form guards nil the same way).
|
||||
|
||||
#include "Game.h"
|
||||
|
||||
namespace BaseLib::Mixin {
|
||||
|
||||
namespace {
|
||||
|
||||
// Shallow-copy every pair from the table at `srcIdx` onto the table at
|
||||
// `dstIdx` (both absolute, positive stack indices). Stack-balanced.
|
||||
void MixinInto(void *L, int dstIdx, int srcIdx) {
|
||||
Game::Lua::PushNil(L);
|
||||
while (Game::Lua::Next(L, srcIdx) != 0) {
|
||||
// stack top: ..., key (-2), value (-1). Copy both so the originals
|
||||
// survive for the next Next() iteration.
|
||||
Game::Lua::PushValue(L, -2); // key copy
|
||||
Game::Lua::PushValue(L, -2); // value copy
|
||||
Game::Lua::SetTable(L, dstIdx); // dst[key] = value; pops the two copies
|
||||
Game::Lua::SetTop(L, -2); // pop value, leave key for Next()
|
||||
}
|
||||
}
|
||||
|
||||
int __fastcall Script_Mixin(void *L) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
if (top < 1 || Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
|
||||
Game::Lua::Error(L, "Usage: Mixin(object, ...)");
|
||||
return 0; // unreachable
|
||||
}
|
||||
for (int i = 2; i <= top; ++i)
|
||||
if (Game::Lua::Type(L, i) == Game::Lua::TYPE_TABLE)
|
||||
MixinInto(L, 1, i);
|
||||
Game::Lua::PushValue(L, 1); // return object
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_CreateFromMixins(void *L) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
Game::Lua::NewTable(L); // new object at index top + 1
|
||||
const int objIdx = top + 1;
|
||||
for (int i = 1; i <= top; ++i)
|
||||
if (Game::Lua::Type(L, i) == Game::Lua::TYPE_TABLE)
|
||||
MixinInto(L, objIdx, i);
|
||||
Game::Lua::SetTop(L, objIdx); // the new table is the sole result
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterFns() {
|
||||
Game::Lua::RegisterGlobalFunction("Mixin", &Script_Mixin);
|
||||
Game::Lua::RegisterGlobalFunction("CreateFromMixins", &Script_CreateFromMixins);
|
||||
}
|
||||
|
||||
// Pure table helpers — register on both the in-game and glue states so they're
|
||||
// always present (matching the "always available" intent).
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
|
||||
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace BaseLib::Mixin
|
||||
@@ -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,283 @@
|
||||
// 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/>.
|
||||
|
||||
// String helpers that 1.12's Lua 5.0 is missing:
|
||||
//
|
||||
// - `string.match(s, pattern [, init])` — first-match extraction (5.1).
|
||||
// - `string.gmatch(s, pattern)` — match iterator (5.1).
|
||||
// - `strsplit(sep, str [, pieces])` — WoW global; split on any char.
|
||||
// - `strjoin(delimiter, ...)` — WoW global; join varargs.
|
||||
// - `strtrim(str [, chars])` — WoW global; trim a char set.
|
||||
// - `strreplace(str, find, replace)` — plain-text replace (see below).
|
||||
// - `string.reverse(s)` / `strrev(s)` — 5.1 string function, missing in 5.0.
|
||||
//
|
||||
// The two pattern `string.*` ones reuse machinery 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.
|
||||
// `strsplit` is a hand-rolled port of 3.3.5's `strsplit` (see below).
|
||||
//
|
||||
// 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>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
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);
|
||||
|
||||
// `strsplit(sep, str [, pieces])` — the WoW global (also aliased as
|
||||
// `string.split`), split `str` on ANY character in `sep` and return the
|
||||
// pieces as multiple values. `pieces > 0` caps the result count, with the
|
||||
// unsplit remainder as the final piece; `0` / omitted = unlimited. Ported
|
||||
// from 3.3.5's `strsplit` (FUN_00816a60) with one deliberate change: we do
|
||||
// NOT `lua_settop(L, 0)` up front. Popping the args would leave the source
|
||||
// string unreferenced, so a GC step triggered by a `pushlstring` could free
|
||||
// the very buffer we're still scanning. Keeping the args on the stack keeps
|
||||
// the source GC-rooted; Lua returns the top N values (our pieces) regardless
|
||||
// of the args sitting below them. Each push is guarded by `lua_checkstack`
|
||||
// (this pushes an unbounded number of results), erroring like 3.3.5 on
|
||||
// genuine stack exhaustion.
|
||||
int __fastcall Script_strsplit(void *L) {
|
||||
const char *sep = Game::Lua::ToString(L, 1);
|
||||
const char *str = Game::Lua::ToString(L, 2);
|
||||
if (sep == nullptr || str == nullptr) {
|
||||
Game::Lua::Error(L, "Usage: strsplit(\"separators\", str [, pieces])");
|
||||
return 0; // unreachable
|
||||
}
|
||||
const int pieces =
|
||||
Game::Lua::IsNumber(L, 3) ? static_cast<int>(Game::Lua::ToNumber(L, 3)) : 0;
|
||||
|
||||
const char *segStart = str;
|
||||
int count = 0;
|
||||
// Only scan for separators while unlimited (pieces == 0) or the cap
|
||||
// hasn't been reached (pieces > 1). pieces == 1 (or <= 0 but non-zero)
|
||||
// yields the whole string as a single piece — matches 3.3.5.
|
||||
if (pieces == 0 || pieces > 1) {
|
||||
for (const char *p = str; *p != '\0'; ++p) {
|
||||
bool isSep = false;
|
||||
for (const char *s = sep; *s != '\0'; ++s) {
|
||||
if (*p == *s) {
|
||||
isSep = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!isSep)
|
||||
continue;
|
||||
++count;
|
||||
if (Game::Lua::CheckStack(L, count) == 0) {
|
||||
Game::Lua::Error(L, "strsplit(): Stack overflow");
|
||||
return 0; // unreachable
|
||||
}
|
||||
Game::Lua::PushLString(L, segStart,
|
||||
static_cast<unsigned int>(p - segStart));
|
||||
segStart = p + 1;
|
||||
if (count == pieces - 1)
|
||||
break; // cap hit; remainder becomes the final piece
|
||||
}
|
||||
}
|
||||
if (Game::Lua::CheckStack(L, count + 1) == 0) {
|
||||
Game::Lua::Error(L, "strsplit(): Stack overflow");
|
||||
return 0; // unreachable
|
||||
}
|
||||
Game::Lua::PushString(L, segStart); // final piece (remainder to end)
|
||||
return count + 1;
|
||||
}
|
||||
|
||||
// `strjoin(delimiter, ...)` — the inverse of `strsplit`: concatenate the
|
||||
// vararg pieces with `delimiter` between each. Standard WoW global (FrameXML
|
||||
// defines it in Lua; there's no C original to mirror). A nil / non-coercible
|
||||
// piece is treated as an empty string rather than erroring, so the delimiter
|
||||
// placement stays predictable. `strjoin(",")` with no pieces returns "".
|
||||
int __fastcall Script_strjoin(void *L) {
|
||||
const char *delim = Game::Lua::ToString(L, 1);
|
||||
if (delim == nullptr) {
|
||||
Game::Lua::Error(L, "Usage: strjoin(delimiter, ...)");
|
||||
return 0; // unreachable
|
||||
}
|
||||
const unsigned delimLen = Game::Lua::StrLen(L, 1);
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
|
||||
std::string out; // no Error() past this point — a longjmp would leak it
|
||||
for (int i = 2; i <= top; ++i) {
|
||||
if (i > 2)
|
||||
out.append(delim, delimLen);
|
||||
const char *piece = Game::Lua::ToString(L, i);
|
||||
if (piece != nullptr)
|
||||
out.append(piece, Game::Lua::StrLen(L, i));
|
||||
}
|
||||
Game::Lua::PushLString(L, out.data(), static_cast<unsigned int>(out.size()));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `strtrim(str [, chars])` — remove any of the characters in `chars` from
|
||||
// both ends of `str` (default: whitespace). `chars` is a literal set of
|
||||
// characters, not a pattern. Standard WoW global. Two-ended scan — no
|
||||
// allocation, embedded NULs preserved (bounds come from the Lua length).
|
||||
int __fastcall Script_strtrim(void *L) {
|
||||
const char *str = Game::Lua::ToString(L, 1);
|
||||
if (str == nullptr) {
|
||||
Game::Lua::Error(L, "Usage: strtrim(str [, chars])");
|
||||
return 0; // unreachable
|
||||
}
|
||||
const unsigned len = Game::Lua::StrLen(L, 1);
|
||||
const char *chars = Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2)
|
||||
: " \t\n\v\f\r";
|
||||
|
||||
bool trim[256] = {false};
|
||||
for (const char *c = chars; *c != '\0'; ++c)
|
||||
trim[static_cast<unsigned char>(*c)] = true;
|
||||
|
||||
unsigned start = 0, end = len;
|
||||
while (start < end && trim[static_cast<unsigned char>(str[start])])
|
||||
++start;
|
||||
while (end > start && trim[static_cast<unsigned char>(str[end - 1])])
|
||||
--end;
|
||||
Game::Lua::PushLString(L, str + start, end - start);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `strreplace(str, find, replace)` — replace every occurrence of the literal
|
||||
// substring `find` with `replace`; returns `(result, count)`. NOT a stock WoW
|
||||
// global and NOT pattern-based (that's `gsub`) — a plain-text replace that
|
||||
// needs no magic-character escaping. Empty `find` returns `str` unchanged
|
||||
// (count 0) rather than looping forever.
|
||||
int __fastcall Script_strreplace(void *L) {
|
||||
const char *str = Game::Lua::ToString(L, 1);
|
||||
const char *find = Game::Lua::ToString(L, 2);
|
||||
const char *repl = Game::Lua::ToString(L, 3);
|
||||
if (str == nullptr || find == nullptr || repl == nullptr) {
|
||||
Game::Lua::Error(L, "Usage: strreplace(str, find, replace)");
|
||||
return 0; // unreachable
|
||||
}
|
||||
const unsigned strLen = Game::Lua::StrLen(L, 1);
|
||||
const unsigned findLen = Game::Lua::StrLen(L, 2);
|
||||
const unsigned replLen = Game::Lua::StrLen(L, 3);
|
||||
|
||||
if (findLen == 0) { // nothing to match — return input unchanged
|
||||
Game::Lua::PushLString(L, str, strLen);
|
||||
Game::Lua::PushNumber(L, 0.0);
|
||||
return 2;
|
||||
}
|
||||
|
||||
std::string out; // no Error() past this point
|
||||
int count = 0;
|
||||
unsigned i = 0;
|
||||
while (i < strLen) {
|
||||
if (i + findLen <= strLen && std::memcmp(str + i, find, findLen) == 0) {
|
||||
out.append(repl, replLen);
|
||||
i += findLen;
|
||||
++count;
|
||||
} else {
|
||||
out.push_back(str[i]);
|
||||
++i;
|
||||
}
|
||||
}
|
||||
Game::Lua::PushLString(L, out.data(), static_cast<unsigned int>(out.size()));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(count));
|
||||
return 2;
|
||||
}
|
||||
|
||||
// `string.reverse(s)` / `strrev(s)` — return `s` with its bytes reversed.
|
||||
// Lua 5.1 added `string.reverse`; 5.0's strlib lacks it. Byte-wise (not
|
||||
// UTF-8 aware), matching stock Lua; embedded NULs preserved (bounds from the
|
||||
// Lua length).
|
||||
int __fastcall Script_string_reverse(void *L) {
|
||||
const char *s = Game::Lua::ToString(L, 1);
|
||||
if (s == nullptr) {
|
||||
Game::Lua::Error(L, "Usage: string.reverse(s)");
|
||||
return 0; // unreachable
|
||||
}
|
||||
const unsigned len = Game::Lua::StrLen(L, 1);
|
||||
std::string out(len, '\0'); // no Error() past this point
|
||||
for (unsigned i = 0; i < len; ++i)
|
||||
out[i] = s[len - 1 - i];
|
||||
Game::Lua::PushLString(L, out.data(), len);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterFns() {
|
||||
Game::Lua::RegisterTableFunction("string", "match", &Script_string_match);
|
||||
Game::Lua::RegisterTableFunction("string", "gmatch", Script_string_gmatch);
|
||||
Game::Lua::RegisterTableFunction("string", "reverse", &Script_string_reverse);
|
||||
Game::Lua::RegisterTableFunction("string", "split", &Script_strsplit);
|
||||
Game::Lua::RegisterGlobalFunction("strsplit", &Script_strsplit);
|
||||
Game::Lua::RegisterGlobalFunction("strjoin", &Script_strjoin);
|
||||
Game::Lua::RegisterGlobalFunction("strtrim", &Script_strtrim);
|
||||
Game::Lua::RegisterGlobalFunction("strreplace", &Script_strreplace);
|
||||
Game::Lua::RegisterGlobalFunction("strrev", &Script_string_reverse);
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -13,8 +13,6 @@
|
||||
TargetUnit("focus");
|
||||
</Binding>
|
||||
<Binding name="AOELOOT">
|
||||
if not C_Loot.IsScanInProgress() then
|
||||
C_Loot.ScanNearbyLoot()
|
||||
end
|
||||
C_Loot.LootAllCorpses()
|
||||
</Binding>
|
||||
</Bindings>
|
||||
|
||||
@@ -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_ChatBubbles.GetAllChatBubbles([includeForbidden])` — returns an
|
||||
// array of the currently-active chat-bubble Frame objects, matching
|
||||
// modern WoW's 7.2.5 signature. Addons use it to recolor / translate /
|
||||
// reposition speech bubbles by reading the bubble's FontString region.
|
||||
//
|
||||
// Vanilla 1.12 chat bubbles are `CGChatBubbleFrame`s — full
|
||||
// `CSimpleFrame`s created purely in C++ (never via `CreateFrame`), so
|
||||
// they have no name and no Lua wrapper until something pushes them. This
|
||||
// is exactly the default-nameplate case: we push each via the shared
|
||||
// `UI::FrameObject::Push`, which delegates to the engine's own
|
||||
// `FrameScript_Object::ScriptRegister` to lazily build the canonical
|
||||
// wrapper. The returned frames respond to real frame methods — the
|
||||
// FontString holding the spoken text (bubble + 0x31c, created with the
|
||||
// bubble as its parent) shows up in `bubble:GetRegions()`, so the modern
|
||||
// "iterate GetRegions, find the FontString, read GetText()" idiom works
|
||||
// unchanged. This is the pre-7.2.5 "walk WorldFrame children" pattern the
|
||||
// wiki describes, but handed the exact set instead of the whole world.
|
||||
//
|
||||
// The engine keeps active bubbles on an intrusive Storm `TSList` whose
|
||||
// head is `VAR_CHAT_BUBBLE_LIST_HEAD` (`0x00B6F038`); each node's forward
|
||||
// link sits at `+0x318` and the list terminator carries the low-bit
|
||||
// sentinel. We mirror the engine's own walk in the per-frame update
|
||||
// `FUN_004b1060` / teardown `FUN_004b0f80` verbatim. Bubbles whose owner
|
||||
// unit has despawned are pruned on the engine's next update tick, so a
|
||||
// just-orphaned bubble can linger one frame — harmless, and addons that
|
||||
// care filter on `bubble:IsShown()` (a real method here) anyway.
|
||||
//
|
||||
// `includeForbidden` is accepted and ignored: vanilla has no forbidden
|
||||
// frames, so every bubble is returned regardless.
|
||||
|
||||
#include "Game.h"
|
||||
#include "ui/FrameObject.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace ChatBubble::Info {
|
||||
|
||||
namespace {
|
||||
|
||||
// Storm TSList of live CGChatBubbleFrames. Head pointer, per-node
|
||||
// forward link, and the end-of-list low-bit sentinel — see the engine
|
||||
// walks at 0x004b1060 / 0x004b0f80.
|
||||
constexpr uintptr_t kListHead = 0x00B6F038;
|
||||
constexpr int kOffNextLink = 0x318;
|
||||
|
||||
} // namespace
|
||||
|
||||
static int __fastcall Script_GetAllChatBubbles(void *L) {
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
uintptr_t node = *reinterpret_cast<const uintptr_t *>(kListHead);
|
||||
int nextIndex = 1;
|
||||
while (node != 0 && (node & 1) == 0) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(nextIndex++));
|
||||
UI::FrameObject::Push(L, reinterpret_cast<void *>(node));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
node = *reinterpret_cast<const uintptr_t *>(node + kOffNextLink);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_ChatBubbles", "GetAllChatBubbles",
|
||||
&Script_GetAllChatBubbles);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace ChatBubble::Info
|
||||
@@ -0,0 +1,170 @@
|
||||
// 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/>.
|
||||
|
||||
// `length = CopyToClipboard(text [, removeMarkup])` — copy `text` to the
|
||||
// Windows clipboard and return the number of bytes copied. Vanilla has no
|
||||
// clipboard access at all (Blizzard never exposed one), so this is a pure
|
||||
// ClassicAPI addition backed by the Win32 clipboard API.
|
||||
//
|
||||
// - `text` the string to copy (numbers are coerced, like Lua does).
|
||||
// - `removeMarkup` optional; when truthy, strip WoW UI escape sequences
|
||||
// (|cAARRGGBB..|r color, |H..|h..|h hyperlinks keeping the
|
||||
// visible text, |T..|t textures, |n newline, || → |) before
|
||||
// copying, so the clipboard gets plain text.
|
||||
// - returns byte length of the copied (possibly stripped) string —
|
||||
// consistent with Lua's `#s`. 0 on an empty string or if
|
||||
// the clipboard couldn't be opened.
|
||||
//
|
||||
// WoW's in-memory strings are UTF-8 here (same assumption the credential and
|
||||
// TTS modules make), so we convert to UTF-16 and set CF_UNICODETEXT; Windows
|
||||
// synthesizes CF_TEXT for legacy consumers.
|
||||
|
||||
#include "Game.h"
|
||||
|
||||
#include <string>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
namespace Clipboard::Copy {
|
||||
|
||||
namespace {
|
||||
|
||||
// Strip WoW UI escape sequences, leaving readable text. See header comment for
|
||||
// the set handled; unknown `|x` sequences are left verbatim so real text that
|
||||
// merely contains a pipe survives.
|
||||
std::string StripMarkup(const char *s, unsigned len) {
|
||||
std::string out;
|
||||
out.reserve(len);
|
||||
unsigned i = 0;
|
||||
while (i < len) {
|
||||
const char c = s[i];
|
||||
if (c != '|') {
|
||||
out.push_back(c);
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
const char n = (i + 1 < len) ? s[i + 1] : '\0';
|
||||
switch (n) {
|
||||
case '|': // escaped pipe
|
||||
out.push_back('|');
|
||||
i += 2;
|
||||
break;
|
||||
case 'n': // newline token
|
||||
out.push_back('\n');
|
||||
i += 2;
|
||||
break;
|
||||
case 'r': // color reset
|
||||
i += 2;
|
||||
break;
|
||||
case 'c': // color open: |c + up to 8 hex digits
|
||||
i += 2;
|
||||
for (int k = 0; k < 8 && i < len; ++k) {
|
||||
const char h = s[i];
|
||||
const bool isHex = (h >= '0' && h <= '9') || (h >= 'a' && h <= 'f') ||
|
||||
(h >= 'A' && h <= 'F');
|
||||
if (!isHex)
|
||||
break;
|
||||
++i;
|
||||
}
|
||||
break;
|
||||
case 'h': // hyperlink boundary (text separator or closer) — drop
|
||||
i += 2;
|
||||
break;
|
||||
case 'H': // hyperlink data opener — skip through to the next |h
|
||||
i += 2;
|
||||
while (i < len && !(s[i] == '|' && i + 1 < len && s[i + 1] == 'h'))
|
||||
++i;
|
||||
break; // the |h is dropped by the 'h' case on the next iteration
|
||||
case 't': // stray texture closer — drop
|
||||
i += 2;
|
||||
break;
|
||||
case 'T': // texture opener — skip through to the next |t
|
||||
i += 2;
|
||||
while (i < len && !(s[i] == '|' && i + 1 < len && s[i + 1] == 't'))
|
||||
++i;
|
||||
break; // the |t is dropped by the 't' case on the next iteration
|
||||
default: // unknown sequence — keep the pipe literally
|
||||
out.push_back('|');
|
||||
++i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Place a UTF-8 byte range on the clipboard as CF_UNICODETEXT. Returns true on
|
||||
// success. Does not free the allocation on success — ownership passes to the
|
||||
// clipboard.
|
||||
bool SetClipboardUtf8(const char *data, int byteLen) {
|
||||
if (!OpenClipboard(nullptr))
|
||||
return false;
|
||||
|
||||
bool ok = false;
|
||||
if (EmptyClipboard()) {
|
||||
// Empty string still writes a valid (empty) clipboard entry.
|
||||
const int wlen =
|
||||
byteLen > 0 ? MultiByteToWideChar(CP_UTF8, 0, data, byteLen, nullptr, 0) : 0;
|
||||
HGLOBAL h = GlobalAlloc(GMEM_MOVEABLE, (static_cast<SIZE_T>(wlen) + 1) * sizeof(wchar_t));
|
||||
if (h != nullptr) {
|
||||
auto *dst = static_cast<wchar_t *>(GlobalLock(h));
|
||||
if (dst != nullptr) {
|
||||
if (wlen > 0)
|
||||
MultiByteToWideChar(CP_UTF8, 0, data, byteLen, dst, wlen);
|
||||
dst[wlen] = L'\0';
|
||||
GlobalUnlock(h);
|
||||
if (SetClipboardData(CF_UNICODETEXT, h) != nullptr)
|
||||
ok = true; // clipboard owns h now
|
||||
}
|
||||
if (!ok)
|
||||
GlobalFree(h);
|
||||
}
|
||||
}
|
||||
|
||||
CloseClipboard();
|
||||
return ok;
|
||||
}
|
||||
|
||||
int __fastcall Script_CopyToClipboard(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1) && !Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: CopyToClipboard(text [, removeMarkup])");
|
||||
return 0; // unreachable
|
||||
}
|
||||
|
||||
const char *text = Game::Lua::ToString(L, 1);
|
||||
const unsigned len = Game::Lua::StrLen(L, 1);
|
||||
const bool removeMarkup = Game::Lua::ToBoolean(L, 2) != 0;
|
||||
|
||||
int copiedLen;
|
||||
if (removeMarkup) {
|
||||
const std::string stripped = StripMarkup(text, len);
|
||||
copiedLen = static_cast<int>(stripped.size());
|
||||
SetClipboardUtf8(stripped.data(), copiedLen);
|
||||
} else {
|
||||
copiedLen = static_cast<int>(len);
|
||||
SetClipboardUtf8(text, copiedLen);
|
||||
}
|
||||
|
||||
Game::Lua::PushNumber(L, static_cast<double>(copiedLen));
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterFns() {
|
||||
Game::Lua::RegisterGlobalFunction("CopyToClipboard", &Script_CopyToClipboard);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
|
||||
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Clipboard::Copy
|
||||
@@ -0,0 +1,292 @@
|
||||
// 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_ColorUtil` — the modern color-space + text-color-code helpers, backported
|
||||
// to 1.12. Every one is pure math / string formatting (no engine state), so
|
||||
// this is a plain reimplementation rather than a hook.
|
||||
//
|
||||
// Conventions verified in-game against a retail-family client that ships
|
||||
// C_ColorUtil natively:
|
||||
// - HSV/HSL hue is in DEGREES, [0, 360); saturation / value / lightness are
|
||||
// [0, 1]. Achromatic (gray) inputs yield hue = -1 (a sentinel, not 0).
|
||||
// - GenerateTextColorCode(color) returns the BARE 8-hex string "AARRGGBB"
|
||||
// (e.g. red -> "ffff0000"), alpha forced to ff (the arg type is colorRGB,
|
||||
// no alpha channel).
|
||||
// - WrapTextInColorCode(text, code) returns "|c" .. code .. text .. "|r".
|
||||
// - WrapTextInColor(text, color) = WrapTextInColorCode(text,
|
||||
// GenerateTextColorCode(color)) -> "|cffff0000Hi|r".
|
||||
//
|
||||
// The color arg is a table read by raw r/g/b fields (ColorMixin-shaped); an
|
||||
// optional a field is honored (default 1.0) though retail's colorRGB type
|
||||
// omits it — for the documented no-alpha case both give ff.
|
||||
|
||||
#include "Game.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
namespace Color::Util {
|
||||
|
||||
namespace {
|
||||
|
||||
double Clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
|
||||
|
||||
int ToByte(double v) {
|
||||
const double c = Clamp01(v);
|
||||
return static_cast<int>(c * 255.0 + 0.5); // round to nearest
|
||||
}
|
||||
|
||||
double Max3(double a, double b, double c) {
|
||||
double m = a > b ? a : b;
|
||||
return m > c ? m : c;
|
||||
}
|
||||
double Min3(double a, double b, double c) {
|
||||
double m = a < b ? a : b;
|
||||
return m < c ? m : c;
|
||||
}
|
||||
double MinLuma(double l) { return l < 1.0 - l ? l : 1.0 - l; }
|
||||
|
||||
// --- Pure conversions --------------------------------------------------
|
||||
|
||||
void RgbToHsv(double r, double g, double b, double &h, double &s, double &v) {
|
||||
const double mx = Max3(r, g, b), mn = Min3(r, g, b), d = mx - mn;
|
||||
v = mx;
|
||||
s = (mx <= 0.0) ? 0.0 : d / mx;
|
||||
if (d <= 0.0) { // achromatic
|
||||
h = -1.0;
|
||||
return;
|
||||
}
|
||||
if (mx == r)
|
||||
h = 60.0 * std::fmod((g - b) / d, 6.0);
|
||||
else if (mx == g)
|
||||
h = 60.0 * ((b - r) / d + 2.0);
|
||||
else
|
||||
h = 60.0 * ((r - g) / d + 4.0);
|
||||
if (h < 0.0)
|
||||
h += 360.0;
|
||||
}
|
||||
|
||||
void HsvToRgb(double h, double s, double v, double &r, double &g, double &b) {
|
||||
if (s <= 0.0 || h < 0.0) { // achromatic (incl. the -1 sentinel)
|
||||
r = g = b = v;
|
||||
return;
|
||||
}
|
||||
h = std::fmod(h, 360.0);
|
||||
if (h < 0.0)
|
||||
h += 360.0;
|
||||
const double hh = h / 60.0;
|
||||
const int i = static_cast<int>(std::floor(hh));
|
||||
const double f = hh - i;
|
||||
const double p = v * (1.0 - s);
|
||||
const double q = v * (1.0 - s * f);
|
||||
const double t = v * (1.0 - s * (1.0 - f));
|
||||
switch (i % 6) {
|
||||
case 0: r = v; g = t; b = p; break;
|
||||
case 1: r = q; g = v; b = p; break;
|
||||
case 2: r = p; g = v; b = t; break;
|
||||
case 3: r = p; g = q; b = v; break;
|
||||
case 4: r = t; g = p; b = v; break;
|
||||
default: r = v; g = p; b = q; break;
|
||||
}
|
||||
}
|
||||
|
||||
void HsvToHsl(double h, double sv, double v, double &ho, double &sl, double &l) {
|
||||
ho = h; // hue (incl. -1) passes through unchanged
|
||||
l = v * (1.0 - sv / 2.0);
|
||||
sl = (l <= 0.0 || l >= 1.0) ? 0.0 : (v - l) / MinLuma(l);
|
||||
}
|
||||
|
||||
void HslToHsv(double h, double sl, double l, double &ho, double &sv, double &v) {
|
||||
ho = h;
|
||||
v = l + sl * MinLuma(l);
|
||||
sv = (v <= 0.0) ? 0.0 : 2.0 * (1.0 - l / v);
|
||||
}
|
||||
|
||||
void HslToRgb(double h, double s, double l, double &r, double &g, double &b) {
|
||||
if (s <= 0.0 || h < 0.0) { // achromatic
|
||||
r = g = b = l;
|
||||
return;
|
||||
}
|
||||
h = std::fmod(h, 360.0);
|
||||
if (h < 0.0)
|
||||
h += 360.0;
|
||||
const double c = (1.0 - std::fabs(2.0 * l - 1.0)) * s;
|
||||
const double hh = h / 60.0;
|
||||
const double x = c * (1.0 - std::fabs(std::fmod(hh, 2.0) - 1.0));
|
||||
const double m = l - c / 2.0;
|
||||
double r1, g1, b1;
|
||||
switch (static_cast<int>(std::floor(hh)) % 6) {
|
||||
case 0: r1 = c; g1 = x; b1 = 0.0; break;
|
||||
case 1: r1 = x; g1 = c; b1 = 0.0; break;
|
||||
case 2: r1 = 0.0; g1 = c; b1 = x; break;
|
||||
case 3: r1 = 0.0; g1 = x; b1 = c; break;
|
||||
case 4: r1 = x; g1 = 0.0; b1 = c; break;
|
||||
default: r1 = c; g1 = 0.0; b1 = x; break;
|
||||
}
|
||||
r = r1 + m;
|
||||
g = g1 + m;
|
||||
b = b1 + m;
|
||||
}
|
||||
|
||||
// --- Lua glue ----------------------------------------------------------
|
||||
|
||||
// Read arg `n` as a number, or raise the usage error.
|
||||
double Arg(void *L, int n, const char *usage) {
|
||||
if (!Game::Lua::IsNumber(L, n)) {
|
||||
Game::Lua::Error(L, "%s", usage);
|
||||
return 0.0; // unreachable
|
||||
}
|
||||
return Game::Lua::ToNumber(L, n);
|
||||
}
|
||||
|
||||
// Read a numeric field from the color table at (absolute, positive) index
|
||||
// `idx`. Raw access — ColorMixin stores r/g/b/a as direct keys.
|
||||
double ColorField(void *L, int idx, const char *key, double dflt) {
|
||||
Game::Lua::PushString(L, key);
|
||||
Game::Lua::RawGet(L, idx);
|
||||
const double v = Game::Lua::IsNumber(L, -1) ? Game::Lua::ToNumber(L, -1) : dflt;
|
||||
Game::Lua::SetTop(L, -2); // pop the value
|
||||
return v;
|
||||
}
|
||||
|
||||
// "AARRGGBB" from a color table at `idx`, alpha default ff.
|
||||
std::string HexFromColor(void *L, int idx) {
|
||||
const int a = ToByte(ColorField(L, idx, "a", 1.0));
|
||||
const int r = ToByte(ColorField(L, idx, "r", 0.0));
|
||||
const int g = ToByte(ColorField(L, idx, "g", 0.0));
|
||||
const int b = ToByte(ColorField(L, idx, "b", 0.0));
|
||||
char buf[9];
|
||||
std::snprintf(buf, sizeof(buf), "%02x%02x%02x%02x", a, r, g, b);
|
||||
return std::string(buf, 8);
|
||||
}
|
||||
|
||||
int __fastcall Script_ConvertRGBToHSV(void *L) {
|
||||
const double r = Arg(L, 1, "Usage: C_ColorUtil.ConvertRGBToHSV(r, g, b)");
|
||||
const double g = Arg(L, 2, "Usage: C_ColorUtil.ConvertRGBToHSV(r, g, b)");
|
||||
const double b = Arg(L, 3, "Usage: C_ColorUtil.ConvertRGBToHSV(r, g, b)");
|
||||
double h, s, v;
|
||||
RgbToHsv(r, g, b, h, s, v);
|
||||
Game::Lua::PushNumber(L, h);
|
||||
Game::Lua::PushNumber(L, s);
|
||||
Game::Lua::PushNumber(L, v);
|
||||
return 3;
|
||||
}
|
||||
|
||||
int __fastcall Script_ConvertHSVToRGB(void *L) {
|
||||
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSVToRGB(h, s, v)");
|
||||
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSVToRGB(h, s, v)");
|
||||
const double v = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSVToRGB(h, s, v)");
|
||||
double r, g, b;
|
||||
HsvToRgb(h, s, v, r, g, b);
|
||||
Game::Lua::PushNumber(L, r);
|
||||
Game::Lua::PushNumber(L, g);
|
||||
Game::Lua::PushNumber(L, b);
|
||||
return 3;
|
||||
}
|
||||
|
||||
int __fastcall Script_ConvertHSVToHSL(void *L) {
|
||||
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSVToHSL(h, s, v)");
|
||||
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSVToHSL(h, s, v)");
|
||||
const double v = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSVToHSL(h, s, v)");
|
||||
double ho, sl, l;
|
||||
HsvToHsl(h, s, v, ho, sl, l);
|
||||
Game::Lua::PushNumber(L, ho);
|
||||
Game::Lua::PushNumber(L, sl);
|
||||
Game::Lua::PushNumber(L, l);
|
||||
return 3;
|
||||
}
|
||||
|
||||
int __fastcall Script_ConvertHSLToHSV(void *L) {
|
||||
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSLToHSV(h, s, l)");
|
||||
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSLToHSV(h, s, l)");
|
||||
const double l = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSLToHSV(h, s, l)");
|
||||
double ho, sv, v;
|
||||
HslToHsv(h, s, l, ho, sv, v);
|
||||
Game::Lua::PushNumber(L, ho);
|
||||
Game::Lua::PushNumber(L, sv);
|
||||
Game::Lua::PushNumber(L, v);
|
||||
return 3;
|
||||
}
|
||||
|
||||
int __fastcall Script_ConvertHSLToRGB(void *L) {
|
||||
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSLToRGB(h, s, l)");
|
||||
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSLToRGB(h, s, l)");
|
||||
const double l = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSLToRGB(h, s, l)");
|
||||
double r, g, b;
|
||||
HslToRgb(h, s, l, r, g, b);
|
||||
Game::Lua::PushNumber(L, r);
|
||||
Game::Lua::PushNumber(L, g);
|
||||
Game::Lua::PushNumber(L, b);
|
||||
return 3;
|
||||
}
|
||||
|
||||
int __fastcall Script_GenerateTextColorCode(void *L) {
|
||||
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
|
||||
Game::Lua::Error(L, "Usage: C_ColorUtil.GenerateTextColorCode(color)");
|
||||
return 0; // unreachable
|
||||
}
|
||||
const std::string hex = HexFromColor(L, 1);
|
||||
Game::Lua::PushString(L, hex.c_str());
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_WrapTextInColor(void *L) {
|
||||
const char *text = Game::Lua::ToString(L, 1);
|
||||
if (text == nullptr || Game::Lua::Type(L, 2) != Game::Lua::TYPE_TABLE) {
|
||||
Game::Lua::Error(L, "Usage: C_ColorUtil.WrapTextInColor(text, color)");
|
||||
return 0; // unreachable
|
||||
}
|
||||
std::string out = "|c" + HexFromColor(L, 2) + text + "|r";
|
||||
Game::Lua::PushString(L, out.c_str());
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_WrapTextInColorCode(void *L) {
|
||||
const char *text = Game::Lua::ToString(L, 1);
|
||||
const char *code = Game::Lua::ToString(L, 2);
|
||||
if (text == nullptr || code == nullptr) {
|
||||
Game::Lua::Error(L, "Usage: C_ColorUtil.WrapTextInColorCode(text, colorCode)");
|
||||
return 0; // unreachable
|
||||
}
|
||||
std::string out = std::string("|c") + code + text + "|r";
|
||||
Game::Lua::PushString(L, out.c_str());
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterFns() {
|
||||
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertRGBToHSV",
|
||||
&Script_ConvertRGBToHSV);
|
||||
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSVToRGB",
|
||||
&Script_ConvertHSVToRGB);
|
||||
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSVToHSL",
|
||||
&Script_ConvertHSVToHSL);
|
||||
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSLToHSV",
|
||||
&Script_ConvertHSLToHSV);
|
||||
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSLToRGB",
|
||||
&Script_ConvertHSLToRGB);
|
||||
Game::Lua::RegisterTableFunction("C_ColorUtil", "GenerateTextColorCode",
|
||||
&Script_GenerateTextColorCode);
|
||||
Game::Lua::RegisterTableFunction("C_ColorUtil", "WrapTextInColor",
|
||||
&Script_WrapTextInColor);
|
||||
Game::Lua::RegisterTableFunction("C_ColorUtil", "WrapTextInColorCode",
|
||||
&Script_WrapTextInColorCode);
|
||||
}
|
||||
|
||||
// Pure helpers — useful on both the in-game and glue states.
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
|
||||
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Color::Util
|
||||
+43
-16
@@ -13,6 +13,7 @@
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "item/BagFamily.h"
|
||||
#include "item/ID.h"
|
||||
#include "item/Location.h"
|
||||
|
||||
@@ -73,15 +74,14 @@ BagInfo ResolveBagInfo(int bagID) {
|
||||
|
||||
const int slots = static_cast<int>(*reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_CONTAINER_SLOTS));
|
||||
// Convert 1.12's raw BagFamily ID to the modern bitmask. See
|
||||
// `Offsets::OFF_ITEMSTATS_BAG_FAMILY` for the encoding-shift story
|
||||
// and `Item::Info::BagFamilyIdToBitmask` for the canonical helper —
|
||||
// we inline the one-liner here to avoid a header dance for a
|
||||
// single-instruction conversion.
|
||||
const uint32_t rawFamily = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_BAG_FAMILY);
|
||||
// BagFamily bitmask: the item's field if populated, else derived from
|
||||
// the container subclass — Turtle leaves bags' m_bagFamily at 0, so a
|
||||
// Soul Bag would otherwise report family 0 and get miscounted as a
|
||||
// general-purpose bag by CalculateTotalNumberOfFreeBagSlots. Shared
|
||||
// with C_Item.GetItemFamily so a bag and its contents agree. See
|
||||
// `Item::BagFamily`.
|
||||
const int family =
|
||||
(rawFamily == 0) ? 0 : static_cast<int>(1u << (rawFamily - 1));
|
||||
static_cast<int>(Item::BagFamily::BitmaskForRecord(record));
|
||||
return {slots, family};
|
||||
}
|
||||
|
||||
@@ -106,14 +106,13 @@ int CountFreeSlotsInBag(void *L, int bagID, int slotCount) {
|
||||
// `(0, 0)` — matching 3.3.5's behavior of always pushing two values
|
||||
// rather than nil/nothing.
|
||||
//
|
||||
// `bagType` is the `BagFamily` bitmask in modern encoding (raw 1.12
|
||||
// ID converted via `1 << (ID-1)`). Empirically on Turtle WoW, only
|
||||
// Light Quiver actually carries a non-zero family for bags themselves
|
||||
// — Soul Pouch, Enchanting Bag, etc. all return 0 because the
|
||||
// vanilla-era server data doesn't populate the field. **Individual
|
||||
// items** (arrows, bullets, shards, herbs) reliably return their
|
||||
// family, so addons that auto-route loot work correctly via
|
||||
// `C_Item.GetItemFamily(itemID)` even though `bagType` is sparse.
|
||||
// `bagType` is the `BagFamily` bitmask in modern encoding. Bags leave the
|
||||
// raw family field empty in Turtle's data (Soul/Herb/quiver/ammo pouch/…
|
||||
// all read 0), so it's derived from the bag's class + subclass via
|
||||
// `Item::BagFamily`: a Soul Bag reports `0x4`, a quiver `0x1`, an ammo
|
||||
// pouch `0x2` — matching what `C_Item.GetItemFamily` reports for both the
|
||||
// bag and the items it holds. See that helper for the Turtle custom-family
|
||||
// caveat (Meat/Fish/Leather/Mining, IDs 10–13).
|
||||
int __fastcall Script_C_Container_GetContainerNumFreeSlots(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L,
|
||||
@@ -130,11 +129,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. Specialty bags are correctly excluded: their family
|
||||
// comes from `Item::BagFamily` (container-subclass fallback), so a Soul
|
||||
// Bag / Herb Bag / Meat Bag reports a non-zero family and its slots are
|
||||
// left out of the general-purpose total — matching retail's FrameXML
|
||||
// behavior. (An earlier version miscounted them because Turtle leaves the
|
||||
// raw family field at 0 for 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
|
||||
@@ -107,11 +107,19 @@ static int AuxResume(void *L, void *co, int nargs) {
|
||||
: "cannot resume non-suspended coroutine");
|
||||
return -1;
|
||||
}
|
||||
if (Game::Lua::CheckStack(co, nargs) == 0) {
|
||||
Game::Lua::PushString(L, "too many arguments to resume coroutine");
|
||||
return -1;
|
||||
}
|
||||
Game::Lua::XMove(L, co, nargs);
|
||||
const int s = Game::Lua::Resume(co, nargs);
|
||||
const bool yielded = LuaCI(co) > LuaBaseCI(co);
|
||||
if (s == LUA_OK || yielded) {
|
||||
const int nres = Game::Lua::GetTop(co);
|
||||
if (Game::Lua::CheckStack(L, nres) == 0) {
|
||||
Game::Lua::PushString(L, "too many results to resume coroutine");
|
||||
return -1;
|
||||
}
|
||||
Game::Lua::XMove(co, L, nres);
|
||||
return nres;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
+23
-16
@@ -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;
|
||||
@@ -96,12 +88,27 @@ void Persist() {
|
||||
} // namespace
|
||||
|
||||
void EnsureLoaded() {
|
||||
if (g_loaded)
|
||||
return;
|
||||
g_path = Storage::ResolveFilePath();
|
||||
if (g_path.empty())
|
||||
return; // session globals not yet populated; retry on next call
|
||||
// Re-resolve the per-character path on every call rather than latching
|
||||
// once. The injected DLL persists across a logout→character-select→login
|
||||
// (no process restart), so a permanent `g_loaded` latch would leave the
|
||||
// previous character's file path and cached sets in place — the next
|
||||
// character would then read, and save into, character A's data. Detecting
|
||||
// a path change here makes the cache self-heal on any character/account
|
||||
// switch. Every mutation Persist()s immediately, so dropping the old
|
||||
// in-memory sets never loses unsaved work.
|
||||
std::string path = Storage::ResolveFilePath();
|
||||
if (path.empty())
|
||||
return; // session globals not yet populated; keep current, retry later
|
||||
if (g_loaded && path == g_path)
|
||||
return; // already loaded for this character
|
||||
|
||||
g_path = std::move(path);
|
||||
g_sets.clear();
|
||||
Storage::Load(g_path, &g_sets);
|
||||
// Per-session "ignore this slot on next save" state belongs to the
|
||||
// character it was set on — clear it when switching characters.
|
||||
for (int i = 0; i < SLOT_COUNT; ++i)
|
||||
g_ignoredForSave[i] = false;
|
||||
g_loaded = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,9 +22,11 @@ namespace EquipmentSet::Storage {
|
||||
// Resolves the per-character file path. Returns an empty string until
|
||||
// the engine has populated all three session globals (account, realm,
|
||||
// character). Path format: `WTF\Account\<acct>\<realm>\<char>\
|
||||
// ClassicAPI_EquipmentSets.txt`. Once the engine has the player in
|
||||
// world the values are stable for the rest of the session, so callers
|
||||
// can cache after the first non-empty return.
|
||||
// ClassicAPI_EquipmentSets.txt`. The values are stable only while the
|
||||
// current character is in world — they change on a logout→login to a
|
||||
// different character (the DLL is NOT reloaded across that transition),
|
||||
// so callers must re-resolve and compare rather than caching the first
|
||||
// non-empty return permanently. See `Data::EnsureLoaded`.
|
||||
std::string ResolveFilePath();
|
||||
|
||||
// Reads the file at `path` and overwrites `outSets` with the parsed
|
||||
|
||||
@@ -40,11 +40,12 @@
|
||||
// `C_EquipmentSet.GetItemIDs` + `C_EquipmentSet.GetItemLocations`
|
||||
// themselves.
|
||||
//
|
||||
// Tooltip filling is done by calling back through Lua (`self:AddLine`
|
||||
// etc.) rather than hitting the engine's tooltip-line internals
|
||||
// directly. The Lua dispatch goes through the engine's own frame-
|
||||
// method registry, so any addon-installed hook on AddLine (DBM,
|
||||
// pfUI's tooltip skin, etc.) still runs.
|
||||
// Tooltip lines are built with the engine's own primitives — the
|
||||
// per-tooltip clear (FUN_GAMETOOLTIP_CLEAR) and the raw add-line
|
||||
// (FUN_GAMETOOLTIP_ADD_LINE), the same native path SetTotem and
|
||||
// SetHyperlinkCompareItem use; no Lua method dispatch. It shows itself
|
||||
// at the end (Script_Show), matching retail: PaperDollFrame.lua sets the
|
||||
// anchor then calls SetEquipmentSet with no trailing :Show().
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
@@ -58,57 +59,41 @@ namespace EquipmentSet::Tooltip {
|
||||
|
||||
namespace {
|
||||
|
||||
// Calls `self:Method()` with no Lua args. Stack at entry must have
|
||||
// the tooltip at index 1 (the standard `frame:method()` self slot).
|
||||
// Leaves the stack unchanged.
|
||||
void CallSelfNoArgs(void *L, const char *methodName) {
|
||||
Game::Lua::PushValue(L, 1);
|
||||
Game::Lua::PushString(L, methodName);
|
||||
Game::Lua::GetTable(L, -2); // method = self[methodName]
|
||||
Game::Lua::Insert(L, -2); // [method, self]
|
||||
Game::Lua::Call(L, 1, 0);
|
||||
using AddLine_t = void(__thiscall *)(void *self, const char *left, const char *right,
|
||||
const void *leftColor, const void *rightColor, int wrap);
|
||||
using ClearTooltip_t = void(__fastcall *)(void *self);
|
||||
using ShowScript_t = int(__fastcall *)(void *L); // engine's Script_Show
|
||||
|
||||
// Packs r,g,b (0..1) into the 0xAARRGGBB value FUN_GAMETOOLTIP_ADD_LINE's
|
||||
// color arg wants — its little-endian bytes are the {b,g,r,a} the engine's
|
||||
// line-color setter reads. Alpha is always opaque.
|
||||
uint32_t Pack(double r, double g, double b) {
|
||||
auto ch = [](double v) {
|
||||
const int i = static_cast<int>(v * 255.0 + 0.5);
|
||||
return static_cast<uint32_t>(i < 0 ? 0 : (i > 255 ? 255 : i));
|
||||
};
|
||||
return 0xFF000000u | (ch(r) << 16) | (ch(g) << 8) | ch(b);
|
||||
}
|
||||
|
||||
// Calls `self:AddLine(text, r, g, b)`. Same self-on-stack precondition.
|
||||
void AddLine(void *L, const char *text, double r, double g, double b) {
|
||||
Game::Lua::PushValue(L, 1);
|
||||
Game::Lua::PushString(L, "AddLine");
|
||||
Game::Lua::GetTable(L, -2);
|
||||
Game::Lua::Insert(L, -2);
|
||||
Game::Lua::PushString(L, text);
|
||||
Game::Lua::PushNumber(L, r);
|
||||
Game::Lua::PushNumber(L, g);
|
||||
Game::Lua::PushNumber(L, b);
|
||||
Game::Lua::Call(L, 5, 0);
|
||||
// Left-aligned colored line via the engine's raw add-line. The packed
|
||||
// color is a local so its address stays valid across the call.
|
||||
void AddColoredLine(void *self, const char *text, double r, double g, double b) {
|
||||
const uint32_t color = Pack(r, g, b);
|
||||
reinterpret_cast<AddLine_t>(Offsets::FUN_GAMETOOLTIP_ADD_LINE)(
|
||||
self, text, nullptr, &color, nullptr, 0);
|
||||
}
|
||||
|
||||
// Calls `self:SetText(text, r, g, b)`. Same precondition. Used for
|
||||
// the header line — `SetText` is the standard vanilla entry for
|
||||
// "first line of a fresh tooltip" and handles internal redraw state
|
||||
// `AddLine` skips when it's the first content after a clear.
|
||||
void SetText(void *L, const char *text, double r, double g, double b) {
|
||||
Game::Lua::PushValue(L, 1);
|
||||
Game::Lua::PushString(L, "SetText");
|
||||
Game::Lua::GetTable(L, -2);
|
||||
Game::Lua::Insert(L, -2);
|
||||
Game::Lua::PushString(L, text);
|
||||
Game::Lua::PushNumber(L, r);
|
||||
Game::Lua::PushNumber(L, g);
|
||||
Game::Lua::PushNumber(L, b);
|
||||
Game::Lua::Call(L, 5, 0); // self + text + r + g + b
|
||||
}
|
||||
|
||||
// Format-and-add one localized count line. Calls
|
||||
// `Game::Lua::PushLocalizedFormatInt` to resolve the format string
|
||||
// (Blizzard's FrameXML globals like `ITEMS_EQUIPPED` first, with the
|
||||
// C `fallback` for stripped-down servers), then dispatches `AddLine`.
|
||||
void AddLocalizedLineInt(void *L, const char *globalName, const char *fallback, int n,
|
||||
double r, double g, double b) {
|
||||
// Adds one localized "N unit" line: `string.format(_G[globalName] or
|
||||
// fallback, n)` (the project's GlobalString localize-or-fallback helper,
|
||||
// with the C `fallback` for servers stripped of the standard strings),
|
||||
// then the native colored add-line.
|
||||
void AddLocalizedLineInt(void *L, void *self, const char *globalName,
|
||||
const char *fallback, int n, double r, double g, double b) {
|
||||
const int savedTop = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushLocalizedFormatInt(L, globalName, fallback, n);
|
||||
const char *text = Game::Lua::ToString(L, -1);
|
||||
if (text != nullptr)
|
||||
AddLine(L, text, r, g, b);
|
||||
AddColoredLine(self, text, r, g, b);
|
||||
Game::Lua::SetTop(L, savedTop);
|
||||
}
|
||||
|
||||
@@ -185,39 +170,36 @@ int __fastcall Script_GameTooltipSetEquipmentSet(void *L) {
|
||||
if (s == nullptr)
|
||||
return 0;
|
||||
|
||||
void *self = Game::Lua::ResolveObject(L, 1);
|
||||
if (self == nullptr)
|
||||
return 0;
|
||||
|
||||
const Tally t = TallySet(*s);
|
||||
const int totalSlots = t.equipped + t.inInventory + t.missing;
|
||||
|
||||
// Trim the Lua stack to just the tooltip self-arg so our internal
|
||||
// method-call sequences don't accumulate the original args past
|
||||
// their useful lifetime. `setName` was a `const char *` lifted out
|
||||
// by ToString — Lua's strings are pinned by reference count, so
|
||||
// dropping the string from the stack doesn't invalidate the
|
||||
// pointer for the lifetime of this call.
|
||||
Game::Lua::SetTop(L, 1);
|
||||
|
||||
// Header — set name, white. Doubles as the "ClearLines" boundary;
|
||||
// SetText replaces line 1 and clears subsequent lines in vanilla.
|
||||
SetText(L, s->name.c_str(), 1.0, 1.0, 1.0);
|
||||
// Header — set name, white. Clear first so it lands as line 0
|
||||
// (the clear resets the line count the add-line body indexes from).
|
||||
reinterpret_cast<ClearTooltip_t>(Offsets::FUN_GAMETOOLTIP_CLEAR)(self);
|
||||
AddColoredLine(self, s->name.c_str(), 1.0, 1.0, 1.0);
|
||||
|
||||
// Body lines — try Blizzard's localized FORMAT_* globals first.
|
||||
// Same key names retail FrameXML uses (`ITEMS_VARIABLE_QUANTITY`,
|
||||
// `ITEMS_EQUIPPED`, ...); addons that need their own wording can
|
||||
// override by reassigning `_G[name]` at runtime. The fallbacks
|
||||
// only fire on servers stripped of the standard GlobalStrings.
|
||||
AddLocalizedLineInt(L, "ITEMS_VARIABLE_QUANTITY", "%d items",
|
||||
AddLocalizedLineInt(L, self, "ITEMS_VARIABLE_QUANTITY", "%d items",
|
||||
totalSlots, 1.0, 1.0, 1.0);
|
||||
|
||||
if (t.equipped > 0) {
|
||||
AddLocalizedLineInt(L, "ITEMS_EQUIPPED", "%d equipped",
|
||||
AddLocalizedLineInt(L, self, "ITEMS_EQUIPPED", "%d equipped",
|
||||
t.equipped, 0.0, 1.0, 0.0); // green
|
||||
}
|
||||
if (t.inInventory > 0) {
|
||||
AddLocalizedLineInt(L, "ITEMS_IN_INVENTORY", "%d in inventory",
|
||||
AddLocalizedLineInt(L, self, "ITEMS_IN_INVENTORY", "%d in inventory",
|
||||
t.inInventory, 1.0, 1.0, 1.0);
|
||||
}
|
||||
if (t.ignored > 0) {
|
||||
AddLocalizedLineInt(L, "ITEM_SLOTS_IGNORED", "%d slots ignored",
|
||||
AddLocalizedLineInt(L, self, "ITEM_SLOTS_IGNORED", "%d slots ignored",
|
||||
t.ignored, 0.5, 0.5, 0.5); // gray
|
||||
}
|
||||
// Missing slots: list each by name using the itemID we stored at
|
||||
@@ -255,18 +237,20 @@ int __fastcall Script_GameTooltipSetEquipmentSet(void *L) {
|
||||
Game::Lua::Call(L, 2, 1);
|
||||
const char *line = Game::Lua::ToString(L, -1);
|
||||
if (line != nullptr)
|
||||
AddLine(L, line, 1.0, 0.0, 0.0); // red
|
||||
AddColoredLine(self, line, 1.0, 0.0, 0.0); // red
|
||||
Game::Lua::SetTop(L, savedTop);
|
||||
}
|
||||
if (unnamedMissing > 0) {
|
||||
// Pre-itemID-format files or items the cache has no record of.
|
||||
// No Blizzard format global maps cleanly here; addons can
|
||||
// override with `_G.CLASSICAPI_EQUIPMENTSET_MISSING`.
|
||||
AddLocalizedLineInt(L, "CLASSICAPI_EQUIPMENTSET_MISSING",
|
||||
AddLocalizedLineInt(L, self, "CLASSICAPI_EQUIPMENTSET_MISSING",
|
||||
"%d missing", unnamedMissing, 1.0, 0.0, 0.0);
|
||||
}
|
||||
|
||||
CallSelfNoArgs(L, "Show");
|
||||
// Show ourselves — self is still at Lua stack index 1 (Script_Show
|
||||
// reads it there). Retail's SetEquipmentSet shows internally too.
|
||||
reinterpret_cast<ShowScript_t>(Offsets::FUN_SCRIPT_FRAME_SHOW)(L);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
+205
-8
@@ -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
|
||||
@@ -36,6 +46,22 @@ struct ReservedName {
|
||||
ReservedName g_reserved[MAX_RESERVED];
|
||||
int g_reservedCount = 0;
|
||||
bool g_writesEnabled = false;
|
||||
// One-shot latch so a genuinely full event table (no NULL slot left to
|
||||
// claim) is logged once per session instead of silently dropping the
|
||||
// event — the failure mode that would otherwise rot unnoticed. Re-armed on
|
||||
// `/reload` (the table is rebuilt fresh). In practice this never triggers:
|
||||
// SuperWoW/nampower clamp the FrameXML table to 700 and the engine leaves
|
||||
// ~200 NULL gaps in the low range, dwarfing our ~46 reservations. The log is
|
||||
// purely a backstop if some future install somehow drains the gap pool.
|
||||
bool g_warnedTableFull = false;
|
||||
|
||||
// One grow attempt per in-game table build. Reset in `PrepareForReload`
|
||||
// (the table is rebuilt fresh on every /reload + logout). See `Grow`.
|
||||
bool g_growLatched = false;
|
||||
// Debug: force a grow on the next table build regardless of headroom, so
|
||||
// the (otherwise dormant) grow path can actually be exercised in-game.
|
||||
// Armed by `_classicapi_ForceEventGrow`, consumed by the next `Grow`.
|
||||
bool g_forceGrowNext = false;
|
||||
|
||||
// Engine's SStrDup at `FUN_STORM_SSTRDUP` — uses `SMemAlloc` internally
|
||||
// and is exactly what the engine itself does when filling event table
|
||||
@@ -51,14 +77,38 @@ char *SStrDup(const char *s) {
|
||||
return fn(s, __FILE__, __LINE__);
|
||||
}
|
||||
|
||||
// Walk the engine's event table from the END looking for a NULL-name
|
||||
// Storm allocator/free — the same pair the engine uses for the event-table
|
||||
// buffer (see RebuildEventTable), so its reload teardown `SMemFree`s our
|
||||
// grown buffer exactly as its own. Both `__stdcall`, 4 args (`ret 0x10`).
|
||||
// SMemAlloc flag bit 8 zero-fills (matching SetEventCount's fresh entries).
|
||||
void *SMemAlloc(unsigned size) {
|
||||
using SMemAlloc_t = void *(__stdcall *)(unsigned size, const char *file,
|
||||
int line, int flags);
|
||||
auto fn = reinterpret_cast<SMemAlloc_t>(Offsets::FUN_STORM_SMEMALLOC);
|
||||
return fn(size, __FILE__, __LINE__, 8 /* zero-fill */);
|
||||
}
|
||||
void SMemFree(void *ptr) {
|
||||
using SMemFree_t = int(__stdcall *)(void *ptr, const char *file, int line,
|
||||
int flags);
|
||||
auto fn = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEMFREE);
|
||||
fn(ptr, __FILE__, __LINE__, 0);
|
||||
}
|
||||
|
||||
// Walk the engine's event table from the LOW end looking for a NULL-name
|
||||
// slot. The bulk-init at `0x0051AB30` leaves ~200 NULL gaps scattered
|
||||
// through the 548-slot input names array (it only writes ~350 of the
|
||||
// slots explicitly; the rest stay zero-initialized from .data). UP
|
||||
// and DOWN walks both find these gaps — we go DOWN for convention with
|
||||
// VanillaMinimapTracking and so the dump in `_classicapi_DumpAllEvents`
|
||||
// shows our events grouped at the tail, visually distinct from engine
|
||||
// events.
|
||||
// slots explicitly; the rest stay zero-initialized from .data) — a big
|
||||
// contiguous hole around 42..106 alone dwarfs our reservation count.
|
||||
//
|
||||
// We go LOW (ascending) deliberately: SuperWoW and nampower own the HIGH
|
||||
// range (fixed event IDs 549..655, and they destructively clamp the table
|
||||
// to 700), and other claim-based DLLs (VanillaMinimapTracking) also walk
|
||||
// from the tail — so the top is the contested end. Claiming from the low
|
||||
// engine gaps keeps us entirely out of their way and independent of their
|
||||
// clamp / hook order. The engine's ~200 low NULL gaps dwarf our ~46
|
||||
// reservations, so a free slot is essentially always available; the
|
||||
// skip-occupied invariant keeps this safe against future engine additions
|
||||
// regardless of direction.
|
||||
int TryClaim(const char *eventName) {
|
||||
if (!g_writesEnabled)
|
||||
return -1;
|
||||
@@ -66,7 +116,7 @@ int TryClaim(const char *eventName) {
|
||||
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
if (base == nullptr || count <= 0)
|
||||
return -1;
|
||||
for (int i = count - 1; i >= 0; --i) {
|
||||
for (int i = 0; i < count; ++i) {
|
||||
auto **namePtr = reinterpret_cast<const char **>(
|
||||
base + i * Offsets::EVENT_ENTRY_STRIDE +
|
||||
Offsets::OFF_EVENT_ENTRY_NAME);
|
||||
@@ -78,9 +128,119 @@ int TryClaim(const char *eventName) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
// Writes are enabled and the table is valid, but every slot is taken —
|
||||
// real capacity exhaustion despite `Grow`. Warn once so it's diagnosable.
|
||||
if (!g_warnedTableFull) {
|
||||
g_warnedTableFull = true;
|
||||
Debug::Log::Printf(
|
||||
"[event] table full (%d slots, no NULL left) — cannot claim '%s'"
|
||||
" and any further custom events; even Grow couldn't make room.",
|
||||
count, eventName ? eventName : "?");
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// On-demand event-table grow. Correctly TIMED: called once per in-game
|
||||
// table build from `RetryClaims` (the `frame:RegisterEvent` hook), on the
|
||||
// FIRST such call — which is AFTER `RebuildEventTable` has built the fresh
|
||||
// table (frames can only register once it exists) but BEFORE any frame has
|
||||
// joined a chain (it's the first registration, and we run before the
|
||||
// original registers it). So every chain is still an empty self-sentinel,
|
||||
// and the buffer can be moved safely.
|
||||
//
|
||||
// (This is what the removed `EnsureCapacity` got wrong: it ran from
|
||||
// `LoadScriptFunctions_h`, BEFORE `RebuildEventTable`, so it saw the stale
|
||||
// previous/glue table and always aborted — it never actually grew. The
|
||||
// realloc logic itself was fine; only the timing was broken.)
|
||||
//
|
||||
// Grows only when the engine's low NULL gaps are fewer than our
|
||||
// reservations — which never happens in practice (~200 gaps vs ~46
|
||||
// reservations), so this is a dormant durability valve. `g_forceGrowNext`
|
||||
// (debug) forces it so the path can be exercised. It grows by reallocating
|
||||
// the entry buffer with the engine's own Storm allocator and swapping the
|
||||
// `{cap@0, count@4, base@8}` globals at `0x00CEEF60` — bypassing
|
||||
// SetEventCount's destructive `= 700` clamp. All consumers (RegisterEvent,
|
||||
// the dispatcher, SuperWoW, nampower) read the base pointer fresh, so the
|
||||
// swap is picked up everywhere; the engine's reload teardown `SMemFree`s
|
||||
// our buffer and frees the (shared) name strings via its per-entry helper,
|
||||
// exactly as its own.
|
||||
void Grow() {
|
||||
if (g_growLatched || !g_writesEnabled)
|
||||
return;
|
||||
g_growLatched = true; // one attempt per build, whether or not it grows
|
||||
|
||||
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
if (base == nullptr || count <= 0 || g_reservedCount <= 0)
|
||||
return;
|
||||
|
||||
int nulls = 0;
|
||||
for (int i = 0; i < count; ++i)
|
||||
if (*reinterpret_cast<const char *const *>(
|
||||
base + i * Offsets::EVENT_ENTRY_STRIDE +
|
||||
Offsets::OFF_EVENT_ENTRY_NAME) == nullptr)
|
||||
++nulls;
|
||||
|
||||
const bool force = g_forceGrowNext;
|
||||
g_forceGrowNext = false;
|
||||
if (nulls >= g_reservedCount && !force)
|
||||
return; // plenty of room — the common (only) real case
|
||||
|
||||
// Growing moves the buffer, invalidating every entry's self-referential
|
||||
// list anchor, so it's safe ONLY while all chains are empty. We run
|
||||
// before the first registration, so that holds — but VERIFY it (a live
|
||||
// chain means someone registered earlier via a path we didn't intercept;
|
||||
// moving the buffer would corrupt their subscription). Abort if so and
|
||||
// fall back to claim-NULL.
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const uint32_t head = *reinterpret_cast<const uint32_t *>(
|
||||
base + i * Offsets::EVENT_ENTRY_STRIDE + Offsets::OFF_EVENT_ENTRY_HEAD);
|
||||
if (head != 0 && (head & 1) == 0) { // populated chain (not 0 / sentinel)
|
||||
Debug::Log::Printf(
|
||||
"[event] grow aborted — chain live at slot %d (not first-reg)", i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
constexpr int kMargin = 16;
|
||||
const int deficit = (nulls < g_reservedCount) ? (g_reservedCount - nulls) : 0;
|
||||
const int newCount = count + deficit + kMargin;
|
||||
auto *newBuf = static_cast<uint8_t *>(
|
||||
SMemAlloc(static_cast<unsigned>(newCount) * Offsets::EVENT_ENTRY_STRIDE));
|
||||
if (newBuf == nullptr)
|
||||
return; // alloc failed — keep the old table; claiming still works
|
||||
|
||||
// Rebuild each entry at its NEW address, mirroring SetEventCount's own
|
||||
// per-entry init: copy the name pointer (shared string, not duplicated);
|
||||
// the `+0x04` field stays 0 (zero-filled, matching the engine); re-init
|
||||
// the anchor as an empty self-sentinel at the new location. Old entries
|
||||
// past the copy and all new entries are already zero-filled + get fresh
|
||||
// anchors. Safe because every chain is empty.
|
||||
for (int i = 0; i < newCount; ++i) {
|
||||
auto *e = reinterpret_cast<uint32_t *>(
|
||||
newBuf + i * Offsets::EVENT_ENTRY_STRIDE);
|
||||
if (i < count) // copy name; new slots stay null
|
||||
e[0] = *reinterpret_cast<const uint32_t *>(
|
||||
base + i * Offsets::EVENT_ENTRY_STRIDE);
|
||||
const uint32_t anchor = static_cast<uint32_t>(
|
||||
reinterpret_cast<uintptr_t>(&e[2])); // entry+0x08
|
||||
e[2] = anchor; // self-referential list anchor
|
||||
e[3] = anchor | 1; // empty-chain sentinel head (+0x0C)
|
||||
}
|
||||
|
||||
// Publish base BEFORE count so the globals never advertise more entries
|
||||
// than the buffer holds. Then free the OLD buffer only — the name
|
||||
// strings live on in newBuf and are freed by the reload teardown.
|
||||
*reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR) = newBuf;
|
||||
*reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_CAP) = newCount;
|
||||
*reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT) = newCount;
|
||||
SMemFree(base);
|
||||
|
||||
Debug::Log::Printf("[event] grew table %d -> %d (%d NULL, %d reserved%s)",
|
||||
count, newCount, nulls, g_reservedCount,
|
||||
force ? ", FORCED" : "");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
AutoReserve::AutoReserve(const char *name) {
|
||||
@@ -122,7 +282,28 @@ int LookupByName(const char *name) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool HasListeners(int slot) {
|
||||
if (slot < 0)
|
||||
return false;
|
||||
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
if (base == nullptr || slot >= count)
|
||||
return false;
|
||||
// The entry's chain head (`+0x0C`): a populated subscriber chain is a
|
||||
// non-zero pointer with the low bit clear; `0` or an odd (tagged)
|
||||
// value is the empty self-sentinel — same test the grow-safety scan
|
||||
// uses. So this is true iff at least one frame registered for the event.
|
||||
const uint32_t head = *reinterpret_cast<const uint32_t *>(
|
||||
base + slot * Offsets::EVENT_ENTRY_STRIDE + Offsets::OFF_EVENT_ENTRY_HEAD);
|
||||
return head != 0 && (head & 1) == 0;
|
||||
}
|
||||
|
||||
void RetryClaims() {
|
||||
// Grow the table first if the low gap pool is short (dormant valve).
|
||||
// Runs at most once per table build; on the first call every chain is
|
||||
// still empty (this is the first registration), which is the only safe
|
||||
// time to move the buffer.
|
||||
Grow();
|
||||
for (int i = 0; i < g_reservedCount; ++i) {
|
||||
if (g_reserved[i].slot < 0)
|
||||
g_reserved[i].slot = TryClaim(g_reserved[i].name);
|
||||
@@ -135,6 +316,8 @@ void PrepareForReload() {
|
||||
for (int i = 0; i < g_reservedCount; ++i)
|
||||
g_reserved[i].slot = -1;
|
||||
g_writesEnabled = false;
|
||||
g_warnedTableFull = false; // fresh table after the rebuild — re-arm
|
||||
g_growLatched = false; // re-arm the one-shot grow for the new table
|
||||
}
|
||||
|
||||
// Diagnostic Lua functions — registered via the auto-register pattern
|
||||
@@ -180,10 +363,24 @@ int __fastcall Script_DumpAllEvents(void *) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// `_classicapi_ForceEventGrow()` — arms a one-shot forced table grow for
|
||||
// the NEXT in-game table build, so the (otherwise dormant) grow path can be
|
||||
// exercised: call it, then `/reload`. The grow fires at the first
|
||||
// `RegisterEvent` after the rebuild (chains still empty) and logs
|
||||
// `[event] grew table N -> M (..., FORCED)`. Diagnostic only.
|
||||
int __fastcall Script_ForceEventGrow(void *) {
|
||||
g_forceGrowNext = true;
|
||||
Debug::Log::Printf(
|
||||
"[event] force-grow armed — /reload to trigger it on the next build");
|
||||
return 0;
|
||||
}
|
||||
|
||||
void RegisterDiagnostics() {
|
||||
Game::Lua::RegisterGlobalFunction("_classicapi_DumpSlot", &Script_DumpSlot);
|
||||
Game::Lua::RegisterGlobalFunction("_classicapi_DumpAllEvents",
|
||||
&Script_DumpAllEvents);
|
||||
Game::Lua::RegisterGlobalFunction("_classicapi_ForceEventGrow",
|
||||
&Script_ForceEventGrow);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterDiagnostics};
|
||||
|
||||
+12
-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);
|
||||
};
|
||||
@@ -63,6 +64,15 @@ int Lookup(const char *name);
|
||||
// fire ourselves (e.g., polyfilling missing event dispatches).
|
||||
int LookupByName(const char *name);
|
||||
|
||||
// True iff at least one frame is currently registered for the event in
|
||||
// `slot` (its subscriber chain is non-empty). Reads the entry's chain
|
||||
// head at `+0x0C` — a couple of pointer derefs, no allocation. Use this
|
||||
// to gate expensive per-fire work (arg synthesis, DBC lookups) so an
|
||||
// event nobody listens to costs almost nothing: `if (HasListeners(slot))
|
||||
// { …build args…; Fire(slot, …); }`. `false` for `slot < 0` or a slot
|
||||
// past the live table.
|
||||
bool HasListeners(int slot);
|
||||
|
||||
// Dispatches a custom event via the engine's printf-style event
|
||||
// dispatcher at `FUN_FIRE_EVENT` (`0x00703F50`). `format` is a
|
||||
// concatenation of `%d` (int), `%u` (uint), `%f` (double), `%s`
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// 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/>.
|
||||
|
||||
// `PLAYER_ENTERING_WORLD(isInitialLogin, isReloadingUi)` — the 8.0.1 payload,
|
||||
// backported. Vanilla fires PEW with no args; backported addons expect the
|
||||
// two booleans to distinguish a fresh login from a `/reload` from an
|
||||
// instance/zone transition.
|
||||
//
|
||||
// Delivery: PEW is a no-arg event, so the engine broadcasts it through
|
||||
// `FUN_FIRE_EVENT_NO_ARGS` (the `__fastcall(int)` dispatcher), NOT the
|
||||
// variadic `FUN_FIRE_EVENT`. We intercept that dispatcher (via the shared
|
||||
// `Event::SignalHook`) and, for the PEW event id, suppress the no-arg fire
|
||||
// and instead re-broadcast through the variadic `FUN_FIRE_EVENT` with a
|
||||
// two-boolean payload. Both fire paths read the same frame chain off the
|
||||
// event's table slot, so the frames registered for PEW receive the args
|
||||
// exactly once. Booleans use the `%d`/`%s`+nullptr trick (`1` / `nil`), so
|
||||
// `if isInitialLogin then …` reads naturally.
|
||||
//
|
||||
// Flags, from signals we already produce — no PEW-specific engine hook:
|
||||
// - isInitialLogin: our GlueModuleAutoRegister runs on every glue-screen
|
||||
// boot (initial launch + each logout return), so a set latch means "the
|
||||
// next enter-world is a fresh login". The first PEW consumes it.
|
||||
// - isReloadingUi: our ModuleAutoRegister runs whenever the in-game Lua
|
||||
// state is (re)built — login OR `/reload`, but NOT a zone transition. So
|
||||
// "script re-init happened and it wasn't the fresh login" == a reload.
|
||||
// - Instance/zone transition rebuilds neither, so both latches are clear →
|
||||
// both false, matching retail.
|
||||
|
||||
#include "Game.h"
|
||||
#include "event/Custom.h"
|
||||
#include "event/SignalHook.h"
|
||||
|
||||
namespace Event::EnteringWorld {
|
||||
|
||||
namespace {
|
||||
|
||||
// Set on glue-screen boot; a pending latch means the next enter-world is a
|
||||
// fresh character login (from the login/char-select screen).
|
||||
bool g_atGlue = false;
|
||||
// Set when the in-game Lua state is (re)built (login or /reload).
|
||||
bool g_scriptReloaded = false;
|
||||
// Cached PLAYER_ENTERING_WORLD event-table slot; -1 = unresolved. Reset per
|
||||
// in-game init (the table is rebuilt, though engine slots are stable) and
|
||||
// re-resolved lazily at fire time.
|
||||
int g_pewId = -1;
|
||||
|
||||
int PewId() {
|
||||
if (g_pewId < 0)
|
||||
g_pewId = Event::Custom::LookupByName("PLAYER_ENTERING_WORLD");
|
||||
return g_pewId;
|
||||
}
|
||||
|
||||
// Shared-hook interceptor. For PEW, re-broadcast with the payload and
|
||||
// suppress the engine's no-arg fire; everything else passes through.
|
||||
bool Intercept(int eventID) {
|
||||
const int pew = PewId();
|
||||
if (pew < 0 || eventID != pew)
|
||||
return false;
|
||||
|
||||
const bool isInitialLogin = g_atGlue;
|
||||
g_atGlue = false;
|
||||
const bool isReloadingUi = !isInitialLogin && g_scriptReloaded;
|
||||
g_scriptReloaded = false;
|
||||
|
||||
// Re-fire through the variadic dispatcher with the two booleans. true → 1
|
||||
// (%d), false → nil (%s + nullptr, which the dispatcher's pushstring
|
||||
// tail-jumps to pushnil).
|
||||
auto *const kNil = static_cast<const char *>(nullptr);
|
||||
if (isInitialLogin && isReloadingUi)
|
||||
Event::Custom::Fire(pew, "%d%d", 1, 1);
|
||||
else if (isInitialLogin)
|
||||
Event::Custom::Fire(pew, "%d%s", 1, kNil);
|
||||
else if (isReloadingUi)
|
||||
Event::Custom::Fire(pew, "%s%d", kNil, 1);
|
||||
else
|
||||
Event::Custom::Fire(pew, "%s%s", kNil, kNil);
|
||||
return true; // handled — suppress the original no-arg PEW fire
|
||||
}
|
||||
|
||||
const Event::SignalHook::AutoSubscribe _intercept{&Intercept};
|
||||
|
||||
// In-game Lua (re)init: login or /reload rebuilt the script state (never a
|
||||
// zone transition). Also re-arm the PEW slot lookup — the event table was
|
||||
// rebuilt.
|
||||
void OnInGameInit() {
|
||||
g_scriptReloaded = true;
|
||||
g_pewId = -1;
|
||||
}
|
||||
|
||||
// Glue-screen boot: we're at the login/char-select screen, so the next
|
||||
// enter-world will be a fresh login.
|
||||
void OnGlueInit() { g_atGlue = true; }
|
||||
|
||||
const Game::ModuleAutoRegister _inGame{&OnInGameInit};
|
||||
const Game::GlueModuleAutoRegister _glue{&OnGlueInit};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Event::EnteringWorld
|
||||
@@ -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/>.
|
||||
|
||||
// `GetFramesRegisteredForEvent(event)` → the Frame objects currently
|
||||
// subscribed to `event`, returned as multiple return values (matching
|
||||
// modern WoW's vararg signature — not a table). Empty result set returns
|
||||
// nothing.
|
||||
//
|
||||
// The engine already keeps exactly this list: every `frame:RegisterEvent`
|
||||
// appends the frame to a subscriber chain hanging off the matching event
|
||||
// table entry. We find the entry by name (same table `Event::Custom`
|
||||
// walks), then walk the chain the same way `Frame::RegisterEvent`
|
||||
// (`FUN_00702140`) does — node next at `+0x04`, subscriber frame at
|
||||
// `+0x08`, with a low-bit-set / null value marking the end sentinel — and
|
||||
// push each frame via the shared `UI::FrameObject::Push` so callers get
|
||||
// the canonical wrapper table (real frame methods, `:GetName()`, etc.).
|
||||
//
|
||||
// Event names resolve through `Event::Custom::LookupByName`, so this also
|
||||
// works for our `AutoReserve`-claimed custom events, not just engine ones.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "event/Custom.h"
|
||||
#include "ui/FrameObject.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Event::Registered {
|
||||
|
||||
namespace {
|
||||
|
||||
int __fastcall Script_GetFramesRegisteredForEvent(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1))
|
||||
return 0;
|
||||
const char *event = Game::Lua::ToString(L, 1);
|
||||
if (event == nullptr)
|
||||
return 0;
|
||||
|
||||
const int slot = Event::Custom::LookupByName(event);
|
||||
if (slot < 0)
|
||||
return 0;
|
||||
|
||||
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
if (base == nullptr)
|
||||
return 0;
|
||||
|
||||
const uint8_t *entry = base + slot * Offsets::EVENT_ENTRY_STRIDE;
|
||||
const uintptr_t head = *reinterpret_cast<const uintptr_t *>(
|
||||
entry + Offsets::OFF_EVENT_ENTRY_HEAD);
|
||||
|
||||
// First pass: count subscribers so we can reserve stack up front — an
|
||||
// event with many registered frames would otherwise risk overflowing
|
||||
// the default Lua stack slack as we push.
|
||||
int count = 0;
|
||||
for (uintptr_t node = head; (node & 1) == 0 && node != 0;
|
||||
node = *reinterpret_cast<const uintptr_t *>(
|
||||
node + Offsets::OFF_EVENT_NODE_NEXT)) {
|
||||
if (*reinterpret_cast<void *const *>(node + Offsets::OFF_EVENT_NODE_FRAME))
|
||||
++count;
|
||||
}
|
||||
if (count == 0)
|
||||
return 0;
|
||||
Game::Lua::CheckStack(L, count);
|
||||
|
||||
int pushed = 0;
|
||||
for (uintptr_t node = head; (node & 1) == 0 && node != 0;
|
||||
node = *reinterpret_cast<const uintptr_t *>(
|
||||
node + Offsets::OFF_EVENT_NODE_NEXT)) {
|
||||
void *frame = *reinterpret_cast<void *const *>(
|
||||
node + Offsets::OFF_EVENT_NODE_FRAME);
|
||||
if (frame != nullptr) {
|
||||
UI::FrameObject::Push(L, frame);
|
||||
++pushed;
|
||||
}
|
||||
}
|
||||
return pushed;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("GetFramesRegisteredForEvent",
|
||||
&Script_GetFramesRegisteredForEvent);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Event::Registered
|
||||
@@ -0,0 +1,49 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 "SignalHook.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
namespace Event::SignalHook {
|
||||
|
||||
namespace {
|
||||
|
||||
// Chained at static-init time by each `AutoSubscribe` constructor, before
|
||||
// `DllMain`. Zero-initialized (namespace static) ahead of any dynamic init,
|
||||
// so the head is valid when subscribers link in.
|
||||
AutoSubscribe *g_head = nullptr;
|
||||
|
||||
// `FUN_FIRE_EVENT_NO_ARGS` is `__fastcall(int eventID)`; we mirror loot's
|
||||
// long-standing shape with a dummy EDX so the trampoline call matches the
|
||||
// register layout, and forward `0` for it (unused by the callee).
|
||||
using FireNoArgs_t = void(__fastcall *)(int eventID, int edx);
|
||||
FireNoArgs_t g_orig = nullptr;
|
||||
|
||||
void __fastcall Hook(int eventID, int /*edx*/) {
|
||||
for (AutoSubscribe *s = g_head; s != nullptr; s = s->next)
|
||||
if (s->fn(eventID))
|
||||
return; // a subscriber handled it — suppress the original fire
|
||||
g_orig(eventID, 0);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hook{
|
||||
Offsets::FUN_FIRE_EVENT_NO_ARGS, reinterpret_cast<void *>(&Hook),
|
||||
reinterpret_cast<void **>(&g_orig)};
|
||||
|
||||
} // namespace
|
||||
|
||||
AutoSubscribe::AutoSubscribe(Interceptor f) : fn(f), next(g_head) { g_head = this; }
|
||||
|
||||
} // namespace Event::SignalHook
|
||||
@@ -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/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
// Shared interceptor for the engine's no-arg event dispatcher
|
||||
// `FUN_FIRE_EVENT_NO_ARGS` (`0x00703E50`) — the `__fastcall(int eventID)`
|
||||
// path every no-arg event broadcast goes through (PLAYER_ENTERING_WORLD,
|
||||
// LOOT_OPENED/CLOSED, zone changes, …), as opposed to the variadic
|
||||
// `FUN_FIRE_EVENT` used by events that carry a printf-style payload.
|
||||
//
|
||||
// MinHook allows only ONE hook per target address, and more than one feature
|
||||
// needs to intercept this dispatcher (loot silent-scan suppression, the
|
||||
// PLAYER_ENTERING_WORLD payload). So — exactly like `Tick::WorldTick` for the
|
||||
// per-frame tick — this module owns the single hook and fans out to
|
||||
// subscribers. Declare a file-scope `static const
|
||||
// Event::SignalHook::AutoSubscribe` in your module.
|
||||
//
|
||||
// An interceptor returns `true` when it has fully handled `eventID` and the
|
||||
// engine's original no-arg fire must be SUPPRESSED — either because it
|
||||
// swallowed the event (loot) or re-fired it a different way (the PEW payload
|
||||
// re-broadcasts through the variadic `FUN_FIRE_EVENT` with args). Returning
|
||||
// `false` lets the event fall through to the next subscriber and ultimately
|
||||
// the original. The FIRST subscriber to return `true` wins and the original
|
||||
// is skipped, so subscribers must claim disjoint event ids.
|
||||
|
||||
namespace Event::SignalHook {
|
||||
|
||||
using Interceptor = bool (*)(int eventID);
|
||||
|
||||
struct AutoSubscribe {
|
||||
explicit AutoSubscribe(Interceptor fn);
|
||||
Interceptor fn;
|
||||
AutoSubscribe *next;
|
||||
};
|
||||
|
||||
} // namespace Event::SignalHook
|
||||
@@ -0,0 +1,953 @@
|
||||
// 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/>.
|
||||
|
||||
// Frame attributes + unit-frame mouseover/click backport.
|
||||
//
|
||||
// Backports `frame:SetAttribute` / `frame:SetAttributeNoHandler` /
|
||||
// `frame:ClearAttribute` / `frame:GetAttribute` to 1.12 as native methods on the base Frame registry
|
||||
// (every frame gets them, like the modern clients). Vanilla has no attribute
|
||||
// system at all — it arrived in 2.0 with secure frames. Attributes are just a
|
||||
// per-frame, case-insensitive key→value store (verified from the 3.3.5
|
||||
// implementation: a name→Lua-value map plus a getter with prefix/name/suffix
|
||||
// wildcard precedence). We store the map in the Lua registry keyed by the frame
|
||||
// (registry[frame]) — invisible to Lua script and tamper-proof, like retail's
|
||||
// C-side attribute store (see PushSub).
|
||||
//
|
||||
// The headline use — SecureUnitButton behavior, minus the secure wrapper (1.12
|
||||
// has no combat lockdown or taint, so protected actions just work):
|
||||
//
|
||||
// unitFrame:SetAttribute("unit", "party1") -- hover → mouseover = party1
|
||||
// unitFrame:SetAttribute("type1", "target") -- left-click → target party1
|
||||
//
|
||||
// ── Mouseover (needs C — the engine's mouseover is a GUID slot) ──
|
||||
//
|
||||
// Installing OnEnter/OnLeave would be clobbered by an addon's own
|
||||
// `SetScript("OnEnter", …)` (pfUI sets `unit` before its scripts), and there's
|
||||
// nothing to read from Lua anyway — the mouseover unit is an engine global. So
|
||||
// we mirror retail / SuperWoW's `SetMouseoverUnit`: watch the engine's
|
||||
// mouse-focus frame (`*(*VAR_UI_CONTEXT_PTR + OFF_UI_CONTEXT_MOUSE_FOCUS)`, the
|
||||
// CFrameScriptObject* `GetMouseFocus` returns) once per frame on the shared
|
||||
// WorldTick, and when it's a `unit`-attributed frame call the engine's FULL
|
||||
// mouseover setter (FUN_00492890) — 1:1 with hovering the unit's 3D model:
|
||||
// model highlight, mouseover tooltip, UPDATE_MOUSEOVER_UNIT, and the GUID slot.
|
||||
// Stomp-proof: the engine's own 3D-hover setter is event-driven, so nothing
|
||||
// overwrites the slot while the cursor is over UI.
|
||||
//
|
||||
// ── Clicks (the frame's own OnClick — scoped, native) ──
|
||||
//
|
||||
// Clicks are events, not state, so they belong on the frame's OnClick — but
|
||||
// ONLY on frames that opt in with a `type` attribute. When a `type*` attribute
|
||||
// is set we install a chained OnClick on that frame (protected, because OnClick
|
||||
// is Button-only). It mirrors retail's SecureActionButton_OnClick: resolve ONE
|
||||
// verb per click from the modifier/button-qualified `type` attribute, perform
|
||||
// it, done. No global hook, no handler on frames that never asked for one.
|
||||
//
|
||||
// Resolution (retail-style): the attribute is `[prefix]type[suffix]`, where the
|
||||
// prefix is the held modifiers ("alt-"/"ctrl-"/"shift-") and the suffix is the
|
||||
// button number (1=Left, 2=Right, …). So `type1`, `shift-type1`, `type` all
|
||||
// resolve via ReadModAttr's precedence. Verbs: `target` (with the engine's
|
||||
// default-interaction precedence — pending spell / cursor item cast/drop on the
|
||||
// unit instead of switching target), `assist`, `focus`, `spell` (reads the
|
||||
// `spell` attribute and casts it on the unit via our native C_Spell.CastAtUnit —
|
||||
// the unit's GUID goes straight to the cast dispatcher, no target juggling, and
|
||||
// ground-target spells land at the unit's feet), `item` (uses the `item`
|
||||
// attribute — a name/itemID/link via C_Item.UseItemByName with the unit as the
|
||||
// use target, or a "bag slot" string like "0 1" via UseContainerItem; the
|
||||
// deprecated `bag`/`slot` attributes also work), `macro` (from the
|
||||
// `macrotext`/`macro` attribute — prefers an addon RunMacro, else runs natively
|
||||
// via the stock ChatEdit_ParseText), `stopcasting`, `menu`/`togglemenu` (pops
|
||||
// the standard unit dropdown at the cursor via the addon's
|
||||
// ClassicAPI_ToggleUnitMenu). Actions call ordinary Lua globals where the entry
|
||||
// is the engine's (TargetUnit, IsAltKeyDown, …); where we own a C++ module the
|
||||
// dispatch goes straight to it, no Lua round-trip (Spell::AtUnit for `spell`,
|
||||
// Unit::Focus for `focus`). All run under the engine's protected OnClick.
|
||||
//
|
||||
// One verb per click is the point: because we own the click when a verb
|
||||
// resolves (and only then chain the previous handler), a configured `type1` no
|
||||
// longer runs *alongside* the addon's own conditional OnClick — that double
|
||||
// dispatch (our fixed "target" + pfUI's ClickAction) was the conflict. An addon
|
||||
// expresses all its click behavior as attributes; unconfigured clicks fall
|
||||
// through to its OnClick (e.g. a right-click menu).
|
||||
//
|
||||
// Ordering / clobbering: it chains the handler present when `type*` is set, so
|
||||
// set `type*` AFTER the frame's own `OnClick`. Addons that re-`SetScript`
|
||||
// `OnClick` later (pfUI does on every raid relayout) replace our closure — so
|
||||
// re-set `type*` after re-SetScript to recover. Re-wiring is safe: WireOnClick
|
||||
// re-wraps a clobbered (Lua) handler but skips its own C closure, so it never
|
||||
// double-chains. The frame must be a Button registered for the relevant clicks
|
||||
// (`RegisterForClicks`) — left is the Button default; right needs
|
||||
// `RegisterForClicks("RightButtonUp")`, which real unit frames do.
|
||||
//
|
||||
// `OnAttributeChanged` is a real SetScript-able handler: SetAttribute (not
|
||||
// SetAttributeNoHandler) fires it with `this` = frame, `arg1` = name, `arg2` =
|
||||
// value. Implemented by co-hooking the base-frame script-name resolver
|
||||
// (`FUN_FRAME_SCRIPT_RESOLVER`) and handing out an external per-frame cell for
|
||||
// that name — the `Tooltip::SetEvents` analog, applied to every frame.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "cursor/Info.h"
|
||||
#include "spell/AtCursor.h"
|
||||
#include "spell/AtUnit.h"
|
||||
#include "tick/WorldTick.h"
|
||||
#include "unit/Focus.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace Frame::Attributes {
|
||||
|
||||
namespace {
|
||||
|
||||
using ScriptFn_t = int(__fastcall *)(void *L);
|
||||
|
||||
int CallScript(uintptr_t fn, void *L) {
|
||||
return reinterpret_cast<ScriptFn_t>(fn)(L);
|
||||
}
|
||||
|
||||
// ---- small string helpers --------------------------------------------------
|
||||
|
||||
void LowerCopy(char *dst, const char *src, size_t n) {
|
||||
size_t i = 0;
|
||||
for (; src && src[i] && i + 1 < n; ++i)
|
||||
dst[i] = static_cast<char>(std::tolower(static_cast<unsigned char>(src[i])));
|
||||
dst[i] = '\0';
|
||||
}
|
||||
|
||||
// Bounded, always-terminated copy (avoids the C4996 strncpy/strcpy warnings).
|
||||
void BoundedCopy(char *dst, const char *src, size_t n) {
|
||||
size_t i = 0;
|
||||
for (; src && src[i] && i + 1 < n; ++i)
|
||||
dst[i] = src[i];
|
||||
dst[i] = '\0';
|
||||
}
|
||||
|
||||
// dst = tolower(a .. b .. c). Truncates at n-1.
|
||||
void Compose3Lower(char *dst, size_t n, const char *a, const char *b, const char *c) {
|
||||
size_t i = 0;
|
||||
const char *parts[3] = {a, b, c};
|
||||
for (const char *s : parts)
|
||||
for (; s && *s && i + 1 < n; ++s)
|
||||
dst[i++] = static_cast<char>(std::tolower(static_cast<unsigned char>(*s)));
|
||||
dst[i] = '\0';
|
||||
}
|
||||
|
||||
// ASCII case-insensitive full-string equality.
|
||||
bool EqI(const char *a, const char *b) {
|
||||
for (; *a && *b; ++a, ++b)
|
||||
if (std::tolower(static_cast<unsigned char>(*a)) !=
|
||||
std::tolower(static_cast<unsigned char>(*b)))
|
||||
return false;
|
||||
return *a == *b;
|
||||
}
|
||||
|
||||
// True if `lname` (already lowercase) is a click "type" action attribute: an
|
||||
// optional modifier prefix (alt-/ctrl-/shift-, any order/combo) followed by
|
||||
// "type" and an optional button digit — i.e. anything the click resolver
|
||||
// (ButtonSuffix + ReadModAttr) reads as a verb selector: type, type1..type5,
|
||||
// shift-type1, alt-ctrl-type2, … . This is the exact set WireOnClick must
|
||||
// (re)install for; keying the wiring off it (rather than a fixed
|
||||
// type/type1/type2 list) means a middle-click (type3) or modifier-only config
|
||||
// still installs the chained OnClick.
|
||||
bool IsTypeAttr(const char *lname) {
|
||||
const char *p = lname;
|
||||
for (bool advanced = true; advanced;) {
|
||||
if (std::strncmp(p, "alt-", 4) == 0) p += 4;
|
||||
else if (std::strncmp(p, "ctrl-", 5) == 0) p += 5;
|
||||
else if (std::strncmp(p, "shift-", 6) == 0) p += 6;
|
||||
else advanced = false;
|
||||
}
|
||||
if (std::strncmp(p, "type", 4) != 0)
|
||||
return false;
|
||||
p += 4;
|
||||
if (p[0] == '\0')
|
||||
return true; // "type"
|
||||
return p[0] >= '1' && p[0] <= '5' && p[1] == '\0'; // "typeN"
|
||||
}
|
||||
|
||||
// ---- attribute storage (in the Lua registry, keyed by the frame) -----------
|
||||
|
||||
// Pushes the frame's attribute subtable onto the stack. Stored as
|
||||
// registry[frame] in the Lua registry (LUA_REGISTRYINDEX) rather than as a
|
||||
// field on the frame's own table — the registry is C-side and has no _G path,
|
||||
// so the store is invisible to `pairs(frame)` and can't be read or tampered
|
||||
// with from Lua, matching how retail keeps attributes C-side. With create=true,
|
||||
// lazily creates and stores it. Returns true iff a subtable is left on top;
|
||||
// false (nothing left on the stack) when absent and !create. 1.12 frames are
|
||||
// immortal, so the strong registry key never leaks.
|
||||
bool PushSub(void *L, int frameIdx, bool create) {
|
||||
Game::Lua::PushValue(L, frameIdx); // [.., frame] (key)
|
||||
Game::Lua::RawGet(L, Game::Lua::REGISTRY_INDEX); // [.., sub|nil]
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE)
|
||||
return true; // [.., sub]
|
||||
Game::Lua::SetTop(L, Game::Lua::GetTop(L) - 1); // drop the nil -> [..]
|
||||
if (!create)
|
||||
return false;
|
||||
Game::Lua::NewTable(L); // [.., sub]
|
||||
Game::Lua::PushValue(L, frameIdx); // [.., sub, frame] (key)
|
||||
Game::Lua::PushValue(L, -2); // [.., sub, frame, sub] (value)
|
||||
Game::Lua::RawSet(L, Game::Lua::REGISTRY_INDEX); // registry[frame] = sub -> [.., sub]
|
||||
return true;
|
||||
}
|
||||
|
||||
// Copies string attribute `lname` (already lowercase) of the frame at
|
||||
// frameIdx into buf. Returns true iff a string value was found. Restores the
|
||||
// stack.
|
||||
bool CopyAttr(void *L, int frameIdx, const char *lname, char *buf, size_t n) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
bool ok = false;
|
||||
if (PushSub(L, frameIdx, false)) { // [.., sub]
|
||||
const int si = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushString(L, lname);
|
||||
Game::Lua::RawGet(L, si); // [.., sub, val]
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_STRING) {
|
||||
const char *s = Game::Lua::ToString(L, -1);
|
||||
if (s) {
|
||||
BoundedCopy(buf, s, n);
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Game::Lua::SetTop(L, top);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// If the frame's attribute subtable has a non-nil value for keyLower, leave that
|
||||
// value on the stack top and return true; otherwise restore the stack and
|
||||
// return false.
|
||||
bool TryPushValue(void *L, int frameIdx, const char *keyLower) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
if (PushSub(L, frameIdx, false)) { // [.., sub]
|
||||
const int si = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushString(L, keyLower);
|
||||
Game::Lua::RawGet(L, si); // [.., sub, val]
|
||||
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_NIL) {
|
||||
Game::Lua::Remove(L, si); // [.., val]
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Game::Lua::SetTop(L, top);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Unit token → GUID goes through `Unit::Identity::GuidForToken` (engine
|
||||
// resolver + `"player"` fast-path, handling our focus / nameplateN / raw-GUID
|
||||
// extensions). For recognized tokens it never raises; unit attributes are
|
||||
// tokens by contract.
|
||||
|
||||
// ---- Lua-global call helpers (used by the click dispatcher) -----------------
|
||||
//
|
||||
// The click dispatch mirrors retail's SecureActionButton_OnClick: it's C. It
|
||||
// performs engine actions by calling ordinary Lua globals (TargetUnit,
|
||||
// IsAltKeyDown, …) and our own actions by calling the C++ module directly
|
||||
// (Spell::AtUnit, Unit::Focus). Both run under the engine's protected OnClick
|
||||
// invocation, so a Lua error inside one is caught there (no crash).
|
||||
|
||||
// The plain global-call primitives (push-if-function, call with no/one arg)
|
||||
// live in Game::Lua now (PushGlobalFunction / CallGlobal / CallGlobalString) —
|
||||
// shared with the other modules that dispatch to FrameXML globals by name. The
|
||||
// two shapes below are specific enough to stay here.
|
||||
|
||||
// _G[name]() -> boolean (false if the global isn't a function). Distinct from
|
||||
// Game::Lua::CallGlobal in that it returns the call's boolean RESULT (used for
|
||||
// the IsAltKeyDown/IsShiftKeyDown modifier probes).
|
||||
bool CallBoolGlobal(void *L, const char *name) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
bool r = false;
|
||||
if (Game::Lua::PushGlobalFunction(L, name)) {
|
||||
Game::Lua::Call(L, 0, 1);
|
||||
r = Game::Lua::ToBoolean(L, -1) != 0;
|
||||
}
|
||||
Game::Lua::SetTop(L, top);
|
||||
return r;
|
||||
}
|
||||
|
||||
// _G[table][method](a, b) — two string args (null → nil, since PushString maps
|
||||
// NULL to nil), no results. Returns true iff the method resolved to a function
|
||||
// and got called. Used when the target is our own C_* function but the call must
|
||||
// cross the Lua boundary — e.g. C_Item.UseItemByName, whose bag walk does
|
||||
// SetTop(L, 0); going through lua_call gives it its own stack frame so that reset
|
||||
// clears only its frame, not the dispatcher's (a direct C++ call would wipe our
|
||||
// stack — the reason `spell`, which is stack-free, calls Spell::AtUnit directly
|
||||
// while `item` routes through here).
|
||||
bool CallTableFunc2Str(void *L, const char *table, const char *method,
|
||||
const char *a, const char *b) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
bool called = false;
|
||||
Game::Lua::PushString(L, table);
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // _G[table]
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE) {
|
||||
const int t = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushString(L, method);
|
||||
Game::Lua::GetTable(L, t); // _G[table][method]
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_FUNCTION) {
|
||||
Game::Lua::PushString(L, a);
|
||||
Game::Lua::PushString(L, b);
|
||||
Game::Lua::Call(L, 2, 0);
|
||||
called = true;
|
||||
}
|
||||
}
|
||||
Game::Lua::SetTop(L, top);
|
||||
return called;
|
||||
}
|
||||
|
||||
// ---- macrotext execution (stock ChatEdit_ParseText, no addon dependency) ----
|
||||
//
|
||||
// 1.12 has no `RunMacroText`/`RunMacro` global — those are addon shims (pfUI
|
||||
// wraps `ChatEdit_ParseText`; SuperCleveRoidMacros ships its own `RunMacro`).
|
||||
// To run macro text without depending on an addon, we replicate pfUI's shim in
|
||||
// C: build a throwaway "edit box" whose `GetText` returns the line and whose
|
||||
// every other method is a harmless no-op (via an `__index` metamethod), then
|
||||
// hand it to the stock FrameXML `ChatEdit_ParseText(editBox, 1)` — the same
|
||||
// path the real chat box uses to dispatch a slash command / send a line.
|
||||
|
||||
// GetText: returns upvalue(1), the captured line.
|
||||
int __fastcall MacroGetText_c(void *L) {
|
||||
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(1));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// A no-op standing in for any edit-box method the parser happens to call.
|
||||
int __fastcall MacroNoop_c(void *) { return 0; }
|
||||
|
||||
// __index(tab, key): hand back the no-op so `editBox:AnyMethod()` is safe.
|
||||
int __fastcall MacroIndex_c(void *L) {
|
||||
Game::Lua::PushCClosure(L, &MacroNoop_c, 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Runs a single macro line through the stock chat parser.
|
||||
void RunMacroLineC(void *L, const char *line, size_t len) {
|
||||
if (len == 0) return;
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
|
||||
Game::Lua::NewTable(L); // fake editBox
|
||||
const int obj = Game::Lua::GetTop(L);
|
||||
|
||||
Game::Lua::PushString(L, "GetText"); // obj.GetText = closure over line
|
||||
Game::Lua::PushLString(L, line, static_cast<unsigned int>(len));
|
||||
Game::Lua::PushCClosure(L, &MacroGetText_c, 1);
|
||||
Game::Lua::SetTable(L, obj);
|
||||
|
||||
Game::Lua::NewTable(L); // metatable { __index = noop }
|
||||
const int mt = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushString(L, "__index");
|
||||
Game::Lua::PushCClosure(L, &MacroIndex_c, 0);
|
||||
Game::Lua::SetTable(L, mt);
|
||||
|
||||
// No lua_setmetatable binding — use the Lua global.
|
||||
if (Game::Lua::PushGlobalFunction(L, "setmetatable")) {
|
||||
Game::Lua::PushValue(L, obj);
|
||||
Game::Lua::PushValue(L, mt);
|
||||
Game::Lua::Call(L, 2, 0);
|
||||
}
|
||||
|
||||
if (Game::Lua::PushGlobalFunction(L, "ChatEdit_ParseText")) {
|
||||
Game::Lua::PushValue(L, obj);
|
||||
Game::Lua::PushNumber(L, 1); // send = 1
|
||||
Game::Lua::Call(L, 2, 0);
|
||||
}
|
||||
|
||||
Game::Lua::SetTop(L, top);
|
||||
}
|
||||
|
||||
// Runs macro text one line at a time — a real macro is line-delimited, and a
|
||||
// single ChatEdit_ParseText call only dispatches one command.
|
||||
void RunMacroTextC(void *L, const char *text) {
|
||||
if (!text) return;
|
||||
for (const char *p = text; *p;) {
|
||||
const char *nl = p;
|
||||
while (*nl && *nl != '\n') ++nl;
|
||||
RunMacroLineC(L, p, static_cast<size_t>(nl - p));
|
||||
p = (*nl == '\n') ? nl + 1 : nl;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- mouse-focus poll (drives the native mouseover slot) -------------------
|
||||
|
||||
// frame CFrameScriptObject* → current `unit` token, for the mouse-focus poll.
|
||||
// Keyed by the same pointer the engine stores at the mouse-focus slot. Single-
|
||||
// threaded (Lua + WorldTick on the main thread), so no synchronization needed.
|
||||
std::unordered_map<const void *, std::string> g_unitByFrame;
|
||||
|
||||
// The last GUID *we* wrote into the mouseover slot. We only touch the slot on a
|
||||
// change of our own value, so when the cursor isn't over a unit frame (guid 0)
|
||||
// we never clobber the engine's own 3D mouseover.
|
||||
uint64_t g_lastSet = 0;
|
||||
|
||||
const void *CurrentMouseFocus() {
|
||||
const void *ctx = *reinterpret_cast<const void *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_UI_CONTEXT_PTR));
|
||||
if (ctx == nullptr)
|
||||
return nullptr;
|
||||
return *reinterpret_cast<const void *const *>(
|
||||
reinterpret_cast<const uint8_t *>(ctx) + Offsets::OFF_UI_CONTEXT_MOUSE_FOCUS);
|
||||
}
|
||||
|
||||
// The engine's real mouseover setter — `__stdcall(guidLo, guidHi, prevLo,
|
||||
// prevHi)`. Highlights the unit's model, builds the mouseover tooltip, fires
|
||||
// UPDATE_MOUSEOVER_UNIT, and writes the GUID slot; pass 0,0 for the optional
|
||||
// previous, and (0,0,0,0) to clear. Same address `Unit::Mouseover` co-hooks, so
|
||||
// calling it here goes through that hook (loss events keep firing correctly).
|
||||
void CallEngineMouseover(uint64_t guid) {
|
||||
reinterpret_cast<void(__stdcall *)(uint32_t, uint32_t, uint32_t, uint32_t)>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_SET_MOUSEOVER_UNIT))(
|
||||
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32), 0, 0);
|
||||
}
|
||||
|
||||
void MouseoverTick() {
|
||||
const void *focus = CurrentMouseFocus();
|
||||
|
||||
uint64_t guid = 0;
|
||||
if (focus != nullptr) {
|
||||
auto it = g_unitByFrame.find(focus);
|
||||
if (it != g_unitByFrame.end())
|
||||
guid = Unit::Identity::GuidForToken(
|
||||
it->second.c_str()); // live — follows target changes
|
||||
}
|
||||
|
||||
if (guid != g_lastSet) {
|
||||
CallEngineMouseover(guid); // sets slot + highlight + tooltip + event
|
||||
g_lastSet = guid;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- click dispatch (the frame's own chained OnClick) ----------------------
|
||||
|
||||
// Runs the chained-onto previous handler (upvalue 1), vanilla-style with no
|
||||
// args (it reads the `this`/`arg1` globals the engine set for this invocation).
|
||||
void ChainOld(void *L) {
|
||||
if (Game::Lua::Type(L, Game::Lua::UpvalueIndex(1)) == Game::Lua::TYPE_FUNCTION) {
|
||||
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(1));
|
||||
Game::Lua::Call(L, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Builds the modifier prefix ("alt-"/"ctrl-"/"shift-", in that order — retail's
|
||||
// order) from the live key state, e.g. "alt-shift-". Empty when none held.
|
||||
void BuildModifierPrefix(void *L, char *buf, size_t n) {
|
||||
buf[0] = '\0';
|
||||
size_t i = 0;
|
||||
auto add = [&](const char *s) {
|
||||
for (const char *p = s; *p && i + 1 < n; ++p)
|
||||
buf[i++] = *p;
|
||||
buf[i] = '\0';
|
||||
};
|
||||
if (CallBoolGlobal(L, "IsAltKeyDown")) add("alt-");
|
||||
if (CallBoolGlobal(L, "IsControlKeyDown")) add("ctrl-");
|
||||
if (CallBoolGlobal(L, "IsShiftKeyDown")) add("shift-");
|
||||
}
|
||||
|
||||
// Button name -> attribute suffix (retail's convention: the button number).
|
||||
const char *ButtonSuffix(const char *btn) {
|
||||
if (EqI(btn, "RightButton")) return "2";
|
||||
if (EqI(btn, "MiddleButton")) return "3";
|
||||
if (EqI(btn, "Button4")) return "4";
|
||||
if (EqI(btn, "Button5")) return "5";
|
||||
return "1"; // LeftButton / unknown
|
||||
}
|
||||
|
||||
// Resolves a modified attribute into `buf`. Precedence (practical subset of the
|
||||
// GetAttribute wildcard rules): prefix..name..suffix (e.g. "shift-type1"),
|
||||
// then name..suffix ("type1"), then name ("type"). So a plain `type1` still
|
||||
// applies under any modifier unless a modifier-specific attribute overrides it.
|
||||
bool ReadModAttr(void *L, int fi, const char *prefix, const char *name,
|
||||
const char *suffix, char *buf, size_t n) {
|
||||
char key[128];
|
||||
Compose3Lower(key, sizeof key, prefix, name, suffix);
|
||||
if (CopyAttr(L, fi, key, buf, n)) return true;
|
||||
Compose3Lower(key, sizeof key, "", name, suffix);
|
||||
if (CopyAttr(L, fi, key, buf, n)) return true;
|
||||
Compose3Lower(key, sizeof key, "", name, "");
|
||||
return CopyAttr(L, fi, key, buf, n);
|
||||
}
|
||||
|
||||
// Reads attribute `keyLower` as an int (stored as a number, or a numeric
|
||||
// string) into *out. Leaves the stack as it found it. Returns true on success.
|
||||
bool ReadAttrInt(void *L, int fi, const char *keyLower, int *out) {
|
||||
bool ok = false;
|
||||
if (TryPushValue(L, fi, keyLower)) { // value left on top
|
||||
const int t = Game::Lua::Type(L, -1);
|
||||
if (t == Game::Lua::TYPE_NUMBER) {
|
||||
*out = static_cast<int>(Game::Lua::ToNumber(L, -1));
|
||||
ok = true;
|
||||
} else if (t == Game::Lua::TYPE_STRING) {
|
||||
const char *s = Game::Lua::ToString(L, -1);
|
||||
char *end = nullptr;
|
||||
const long v = s ? std::strtol(s, &end, 10) : 0;
|
||||
if (s && end != s) {
|
||||
*out = static_cast<int>(v);
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
Game::Lua::SetTop(L, Game::Lua::GetTop(L) - 1); // pop the peeked value
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Numeric analog of ReadModAttr (prefix..name..suffix precedence).
|
||||
bool ReadModAttrInt(void *L, int fi, const char *prefix, const char *name,
|
||||
const char *suffix, int *out) {
|
||||
char key[128];
|
||||
Compose3Lower(key, sizeof key, prefix, name, suffix);
|
||||
if (ReadAttrInt(L, fi, key, out)) return true;
|
||||
Compose3Lower(key, sizeof key, "", name, suffix);
|
||||
if (ReadAttrInt(L, fi, key, out)) return true;
|
||||
Compose3Lower(key, sizeof key, "", name, "");
|
||||
return ReadAttrInt(L, fi, key, out);
|
||||
}
|
||||
|
||||
// Parses a "bag slot" string (two integers, e.g. "0 1") into bag/slot. Returns
|
||||
// false unless the whole string is exactly two integers, so a normal item name
|
||||
// or a bare itemID ("12345") falls through to the name path.
|
||||
bool ParseBagSlot(const char *s, int *bag, int *slot) {
|
||||
char *end = nullptr;
|
||||
const long b = std::strtol(s, &end, 10);
|
||||
if (end == s) return false; // not a leading number
|
||||
const char *p = end;
|
||||
const long sl = std::strtol(p, &end, 10);
|
||||
if (end == p) return false; // only one number
|
||||
while (*end == ' ' || *end == '\t') ++end;
|
||||
if (*end != '\0') return false; // trailing junk
|
||||
*bag = static_cast<int>(b);
|
||||
*slot = static_cast<int>(sl);
|
||||
return true;
|
||||
}
|
||||
|
||||
// _G[name](a, b) — two number args, no results. Returns true iff called.
|
||||
bool CallGlobalNum2(void *L, const char *name, double a, double b) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
const bool called = Game::Lua::PushGlobalFunction(L, name);
|
||||
if (called) {
|
||||
Game::Lua::PushNumber(L, a);
|
||||
Game::Lua::PushNumber(L, b);
|
||||
Game::Lua::Call(L, 2, 0);
|
||||
}
|
||||
Game::Lua::SetTop(L, top);
|
||||
return called;
|
||||
}
|
||||
|
||||
// Performs the resolved `verb` on `unit` (a token attribute value, may be null).
|
||||
// Returns true if it owned the click (so the chained handler is skipped).
|
||||
bool DispatchVerb(void *L, int fi, const char *prefix, const char *suffix,
|
||||
const char *verb, const char *unit) {
|
||||
if (EqI(verb, "target")) {
|
||||
if (!unit) return false;
|
||||
// `unit="none"` clears the target (retail's SecureActionButton behavior).
|
||||
if (EqI(unit, "none")) {
|
||||
Game::Lua::CallGlobal(L, "ClearTarget");
|
||||
return true;
|
||||
}
|
||||
// Cursor / pending-spell take precedence, matching the engine's default
|
||||
// unit interaction — cast the pending spell / drop the item on the unit
|
||||
// instead of switching target. The two predicates are ours (direct C++);
|
||||
// the actions are engine-only, so they go through the Lua globals (which
|
||||
// are the engine's own entries, with token→GUID resolution).
|
||||
if (Spell::AtCursor::IsPlacementActive())
|
||||
Game::Lua::CallGlobalString(L, "SpellTargetUnit", unit);
|
||||
else if (Cursor::Info::HasItem())
|
||||
Game::Lua::CallGlobalString(L, "DropItemOnUnit", unit);
|
||||
else
|
||||
Game::Lua::CallGlobalString(L, "TargetUnit", unit);
|
||||
return true;
|
||||
}
|
||||
if (EqI(verb, "assist")) {
|
||||
if (!unit) return false;
|
||||
Game::Lua::CallGlobalString(L, "AssistUnit", unit);
|
||||
return true;
|
||||
}
|
||||
if (EqI(verb, "focus")) {
|
||||
if (!unit) return false;
|
||||
Unit::Focus::Set(Unit::Identity::GuidForToken(unit));
|
||||
return true;
|
||||
}
|
||||
if (EqI(verb, "spell")) {
|
||||
if (!unit) return false;
|
||||
char spell[128];
|
||||
if (!ReadModAttr(L, fi, prefix, "spell", suffix, spell, sizeof spell))
|
||||
return false;
|
||||
Spell::AtUnit::CastByName(spell, unit);
|
||||
return true;
|
||||
}
|
||||
if (EqI(verb, "item")) {
|
||||
char item[128];
|
||||
int bag, slot;
|
||||
if (ReadModAttr(L, fi, prefix, "item", suffix, item, sizeof item)) {
|
||||
if (ParseBagSlot(item, &bag, &slot))
|
||||
CallGlobalNum2(L, "UseContainerItem", bag, slot);
|
||||
else
|
||||
CallTableFunc2Str(L, "C_Item", "UseItemByName", item, unit);
|
||||
return true;
|
||||
}
|
||||
// Deprecated form: no `item`, take the `bag` + `slot` attributes.
|
||||
if (ReadModAttrInt(L, fi, prefix, "bag", suffix, &bag) &&
|
||||
ReadModAttrInt(L, fi, prefix, "slot", suffix, &slot)) {
|
||||
CallGlobalNum2(L, "UseContainerItem", bag, slot);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (EqI(verb, "macro")) {
|
||||
char macro[512];
|
||||
if (!ReadModAttr(L, fi, prefix, "macrotext", suffix, macro, sizeof macro) &&
|
||||
!ReadModAttr(L, fi, prefix, "macro", suffix, macro, sizeof macro))
|
||||
return false;
|
||||
// Prefer an addon-provided RunMacro (SuperCleveRoidMacros, pfUI, …) — it
|
||||
// handles named macros and extended macro text; fall back to the stock
|
||||
// ChatEdit_ParseText path when no RunMacro global is present.
|
||||
if (!Game::Lua::CallGlobalString(L, "RunMacro", macro))
|
||||
RunMacroTextC(L, macro);
|
||||
return true;
|
||||
}
|
||||
if (EqI(verb, "stop") || EqI(verb, "stopcasting")) {
|
||||
Game::Lua::CallGlobal(L, "SpellStopCasting");
|
||||
return true;
|
||||
}
|
||||
if (EqI(verb, "menu") || EqI(verb, "togglemenu")) {
|
||||
if (!unit) return false;
|
||||
// The unit dropdown is pure FrameXML work (UnitPopup + ToggleDropDown),
|
||||
// so it lives in the !!!ClassicAPI addon; we just pop it at the cursor.
|
||||
Game::Lua::CallGlobalString(L, "ClassicAPI_ToggleUnitMenu", unit);
|
||||
return true;
|
||||
}
|
||||
// Unknown verb → not handled here; the chained handler runs.
|
||||
return false;
|
||||
}
|
||||
|
||||
// The chained OnClick handler. Upvalues: 1 = previous handler (or nil), 2 = the
|
||||
// frame (captured at install time, so we don't depend on the `this` global).
|
||||
// Resolves one verb per click from the (modifier/button) `type` attribute and
|
||||
// performs it — retail's one-action-per-click model. Only chains the previous
|
||||
// handler when we DIDN'T own the click, so a configured `type1` no longer runs
|
||||
// alongside the addon's own OnClick (that double-dispatch was the conflict).
|
||||
int __fastcall OnClick_c(void *L) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(2)); // frame
|
||||
const int fi = Game::Lua::GetTop(L);
|
||||
bool handled = false;
|
||||
if (Game::Lua::Type(L, fi) == Game::Lua::TYPE_TABLE) {
|
||||
char btn[32] = {0};
|
||||
Game::Lua::PushString(L, "arg1");
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_STRING) {
|
||||
const char *b = Game::Lua::ToString(L, -1);
|
||||
if (b)
|
||||
BoundedCopy(btn, b, sizeof btn);
|
||||
}
|
||||
Game::Lua::SetTop(L, fi); // drop arg1, keep frame
|
||||
|
||||
const char *suffix = ButtonSuffix(btn);
|
||||
char prefix[24];
|
||||
BuildModifierPrefix(L, prefix, sizeof prefix);
|
||||
|
||||
char verb[32];
|
||||
if (ReadModAttr(L, fi, prefix, "type", suffix, verb, sizeof verb)) {
|
||||
char unit[128];
|
||||
const bool haveUnit =
|
||||
ReadModAttr(L, fi, prefix, "unit", suffix, unit, sizeof unit);
|
||||
handled = DispatchVerb(L, fi, prefix, suffix, verb,
|
||||
haveUnit ? unit : nullptr);
|
||||
}
|
||||
}
|
||||
Game::Lua::SetTop(L, top);
|
||||
if (!handled)
|
||||
ChainOld(L); // let the frame's own OnClick handle unconfigured clicks
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Installs OnClick as a closure over (previous handler, frame). Operates on
|
||||
// self at stack index 1. Raises if `self` isn't a Button (OnClick is a Button
|
||||
// script) — callers run it under PCall.
|
||||
//
|
||||
// Self-healing against clobbering: addons re-`SetScript("OnClick", …)` their
|
||||
// own handler (pfUI does it on every raid relayout via UpdateScripts), which
|
||||
// replaces our closure. So we re-wire whenever a `type*` attribute is (re)set,
|
||||
// but only when the current OnClick is a *Lua* handler (a real clobber) or nil
|
||||
// — if it's already a C function it's our own closure, and re-wrapping would
|
||||
// double-chain. `IsCFunction` cleanly distinguishes the two here (addon click
|
||||
// handlers are Lua; the only C-function OnClick we ever see is ours).
|
||||
int __fastcall WireOnClick(void *L) {
|
||||
Game::Lua::SetTop(L, 1); // (self)
|
||||
Game::Lua::PushString(L, "OnClick"); // (self, name)
|
||||
CallScript(Offsets::FUN_SCRIPT_FRAME_GETSCRIPT, L); // (self, name, old)
|
||||
if (Game::Lua::GetTop(L) < 3) // defensive
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::SetTop(L, 3); // (self, name, old|nil)
|
||||
if (Game::Lua::Type(L, 3) == Game::Lua::TYPE_FUNCTION &&
|
||||
Game::Lua::IsCFunction(L, 3) != 0)
|
||||
return 0; // already our closure
|
||||
Game::Lua::PushValue(L, 1); // (self, name, old, self)
|
||||
Game::Lua::PushCClosure(L, &OnClick_c, 2); // upvalues (old, self)
|
||||
CallScript(Offsets::FUN_SCRIPT_FRAME_SETSCRIPT, L);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---- OnAttributeChanged script (settable via SetScript, like retail) --------
|
||||
//
|
||||
// Backport of the modern `OnAttributeChanged` widget script: SetAttribute (but
|
||||
// NOT SetAttributeNoHandler) fires the frame's OnAttributeChanged handler. 1.12
|
||||
// has no such script, so we make it SetScript/GetScript/HookScript-able the same
|
||||
// way Tooltip::SetEvents adds OnTooltipSet* — co-hook the frame script-name
|
||||
// resolver (FUN_FRAME_SCRIPT_RESOLVER, the base every frame type delegates to)
|
||||
// and, for the one name the engine doesn't know, hand back an external 8-byte
|
||||
// {handler, context} cell. Frames are immortal in 1.12 (no destroy), so a cell
|
||||
// keyed by the frame's C object pointer never goes stale.
|
||||
//
|
||||
// 1.12 passes script args as globals, not params (see OnClick_c reading arg1),
|
||||
// so we fire with `this` = frame (bound by the invoker), `arg1` = name and
|
||||
// `arg2` = the new value: `function() local name, value = arg1, arg2 … end`.
|
||||
|
||||
struct AttrScriptSlot { uint32_t v[2]; }; // {handler, exec context}
|
||||
std::unordered_map<const void *, AttrScriptSlot> g_attrHandlers;
|
||||
|
||||
uint32_t *AttrSlotFor(const void *frame, bool create) {
|
||||
auto it = g_attrHandlers.find(frame);
|
||||
if (it != g_attrHandlers.end())
|
||||
return it->second.v;
|
||||
if (!create)
|
||||
return nullptr;
|
||||
AttrScriptSlot &s = g_attrHandlers[frame]; // node-based → stable address
|
||||
s.v[0] = 0;
|
||||
s.v[1] = 0;
|
||||
return s.v;
|
||||
}
|
||||
|
||||
// Resolver co-hook. The engine's __thiscall(frame, name) is modelled as
|
||||
// __fastcall with a dummy edx — `name` lands on the first stack slot either way
|
||||
// (same trick as Tooltip::SetEvents). The original returns 0 for a name it
|
||||
// doesn't know; we then claim "OnAttributeChanged".
|
||||
using FrameResolver_t = int(__fastcall *)(void *frame, void *edx, const char *name);
|
||||
FrameResolver_t g_frameResolverOriginal = nullptr;
|
||||
|
||||
int __fastcall FrameResolver_h(void *frame, void *edx, const char *name) {
|
||||
const int slot = g_frameResolverOriginal(frame, edx, name);
|
||||
if (slot != 0) // a real base-frame / subtype script — leave it
|
||||
return slot;
|
||||
if (EqI(name, "onattributechanged"))
|
||||
return reinterpret_cast<int>(AttrSlotFor(frame, /*create*/ true));
|
||||
return 0;
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _attrResolverHook{
|
||||
Offsets::FUN_FRAME_SCRIPT_RESOLVER,
|
||||
reinterpret_cast<void *>(&FrameResolver_h),
|
||||
reinterpret_cast<void **>(&g_frameResolverOriginal)};
|
||||
|
||||
// Fire OnAttributeChanged on `frame`, with arg1 = name and arg2 = the new value
|
||||
// (still at stack[3] in DoSet). Recursion-guarded so a handler that itself calls
|
||||
// SetAttribute doesn't loop. The invoker (FUN_FRAME_INVOKE_SCRIPT) binds `this`
|
||||
// and runs the handler under its own protected pcall; it doesn't restore the
|
||||
// stack top, so snapshot/restore around it (as Tooltip::SetEvents does).
|
||||
int g_firingAttr = 0;
|
||||
|
||||
using InvokeAttrScript_t = void(__fastcall *)(uint32_t handler, void *frame);
|
||||
|
||||
void FireAttributeChanged(void *L, void *frame, const char *name) {
|
||||
if (frame == nullptr || g_firingAttr > 0)
|
||||
return;
|
||||
uint32_t *slot = AttrSlotFor(frame, /*create*/ false);
|
||||
if (slot == nullptr || slot[0] == 0)
|
||||
return;
|
||||
|
||||
const int savedTop = Game::Lua::GetTop(L);
|
||||
|
||||
// Snapshot the caller's arg1/arg2 first. SetAttribute can be called from
|
||||
// inside a running script handler that still needs them, and the invoker
|
||||
// below neither saves the globals nor restores the stack top — so we save
|
||||
// (onto the stack, below anything the invoker pushes), overwrite, fire,
|
||||
// then put them back. 1.12 passes handler args as globals, not params.
|
||||
Game::Lua::PushString(L, "arg1");
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // [.., oldArg1]
|
||||
Game::Lua::PushString(L, "arg2");
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // [.., oldArg1, oldArg2]
|
||||
const int oldArg1 = savedTop + 1;
|
||||
const int oldArg2 = savedTop + 2;
|
||||
|
||||
Game::Lua::PushString(L, "arg1");
|
||||
Game::Lua::PushString(L, name);
|
||||
Game::Lua::RawSet(L, Game::Lua::GLOBALS_INDEX); // _G.arg1 = name
|
||||
Game::Lua::PushString(L, "arg2");
|
||||
if (savedTop >= 3)
|
||||
Game::Lua::PushValue(L, 3); // the new value
|
||||
else
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::RawSet(L, Game::Lua::GLOBALS_INDEX); // _G.arg2 = value
|
||||
|
||||
++g_firingAttr;
|
||||
reinterpret_cast<InvokeAttrScript_t>(Offsets::FUN_FRAME_INVOKE_SCRIPT)(slot[0], frame);
|
||||
--g_firingAttr;
|
||||
|
||||
// Restore arg1/arg2 from the snapshot (the fired handler may also have
|
||||
// written them; the caller's values win).
|
||||
Game::Lua::PushString(L, "arg1");
|
||||
Game::Lua::PushValue(L, oldArg1);
|
||||
Game::Lua::RawSet(L, Game::Lua::GLOBALS_INDEX);
|
||||
Game::Lua::PushString(L, "arg2");
|
||||
Game::Lua::PushValue(L, oldArg2);
|
||||
Game::Lua::RawSet(L, Game::Lua::GLOBALS_INDEX);
|
||||
|
||||
Game::Lua::SetTop(L, savedTop);
|
||||
}
|
||||
|
||||
// ---- the methods -----------------------------------------------------------
|
||||
|
||||
// Enables the frame's mouse so it can become the mouse-focus (a bare frame
|
||||
// otherwise never registers as hovered). Operates on self at index 1.
|
||||
void EnableFrameMouse(void *L) {
|
||||
Game::Lua::SetTop(L, 1); // (self)
|
||||
Game::Lua::PushBoolean(L, 1);
|
||||
CallScript(Offsets::FUN_SCRIPT_FRAME_ENABLEMOUSE, L);
|
||||
}
|
||||
|
||||
int DoSet(void *L, bool fireHandler) { // (self, name, value)
|
||||
if (!Game::Lua::IsString(L, 2)) {
|
||||
Game::Lua::Error(L, "Usage: frame:SetAttribute(\"name\", value)");
|
||||
return 0;
|
||||
}
|
||||
char lname[256];
|
||||
LowerCopy(lname, Game::Lua::ToString(L, 2), sizeof lname);
|
||||
|
||||
if (PushSub(L, 1, true)) { // [.., sub]
|
||||
const int si = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushString(L, lname); // [.., sub, key]
|
||||
Game::Lua::PushValue(L, 3); // [.., sub, key, value] (nil if absent)
|
||||
Game::Lua::RawSet(L, si); // sub[lname] = value
|
||||
Game::Lua::SetTop(L, si - 1); // drop sub
|
||||
}
|
||||
|
||||
const bool isString = Game::Lua::Type(L, 3) == Game::Lua::TYPE_STRING;
|
||||
|
||||
// `unit` drives the mouseover binding: index it by the frame's C object
|
||||
// (the key the mouse-focus poll uses) and enable its mouse so it can be
|
||||
// hovered.
|
||||
if (std::strcmp(lname, "unit") == 0) {
|
||||
void *obj = Game::Lua::ResolveObject(L, 1);
|
||||
if (obj != nullptr) {
|
||||
if (isString) {
|
||||
const char *tok = Game::Lua::ToString(L, 3);
|
||||
const bool isNew = g_unitByFrame.find(obj) == g_unitByFrame.end();
|
||||
g_unitByFrame[obj] = tok ? tok : "";
|
||||
if (isNew)
|
||||
EnableFrameMouse(L);
|
||||
} else {
|
||||
g_unitByFrame.erase(obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A `type*` attribute makes the frame a clickable unit button: (re)install
|
||||
// the chained OnClick (PCall-guarded — OnClick is Button-only). WireOnClick
|
||||
// is idempotent and self-healing, so calling it on every `type*` set both
|
||||
// avoids double-chaining and reinstalls after an addon clobbered our
|
||||
// OnClick — set `type*` again after re-SetScript to recover. The handler
|
||||
// reads all the attributes fresh at click time. IsTypeAttr covers every
|
||||
// form the resolver reads (type, typeN, modifier-prefixed), so a middle-
|
||||
// click or modifier-only binding still wires.
|
||||
else if (IsTypeAttr(lname) && isString) {
|
||||
Game::Lua::PushCClosure(L, &WireOnClick, 0);
|
||||
Game::Lua::PushValue(L, 1); // arg = self
|
||||
Game::Lua::PCall(L, 1, 0, 0); // ignore errors (non-Button frame)
|
||||
}
|
||||
|
||||
if (fireHandler)
|
||||
FireAttributeChanged(L, Game::Lua::ResolveObject(L, 1), lname);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int __fastcall Script_SetAttribute(void *L) { return DoSet(L, /*fireHandler*/ true); }
|
||||
int __fastcall Script_SetAttributeNoHandler(void *L) { return DoSet(L, /*fireHandler*/ false); }
|
||||
|
||||
// `cleared = frame:ClearAttribute("name")` — retail (11.2.0) attribute removal;
|
||||
// `cleared` is true iff the attribute was set (and is now gone). Routes through
|
||||
// DoSet's nil-value path so it also drops a `unit` mouseover binding, but does
|
||||
// NOT fire OnAttributeChanged — verified against retail (a live 12.0 test showed
|
||||
// ClearAttribute firing no handler, unlike SetAttribute).
|
||||
int __fastcall Script_ClearAttribute(void *L) {
|
||||
if (!Game::Lua::IsString(L, 2)) {
|
||||
Game::Lua::Error(L, "Usage: frame:ClearAttribute(\"name\")");
|
||||
return 0;
|
||||
}
|
||||
char lname[256];
|
||||
LowerCopy(lname, Game::Lua::ToString(L, 2), sizeof lname);
|
||||
|
||||
const bool existed = TryPushValue(L, 1, lname); // leaves the value on top if set
|
||||
if (existed)
|
||||
Game::Lua::SetTop(L, Game::Lua::GetTop(L) - 1); // drop the peeked value
|
||||
|
||||
Game::Lua::SetTop(L, 2); // (self, name)
|
||||
Game::Lua::PushNil(L); // (self, name, nil) → value = nil clears the key
|
||||
DoSet(L, /*fireHandler*/ false); // retail ClearAttribute fires no OnAttributeChanged
|
||||
|
||||
Game::Lua::PushBool(L, existed);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetAttribute(void *L) {
|
||||
if (!Game::Lua::IsString(L, 2)) {
|
||||
Game::Lua::Error(L, "Usage: frame:GetAttribute(\"name\")");
|
||||
return 0;
|
||||
}
|
||||
// 3-arg form: (prefix, name, suffix) with wildcard precedence.
|
||||
if (Game::Lua::GetTop(L) >= 4 && Game::Lua::IsString(L, 3)) {
|
||||
const char *pfx = Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : "";
|
||||
const char *nm = Game::Lua::ToString(L, 3);
|
||||
const char *sfx = Game::Lua::IsString(L, 4) ? Game::Lua::ToString(L, 4) : "";
|
||||
char key[256];
|
||||
Compose3Lower(key, sizeof key, pfx, nm, sfx);
|
||||
if (TryPushValue(L, 1, key)) return 1;
|
||||
Compose3Lower(key, sizeof key, "*", nm, sfx);
|
||||
if (TryPushValue(L, 1, key)) return 1;
|
||||
Compose3Lower(key, sizeof key, pfx, nm, "*");
|
||||
if (TryPushValue(L, 1, key)) return 1;
|
||||
Compose3Lower(key, sizeof key, "*", nm, "*");
|
||||
if (TryPushValue(L, 1, key)) return 1;
|
||||
Compose3Lower(key, sizeof key, "", nm, "");
|
||||
if (TryPushValue(L, 1, key)) return 1;
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
char lname[256];
|
||||
LowerCopy(lname, Game::Lua::ToString(L, 2), sizeof lname);
|
||||
if (TryPushValue(L, 1, lname))
|
||||
return 1;
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ---- registration ----------------------------------------------------------
|
||||
|
||||
const Game::Lua::FrameMethodEntry g_frameMethods[] = {
|
||||
{"SetAttribute", &Script_SetAttribute},
|
||||
{"SetAttributeNoHandler", &Script_SetAttributeNoHandler},
|
||||
{"ClearAttribute", &Script_ClearAttribute},
|
||||
{"GetAttribute", &Script_GetAttribute},
|
||||
};
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterFrameMethods(
|
||||
reinterpret_cast<void *>(Offsets::VAR_FRAME_METHOD_REGISTRY),
|
||||
g_frameMethods,
|
||||
static_cast<int>(sizeof(g_frameMethods) / sizeof(g_frameMethods[0])));
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
const Tick::WorldTick::AutoSubscribe _tick{&MouseoverTick};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Frame::Attributes
|
||||
@@ -26,6 +26,12 @@
|
||||
// - `region:SetSize(w, h)` / `region:GetSize()` — registered on the
|
||||
// REGION base method registry, so they resolve on frames, buttons,
|
||||
// textures, fontstrings — everything.
|
||||
// - `region:IsMouseOver([top, bottom, left, right])` — cursor-in-rect
|
||||
// test with optional per-edge offsets, also on the REGION registry.
|
||||
// - `region:GetRect()` — left, bottom, width, height, composed from the
|
||||
// engine's Region getters. Also on the REGION registry.
|
||||
// - `region:IsDragging()` — true while the region is the frame currently
|
||||
// being moved/sized. Reads the engine's global drag target.
|
||||
// - `frame:SetShown(shown)` — Show/Hide are per-branch script functions
|
||||
// (each with its own object typecheck), so SetShown is registered
|
||||
// separately on Frame / Texture / FontString with matching delegates.
|
||||
@@ -34,6 +40,9 @@
|
||||
// - `frame:HookScript(scriptType, handler)` — absent in vanilla
|
||||
// entirely. Chains after any existing handler and invokes `handler`
|
||||
// with modern positional args (see the implementation comment).
|
||||
// - `frame:GetEffectiveAlpha()` — own alpha × the parent chain's, the
|
||||
// product WoW composites at render time. Frame-scoped (matches
|
||||
// GetAlpha); walks the parents via the engine's own getters.
|
||||
//
|
||||
// Implementation style: each new method delegates to the ENGINE's own
|
||||
// Script_* implementations by reshaping the Lua stack and calling them
|
||||
@@ -83,6 +92,119 @@ int __fastcall Script_GetSize(void *L) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
// ---- IsMouseOver (Region base — all region types) --------------------------
|
||||
|
||||
// `region:IsMouseOver([topOffset, bottomOffset, leftOffset, rightOffset])` —
|
||||
// true when the cursor is within the region's rect, each edge optionally
|
||||
// shifted by the matching offset (added to that edge, modern semantics:
|
||||
// +top/-bottom/-left/+right enlarges). Vanilla addons have always done this
|
||||
// in Lua as MouseIsOver(); we register it as the native Region method later
|
||||
// clients expose.
|
||||
//
|
||||
// The math mirrors the engine exactly: GetLeft/Right/Top/Bottom (Region
|
||||
// registry, all types) and GetCursorPosition both return values scaled by the
|
||||
// same k = FUN_0041ad70()*DAT_007ffd68 factor, but the rect getters also
|
||||
// divide by the region's effective scale — so dividing the cursor by that same
|
||||
// effective scale (read from obj+0x7C, the field GetLeft/GetEffectiveScale
|
||||
// themselves read) puts both in the region's logical space. A getter returns
|
||||
// nil for an unpositioned region → we report false.
|
||||
|
||||
// Calls a Region getter that returns one number; false if it pushed nil/none.
|
||||
bool CallRegionNumber(void *L, uintptr_t fn, double *out) {
|
||||
Game::Lua::SetTop(L, 1); // (self)
|
||||
CallScript(fn, L); // (self, value | nil)
|
||||
if (!Game::Lua::IsNumber(L, 2))
|
||||
return false;
|
||||
*out = Game::Lua::ToNumber(L, 2);
|
||||
return true;
|
||||
}
|
||||
|
||||
int __fastcall Script_IsMouseOver(void *L) {
|
||||
// Offsets first — the stack gets reshaped by the delegated getters.
|
||||
const double offTop = Game::Lua::IsNumber(L, 2) ? Game::Lua::ToNumber(L, 2) : 0.0;
|
||||
const double offBottom = Game::Lua::IsNumber(L, 3) ? Game::Lua::ToNumber(L, 3) : 0.0;
|
||||
const double offLeft = Game::Lua::IsNumber(L, 4) ? Game::Lua::ToNumber(L, 4) : 0.0;
|
||||
const double offRight = Game::Lua::IsNumber(L, 5) ? Game::Lua::ToNumber(L, 5) : 0.0;
|
||||
|
||||
void *obj = Game::Lua::ResolveObject(L, 1);
|
||||
if (obj == nullptr) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
const float effScale = *reinterpret_cast<const float *>(
|
||||
reinterpret_cast<const uint8_t *>(obj) + Offsets::OFF_REGION_EFFECTIVE_SCALE);
|
||||
|
||||
double left, right, top, bottom;
|
||||
if (effScale == 0.0f ||
|
||||
!CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETLEFT, &left) ||
|
||||
!CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETRIGHT, &right) ||
|
||||
!CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETTOP, &top) ||
|
||||
!CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETBOTTOM, &bottom)) {
|
||||
Game::Lua::PushBool(L, false); // no resolved rect
|
||||
return 1;
|
||||
}
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
CallScript(Offsets::FUN_SCRIPT_GETCURSORPOSITION, L); // (x, y)
|
||||
const double x = Game::Lua::ToNumber(L, 1) / effScale;
|
||||
const double y = Game::Lua::ToNumber(L, 2) / effScale;
|
||||
|
||||
left += offLeft;
|
||||
right += offRight;
|
||||
top += offTop;
|
||||
bottom += offBottom;
|
||||
|
||||
const bool inside = x >= left && x <= right && y >= bottom && y <= top;
|
||||
Game::Lua::PushBool(L, inside);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ---- GetRect (Region base — all region types) ------------------------------
|
||||
|
||||
// `region:GetRect()` → left, bottom, width, height. Composed from the
|
||||
// engine's own Region getters (so all UI-scale conversion is its code).
|
||||
// Matches retail: returns nothing for a region with no resolved rect —
|
||||
// gated on GetLeft, which pushes nil when the position isn't resolved (a
|
||||
// size-only region with no anchor has width/height but no rect).
|
||||
int __fastcall Script_GetRect(void *L) {
|
||||
double left, bottom, width, height;
|
||||
if (!CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETLEFT, &left))
|
||||
return 0;
|
||||
CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETBOTTOM, &bottom);
|
||||
CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETWIDTH, &width);
|
||||
CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETHEIGHT, &height);
|
||||
Game::Lua::SetTop(L, 1);
|
||||
Game::Lua::PushNumber(L, left);
|
||||
Game::Lua::PushNumber(L, bottom);
|
||||
Game::Lua::PushNumber(L, width);
|
||||
Game::Lua::PushNumber(L, height);
|
||||
return 4;
|
||||
}
|
||||
|
||||
// ---- IsDragging (Region base — all region types) ---------------------------
|
||||
|
||||
// `region:IsDragging()` — true while this region is the frame currently
|
||||
// being moved/sized (StartMoving/StartSizing active, before
|
||||
// StopMovingOrSizing). The engine tracks a single drag target in the global
|
||||
// UI context at +0xCFC; StopMovingOrSizing itself gates on
|
||||
// `context+0xCFC == self`, which is exactly this predicate. Reading the
|
||||
// global context (rather than self+0xA0) keeps it correct for non-frame
|
||||
// regions — a texture/fontstring self can never match the frame stored
|
||||
// there, so it reports false, as it should.
|
||||
int __fastcall Script_IsDragging(void *L) {
|
||||
void *self = Game::Lua::ResolveObject(L, 1);
|
||||
void *ctx = *reinterpret_cast<void *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_UI_CONTEXT_PTR));
|
||||
void *dragTarget =
|
||||
ctx != nullptr
|
||||
? *reinterpret_cast<void *const *>(
|
||||
reinterpret_cast<const uint8_t *>(ctx) +
|
||||
Offsets::OFF_UI_CONTEXT_DRAG_TARGET)
|
||||
: nullptr;
|
||||
Game::Lua::PushBool(L, self != nullptr && self == dragTarget);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ---- SetShown (per-branch Show/Hide delegates) -----------------------------
|
||||
|
||||
// `object:SetShown(shown)` — any falsy value hides, truthy shows,
|
||||
@@ -95,6 +217,33 @@ int __fastcall Script_SetShown(void *L) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---- GetEffectiveAlpha (Frame) ---------------------------------------------
|
||||
|
||||
// `frame:GetEffectiveAlpha()` — the frame's own alpha times every
|
||||
// ancestor's alpha, i.e. the product WoW composites at render time (fading
|
||||
// UIParent fades all children). Vanilla exposes only GetAlpha (own alpha);
|
||||
// we walk self → parent → … via the engine's own GetAlpha (Frame) and
|
||||
// GetParent (Region), so no field offsets or scale math are re-derived. The
|
||||
// iteration cap is a defensive guard against a pathological chain (WoW frame
|
||||
// trees are shallow; the loop normally ends when GetParent returns nil at
|
||||
// the top of the tree).
|
||||
int __fastcall Script_GetEffectiveAlpha(void *L) {
|
||||
Game::Lua::SetTop(L, 1); // (obj) = self
|
||||
double eff = 1.0;
|
||||
for (int guard = 0; guard < 128; ++guard) {
|
||||
CallScript(Offsets::FUN_SCRIPT_FRAME_GETALPHA, L); // (obj, alpha)
|
||||
eff *= Game::Lua::ToNumber(L, 2);
|
||||
Game::Lua::SetTop(L, 1); // (obj)
|
||||
CallScript(Offsets::FUN_SCRIPT_REGION_GETPARENT, L); // (obj, parent|nil)
|
||||
if (Game::Lua::Type(L, 2) == Game::Lua::TYPE_NIL)
|
||||
break;
|
||||
Game::Lua::Remove(L, 1); // drop obj → (parent) becomes index 1
|
||||
}
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::PushNumber(L, eff);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ---- SetResizeBounds (Frame) -----------------------------------------------
|
||||
|
||||
// `frame:SetResizeBounds(minWidth, minHeight [, maxWidth, maxHeight])` —
|
||||
@@ -274,6 +423,9 @@ const Game::HookAutoRegister _setPointHook{
|
||||
const Game::Lua::FrameMethodEntry g_regionMethods[] = {
|
||||
{"SetSize", &Script_SetSize},
|
||||
{"GetSize", &Script_GetSize},
|
||||
{"IsMouseOver", &Script_IsMouseOver},
|
||||
{"GetRect", &Script_GetRect},
|
||||
{"IsDragging", &Script_IsDragging},
|
||||
};
|
||||
|
||||
const Game::Lua::FrameMethodEntry g_frameMethods[] = {
|
||||
@@ -282,6 +434,7 @@ const Game::Lua::FrameMethodEntry g_frameMethods[] = {
|
||||
{"SetResizeBounds", &Script_SetResizeBounds},
|
||||
{"HookScript", &Script_HookScript},
|
||||
{"IsEventRegistered", &Script_IsEventRegistered},
|
||||
{"GetEffectiveAlpha", &Script_GetEffectiveAlpha},
|
||||
};
|
||||
|
||||
const Game::Lua::FrameMethodEntry g_textureMethods[] = {
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
// 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/>.
|
||||
|
||||
// Modern positional script arguments — make every frame-script handler
|
||||
// receive `(self, arg1..argN)` the way later clients do, on top of the
|
||||
// vanilla `this`/`arg1`/`event` globals.
|
||||
//
|
||||
// frame:SetScript("OnMouseWheel", function() print(this, arg1) end) -- vanilla
|
||||
// frame:SetScript("OnMouseWheel", function(self, delta) print(self, delta) end) -- modern
|
||||
//
|
||||
// Vanilla ALWAYS invokes a script handler with ZERO Lua arguments: the two
|
||||
// runners the engine funnels every dispatch through — FUN_FRAME_INVOKE_SCRIPT
|
||||
// (no-arg scripts) and FUN_FRAME_RUN_SCRIPT_ARGS (scripts with values) — set
|
||||
// the `this`/`arg1..argN` globals and then `lua_pcall(handler, /*nargs*/0)`.
|
||||
// So `function(self, delta)` gets nothing today.
|
||||
//
|
||||
// We co-hook both runners and REPLACE their pcall tail: keep setting the same
|
||||
// globals (so vanilla handlers reading `this`/`arg1` keep working — this is
|
||||
// purely additive; Lua silently drops the extra positional args a zero-param
|
||||
// handler doesn't declare), but push `self` + `arg1..argN` as real arguments
|
||||
// and `pcall` with `nargs = 1 + N`. The reimplementation mirrors the engine's
|
||||
// exact global save/set/restore and its message-handler errfunc; only the
|
||||
// final push+pcall differs.
|
||||
//
|
||||
// Convention: `(self, arg1..argN)` for every script, plus `event` inserted as
|
||||
// the first positional for OnEvent — `(self, event, arg1..argN)` — matching
|
||||
// retail (and Frame::Modern's HookScript). An OnEvent dispatch is detected
|
||||
// structurally at the runner: the handler ref being invoked equals the frame's
|
||||
// OnEvent slot handler (frame+OFF_FRAME_ONEVENT_SLOT). This is exact — each
|
||||
// SetScript creates a distinct ref, so a function bound to two scripts still
|
||||
// differs per slot — so no fragile "are we mid event-dispatch" flag is needed.
|
||||
//
|
||||
// Runtime switch, default OFF (opt-in): `SetModernScriptArgs(true)` enables it,
|
||||
// `GetModernScriptArgs()` reads the state. While disabled both detours are a
|
||||
// straight passthrough to the original runner (zero reimplementation risk).
|
||||
// Opt-in because this lands on the hottest Lua path in the engine
|
||||
// (FUN_FRAME_RUN_SCRIPT_ARGS fires for every OnUpdate frame).
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Frame::ScriptArgs {
|
||||
|
||||
namespace {
|
||||
|
||||
// Runtime switch. Default OFF (opt-in). When false, both detours tail-call the
|
||||
// original runner unchanged (exact vanilla behavior, no reimplementation risk).
|
||||
bool g_enabled = false;
|
||||
|
||||
// Engine cap: the runner stops setting arg globals at index 19 (arg1..arg19).
|
||||
constexpr int kMaxArgs = 19;
|
||||
|
||||
// --- raw engine primitives not exposed via Game::Lua -----------------------
|
||||
// lua_rawgeti(L, idx, n) — push table_at_idx[n]. Used with idx = REGISTRY to
|
||||
// push a value stored under an integer ref (handler / frame object / message
|
||||
// handler / luaL_ref'd saved globals).
|
||||
using RawGetI_t = void(__fastcall *)(void *L, int idx, int ref);
|
||||
// luaL_ref(L, t) → int — pop the top, store it under a fresh int key in table
|
||||
// `t`, return the key. luaL_unref frees it.
|
||||
using LuaLRef_t = int(__fastcall *)(void *L, int t);
|
||||
using LuaLUnref_t = void(__fastcall *)(void *L, int t, int ref);
|
||||
// FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER — __thiscall(frame, nameOrNull);
|
||||
// lazily materializes the frame's Lua-registry ref at frame+OFF_COBJECT_LUA_REF.
|
||||
using ScriptRegister_t = void(__thiscall *)(void *frame, void *nameOrNull);
|
||||
|
||||
const auto RawGetI = reinterpret_cast<RawGetI_t>(Offsets::LUA_RAWGETI);
|
||||
const auto LuaLRef = reinterpret_cast<LuaLRef_t>(Offsets::LUA_REF_REF);
|
||||
const auto LuaLUnref = reinterpret_cast<LuaLUnref_t>(Offsets::LUA_REF_UNREF);
|
||||
|
||||
// Returns the frame's Lua object ref (frame+0x08), lazily creating it exactly
|
||||
// as the engine runner does (`if (refcount == 0) ScriptRegister(frame, 0)`).
|
||||
int EnsureLuaRef(void *frame) {
|
||||
auto *f = reinterpret_cast<uint8_t *>(frame);
|
||||
if (*reinterpret_cast<int *>(f + Offsets::OFF_COBJECT_LUA_REFCOUNT) == 0)
|
||||
reinterpret_cast<ScriptRegister_t>(
|
||||
Offsets::FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER)(frame, nullptr);
|
||||
return *reinterpret_cast<int *>(f + Offsets::OFF_COBJECT_LUA_REF);
|
||||
}
|
||||
|
||||
// Writes "arg" + k (k in 1..19) into buf; buf must hold at least 6 bytes.
|
||||
void ArgName(char *buf, int k) {
|
||||
buf[0] = 'a';
|
||||
buf[1] = 'r';
|
||||
buf[2] = 'g';
|
||||
if (k < 10) {
|
||||
buf[3] = static_cast<char>('0' + k);
|
||||
buf[4] = '\0';
|
||||
} else {
|
||||
buf[3] = static_cast<char>('0' + k / 10);
|
||||
buf[4] = static_cast<char>('0' + k % 10);
|
||||
buf[5] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
// Sets _G[name] = <value currently on the stack top>, capturing the previous
|
||||
// _G[name] into *outOldRef (a luaL_ref for later restore). Stack-neutral:
|
||||
// consumes the value, leaves the stack as it was minus that value.
|
||||
void SetGlobalSaving(void *L, const char *name, int *outOldRef) {
|
||||
using namespace Game::Lua;
|
||||
PushString(L, name); // [.., newVal, name]
|
||||
GetTable(L, GLOBALS_INDEX); // [.., newVal, oldVal]
|
||||
*outOldRef = LuaLRef(L, REGISTRY_INDEX); // [.., newVal]
|
||||
PushString(L, name); // [.., newVal, name]
|
||||
Insert(L, -2); // [.., name, newVal]
|
||||
SetTable(L, GLOBALS_INDEX); // [..]
|
||||
}
|
||||
|
||||
// Restores _G[name] to the value at oldRef, then frees the ref.
|
||||
void RestoreGlobal(void *L, const char *name, int oldRef) {
|
||||
using namespace Game::Lua;
|
||||
RawGetI(L, REGISTRY_INDEX, oldRef); // [.., oldVal]
|
||||
PushString(L, name); // [.., oldVal, name]
|
||||
Insert(L, -2); // [.., name, oldVal]
|
||||
SetTable(L, GLOBALS_INDEX); // [..]
|
||||
LuaLUnref(L, REGISTRY_INDEX, oldRef);
|
||||
}
|
||||
|
||||
// Reimplements the runner tail: set globals (with save/restore), then invoke
|
||||
// the handler with modern positional args `(self, arg1..argN)` under the
|
||||
// engine's own message-handler errfunc. `fmt`/`vaPtr` are null for the no-arg
|
||||
// runner (N == 0).
|
||||
void RunModern(int handlerRef, void *frame, const char *fmt, const void *vaPtr) {
|
||||
using namespace Game::Lua;
|
||||
void *L = State();
|
||||
if (L == nullptr)
|
||||
return;
|
||||
|
||||
// --- _G.this = frame -----------------------------------------------------
|
||||
const bool haveThis = frame != nullptr;
|
||||
// OnEvent dispatch? The event dispatchers invoke the frame's OnEvent slot
|
||||
// (frame+0x0C); matching the handler ref against that slot identifies it
|
||||
// exactly. When so, `event` (a global the dispatcher has set) is prepended
|
||||
// as the first positional to mirror retail's (self, event, arg1..argN).
|
||||
const bool isOnEvent =
|
||||
haveThis &&
|
||||
*reinterpret_cast<const int *>(reinterpret_cast<uint8_t *>(frame) +
|
||||
Offsets::OFF_FRAME_ONEVENT_SLOT) ==
|
||||
handlerRef;
|
||||
int selfRef = 0;
|
||||
int oldThisRef = 0;
|
||||
if (haveThis) {
|
||||
selfRef = EnsureLuaRef(frame);
|
||||
RawGetI(L, REGISTRY_INDEX, selfRef); // push frame object
|
||||
SetGlobalSaving(L, "this", &oldThisRef);
|
||||
}
|
||||
|
||||
// --- _G.arg1..argN from the format --------------------------------------
|
||||
int oldArgRefs[kMaxArgs];
|
||||
char argNames[kMaxArgs][6];
|
||||
int n = 0;
|
||||
if (fmt != nullptr) {
|
||||
const char *cur = static_cast<const char *>(vaPtr);
|
||||
for (const char *p = fmt; *p != '\0' && n < kMaxArgs; ++p) {
|
||||
if (*p != '%')
|
||||
continue;
|
||||
++p;
|
||||
if (*p == '\0')
|
||||
break;
|
||||
bool consumed = true;
|
||||
switch (*p) {
|
||||
case 'd':
|
||||
PushNumber(L, static_cast<double>(*reinterpret_cast<const int *>(cur)));
|
||||
cur += 4;
|
||||
break;
|
||||
case 'u':
|
||||
PushNumber(L, static_cast<double>(
|
||||
*reinterpret_cast<const unsigned int *>(cur)));
|
||||
cur += 4;
|
||||
break;
|
||||
case 'f':
|
||||
PushNumber(L, *reinterpret_cast<const double *>(cur));
|
||||
cur += 8;
|
||||
break;
|
||||
case 's':
|
||||
PushString(L, *reinterpret_cast<const char *const *>(cur));
|
||||
cur += 4;
|
||||
break;
|
||||
default:
|
||||
consumed = false; // unknown spec — skip, consume nothing
|
||||
break;
|
||||
}
|
||||
if (!consumed)
|
||||
continue;
|
||||
ArgName(argNames[n], n + 1);
|
||||
SetGlobalSaving(L, argNames[n], &oldArgRefs[n]);
|
||||
++n;
|
||||
}
|
||||
}
|
||||
|
||||
// --- call handler(self, arg1..argN) -------------------------------------
|
||||
const int base = GetTop(L);
|
||||
const int errRef = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_FRAMESCRIPT_ERROR_HANDLER_REF));
|
||||
int errIdx = 0;
|
||||
if (errRef > 0) {
|
||||
RawGetI(L, REGISTRY_INDEX, errRef); // message handler
|
||||
errIdx = base + 1; // absolute index of the errfunc
|
||||
}
|
||||
RawGetI(L, REGISTRY_INDEX, handlerRef); // handler
|
||||
if (haveThis)
|
||||
RawGetI(L, REGISTRY_INDEX, selfRef); // self
|
||||
else
|
||||
PushNil(L);
|
||||
if (isOnEvent) { // event goes before the args, retail-style
|
||||
PushString(L, "event");
|
||||
GetTable(L, GLOBALS_INDEX);
|
||||
}
|
||||
for (int k = 0; k < n; ++k) { // positional args, re-read from the globals
|
||||
PushString(L, argNames[k]);
|
||||
GetTable(L, GLOBALS_INDEX);
|
||||
}
|
||||
PCall(L, 1 + (isOnEvent ? 1 : 0) + n, 0, errIdx);
|
||||
SetTop(L, base); // drop the message handler + any leftover error object
|
||||
|
||||
// --- restore globals (reverse order) ------------------------------------
|
||||
for (int k = n - 1; k >= 0; --k)
|
||||
RestoreGlobal(L, argNames[k], oldArgRefs[k]);
|
||||
if (haveThis)
|
||||
RestoreGlobal(L, "this", oldThisRef);
|
||||
}
|
||||
|
||||
// --- FUN_FRAME_RUN_SCRIPT_ARGS co-hook (scripts with values) ----------------
|
||||
using RunArgs_t = void(__cdecl *)(int handlerRef, void *frame, const char *fmt,
|
||||
const void *vaPtr);
|
||||
RunArgs_t g_origRunArgs = nullptr;
|
||||
|
||||
void __cdecl RunArgs_h(int handlerRef, void *frame, const char *fmt,
|
||||
const void *vaPtr) {
|
||||
if (!g_enabled || handlerRef == 0 || fmt == nullptr) {
|
||||
g_origRunArgs(handlerRef, frame, fmt, vaPtr);
|
||||
return;
|
||||
}
|
||||
RunModern(handlerRef, frame, fmt, vaPtr);
|
||||
}
|
||||
|
||||
// --- FUN_FRAME_INVOKE_SCRIPT co-hook (no-arg scripts) -----------------------
|
||||
// Same address Tooltip::SetEvents calls directly to fire OnTooltipSet*; with
|
||||
// the switch enabled those fire with (self) too, which is the modern shape.
|
||||
using Invoke_t = void(__fastcall *)(int handlerRef, void *frame);
|
||||
Invoke_t g_origInvoke = nullptr;
|
||||
|
||||
void __fastcall Invoke_h(int handlerRef, void *frame) {
|
||||
if (!g_enabled || handlerRef == 0 || frame == nullptr) {
|
||||
g_origInvoke(handlerRef, frame);
|
||||
return;
|
||||
}
|
||||
RunModern(handlerRef, frame, /*fmt*/ nullptr, /*vaPtr*/ nullptr);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _runArgsHook{
|
||||
Offsets::FUN_FRAME_RUN_SCRIPT_ARGS,
|
||||
reinterpret_cast<void *>(&RunArgs_h),
|
||||
reinterpret_cast<void **>(&g_origRunArgs)};
|
||||
|
||||
const Game::HookAutoRegister _invokeHook{
|
||||
Offsets::FUN_FRAME_INVOKE_SCRIPT,
|
||||
reinterpret_cast<void *>(&Invoke_h),
|
||||
reinterpret_cast<void **>(&g_origInvoke)};
|
||||
|
||||
// --- Lua toggle -------------------------------------------------------------
|
||||
int __fastcall Script_SetModernScriptArgs(void *L) {
|
||||
g_enabled = Game::Lua::ToBoolean(L, 1) != 0;
|
||||
Game::Lua::PushBool(L, g_enabled);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetModernScriptArgs(void *L) {
|
||||
Game::Lua::PushBool(L, g_enabled);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("SetModernScriptArgs",
|
||||
&Script_SetModernScriptArgs);
|
||||
Game::Lua::RegisterGlobalFunction("GetModernScriptArgs",
|
||||
&Script_GetModernScriptArgs);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Frame::ScriptArgs
|
||||
@@ -282,7 +282,25 @@ void RegisterLuaFunctions() {
|
||||
&Script_C_FriendList_IsWhoQueryPending);
|
||||
}
|
||||
|
||||
// Clear transient /who state on every in-game Lua (re)build — login OR
|
||||
// /reload. The DLL survives a logout→character-select→login, so a query
|
||||
// whose SMSG_WHO response never crossed that boundary would otherwise leave
|
||||
// g_inFlight stuck (IsWhoQueryPending() wrongly true for the next character)
|
||||
// and our VAR_WHO_TO_UI_FLAG override forced on (routing the next
|
||||
// character's unrelated /who to the WhoList). ResetIfStuck only heals this
|
||||
// lazily on a *subsequent* send; a fresh-init reset makes every character
|
||||
// start clean. RestoreFlagIfOwned puts the engine flag back to the value we
|
||||
// snapshotted before overriding. ModuleAutoRegister fires per login and per
|
||||
// /reload, before any addon runs, so this always precedes a new burst.
|
||||
void ResetState() {
|
||||
g_inFlight = 0;
|
||||
RestoreFlagIfOwned();
|
||||
g_haveSent = false;
|
||||
g_lastSendTick = 0;
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
const Game::ModuleAutoRegister _resetOnInit{&ResetState};
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// 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/>.
|
||||
|
||||
// `ClosestGameObjectPosition(gameObjectID)` → `xPos, yPos, distance` — the
|
||||
// world position of the nearest game object with the given
|
||||
// game-object-template ID, and its distance from the player in yards.
|
||||
// Returns nothing when none is found (the `MayReturnNothing` contract).
|
||||
//
|
||||
// The game-object analog of `ClosestUnitPosition`; same shared scan
|
||||
// (Object::ClosestByEntry), just filtering on the GameObject GUID type
|
||||
// (`0xF110`) and `TYPEMASK_GAMEOBJECT`. Same retail-vs-vanilla caveat:
|
||||
// this returns the nearest CURRENTLY-VISIBLE object of that entry rather
|
||||
// than reading retail's static starting-zone database (which vanilla
|
||||
// doesn't have).
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "guid/Guid.h"
|
||||
#include "object/ClosestByEntry.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
namespace GameObject::ClosestPosition {
|
||||
|
||||
namespace {
|
||||
|
||||
int __fastcall Script_ClosestGameObjectPosition(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: ClosestGameObjectPosition(gameObjectID)");
|
||||
return 0;
|
||||
}
|
||||
const auto entry = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
|
||||
const Object::ClosestByEntry::Result r = Object::ClosestByEntry::Find(
|
||||
Offsets::TYPEMASK_GAMEOBJECT, Guid::Type::GameObject, entry);
|
||||
if (!r.found)
|
||||
return 0;
|
||||
|
||||
Game::Lua::PushNumber(L, static_cast<double>(r.x));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(r.y));
|
||||
Game::Lua::PushNumber(L, std::sqrt(static_cast<double>(r.distSq)));
|
||||
return 3;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("ClosestGameObjectPosition",
|
||||
&Script_ClosestGameObjectPosition);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace GameObject::ClosestPosition
|
||||
@@ -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_Glue` session helpers.
|
||||
//
|
||||
// `C_Glue.IsFirstLoadThisSession()` — glue-only. Returns true only while the
|
||||
// FIRST glue screen since the process launched is showing, and false on
|
||||
// every return to glue after a world login (a genuine world→glue logout;
|
||||
// login↔character-select stays within the first glue session). Retail uses
|
||||
// it so the login UI runs one-time startup (intro cinematic, news) only on
|
||||
// the initial launch, not on each relog.
|
||||
//
|
||||
// Backed by a process-static boot counter, no engine state needed: the glue
|
||||
// registration callback fires exactly once per glue boot
|
||||
// (FUN_LOAD_GLUE_SCRIPT_FUNCTIONS — initial launch + each world→glue return),
|
||||
// so the counter equals "how many glue screens this session" and the first
|
||||
// one is boot #1. The glue Lua state is recreated per boot, so C_Glue is
|
||||
// re-registered each time; the counter is process-static and survives that.
|
||||
//
|
||||
// `C_Glue.IsOnGlueScreen()` — true when a GlueXML screen is showing (no
|
||||
// character logged in), false in the world. Registered on BOTH Lua states,
|
||||
// and the answer is a compile-time constant per state: the glue and in-game
|
||||
// Lua states are mutually exclusive (glue exists only when no character is in
|
||||
// the world), so the glue registration returns true and the in-game one
|
||||
// returns false — no runtime flag to consult. Available in both states so
|
||||
// shared code that runs in either environment can branch on it.
|
||||
|
||||
#include "Game.h"
|
||||
|
||||
namespace Glue::Session {
|
||||
|
||||
namespace {
|
||||
|
||||
// Glue-screen boots since process start. 1 during the very first glue screen.
|
||||
int g_glueBootCount = 0;
|
||||
|
||||
int __fastcall Script_IsFirstLoadThisSession(void *L) {
|
||||
Game::Lua::PushBool(L, g_glueBootCount <= 1);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_IsOnGlueScreen_True(void *L) {
|
||||
Game::Lua::PushBool(L, true);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_IsOnGlueScreen_False(void *L) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterGlue() {
|
||||
++g_glueBootCount; // one glue boot per callback invocation
|
||||
Game::Lua::RegisterTableFunction("C_Glue", "IsFirstLoadThisSession",
|
||||
&Script_IsFirstLoadThisSession);
|
||||
Game::Lua::RegisterTableFunction("C_Glue", "IsOnGlueScreen",
|
||||
&Script_IsOnGlueScreen_True);
|
||||
}
|
||||
|
||||
void RegisterInGame() {
|
||||
// In-world: only IsOnGlueScreen makes sense (and it's always false).
|
||||
// IsFirstLoadThisSession is a glue concept, so it's not registered here.
|
||||
Game::Lua::RegisterTableFunction("C_Glue", "IsOnGlueScreen",
|
||||
&Script_IsOnGlueScreen_False);
|
||||
}
|
||||
|
||||
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Glue::Session
|
||||
@@ -0,0 +1,112 @@
|
||||
// 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 "group/MemberStats.h"
|
||||
|
||||
#include "Offsets.h"
|
||||
|
||||
namespace Group::MemberStats {
|
||||
|
||||
namespace {
|
||||
|
||||
// Both roster lookups are `__fastcall(u64 *guid) -> block*` (NULL on miss).
|
||||
using Lookup_t = const uint8_t *(__fastcall *)(uint64_t *guid);
|
||||
|
||||
uint16_t U16(const uint8_t *p, int off) {
|
||||
return *reinterpret_cast<const uint16_t *>(p + off);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Stats Lookup(uint64_t guid) {
|
||||
Stats s;
|
||||
if (guid == 0)
|
||||
return s;
|
||||
|
||||
// Party stats block — the full record.
|
||||
auto statsFn = reinterpret_cast<Lookup_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_GROUP_MEMBER_STATS_LOOKUP));
|
||||
if (const uint8_t *e = statsFn(&guid)) {
|
||||
s.valid = true;
|
||||
s.full = true;
|
||||
s.powerType = e[Offsets::OFF_GROUP_MEMBER_STATS_POWER_TYPE];
|
||||
s.health = U16(e, Offsets::OFF_GROUP_MEMBER_STATS_HEALTH);
|
||||
s.maxHealth = U16(e, Offsets::OFF_GROUP_MEMBER_STATS_MAX_HEALTH);
|
||||
s.power = U16(e, Offsets::OFF_GROUP_MEMBER_STATS_POWER);
|
||||
s.maxPower = U16(e, Offsets::OFF_GROUP_MEMBER_STATS_MAX_POWER);
|
||||
s.level = U16(e, Offsets::OFF_GROUP_MEMBER_STATS_LEVEL);
|
||||
s.areaId = U16(e, Offsets::OFF_GROUP_MEMBER_AREA_ID);
|
||||
s.online = (e[Offsets::OFF_GROUP_MEMBER_STATUS_FLAGS] &
|
||||
Offsets::GROUP_MEMBER_STATUS_ONLINE) != 0;
|
||||
// The party stats block carries no dead / subgroup / rank fields; those
|
||||
// are raid-only (dead comes from UnitIsDead on the live unit otherwise).
|
||||
return s;
|
||||
}
|
||||
|
||||
// Raid slot — same struct GetRaidRosterInfo reads.
|
||||
auto slotFn = reinterpret_cast<Lookup_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_GROUP_MEMBER_SLOT_LOOKUP));
|
||||
if (const uint8_t *e = slotFn(&guid)) {
|
||||
s.valid = true;
|
||||
s.full = false;
|
||||
s.powerType = e[Offsets::OFF_RAID_SLOT_POWER_TYPE];
|
||||
s.health = U16(e, Offsets::OFF_RAID_SLOT_HEALTH);
|
||||
s.maxHealth = U16(e, Offsets::OFF_RAID_SLOT_MAX_HEALTH);
|
||||
s.power = U16(e, Offsets::OFF_RAID_SLOT_POWER);
|
||||
s.maxPower = U16(e, Offsets::OFF_RAID_SLOT_MAX_POWER);
|
||||
s.level = U16(e, Offsets::OFF_RAID_SLOT_LEVEL);
|
||||
s.areaId = U16(e, Offsets::OFF_RAID_SLOT_AREA_ID);
|
||||
const uint32_t flags =
|
||||
*reinterpret_cast<const uint32_t *>(e + Offsets::OFF_RAID_SLOT_FLAGS);
|
||||
s.online = (flags & Offsets::RAID_SLOT_FLAG_ONLINE) != 0;
|
||||
s.dead = (flags & Offsets::RAID_SLOT_FLAG_DEAD) != 0;
|
||||
s.subgroup = static_cast<int>(*reinterpret_cast<const uint32_t *>(
|
||||
e + Offsets::OFF_RAID_SLOT_SUBGROUP)) + 1;
|
||||
s.rank = static_cast<int>(
|
||||
*reinterpret_cast<const uint32_t *>(e + Offsets::OFF_RAID_SLOT_RANK));
|
||||
return s;
|
||||
}
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
const uint16_t *AuraArray(uint64_t guid) {
|
||||
if (guid == 0)
|
||||
return nullptr;
|
||||
|
||||
// Party stats block first — auras at +0x1a, but only for an online member
|
||||
// (the engine's Script_UnitBuff bails on the party path when the online bit
|
||||
// is clear).
|
||||
auto statsFn = reinterpret_cast<Lookup_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_GROUP_MEMBER_STATS_LOOKUP));
|
||||
if (const uint8_t *e = statsFn(&guid)) {
|
||||
if ((e[Offsets::OFF_GROUP_MEMBER_STATUS_FLAGS] &
|
||||
Offsets::GROUP_MEMBER_STATUS_ONLINE) == 0)
|
||||
return nullptr;
|
||||
return reinterpret_cast<const uint16_t *>(
|
||||
e + Offsets::OFF_GROUP_MEMBER_STATS_AURAS);
|
||||
}
|
||||
|
||||
// Raid slot — auras at +0x64, no online gate (mirrors the engine's raid
|
||||
// branch, which reads the array unconditionally).
|
||||
auto slotFn = reinterpret_cast<Lookup_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_GROUP_MEMBER_SLOT_LOOKUP));
|
||||
if (const uint8_t *e = slotFn(&guid)) {
|
||||
return reinterpret_cast<const uint16_t *>(
|
||||
e + Offsets::OFF_RAID_SLOT_AURAS);
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace Group::MemberStats
|
||||
@@ -0,0 +1,67 @@
|
||||
// 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>
|
||||
|
||||
namespace Group::MemberStats {
|
||||
|
||||
// Cached stats for a party/raid member, read from the group roster
|
||||
// (fed by SMSG_PARTY_MEMBER_STATS). This is the ONLY source for a member with
|
||||
// no live CGUnit — a groupmate on a different map or otherwise out of range —
|
||||
// and is exactly what the engine's UnitHealth / UnitMana fall back to. Any
|
||||
// module that reads unit fields off the descriptor should consult this when
|
||||
// the descriptor is unavailable so it doesn't report 0 for out-of-range
|
||||
// groupmates.
|
||||
//
|
||||
// `full` distinguishes the two backing structures: the party stats block and
|
||||
// the raid slot. Both carry health / power / level / area / online; `dead`,
|
||||
// `subgroup`, and `rank` come only from the raid slot (0 / false on the party
|
||||
// path). Power values are RAW (divide by the power divisor for display);
|
||||
// health is the roster u16, matching what UnitHealth returns out of range.
|
||||
struct Stats {
|
||||
bool valid = false;
|
||||
bool full = false; // true = party stats block; false = raid slot
|
||||
int powerType = 0;
|
||||
uint32_t health = 0;
|
||||
uint32_t maxHealth = 0;
|
||||
uint32_t power = 0; // raw (pre-divisor)
|
||||
uint32_t maxPower = 0; // raw (pre-divisor)
|
||||
int level = 0;
|
||||
int areaId = 0; // AreaTable.dbc ID
|
||||
bool online = false;
|
||||
bool dead = false; // raid slot only
|
||||
int subgroup = 0; // raid slot only, 1-based (0 = party path)
|
||||
int rank = 0; // raid slot only
|
||||
};
|
||||
|
||||
// Look up a member by GUID. Tries the party stats block, then the raid slot,
|
||||
// mirroring the engine's UnitHealth/UnitMana out-of-range fallback. Returns an
|
||||
// invalid `Stats` (`valid == false`) when the GUID isn't a rostered member.
|
||||
// Pure — no Lua, no throw; safe from any context.
|
||||
Stats Lookup(uint64_t guid);
|
||||
|
||||
// Pointer to an out-of-range groupmate's 48-slot aura spell-ID array (buffs
|
||||
// 0..31, debuffs 32..47), or nullptr if `guid` isn't a rostered member — or is
|
||||
// an *offline* party member (the engine surfaces no auras for those). Mirrors
|
||||
// the engine's own out-of-range fallback in Script_UnitBuff/Script_UnitDebuff
|
||||
// exactly: the party stats block at +OFF_GROUP_MEMBER_STATS_AURAS gated on the
|
||||
// online flag, else the raid slot at +OFF_RAID_SLOT_AURAS. Spell IDs only —
|
||||
// SMSG_PARTY_MEMBER_STATS carries no caster/duration/stacks, and truncates IDs
|
||||
// to u16 (custom IDs > 65535 are wrong; same as the built-in UnitBuff). The
|
||||
// pointer is into live engine memory — read it immediately, don't cache it.
|
||||
// Pure — no Lua, no throw; safe from any context.
|
||||
const uint16_t *AuraArray(uint64_t guid);
|
||||
|
||||
} // namespace Group::MemberStats
|
||||
@@ -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() {
|
||||
|
||||
+13
-2
@@ -21,7 +21,7 @@
|
||||
namespace Item::Arg {
|
||||
|
||||
Resolved Resolve(void *L, int idx) {
|
||||
Resolved out{0, nullptr};
|
||||
Resolved out{0, 0, nullptr};
|
||||
if (Game::Lua::IsNumber(L, idx)) {
|
||||
out.itemID = static_cast<int>(Game::Lua::ToNumber(L, idx));
|
||||
return out;
|
||||
@@ -34,7 +34,18 @@ Resolved Resolve(void *L, int idx) {
|
||||
return out;
|
||||
}
|
||||
if (const char *m = std::strstr(s, "item:")) {
|
||||
out.itemID = std::atoi(m + 5);
|
||||
m += 5;
|
||||
out.itemID = std::atoi(m);
|
||||
// Random suffix is the 3rd colon-separated field: itemID:enchant:suffix.
|
||||
// Walk to the 2nd ':' (stopping at the link boundary), read the number
|
||||
// after it. Bare "item:N" (no colons) leaves suffix 0.
|
||||
int colons = 0;
|
||||
for (const char *p = m; *p != '\0' && *p != '|'; ++p) {
|
||||
if (*p == ':' && ++colons == 2) {
|
||||
out.suffix = std::atoi(p + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Plain string — try numeric, otherwise expose as a name for
|
||||
|
||||
@@ -22,6 +22,7 @@ namespace Item::Arg {
|
||||
// can ignore `name` and treat `itemID == 0` as "no usable input".
|
||||
struct Resolved {
|
||||
int itemID;
|
||||
int suffix; // random-property / suffix ID (3rd `item:` field), 0 if none
|
||||
const char *name;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,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/>.
|
||||
|
||||
#include "item/BagFamily.h"
|
||||
|
||||
#include "Offsets.h"
|
||||
|
||||
namespace Item::BagFamily {
|
||||
|
||||
namespace {
|
||||
|
||||
// Chained at static-init by each `AutoResolver` constructor, before
|
||||
// `DllMain`. Zero-initialized (namespace static) ahead of any dynamic
|
||||
// init, so the head is valid when resolvers link in.
|
||||
AutoResolver *g_resolvers = nullptr;
|
||||
|
||||
// Stock vanilla bags/quivers that shipped with an empty `m_bagFamily`
|
||||
// field, recovered from (class, subclass). Correspondence cross-referenced
|
||||
// by name between ItemSubClass.dbc and ItemBagFamily.dbc. Everything else
|
||||
// in stock vanilla either carries the field directly or has no family, so
|
||||
// it isn't listed — server-custom bags come in through the resolver chain.
|
||||
uint32_t StockSubclassToId(uint32_t itemClass, uint32_t subclass) {
|
||||
if (itemClass == kItemClassContainer) {
|
||||
switch (subclass) {
|
||||
case 1: return 3; // Soul Bag → Soul Shards
|
||||
case 2: return 6; // Herb Bag → Herbs
|
||||
case 3: return 7; // Enchanting Bag → Enchanting Supplies
|
||||
case 4: return 8; // Engineering Bag → Engineering Supplies
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
if (itemClass == kItemClassQuiver) {
|
||||
switch (subclass) {
|
||||
case 2: return 1; // Quiver → Arrows
|
||||
case 3: return 2; // Ammo Pouch → Bullets
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
AutoResolver::AutoResolver(Resolver f) : fn(f), next(g_resolvers) {
|
||||
g_resolvers = this;
|
||||
}
|
||||
|
||||
uint32_t SubclassToId(uint32_t itemClass, uint32_t subclass) {
|
||||
if (const uint32_t id = StockSubclassToId(itemClass, subclass))
|
||||
return id;
|
||||
for (AutoResolver *r = g_resolvers; r != nullptr; r = r->next)
|
||||
if (const uint32_t id = r->fn(itemClass, subclass))
|
||||
return id;
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint32_t BitmaskForRecord(const uint8_t *record) {
|
||||
uint32_t rawId = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_BAG_FAMILY);
|
||||
if (rawId == 0) {
|
||||
const uint32_t cls = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_CLASS);
|
||||
const uint32_t sub = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SUBCLASS);
|
||||
rawId = SubclassToId(cls, sub);
|
||||
}
|
||||
return IdToBitmask(rawId);
|
||||
}
|
||||
|
||||
} // namespace Item::BagFamily
|
||||
@@ -0,0 +1,72 @@
|
||||
// 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 BagFamily derivation for a cached `ItemStats_C` record. Used by
|
||||
// `C_Item.GetItemFamily` and `C_Container.GetContainerNumFreeSlots` /
|
||||
// `CalculateTotalNumberOfFreeBagSlots` so the family a bag reports matches
|
||||
// the family the items it holds report.
|
||||
//
|
||||
// This module knows only STOCK vanilla data. Server-custom container
|
||||
// families (e.g. Turtle's cooking/gathering bags) plug in through the
|
||||
// `AutoResolver` extension point below, so non-stock hardcoded data lives
|
||||
// in its own module (see `src/turtle/`) rather than here.
|
||||
namespace Item::BagFamily {
|
||||
|
||||
// Item.dbc `m_class` values. Containers hold the profession/soul bags;
|
||||
// Quivers hold arrows (subclass 2) and bullets/ammo pouches (subclass 3).
|
||||
// See ItemClass.dbc.
|
||||
constexpr uint32_t kItemClassContainer = 1;
|
||||
constexpr uint32_t kItemClassQuiver = 11;
|
||||
|
||||
// Convert 1.12's raw 1-based `ItemBagFamily.dbc` ID to the modern bitmask
|
||||
// encoding (`1 << (id - 1)`); `0` stays `0`. Vanilla stores the raw ID
|
||||
// (`arrow=1, bullet=2, soul shard=3, herb=6, …`); Wrath+ flipped the same
|
||||
// field to the bitmask form, which is what backported addons expect.
|
||||
inline uint32_t IdToBitmask(uint32_t rawId) {
|
||||
return (rawId == 0) ? 0 : (1u << (rawId - 1));
|
||||
}
|
||||
|
||||
// (class, subclass) → `ItemBagFamily.dbc` raw ID, for bags/quivers whose
|
||||
// `m_bagFamily` field shipped empty. Covers the stock vanilla profession
|
||||
// bags (Container class: Soul/Herb/Enchanting/Engineering) and quivers
|
||||
// (Quiver class: Quiver→arrows, Ammo Pouch→bullets); for anything else it
|
||||
// consults the registered custom resolvers (see `AutoResolver`) before
|
||||
// giving up with `0`. Keyed on class because subclass numbers repeat
|
||||
// across classes (Container subclass 2 = Herb Bag, Quiver subclass 2 =
|
||||
// Quiver). Returns a raw family ID, not a bitmask.
|
||||
uint32_t SubclassToId(uint32_t itemClass, uint32_t subclass);
|
||||
|
||||
// BagFamily bitmask for a cached `ItemStats_C` record: the item's
|
||||
// `m_bagFamily` field if populated, otherwise — for a container whose
|
||||
// field is empty — derived from the container subclass. Pass a non-null
|
||||
// record.
|
||||
uint32_t BitmaskForRecord(const uint8_t *record);
|
||||
|
||||
// Extension point for server-custom bag families. A resolver maps a
|
||||
// (class, subclass) pair to an `ItemBagFamily.dbc` raw ID, or returns `0`
|
||||
// if it doesn't recognize it. Consulted by `SubclassToId` only after the
|
||||
// stock table misses. Declare a file-scope
|
||||
// `static const Item::BagFamily::AutoResolver` in a platform module to
|
||||
// register one at static-init time.
|
||||
using Resolver = uint32_t (*)(uint32_t itemClass, uint32_t subclass);
|
||||
struct AutoResolver {
|
||||
explicit AutoResolver(Resolver fn);
|
||||
Resolver fn;
|
||||
AutoResolver *next;
|
||||
};
|
||||
|
||||
} // namespace Item::BagFamily
|
||||
@@ -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
|
||||
+16
-19
@@ -11,11 +11,14 @@
|
||||
// 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/Count.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "item/Arg.h"
|
||||
#include "item/ID.h"
|
||||
#include "item/Location.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
@@ -94,22 +97,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 +148,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 +157,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 +239,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);
|
||||
@@ -265,6 +253,15 @@ int __fastcall Script_C_Item_GetItemCount(void *L) {
|
||||
|
||||
} // namespace
|
||||
|
||||
int InInventory(void *L, int itemID) {
|
||||
if (itemID <= 0)
|
||||
return 0;
|
||||
int total = CountEquipped(itemID, /*includeUses*/ false);
|
||||
for (int bag = 0; bag <= 4; bag++)
|
||||
total += CountInBag(L, bag, itemID, /*includeUses*/ false);
|
||||
return total;
|
||||
}
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Item", "GetItemCount",
|
||||
&Script_C_Item_GetItemCount);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// 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 Item::Count {
|
||||
|
||||
// Total plain stack count of `itemID` across the player's equipped slots
|
||||
// and bags 0..4 (no bank, no charge multiplier) — the "carried on me"
|
||||
// count. Needs the Lua state `L`: the bag walk uses the engine's slot
|
||||
// packer, which reads and clears the Lua stack, so the caller must have
|
||||
// already consumed any stack arguments it still needs before calling.
|
||||
int InInventory(void *L, int itemID);
|
||||
|
||||
} // namespace Item::Count
|
||||
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user