mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-11 12:27:35 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -38,3 +38,7 @@ Util\LinkUtil.lua
|
||||
Util\TradeSkillLink.lua
|
||||
Util\IconDataProvider.lua
|
||||
Util\GameTooltip.lua
|
||||
Util\Rectangle.lua
|
||||
Util\Vector2D.lua
|
||||
Util\Vector3D.lua
|
||||
Util\Vector4D.lua
|
||||
|
||||
@@ -38,4 +38,8 @@ if C_AddOns.DoesAddOnExist('pfUI') then
|
||||
end)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
if C_AddOns.DoesAddOnExist("SpecialTalentUI") then
|
||||
EventUtil.ContinueOnAddOnLoaded("SpecialTalentUI", CAPI_ApplyStandardColorGlobals)
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -248,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]);
|
||||
@@ -259,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);
|
||||
@@ -296,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);
|
||||
@@ -393,29 +396,13 @@ local function CreateFrame_TradeSkillLink()
|
||||
"ClassicAPITradeSkillLinkDetailScroll", f, DETAIL_SCROLL_TEMPLATE);
|
||||
detailScroll:SetSize(LO.detail[3], LO.detail[4]);
|
||||
detailScroll:SetPoint("TOPLEFT", LO.detail[1], LO.detail[2]);
|
||||
detailScroll.scrollBarHideable = 1; -- template sets this too; belt-and-suspenders
|
||||
f.detailScroll = detailScroll;
|
||||
-- Off until a recipe overflows the pane (toggled in RenderDetail).
|
||||
-- The template shows its slider and enables the wheel by default; on
|
||||
-- the roomy Turtle pane that leaves a dead scrollbar that still eats
|
||||
-- wheel events and over-scrolls a hair. Start both disabled.
|
||||
-- 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);
|
||||
local detailSB = getglobal("ClassicAPITradeSkillLinkDetailScrollScrollBar");
|
||||
if detailSB then
|
||||
detailSB:Hide();
|
||||
end
|
||||
-- The template re-shows its slider when the frame transitions to shown
|
||||
-- (on first open, before the auto-selected recipe's RenderDetail runs),
|
||||
-- leaving a dead scrollbar until the first click. Re-assert the hidden
|
||||
-- state on show unless the current recipe genuinely overflows.
|
||||
detailScroll:SetScript("OnShow", function()
|
||||
f:UpdateDetailScrollbar();
|
||||
RunNextFrame(function()
|
||||
if f:IsShown() then
|
||||
f:UpdateDetailScrollbar();
|
||||
end
|
||||
end);
|
||||
end);
|
||||
f.detailScroll = detailScroll;
|
||||
|
||||
local detailChild = CreateFrame("Frame",
|
||||
"ClassicAPITradeSkillLinkDetailChild", detailScroll);
|
||||
@@ -514,18 +501,20 @@ local function CreateFrame_TradeSkillLink()
|
||||
return btn;
|
||||
end
|
||||
|
||||
-- Show the detail slider + enable the wheel only when the current
|
||||
-- recipe overflows the pane (tracked by `detailNeedScroll`).
|
||||
function f:UpdateDetailScrollbar()
|
||||
local need = self.detailNeedScroll and true or false;
|
||||
self.detailScroll:EnableMouseWheel(need);
|
||||
-- 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(need) end
|
||||
if sb then sb:SetShown(show) end
|
||||
local top = getglobal(base .. "Top");
|
||||
if top then top:SetShown(need) end
|
||||
if top then top:SetShown(show) end
|
||||
local bottom = getglobal(base .. "Bottom");
|
||||
if bottom then bottom:SetShown(need) end
|
||||
if bottom then bottom:SetShown(show) end
|
||||
end
|
||||
|
||||
function f:RenderDetail(entry)
|
||||
@@ -571,19 +560,21 @@ local function CreateFrame_TradeSkillLink()
|
||||
local total = table.getn(reagents);
|
||||
for i = 1, total do
|
||||
local btn = AcquireReagent(i);
|
||||
local r = reagents[i];
|
||||
local name, tex, link = itemInfo(r.itemID);
|
||||
local reagent = reagents[i];
|
||||
local name, tex, link = itemInfo(reagent.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;
|
||||
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
|
||||
btn:GetNormalTexture():SetVertexColor(1, 1, 1);
|
||||
btn.nameText:SetTextColor(HIGHLIGHT_FONT_COLOR:GetRGB());
|
||||
local r, g, b = HIGHLIGHT_FONT_COLOR:GetRGB()
|
||||
btn:GetNormalTexture():SetVertexColor(r, g, b);
|
||||
btn.nameText:SetTextColor(r, g, b);
|
||||
else
|
||||
btn:GetNormalTexture():SetVertexColor(0.5, 0.5, 0.5);
|
||||
btn.nameText:SetTextColor(GRAY_FONT_COLOR:GetRGB());
|
||||
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);
|
||||
@@ -594,29 +585,32 @@ local function CreateFrame_TradeSkillLink()
|
||||
self.reagents[i]:Hide();
|
||||
end
|
||||
|
||||
-- Grow the scroll child to the content height so the pane scrolls
|
||||
-- once the header + reagent rows exceed the visible area. Reagent
|
||||
-- row 0 sits ~67px below the child top (icon + label); add the
|
||||
-- wrapped description height for spell recipes.
|
||||
local descH = (descText ~= "") and self.prodDesc:GetHeight() or 0;
|
||||
local rows = math.ceil(total / 2);
|
||||
local contentH = 67 + descH + rows * REAGENT_ROW_HEIGHT + 8;
|
||||
self.detailChild:SetHeight(math.max(contentH, LO.detail[4]));
|
||||
-- Reset to the top and manage the slider explicitly: a range that
|
||||
-- stays 0 (content fits, e.g. Turtle's roomy pane) never fires the
|
||||
-- range-changed event that `scrollBarHideable` relies on, so the
|
||||
-- slider would otherwise sit visible-but-useless. Show it only when
|
||||
-- the content actually overflows.
|
||||
self.detailNeedScroll = contentH > LO.detail[4];
|
||||
local 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
|
||||
applyContentHeight();
|
||||
local sb = getglobal("ClassicAPITradeSkillLinkDetailScrollScrollBar");
|
||||
if sb then
|
||||
sb:SetValue(0);
|
||||
end
|
||||
self.detailScroll:SetVerticalScroll(0);
|
||||
self:UpdateDetailScrollbar();
|
||||
RunNextFrame(function()
|
||||
if self:IsShown() then
|
||||
self:UpdateDetailScrollbar();
|
||||
if self:IsShown() and self.selectedSpellID == entry.spellID then
|
||||
applyContentHeight();
|
||||
end
|
||||
end)
|
||||
end
|
||||
@@ -639,8 +633,8 @@ local function CreateFrame_TradeSkillLink()
|
||||
self.reagents[i]:Hide();
|
||||
end
|
||||
self.detailChild:SetHeight(LO.detail[4]);
|
||||
self.detailNeedScroll = false;
|
||||
self:UpdateDetailScrollbar();
|
||||
self.detailScroll:UpdateScrollChildRect();
|
||||
self:SetDetailBarShown(false);
|
||||
self.detailScroll:SetVerticalScroll(0);
|
||||
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
|
||||
+3
-1
@@ -118,7 +118,9 @@ 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
|
||||
|
||||
@@ -21,7 +21,9 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [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` |
|
||||
@@ -29,7 +31,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [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` |
|
||||
@@ -37,7 +39,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [FriendList](docs/API.md#friendlist) | `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName` |
|
||||
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID`, `ClosestGameObjectPosition` |
|
||||
| [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) | `GetMouseButtonClicked`, `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
|
||||
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
|
||||
@@ -45,7 +47,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
|
||||
| [LootHistory](docs/API.md#loothistory) | `C_LootHistory.GetNumItems`, `C_LootHistory.GetItem`, `C_LootHistory.GetPlayerInfo`, `C_LootHistory.Clear` |
|
||||
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount` |
|
||||
| [Lua](docs/API.md#lua) | `coroutine.create`, `coroutine.resume`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `math.fmod`, `math.huge`, `math.modf`, `select`, `string.gmatch`, `string.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.wipe` |
|
||||
| [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.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapOverlays`, `C_Map.GetMapWorldSize` |
|
||||
@@ -56,17 +58,18 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [NewItems](docs/API.md#newitems) | `C_NewItems.ClearAll`, `C_NewItems.IsNewItem`, `C_NewItems.RemoveNewItem` |
|
||||
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GetClass`, `C_PlayerInfo.GetName`, `C_PlayerInfo.GetRace`, `C_PlayerInfo.GetSex`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `C_PlayerInfo.IsConnected` |
|
||||
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetHeaderIndexForQuest`, `C_QuestLog.GetLogIndexForQuestID`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
|
||||
| [Screen](docs/API.md#screen) | `GetPhysicalScreenSize` |
|
||||
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
|
||||
| [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`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsStealthed`, `IsSwimming` |
|
||||
| [System](docs/API.md#system) | `CopyToClipboard`, `GetPhysicalScreenSize` |
|
||||
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
|
||||
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes` |
|
||||
| [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` |
|
||||
| [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) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
|
||||
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `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.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` |
|
||||
|
||||
+747
-82
File diff suppressed because it is too large
Load Diff
+229
@@ -140,6 +140,23 @@ enum Offsets {
|
||||
// and call the inner invoker, which is self-contained.
|
||||
FUN_GAMETOOLTIP_SCRIPT_RESOLVER = 0x005295D0,
|
||||
FUN_FRAME_INVOKE_SCRIPT = 0x00704D50,
|
||||
// The other per-object tooltip builders, co-hooked the same way as
|
||||
// FUN_GAMETOOLTIP_BUILD_ITEM to back OnTooltipSetSpell / OnTooltipSetUnit /
|
||||
// OnTooltipSetGameObject (see Tooltip::SetEvents). Each is the single funnel
|
||||
// its Set*/mouseover paths converge on and clears the tooltip
|
||||
// (FUN_00530050) before repopulating, so firing after it covers every way
|
||||
// that object type gets set:
|
||||
// - Spell FUN_GAMETOOLTIP_BUILD_SPELL_TOOLTIP (0x0052E610, 7 stack args,
|
||||
// RET 0x1c) — SetSpell/SetSpellByID/SetTalent/SetShapeshift funnel.
|
||||
// - Unit (0x00529fe0, __thiscall(self, guid*), RET 4) — SetUnit +
|
||||
// the two engine mouseover paths (FUN_004919d0 / FUN_00492890).
|
||||
// Returns an int the caller (Script_GameTooltip_SetUnit) tests, so the
|
||||
// co-hook must forward the original's return value.
|
||||
// - GameObject (0x0052aa20, __thiscall(self, guid*), RET 4) — the GO
|
||||
// hover populator; resolves the GO (OBJ_TYPE_GAMEOBJECT) and writes the
|
||||
// GO field at +0x370.
|
||||
FUN_GAMETOOLTIP_BUILD_UNIT = 0x00529FE0,
|
||||
FUN_GAMETOOLTIP_BUILD_GAMEOBJECT = 0x0052AA20,
|
||||
FUN_GAMETOOLTIP_ADD_LINE = 0x00530270, // __thiscall(self, left, right, lColorBGRA*, rColorBGRA*, wrap)
|
||||
OFF_GAMETOOLTIP_NUM_LINES = 0x31C, // int — live line count (AddLine index; +0x320 is the cap)
|
||||
// The displayed item's identity is read via the existing
|
||||
@@ -463,6 +480,30 @@ enum Offsets {
|
||||
// (lo) and `guid[1]` (hi) through the pointer.
|
||||
FUN_TARGET_BY_GUID = 0x00489A40,
|
||||
|
||||
// Tab-targeting internals, shared with our backported TargetNearest* /
|
||||
// TargetDirection* family (`target/Nearest.cpp`).
|
||||
//
|
||||
// `FUN_TARGET_CANDIDATE_VALID` (0x00493E40) — the engine's per-mode
|
||||
// candidate validity predicate used by the native `TargetNearestEnemy`
|
||||
// / `TargetNearestFriend` cycle. `__fastcall(CGUnit *player /*ecx*/,
|
||||
// CGUnit *candidate /*edx*/, int mode) → int` (nonzero = valid). Modes:
|
||||
// 1 = enemy (attackable + alive + not-critter), 2 = friend (assistable +
|
||||
// alive), 3 = party, 4 = raid. Wraps the reaction cores
|
||||
// `FUN_00606980` (can-attack) / `FUN_006066f0` (can-assist), so calling
|
||||
// it gives us the engine's exact hostility semantics for free.
|
||||
FUN_TARGET_CANDIDATE_VALID = 0x00493E40,
|
||||
// `FUN_SET_SELECTION_BY_GUID` (0x00493540) — canonical target setter the
|
||||
// native tab-cycle commits through. `__cdecl(uint32_t guidLo, uint32_t
|
||||
// guidHi)`. Validates + fires CMSG_SET_SELECTION + client target +
|
||||
// event 0xC3, and maintains the current-selection globals below.
|
||||
// Passing {0,0} clears the target.
|
||||
FUN_SET_SELECTION_BY_GUID = 0x00493540,
|
||||
// Current target-selection GUID (lo/hi), written by
|
||||
// `FUN_SET_SELECTION_BY_GUID`. We read it to detect whether the player's
|
||||
// target changed out from under an in-progress tab-cycle.
|
||||
VAR_CURRENT_SELECTION_GUID_LO = 0x00B4E2D8,
|
||||
VAR_CURRENT_SELECTION_GUID_HI = 0x00B4E2DC,
|
||||
|
||||
// Local-player CGObject-like global. Not the same pointer as
|
||||
// ResolveUnitToken("player") returns — that one's the canonical
|
||||
// CGPlayer_C used by inventory etc. This pointer's +0xC0 field
|
||||
@@ -640,6 +681,25 @@ enum Offsets {
|
||||
// The live (new) value is NOT passed — read it from the object (the
|
||||
// engine's bag callback reads the invMgr GUID array).
|
||||
|
||||
// ---- Unit-event token observers (Unit::TokenObserver) ---------------
|
||||
// The engine makes target/party/raid/pet/mouseover units fire per-token
|
||||
// unit events (UNIT_HEALTH, UNIT_MANA, UNIT_AURA, …) by watching bank-UNIT
|
||||
// descriptor fields: FUN_0051bbb0 loops event index i in
|
||||
// [0, EVENT_NAME_UNIT_MAX); for each whose static name slot is non-null it
|
||||
// registers a bank-3 observer on field i*4 (per-index size below) with
|
||||
// callback FUN_0051bd50 → the broadcast FUN_00515e50(guid, i), which fires
|
||||
// event i once per token referencing the guid. The FIELD INDEX IS THE EVENT
|
||||
// ID (health field +0x40 → event 16 = UNIT_HEALTH). Unit::TokenObserver
|
||||
// replicates that exact loop with a caller's own callback to make synthetic
|
||||
// tokens (focus, nameplateN) first-class; the registrar always appends a
|
||||
// fresh node (FUN_00467f00, no dedup), so ours coexists with the engine's —
|
||||
// a unit that is also the target fires both "target" and our token. The
|
||||
// broadcast always fires with format "%s" + the token (DAT_0082e280).
|
||||
// Per-index watched size (FUN_0051bbb0's switch): i∈{0,2,4,10}=8,
|
||||
// 0x29=0xD8 (UNIT_FIELD_AURA), 0x6B=0x30, 0xA7/0xAE=0x1C, else 4.
|
||||
VAR_EVENT_NAME_TABLE_STATIC = 0x00BE1198, // char*[] boot name array; slot i (base+i*4) non-null ⇒ real event i
|
||||
EVENT_NAME_UNIT_MAX = 0xB6, // FUN_0051bbb0's loop bound
|
||||
|
||||
// The engine's inventory observer setup — registers the bag-slot
|
||||
// GUID-field observers above, once per enter-world (sole caller is the
|
||||
// enter-world initializer FUN_004908C0, latched by DAT_00B4B424).
|
||||
@@ -655,6 +715,7 @@ enum Offsets {
|
||||
OFF_DESC_PLAYER_EQUIP_FIRST = 0x4A8,
|
||||
DESC_PLAYER_EQUIP_SLOTS = 19,
|
||||
DESC_OBSERVER_BANK_PLAYER = 4,
|
||||
DESC_OBSERVER_BANK_UNIT = 3, // CGUnit descriptor bank (FUN_0051bbb0's watch loop)
|
||||
|
||||
// ITEM_FIELD_DURABILITY, ITEM-bank-relative: descriptor-absolute
|
||||
// +0xA0 (field index 0x28, which counts the 6 OBJECT fields) minus
|
||||
@@ -782,6 +843,15 @@ enum Offsets {
|
||||
// mana/rage/focus/energy/happiness order as POWER1..5). Vanilla
|
||||
// 1.12 has 5 power types; the WotLK additions (Runes / Runic Power)
|
||||
// don't exist in this descriptor layout.
|
||||
|
||||
// UNIT_MOD_CAST_SPEED — the cast-time multiplier float (1.0 = normal,
|
||||
// <1.0 = faster). The server folds it into `SpellEntry::GetCastTime`
|
||||
// (`castTime *= GetFloatValue(UNIT_MOD_CAST_SPEED)`); the client reads it
|
||||
// in `FUN_006e3340` at descriptor `+0x22c`. Backs `UnitSpellHaste`
|
||||
// (`haste% = (1/mult - 1) * 100`). nampower surfaces the raw float as its
|
||||
// `modCastSpeed` unit field.
|
||||
OFF_UNIT_MOD_CAST_SPEED = 0x22C,
|
||||
|
||||
UNIT_POWER_MIN_TYPE = 0,
|
||||
UNIT_POWER_MAX_TYPE = 4, // happiness; types 5/6 only valid post-WotLK
|
||||
|
||||
@@ -1476,6 +1546,15 @@ enum Offsets {
|
||||
// `0x0048BACF` / `0x0048BADC`.
|
||||
OFF_CGOBJECT_VTBL_GET_POSITION = 0x14,
|
||||
|
||||
// CGUnit movement-block orientation (facing) in radians, 0..2*pi. Lives
|
||||
// right after the {x,y,z} position block (at +0x9B8), i.e. pos+0xC. The
|
||||
// `GetPosition` virtual copies only x/y/z into the caller's buffer, so
|
||||
// read facing off the object directly. Backs `GetPlayerFacing`. Empirically
|
||||
// located by probing the player object across a known 180-degree turn
|
||||
// (value flipped by ~pi, stayed in range); mirrored at +0x9F8 / +0xC98
|
||||
// (last-sent / render copies) — +0x9C4 is the authoritative one.
|
||||
OFF_UNIT_FACING = 0x9C4,
|
||||
|
||||
// CGObject `m_objectType` field — 0 = (base) object, 1 = item,
|
||||
// 2 = container, 3 = unit, 4 = player, 5 = gameobject, … Read as
|
||||
// `*(int *)(obj + 0x14)`. (Distinct from GET_POSITION above, which
|
||||
@@ -2405,6 +2484,29 @@ enum Offsets {
|
||||
// "DISTANT" / other) — the node's reachability for the open session.
|
||||
FUN_TAXI_NODE_TYPE = 0x004DC4E0,
|
||||
|
||||
// Per-slot computed route (the chain that TakeTaxiNode sends to the
|
||||
// server). Derived from FUN_004dc8d0 (the TakeTaxiNode worker) and the
|
||||
// route builder FUN_004dbce0:
|
||||
// +0x0C (byte) nonzero when the multi-hop route array below is built
|
||||
// +0x14 (int*) array of TaxiNodes.dbc IDs, current->dest in flight
|
||||
// order, length at +0x18
|
||||
// +0x18 (int) route length (node count; hops = count - 1)
|
||||
// A direct hop (a TaxiPath edge exists current->dest) is flown as just
|
||||
// {current, dest} and does NOT populate +0x14 — see VAR_TAXI_ADJACENCY.
|
||||
OFF_TAXI_SLOT_MULTIHOP = 0x0C,
|
||||
OFF_TAXI_SLOT_ROUTE = 0x14,
|
||||
OFF_TAXI_SLOT_ROUTE_COUNT = 0x18,
|
||||
|
||||
// Pointer to the CURRENT flight master's TaxiNodes.dbc record (id @ +0);
|
||||
// the origin of every route. Used by FUN_004dc8d0 / FUN_004dbce0.
|
||||
VAR_TAXI_CURRENT_REC = 0x00BB4A80,
|
||||
|
||||
// Node x node -> pathID adjacency matrix (256-wide rows, int cells; cell
|
||||
// [from*0x100 + to] is the direct TaxiPath id, or 0 if none). Backs the
|
||||
// direct-hop test FUN_004dbad0. IDs are 1..256.
|
||||
VAR_TAXI_ADJACENCY = 0x00B7365C,
|
||||
TAXI_ADJACENCY_DIM = 0x100,
|
||||
|
||||
// TaxiPath.dbc (4 fields, 0x10 bytes): {id, fromNode@+0x04, toNode@+0x08,
|
||||
// cost}. A real flight master is a node some flight path connects to;
|
||||
// Northshire Abbey / spurious duplicate rows / standalone markers are not
|
||||
@@ -2413,6 +2515,23 @@ enum Offsets {
|
||||
VAR_TAXIPATH_COUNT = 0x00C0D69C,
|
||||
OFF_TAXIPATH_FROM = 0x04,
|
||||
OFF_TAXIPATH_TO = 0x08,
|
||||
OFF_TAXIPATH_COST = 0x0C,
|
||||
|
||||
// TaxiPathNode.dbc — the per-path flight waypoints. 1-based
|
||||
// record-pointer array (records/count slots per docs/DBCs.md). Record
|
||||
// (verified against the extracted DBC + the loading-screen spline
|
||||
// reader FUN_004075A0, which reads mapID@+0xC, X@+0x10, Y@+0x14):
|
||||
// {ID@+0, pathID@+4, nodeIndex@+8, mapID@+0xC,
|
||||
// X@+0x10, Y@+0x14, Z@+0x18 (float), flags@+0x1C, delay@+0x20}
|
||||
// Waypoints of a path share pathID and order by nodeIndex.
|
||||
VAR_TAXIPATHNODE_RECORDS = 0x00C0D6AC,
|
||||
VAR_TAXIPATHNODE_COUNT = 0x00C0D6B0,
|
||||
OFF_TAXIPATHNODE_PATH_ID = 0x04,
|
||||
OFF_TAXIPATHNODE_INDEX = 0x08,
|
||||
OFF_TAXIPATHNODE_MAP = 0x0C,
|
||||
OFF_TAXIPATHNODE_X = 0x10,
|
||||
OFF_TAXIPATHNODE_Y = 0x14,
|
||||
OFF_TAXIPATHNODE_Z = 0x18,
|
||||
|
||||
// Engine player-info cache — populated by the
|
||||
// SMSG_NAME_QUERY_RESPONSE handler (opcode 0x51) at 0x005551A0,
|
||||
@@ -3677,6 +3796,60 @@ enum Offsets {
|
||||
GROUP_MEMBER_STATUS_ONLINE = 0x1,
|
||||
FUN_GROUP_MEMBER_SLOT_LOOKUP = 0x00496420,
|
||||
|
||||
// Health/power fields within the two group-member blocks — the
|
||||
// out-of-range fallback the engine's UnitHealth/UnitMana (and their Max
|
||||
// variants) use when a groupmate has no live CGUnit (different map / out
|
||||
// of range), sourced from SMSG_PARTY_MEMBER_STATS. The two blocks have
|
||||
// DIFFERENT layouts (all u16):
|
||||
//
|
||||
// Stats block (FUN_GROUP_MEMBER_STATS_LOOKUP): statusFlags@+8,
|
||||
// powerType@+9, health@+0xa, maxHealth@+0xc, power@+0xe, maxPower@+0x10,
|
||||
// level@+0x12, areaId@+0x14. The full record.
|
||||
// Raid slot (FUN_GROUP_MEMBER_SLOT_LOOKUP): powerType@+0x58,
|
||||
// health@+0x5c, maxHealth@+0x5e, power@+0x60, maxPower@+0x62. Leaner —
|
||||
// no level/area/flags.
|
||||
//
|
||||
// Power values are raw (divide by the power divisor for display). Verified
|
||||
// from Script_UnitHealth (0x005174d0) / Script_UnitHealthMax (0x005175b0)
|
||||
// / Script_UnitMana (0x00517670) / Script_UnitManaMax (0x005177e0).
|
||||
OFF_GROUP_MEMBER_STATS_HEALTH = 0xa,
|
||||
OFF_GROUP_MEMBER_STATS_MAX_HEALTH = 0xc,
|
||||
OFF_GROUP_MEMBER_STATS_POWER_TYPE = 0x9,
|
||||
OFF_GROUP_MEMBER_STATS_POWER = 0xe,
|
||||
OFF_GROUP_MEMBER_STATS_MAX_POWER = 0x10,
|
||||
OFF_GROUP_MEMBER_STATS_LEVEL = 0x12,
|
||||
OFF_RAID_SLOT_POWER_TYPE = 0x58,
|
||||
OFF_RAID_SLOT_HEALTH = 0x5c,
|
||||
OFF_RAID_SLOT_MAX_HEALTH = 0x5e,
|
||||
OFF_RAID_SLOT_POWER = 0x60,
|
||||
OFF_RAID_SLOT_MAX_POWER = 0x62,
|
||||
// Remaining raid-member-struct fields, mapped from Script_GetRaidRosterInfo
|
||||
// (0x004bb560). Same struct FUN_GROUP_MEMBER_SLOT_LOOKUP returns; all
|
||||
// object-independent (readable for out-of-range raiders). Name + class are
|
||||
// NOT here — GetRaidRosterInfo reads those from the NameCache by GUID.
|
||||
OFF_RAID_SLOT_SUBGROUP = 0x08, // u32, 0-based (roster UI shows +1)
|
||||
OFF_RAID_SLOT_RANK = 0x0c, // u32
|
||||
OFF_RAID_SLOT_FLAGS = 0x18, // u32
|
||||
OFF_RAID_SLOT_LEVEL = 0x22, // u16
|
||||
OFF_RAID_SLOT_AREA_ID = 0x24, // u16 -> AreaTable.dbc
|
||||
RAID_SLOT_FLAG_ONLINE = 0x1,
|
||||
RAID_SLOT_FLAG_DEAD = 0x4,
|
||||
|
||||
// Group-member aura spell-ID arrays — the out-of-range fallback the
|
||||
// engine's Script_UnitBuff (0x00519500) / Script_UnitDebuff (0x005198f0)
|
||||
// read when a groupmate has no live CGUnit. The server transmits a
|
||||
// member's current auras (spell IDs only — no caster/duration/stacks) in
|
||||
// SMSG_PARTY_MEMBER_STATS via GROUP_UPDATE_FLAG_AURAS (buffs, slots 0..31)
|
||||
// + GROUP_UPDATE_FLAG_AURAS_NEGATIVE (debuffs, slots 32..47); the client
|
||||
// keeps them in the two group-member blocks. Both arrays are 48 u16
|
||||
// entries (UNIT_AURA_TOTAL) indexed by the same absolute slot the
|
||||
// descriptor uses. Spell IDs are truncated to u16 on the wire, so custom
|
||||
// IDs > 65535 come through wrong (same limitation as the built-in
|
||||
// UnitBuff/UnitDebuff). Read the party array only when the member's online
|
||||
// flag (OFF_GROUP_MEMBER_STATUS_FLAGS bit 0) is set — the engine does.
|
||||
OFF_GROUP_MEMBER_STATS_AURAS = 0x1a, // party stats block (FUN_00496400)
|
||||
OFF_RAID_SLOT_AURAS = 0x64, // raid slot (FUN_00496420)
|
||||
|
||||
// Per-slot GUID tables — used to map party/raid token strings
|
||||
// ("party1", "raid17") to GUIDs without going through
|
||||
// `FUN_RESOLVE_UNIT_TOKEN`, which only resolves to a local CGUnit
|
||||
@@ -3914,6 +4087,10 @@ enum Offsets {
|
||||
// the frame into the matching entry's chain.
|
||||
VAR_EVENT_TABLE_BASE_PTR = 0x00CEEF68,
|
||||
VAR_EVENT_TABLE_COUNT = 0x00CEEF64,
|
||||
// The FrameScript_EventObject is a `{cap@+0, count@+4, data@+8}` at
|
||||
// 0x00CEEF60, so COUNT/BASE above are its +4/+8. `Event::Custom::Grow`
|
||||
// writes all three (base, cap, count) when it enlarges the table.
|
||||
VAR_EVENT_TABLE_CAP = 0x00CEEF60,
|
||||
EVENT_ENTRY_STRIDE = 0x10,
|
||||
OFF_EVENT_ENTRY_NAME = 0x00,
|
||||
OFF_EVENT_ENTRY_HEAD = 0x0C,
|
||||
@@ -3954,6 +4131,17 @@ enum Offsets {
|
||||
// table after the rebuild settles. Kept for reference.
|
||||
FUN_REBUILD_EVENT_TABLE = 0x00703D90,
|
||||
|
||||
// Event-table allocator — `__thiscall(EventObject *this, uint32 count)`.
|
||||
// Called by `RebuildEventTable` to (re)size the entry array. SuperWoW and
|
||||
// nampower co-hook it to destructively clamp the FrameXML count (`> 200`;
|
||||
// glue is smaller) to 700. We deliberately DON'T hook it: our default is
|
||||
// claim-NULL into the engine's ~200 low gaps (which always suffices), and
|
||||
// when a grow IS needed `Event::Custom::Grow` reallocates the buffer +
|
||||
// swaps the globals directly (bypassing this clamp) rather than routing a
|
||||
// size through here. Also documents the empty-entry init the grow mirrors
|
||||
// ({name=0, +4=0, +8=&self, +0xC=(&self)|1}). Kept for reference.
|
||||
FUN_FRAMESCRIPT_SET_EVENT_COUNT = 0x007053B0,
|
||||
|
||||
// `FireEvent` — the engine's event dispatcher. 149 callers in the
|
||||
// binary. `__cdecl void(int eventID, const char *format, ...)`.
|
||||
// Indexes into `[VAR_EVENT_TABLE_BASE_PTR] + eventID * 0x10` and walks
|
||||
@@ -4167,6 +4355,16 @@ enum Offsets {
|
||||
// from `SMemAlloc` (which it requires).
|
||||
FUN_STORM_SSTRDUP = 0x0064A620,
|
||||
|
||||
// Storm allocator/free — the pair the engine uses for the event-table
|
||||
// buffer, so its reload teardown `SMemFree`s our grown buffer as its own.
|
||||
// Both `__stdcall`, 4 args (`ret 0x10`); SMemAlloc flag bit 8 (`& 8`)
|
||||
// zero-fills. `Event::Custom::Grow` uses them to reallocate the event
|
||||
// buffer. (Same addresses as FUN_STORM_SMEM_ALLOC / _FREE above.)
|
||||
// void* __stdcall SMemAlloc(uint size, const char *file, int line, int flags)
|
||||
// char __stdcall SMemFree (void *ptr, const char *file, int line, int flags)
|
||||
FUN_STORM_SMEMALLOC = 0x006462E0,
|
||||
FUN_STORM_SMEMFREE = 0x00646430,
|
||||
|
||||
// Talent system — per-player talent state populated at login from
|
||||
// (class, race) + Talent.dbc / TalentTab.dbc. The engine maintains
|
||||
// one `TabInfo *` per talent tab in the array at
|
||||
@@ -4228,6 +4426,31 @@ enum Offsets {
|
||||
VAR_TALENT_DBC_RECORDS = 0x00C0D6E8,
|
||||
VAR_TALENT_DBC_COUNT = 0x00C0D6EC,
|
||||
|
||||
// Flat (row-order) DBC arrays used by the talent-tree builder
|
||||
// `FUN_004f2c00` — DISTINCT from the sparse by-ID index above. The builder
|
||||
// walks TalentTab.dbc in row order, filtering each tab by
|
||||
// `FUN_004f2e50(race, class, raceMask@+0x2c, classMask@+0x30)` (a plain
|
||||
// bitmask test; mask 0 = all), then walks Talent.dbc in row order grouping
|
||||
// contiguous TabID runs — so `GetTalentInfo(tab, idx)` ordering IS just
|
||||
// "TalentTab rows for the class in row order" × "Talent.dbc rows for that
|
||||
// TabID in row order". Reading these arrays with the same walk (but an
|
||||
// arbitrary class-mask) reproduces the engine's ordering for any class,
|
||||
// which is how `GetTalentIDByIndex`'s optional classID arg works.
|
||||
//
|
||||
// Each global holds a base POINTER (deref) to the contiguous record array;
|
||||
// the sibling +4 holds the record count. Strides match the by-ID records:
|
||||
// TalentTab 0x3c, Talent 0x54 (TALENT_ENTRY_STRIDE).
|
||||
// TalentTab record: ID@+0x00, raceMask@+0x2c, classMask@+0x30
|
||||
// Talent record: ID@+0x00, TabID@+0x04 (rest per the by-ID layout)
|
||||
VAR_TALENTTAB_DBC_FLAT_RECORDS = 0x00C0D6CC,
|
||||
VAR_TALENTTAB_DBC_FLAT_COUNT = 0x00C0D6D0,
|
||||
TALENTTAB_ENTRY_STRIDE = 0x3c,
|
||||
OFF_TALENTTAB_ID = 0x00,
|
||||
OFF_TALENTTAB_CLASS_MASK = 0x30,
|
||||
VAR_TALENT_DBC_FLAT_RECORDS = 0x00C0D6E0,
|
||||
VAR_TALENT_DBC_FLAT_COUNT = 0x00C0D6E4,
|
||||
OFF_TALENT_DBC_TAB_ID = 0x04,
|
||||
|
||||
// Engine's `Script_GetTalentInfo` Lua C function. We call it from
|
||||
// `GetTalentSpellID` to derive the player's currentRank without
|
||||
// re-implementing the spell-knowledge checks at `0x0060C740` /
|
||||
@@ -5115,6 +5338,12 @@ enum Offsets {
|
||||
// (form ID for shapeshift, channel index for periodic, etc.).
|
||||
OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME = 0x16C, // int32[3]
|
||||
OFF_SPELL_RECORD_EFFECT_MISC_VALUE = 0x1A8, // int32[3]
|
||||
// Per-effect SpellMechanic ids (EffectMechanic[3]). One int32[3] (0xC)
|
||||
// after EffectBasePoints (0x130); cross-checked against EffectRadiusIndex
|
||||
// (0x160) and EffectApplyAuraName (0x16C). Vanilla often stores a spell's
|
||||
// mechanic on an effect rather than the spell-level Mechanic (0x14), so
|
||||
// bleed / DoT detection needs this array in addition to that field.
|
||||
OFF_SPELL_RECORD_EFFECT_MECHANIC = 0x13C, // int32[3]
|
||||
SPELL_RECORD_EFFECT_COUNT = 3,
|
||||
|
||||
// SPELL_EFFECT_* id array (`Effect[3]`) — five arrays before
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
|
||||
+330
-42
@@ -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,12 @@ 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);
|
||||
}
|
||||
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);
|
||||
// `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
|
||||
@@ -633,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
|
||||
|
||||
+157
-2
@@ -70,6 +70,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
|
||||
@@ -135,6 +178,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 —
|
||||
@@ -156,6 +238,10 @@ void Evict(uint64_t targetGuid, uint32_t spellId) {
|
||||
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
|
||||
@@ -191,6 +277,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};
|
||||
@@ -252,6 +344,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,
|
||||
@@ -283,9 +381,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);
|
||||
}
|
||||
|
||||
@@ -423,4 +527,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
|
||||
|
||||
@@ -74,4 +74,19 @@ void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count);
|
||||
// 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
|
||||
|
||||
@@ -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,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
|
||||
@@ -88,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+178
-7
@@ -46,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
|
||||
@@ -61,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;
|
||||
@@ -76,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);
|
||||
@@ -88,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) {
|
||||
@@ -133,6 +283,11 @@ int LookupByName(const char *name) {
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -145,6 +300,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
|
||||
@@ -190,10 +347,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};
|
||||
|
||||
@@ -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
|
||||
@@ -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,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
|
||||
+1
-12
@@ -92,18 +92,7 @@ int MapRollType(int packetType) {
|
||||
// have gone). Guaranteed loaded at hook time: the roll handler's wrapper
|
||||
// gates on this same lookup succeeding. Returns nullptr if not cached.
|
||||
const uint8_t *LookupPlayerRecord(uint64_t guid) {
|
||||
if (guid == 0)
|
||||
return nullptr;
|
||||
using Lookup_t = const uint8_t *(__thiscall *)(void *cache, uint32_t lo,
|
||||
uint32_t hi, void *cookie,
|
||||
void *cb, void *ud, char flag);
|
||||
auto fn = reinterpret_cast<Lookup_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_PLAYER_INFO_LOOKUP));
|
||||
uint32_t cookie[2] = {0, 0};
|
||||
return fn(reinterpret_cast<void *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_PLAYER_NAME_CACHE)),
|
||||
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32),
|
||||
cookie, nullptr, nullptr, 0);
|
||||
return Unit::Identity::PlayerInfoRecord(guid);
|
||||
}
|
||||
|
||||
const char *NameFromRecord(const uint8_t *rec) {
|
||||
|
||||
+96
-35
@@ -30,17 +30,23 @@
|
||||
// (dispatcher leaves `_G.arg<N>` alone when no codes are parsed),
|
||||
// restore.
|
||||
// - `NAME_PLATE_UNIT_ADDED` / `_REMOVED` — `arg1` is the
|
||||
// `"nameplateN"` unit token (formatted from the plate's index in
|
||||
// `g_orderedGUIDs` at fire time). The token resolves to the unit
|
||||
// via the `nameplateN`-aware token resolver in `unit/TokenExtensions.cpp`
|
||||
// — addons can pass it straight to `UnitName`, `UnitGUID`, etc.,
|
||||
// or to `GetNamePlateForUnit` for the frame.
|
||||
// `"nameplateN"` unit token (`N` = the plate's assigned slot). The token
|
||||
// resolves to the unit via the `nameplateN`-aware token resolver in
|
||||
// `unit/TokenExtensions.cpp` — addons can pass it straight to `UnitName`,
|
||||
// `UnitGUID`, etc., or to `GetNamePlateForUnit` for the frame.
|
||||
//
|
||||
// Slot assignment (retail-exact): a plate keeps its slot for its whole
|
||||
// lifetime, so surviving plates are NEVER renumbered when another is removed
|
||||
// — a removal frees the slot and the next new plate reuses the lowest free
|
||||
// one. `g_slots` is therefore a sparse array (freed middle slots read back as
|
||||
// the empty GUID 0 until reused), not a compacting list.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "event/Custom.h"
|
||||
#include "nameplate/Walk.h"
|
||||
#include "tick/WorldTick.h"
|
||||
#include "unit/TokenObserver.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
@@ -84,13 +90,27 @@ std::unordered_map<uint64_t, const void *> g_currentTickPlates;
|
||||
// then tops out as pool reuse covers all subsequent shows.
|
||||
std::unordered_set<const void *> g_seenPlates;
|
||||
|
||||
// Ordered list of currently-visible nameplate GUIDs, in
|
||||
// creation-order. Append on UNIT_ADDED, erase on UNIT_REMOVED. Backs
|
||||
// the `nameplateN` unit-token resolver in `unit/TokenExtensions.cpp`. Order
|
||||
// matches modern WoW semantics: stable for the lifetime of each
|
||||
// plate, gaps when middle plates vanish (until the next REMOVED
|
||||
// shifts later entries down).
|
||||
std::vector<uint64_t> g_orderedGUIDs;
|
||||
// Nameplate token slots: slot `i` (0-based) holds the GUID assigned to
|
||||
// `nameplate(i+1)`, or 0 when free. Retail-exact slot assignment — a plate
|
||||
// keeps its slot for its whole lifetime (survivors are never renumbered),
|
||||
// UNIT_REMOVED frees the slot (sets 0), and a new plate reuses the lowest
|
||||
// free slot. Sparse: freed middle slots stay as gaps until reused. Backs the
|
||||
// `nameplateN` unit-token resolver in `unit/TokenExtensions.cpp`.
|
||||
std::vector<uint64_t> g_slots;
|
||||
|
||||
// Assign `guid` the lowest free slot, reusing a vacated one when present and
|
||||
// growing the array only when every slot is occupied. Returns the 0-based
|
||||
// slot. Never shifts an existing entry.
|
||||
int AssignSlot(uint64_t guid) {
|
||||
for (size_t i = 0; i < g_slots.size(); ++i) {
|
||||
if (g_slots[i] == 0) {
|
||||
g_slots[i] = guid;
|
||||
return static_cast<int>(i);
|
||||
}
|
||||
}
|
||||
g_slots.push_back(guid);
|
||||
return static_cast<int>(g_slots.size() - 1);
|
||||
}
|
||||
|
||||
// Fire `eventName` with a pre-formatted string as `arg1`. The engine
|
||||
// dispatcher's `%s` format code pushes the C string into `_G.arg1`
|
||||
@@ -114,6 +134,32 @@ const char *FormatNamePlateToken(char *buf, size_t bufSize, int oneBasedIndex) {
|
||||
return buf;
|
||||
}
|
||||
|
||||
// Descriptor-field observer callback — makes unit events (UNIT_HEALTH,
|
||||
// UNIT_AURA, …) fire with the `"nameplateN"` token, so a nameplated unit is a
|
||||
// first-class unit-event source exactly like a party member. Registered per
|
||||
// nameplate GUID via `Unit::TokenObserver` on UNIT_ADDED, unregistered on
|
||||
// UNIT_REMOVED. We reverse-look-up the changed GUID's slot at fire time
|
||||
// (cheap; the array is small) so the token reflects its current assignment.
|
||||
// The event id is the field index (`fieldOffset >> 2`); the engine fires every
|
||||
// unit event as "%s" + token, so we match. A GUID no longer in any slot
|
||||
// (removal raced this field change) simply drops the fire.
|
||||
int __fastcall NamePlateFieldCb(uint32_t fieldOffset, uint32_t /*size*/,
|
||||
uint32_t guidLo, uint32_t guidHi,
|
||||
const uint32_t * /*oldValue*/, void * /*userArg*/) {
|
||||
const uint64_t guid =
|
||||
(static_cast<uint64_t>(guidHi) << 32) | static_cast<uint64_t>(guidLo);
|
||||
auto it = std::find(g_slots.begin(), g_slots.end(), guid);
|
||||
if (it == g_slots.end())
|
||||
return 1;
|
||||
const int oneBased = static_cast<int>(it - g_slots.begin()) + 1;
|
||||
char tokenBuf[24];
|
||||
FormatNamePlateToken(tokenBuf, sizeof tokenBuf, oneBased);
|
||||
using Fire_t = void(__cdecl *)(int eventId, const char *fmt, ...);
|
||||
reinterpret_cast<Fire_t>(static_cast<uintptr_t>(Offsets::FUN_FIRE_EVENT))(
|
||||
static_cast<int>(fieldOffset >> 2), "%s", tokenBuf);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Fire `eventName` with the nameplate `Frame` set as `_G.arg1`.
|
||||
// `FUN_FIRE_EVENT`'s format-string parser only handles primitive
|
||||
// types, but it only mutates `_G.arg<N>` for codes it actually
|
||||
@@ -178,38 +224,42 @@ void OnWorldTick() {
|
||||
|
||||
// Fire CREATED (with the Frame as arg1) for never-before-seen
|
||||
// frame pointers; ADDED (with `"nameplateN"` token as arg1) for
|
||||
// GUIDs not in last tick's snapshot. New entries are appended to
|
||||
// the ordered list *before* firing so the token resolves to the
|
||||
// newly-added plate during the event handler.
|
||||
// GUIDs not in last tick's snapshot. The slot is assigned *before*
|
||||
// firing so the token resolves to the newly-added plate during the
|
||||
// event handler.
|
||||
for (const auto &kv : g_currentTickPlates) {
|
||||
if (g_seenPlates.insert(kv.second).second)
|
||||
FireWithFrame(kEventCreated, const_cast<void *>(kv.second));
|
||||
if (g_lastTickPlates.find(kv.first) == g_lastTickPlates.end()) {
|
||||
g_orderedGUIDs.push_back(kv.first);
|
||||
const int slot = AssignSlot(kv.first);
|
||||
// Watch this unit's fields so UNIT_HEALTH/UNIT_AURA/… fire with
|
||||
// its "nameplateN" token (the engine only watches its own
|
||||
// target/party/raid tokens, not nameplates).
|
||||
Unit::TokenObserver::Register(kv.first, &NamePlateFieldCb);
|
||||
char tokenBuf[24];
|
||||
FireWithString(kEventUnitAdded,
|
||||
FormatNamePlateToken(tokenBuf, sizeof tokenBuf,
|
||||
static_cast<int>(g_orderedGUIDs.size())));
|
||||
FormatNamePlateToken(tokenBuf, sizeof tokenBuf, slot + 1));
|
||||
}
|
||||
}
|
||||
|
||||
// Fire REMOVED for GUIDs in last tick's snapshot but not current.
|
||||
// We compute the token from the position *before* erasing so the
|
||||
// event payload reflects the slot the unit just vacated; the
|
||||
// handler can still resolve the token to the unit via
|
||||
// `g_orderedGUIDs[slot]` during dispatch. Later plates shift down
|
||||
// when we erase — matches modern semantics.
|
||||
// Fire REMOVED for GUIDs in last tick's snapshot but not current. The
|
||||
// slot is *freed* (set 0), not erased, so surviving plates keep their
|
||||
// index (retail-exact — no shift); the next ADD reuses it. Token is
|
||||
// computed before freeing so the payload names the slot just vacated.
|
||||
// Trailing empties are trimmed so the array tracks the high-water mark.
|
||||
for (const auto &kv : g_lastTickPlates) {
|
||||
if (g_currentTickPlates.find(kv.first) == g_currentTickPlates.end()) {
|
||||
auto it = std::find(g_orderedGUIDs.begin(), g_orderedGUIDs.end(),
|
||||
kv.first);
|
||||
if (it == g_orderedGUIDs.end())
|
||||
auto it = std::find(g_slots.begin(), g_slots.end(), kv.first);
|
||||
if (it == g_slots.end())
|
||||
continue;
|
||||
const int oneBased = static_cast<int>(it - g_orderedGUIDs.begin()) + 1;
|
||||
const int oneBased = static_cast<int>(it - g_slots.begin()) + 1;
|
||||
char tokenBuf[24];
|
||||
FireWithString(kEventUnitRemoved,
|
||||
FormatNamePlateToken(tokenBuf, sizeof tokenBuf, oneBased));
|
||||
g_orderedGUIDs.erase(it);
|
||||
Unit::TokenObserver::Unregister(kv.first, &NamePlateFieldCb);
|
||||
*it = 0; // free the slot (no shift of survivors)
|
||||
while (!g_slots.empty() && g_slots.back() == 0)
|
||||
g_slots.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,24 +280,35 @@ static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
|
||||
// suppress every refire, and the freshly-built wrapper would lack
|
||||
// the addon's `.nameplate` field.
|
||||
//
|
||||
// `g_orderedGUIDs` is also cleared so the post-reload token indices
|
||||
// start at `nameplate1` again, matching the order plates re-fire in.
|
||||
// `g_slots` is also cleared so post-reload token slots start fresh.
|
||||
void PrepareForReload() {
|
||||
g_seenPlates.clear();
|
||||
g_lastTickPlates.clear();
|
||||
g_orderedGUIDs.clear();
|
||||
// Objects survive a /reload, so their observer nodes do too. The first
|
||||
// post-reload tick re-fires ADDED and re-registers, which would stack a
|
||||
// second observer per field (the registrar never dedups) → double events.
|
||||
// Tear ours down here so re-registration starts clean (skip free slots).
|
||||
for (uint64_t guid : g_slots)
|
||||
if (guid != 0)
|
||||
Unit::TokenObserver::Unregister(guid, &NamePlateFieldCb);
|
||||
g_slots.clear();
|
||||
}
|
||||
|
||||
// Exposed via `nameplate/Walk.h` so the `nameplateN` token resolver
|
||||
// in `unit/TokenExtensions.cpp` can map an index to a GUID without seeing
|
||||
// the internal vector.
|
||||
// the internal array. Returns 0 for an out-of-range OR currently-free slot.
|
||||
uint64_t GetGUIDByIndex(int oneBased) {
|
||||
if (oneBased <= 0)
|
||||
return 0;
|
||||
const size_t idx = static_cast<size_t>(oneBased - 1);
|
||||
if (idx >= g_orderedGUIDs.size())
|
||||
if (idx >= g_slots.size())
|
||||
return 0;
|
||||
return g_orderedGUIDs[idx];
|
||||
return g_slots[idx]; // 0 when the slot is free
|
||||
}
|
||||
|
||||
// Number of slots (1-based max index). Because the slot array is SPARSE,
|
||||
// callers that iterate all plates must scan `1..GetSlotCount()` and skip the
|
||||
// slots where `GetGUIDByIndex` returns 0 — they can't stop at the first gap.
|
||||
int GetSlotCount() { return static_cast<int>(g_slots.size()); }
|
||||
|
||||
} // namespace NamePlate::Events
|
||||
|
||||
+7
-66
@@ -56,6 +56,7 @@
|
||||
#include "Offsets.h"
|
||||
#include "guid/Guid.h"
|
||||
#include "nameplate/Walk.h"
|
||||
#include "ui/FrameObject.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
@@ -66,9 +67,6 @@ namespace {
|
||||
using NamePlate::Walk::ForEachNamePlatedUnit;
|
||||
using NamePlate::Walk::kOffUnitNamePlate;
|
||||
|
||||
using LuaRawGetI_t = void(__fastcall *)(void *L, int idx, int n);
|
||||
using ScriptRegister_t = void(__fastcall *)(void *this_, void *edx_unused,
|
||||
const char *name);
|
||||
using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
|
||||
using ResolveByGUID_t = void *(__fastcall *)(int type, const char *debugName,
|
||||
uint32_t guidLo, uint32_t guidHi,
|
||||
@@ -115,70 +113,13 @@ static int __fastcall Script_GetNamePlateGUIDs(void *L) {
|
||||
// frame-push path. Internal callers (Script_GetNamePlates etc.) call
|
||||
// it through the unqualified name (they live in the same namespace).
|
||||
//
|
||||
// Delegate wrapper construction to the engine's own
|
||||
// `FrameScript_Object::ScriptRegister`. First call for a never-seen
|
||||
// nameplate builds a `{[0] = lightuserdata}` table with the
|
||||
// framescript metatable, `luaL_ref`s it into the registry, and
|
||||
// writes the refkey to `nameplate + 0x08` (`OFF_COBJECT_LUA_REGISTRY_REF`).
|
||||
// Subsequent calls find a populated refkey and just rawgeti.
|
||||
//
|
||||
// Why delegate instead of building our own wrapper:
|
||||
//
|
||||
// - **Single source of truth.** Every code path that pushes this
|
||||
// frame — ours via this function, addons' `CreateFrame("Type",
|
||||
// "name", plate)` extracting the parent, the engine's own internal
|
||||
// pushers — funnels through `rawgeti(REGISTRY, plate+0x08)` and
|
||||
// gets the same Lua table. Earlier "build our own wrapper +
|
||||
// `luaL_ref` it" approach kept a side cache, but pfUI's
|
||||
// `CreateFrame("Button", "name", parent)` inside `NAME_PLATE_CREATED`
|
||||
// ends up causing the engine to register the parent itself —
|
||||
// the engine's wrapper diverges from ours, and later
|
||||
// `GetNamePlateForUnit` calls return the engine's bare wrapper
|
||||
// instead of our decorated one. Addon fields on the wrapper
|
||||
// (`plate.nameplate = styledButton`) disappear from the API's
|
||||
// perspective even though they still exist on the orphaned
|
||||
// table.
|
||||
//
|
||||
// - **Refcount-pinning is benign here.** `ScriptRegister`
|
||||
// unconditionally increments `nameplate + 0x04`, which normally
|
||||
// keeps the engine from GC-ing the CObject. Nameplates are
|
||||
// pool-managed and never destroyed during a session, so pinning
|
||||
// doesn't actually leak anything. We call `ScriptRegister` at
|
||||
// most once per nameplate pointer (gated by `refKey <= 0`), so
|
||||
// the refcount tops out at one increment per pool slot.
|
||||
//
|
||||
// Defensive fallback: if the registry slot exists but isn't a table
|
||||
// any more (some other code freed it), we re-register. This shouldn't
|
||||
// happen in practice — engines only unref on frame destruction —
|
||||
// but a stale type check keeps callers from ever seeing a non-table.
|
||||
// Thin wrapper over the shared `UI::FrameObject::Push` — nameplates and
|
||||
// chat bubbles are both engine-created-in-C++ `CSimpleFrame`s that need
|
||||
// the same lazy `ScriptRegister` → `rawgeti` push so addons get the
|
||||
// engine's canonical wrapper (see that helper for the pfUI-divergence
|
||||
// rationale that motivated delegating to the engine).
|
||||
void PushNamePlateFrame(void *L, void *nameplate) {
|
||||
auto rawgeti = reinterpret_cast<LuaRawGetI_t>(
|
||||
Offsets::FUN_FRAMESCRIPT_PUSH_OBJECT);
|
||||
auto scriptRegister = reinterpret_cast<ScriptRegister_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER));
|
||||
|
||||
int refKey = *reinterpret_cast<const int *>(
|
||||
static_cast<uint8_t *>(nameplate) + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
|
||||
if (refKey <= 0) {
|
||||
scriptRegister(nameplate, nullptr, nullptr);
|
||||
refKey = *reinterpret_cast<const int *>(
|
||||
static_cast<uint8_t *>(nameplate) + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
|
||||
}
|
||||
|
||||
rawgeti(L, Game::Lua::REGISTRY_INDEX, refKey);
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE)
|
||||
return;
|
||||
Game::Lua::SetTop(L, -2);
|
||||
|
||||
// Slot got freed somewhere — re-register to rebuild a fresh
|
||||
// wrapper and slot. Zero the refcount so `ScriptRegister`
|
||||
// re-enters its build branch.
|
||||
*reinterpret_cast<int *>(
|
||||
static_cast<uint8_t *>(nameplate) + Offsets::OFF_COBJECT_LUA_REFCOUNT) = 0;
|
||||
scriptRegister(nameplate, nullptr, nullptr);
|
||||
refKey = *reinterpret_cast<const int *>(
|
||||
static_cast<uint8_t *>(nameplate) + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
|
||||
rawgeti(L, Game::Lua::REGISTRY_INDEX, refKey);
|
||||
UI::FrameObject::Push(L, nameplate);
|
||||
}
|
||||
|
||||
// GUID → nameplate Frame pushed on stack. Pushes nil if the GUID
|
||||
|
||||
+10
-4
@@ -37,12 +37,18 @@ void PushNamePlateFrame(void *L, void *nameplate);
|
||||
namespace NamePlate::Events {
|
||||
|
||||
// Returns the GUID currently bound to `nameplateN` (1-based, matching
|
||||
// the modern Lua token form). `0` means no nameplate at that index.
|
||||
// Backed by the per-tick UNIT_ADDED / UNIT_REMOVED diff in
|
||||
// `Events.cpp` — order is creation-order (append on ADDED, erase on
|
||||
// REMOVED), stable within a frame.
|
||||
// the modern Lua token form). `0` means the slot is out of range OR
|
||||
// currently free. Backed by the retail-exact slot assignment in
|
||||
// `Events.cpp`: a plate keeps its slot for its lifetime and freed slots
|
||||
// are reused, so the slot array is SPARSE (gaps read back as 0).
|
||||
uint64_t GetGUIDByIndex(int oneBased);
|
||||
|
||||
// Highest slot index in use (1-based). Callers that iterate every plate
|
||||
// must loop `1..GetSlotCount()` and skip indices where `GetGUIDByIndex`
|
||||
// returns 0 — they must NOT stop at the first gap, since the array is
|
||||
// sparse.
|
||||
int GetSlotCount();
|
||||
|
||||
// Clears the per-tick diff state (seen pointers, last tick's
|
||||
// snapshot, ordered GUID list) so the post-`/reload` tick refires
|
||||
// CREATED + UNIT_ADDED for every currently-visible plate. Called
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// 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/>.
|
||||
|
||||
// `GetPlayerFacing()` → facing angle of the player in radians (0 =
|
||||
// north/+Y in WoW's world convention, increasing counter-clockwise), or
|
||||
// nothing when there's no player (glue / loading). Vanilla 1.12 doesn't
|
||||
// ship it (only character-create/select facing exist), but it's the
|
||||
// missing half that makes our backported `TargetDirectionEnemy/Friend`
|
||||
// usable — a caller passes `GetPlayerFacing()` to target what's in front.
|
||||
//
|
||||
// The orientation is a field in the player's CGUnit movement block
|
||||
// (`OFF_UNIT_FACING` = +0x9C4), sitting just past the {x,y,z} position at
|
||||
// +0x9B8. The `GetPosition` virtual only copies x/y/z into a caller buffer,
|
||||
// so the facing is read off the object directly.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "unit/Position.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Player::Facing {
|
||||
|
||||
namespace {
|
||||
|
||||
int __fastcall Script_GetPlayerFacing(void *L) {
|
||||
void *player = Unit::Position::ResolveToken("player");
|
||||
if (player == nullptr)
|
||||
return 0;
|
||||
|
||||
const float facing = *reinterpret_cast<const float *>(
|
||||
static_cast<const uint8_t *>(player) + Offsets::OFF_UNIT_FACING);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(facing));
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("GetPlayerFacing", &Script_GetPlayerFacing);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Player::Facing
|
||||
+5
-22
@@ -39,6 +39,7 @@
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "guid/Guid.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
@@ -47,20 +48,8 @@ namespace Player::Info {
|
||||
|
||||
namespace {
|
||||
|
||||
// `FUN_0055F080` — `__thiscall(this=cache, guidLo, guidHi, &cookie,
|
||||
// callback, userData, retryFlag) → entryData* or NULL`.
|
||||
using LookupOrFetch_t = const uint8_t *(__thiscall *)(
|
||||
void *cache,
|
||||
uint32_t guidLo,
|
||||
uint32_t guidHi,
|
||||
uint64_t *cookie,
|
||||
void *callback,
|
||||
void *userData,
|
||||
int retryFlag);
|
||||
|
||||
// Thin shim: parses a GUID string and splits to hi/lo dwords for the
|
||||
// engine's `__thiscall` cache lookup, which takes them as separate
|
||||
// args. Shared parser lives in `Guid::Parse`.
|
||||
// Thin shim: parses a GUID string and splits to hi/lo dwords. Shared parser
|
||||
// lives in `Guid::Parse`.
|
||||
bool ParseGUID(const char *str, uint32_t &outHi, uint32_t &outLo) {
|
||||
uint64_t value = 0;
|
||||
if (!Guid::Parse(str, &value))
|
||||
@@ -82,14 +71,8 @@ int __fastcall Script_GetPlayerInfoByGUID(void *L) {
|
||||
if (hi == 0 && lo == 0)
|
||||
return 0;
|
||||
|
||||
auto fn = reinterpret_cast<LookupOrFetch_t>(Offsets::FUN_PLAYER_INFO_LOOKUP);
|
||||
auto *cache = reinterpret_cast<void *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_PLAYER_NAME_CACHE));
|
||||
|
||||
uint64_t cookie = 0;
|
||||
const uint8_t *entry = fn(cache, lo, hi, &cookie,
|
||||
nullptr /*callback*/, nullptr /*userData*/,
|
||||
0 /*retryFlag*/);
|
||||
const uint8_t *entry = Unit::Identity::PlayerInfoRecord(
|
||||
(static_cast<uint64_t>(hi) << 32) | lo);
|
||||
|
||||
const char *name = nullptr;
|
||||
const char *realm = nullptr;
|
||||
|
||||
+46
-4
@@ -91,6 +91,7 @@ using GetDuration_t = int(__fastcall *)(const uint8_t *spellRecord, int unit, in
|
||||
constexpr int OFF_NAME = 0x1E0; // localized name[9]
|
||||
constexpr int OFF_ICON_ID = 0x1D4; // -> SpellIcon.dbc
|
||||
constexpr int OFF_ATTRIBUTES = 0x18; // u32 Attributes flags
|
||||
constexpr int OFF_ATTRIBUTES_EX2 = 0x24; // u32 AttributesEx2 flags
|
||||
constexpr int OFF_SUBRECORD_VALUE = 0x04; // SpellIcon path field
|
||||
|
||||
// SPELL_ATTR_TRADESPELL — set on profession recipe casts (enchant,
|
||||
@@ -99,6 +100,12 @@ constexpr int OFF_SUBRECORD_VALUE = 0x04; // SpellIcon path field
|
||||
// there, +0x18 here — the bit value is the same across builds).
|
||||
constexpr uint32_t SPELL_ATTR_TRADESPELL = 0x20;
|
||||
|
||||
// SPELL_ATTR_RANGED — every ranged ability carries it; the server adds a
|
||||
// flat +500ms "aim time" to these (SpellEntry::GetCastTime). Auto-repeat
|
||||
// shots (Auto Shot / Shoot, AttributesEx2 bit 0x20) are the one exclusion.
|
||||
constexpr uint32_t SPELL_ATTR_RANGED = 0x2;
|
||||
constexpr uint32_t SPELL_ATTR_EX2_AUTOREPEAT = 0x20;
|
||||
|
||||
struct TrackedSpell {
|
||||
int spellID; // 0 = not casting / channeling
|
||||
int startMs;
|
||||
@@ -163,8 +170,30 @@ void *Resolve(const char *token) {
|
||||
// matches the engine's cast bar exactly — including talents like a Mage's
|
||||
// faster casts. Unit arg 0 = local player; flag 0 clamps negatives to 0.
|
||||
int CastTimeMs(int spellID) {
|
||||
return static_cast<int>(
|
||||
int ms = static_cast<int>(
|
||||
reinterpret_cast<GetCastTime_t>(Offsets::FUN_GET_CAST_TIME)(spellID, 0, 0));
|
||||
// FUN_006e3340 (also the spell tooltip's cast-time source) does NOT add the
|
||||
// flat +500ms "aim time" the server applies to ranged, non-autorepeat
|
||||
// spells — SpellEntry::GetCastTime does `castTime += 500` for
|
||||
// SPELL_ATTR_RANGED. So our zero-latency prediction runs ~500ms short of
|
||||
// the server for Volley / Aimed Shot / Multi-Shot; the confirming
|
||||
// SMSG_SPELL_START then snaps the bar out ~1 RTT later. Mirror the server
|
||||
// here so the prediction is right from frame 1 (only for spells that
|
||||
// already have a cast time — instant ranged abilities that the server
|
||||
// gives a 500ms aim time still surface through the packet path). The
|
||||
// cast-time reconcile in HandleSpellStart remains the backstop.
|
||||
if (ms > 0) {
|
||||
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
|
||||
if (rec != nullptr) {
|
||||
const uint32_t attr =
|
||||
*reinterpret_cast<const uint32_t *>(rec + OFF_ATTRIBUTES);
|
||||
const uint32_t attrEx2 =
|
||||
*reinterpret_cast<const uint32_t *>(rec + OFF_ATTRIBUTES_EX2);
|
||||
if ((attr & SPELL_ATTR_RANGED) && !(attrEx2 & SPELL_ATTR_EX2_AUTOREPEAT))
|
||||
ms += 500;
|
||||
}
|
||||
}
|
||||
return ms;
|
||||
}
|
||||
|
||||
// Effective channel duration (ms) for the local player, 0 if none. Uses
|
||||
@@ -407,13 +436,26 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
|
||||
// Spell_C_CastSpell at `spellID == VAR_CURRENT_CAST_SPELL`, so the
|
||||
// client path (FUN_CAST_START_SET hook) never fires for them — this
|
||||
// server packet is the only signal. Dedup against a fresh client stamp
|
||||
// (same spell, started within ~latency) so a normal cast's confirming
|
||||
// packet doesn't restart its bar; otherwise it's a chained recast the
|
||||
// (same spell, started within ~latency): a normal cast's confirming
|
||||
// packet mustn't RESTART its bar; otherwise it's a chained recast the
|
||||
// client bailed on — take it, and flag it so the VAR==0 poll (which
|
||||
// never saw it) won't clear it.
|
||||
if (g_cast.spellID == spellID && now < g_cast.endMs &&
|
||||
now - g_cast.startMs < kCastStartDedupMs)
|
||||
now - g_cast.startMs < kCastStartDedupMs) {
|
||||
// Confirming packet. Keep startMs (no visual restart), but SNAP the
|
||||
// end time to the server's authoritative castTime. The client stamp
|
||||
// came from FUN_006e3340, which folds in the caster's cast-speed /
|
||||
// ranged-haste multiplier (descriptor +0x22c) — right for spells the
|
||||
// server also hastes (Aimed/Multi-Shot), WRONG for ones it doesn't.
|
||||
// Volley is the case that surfaced this: not channeled, not sped by
|
||||
// Quick Shots server-side, so the hasted prediction ended the bar
|
||||
// early and the player was "still casting after the bar finished."
|
||||
// The server's castTime is reality; reconcile to it (preserving any
|
||||
// pushback already accumulated). A no-op when client and server agree.
|
||||
g_cast.endMs =
|
||||
g_cast.startMs + static_cast<int>(castTime) + g_cast.delayMs;
|
||||
return;
|
||||
}
|
||||
g_cast = TrackedSpell{spellID, now, now + static_cast<int>(castTime), 0};
|
||||
g_castFromServer = true;
|
||||
g_channel.spellID = 0; // a cast supersedes any channel
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Spell.GetSpellEffectMechanics(spellID)` -> { m1, m2, m3 }
|
||||
//
|
||||
// Returns a spell's three per-effect mechanic ids (Spell.dbc
|
||||
// `EffectMechanic[3]`) as a 1-based array table, or nil for an invalid /
|
||||
// out-of-range spell ID. Each entry is a standard WoW SpellMechanic id
|
||||
// (0 = that effect carries no mechanic); the numbering matches
|
||||
// C_Spell.GetSpellMechanicByID (1 = Charm, 7 = Root, 12 = Stun,
|
||||
// 15 = Bleed, 17 = Polymorph, ...).
|
||||
//
|
||||
// Complements GetSpellMechanicByID, which reads only the SPELL-level
|
||||
// `Mechanic` field (+0x14). Vanilla frequently stores the meaningful
|
||||
// mechanic on an *effect* rather than the spell — periodic damage such
|
||||
// as bleeds is the common case — so effect-mechanic-aware callers (e.g.
|
||||
// bleed classification for immunity tracking) need this array too:
|
||||
//
|
||||
// local em = C_Spell.GetSpellEffectMechanics(spellID)
|
||||
// if em then for i = 1, 3 do if em[i] == 15 then --[[ bleed ]] end end end
|
||||
//
|
||||
// Reads Spell.dbc directly, so it covers every spell the client knows —
|
||||
// not just the player's spellbook — with no caching or network round-trip
|
||||
// (Spell.dbc is resident from boot).
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "spell/Arg.h"
|
||||
#include "spell/Lookup.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Spell::EffectMechanic {
|
||||
|
||||
namespace {
|
||||
|
||||
int __fastcall Script_GetSpellEffectMechanics(void *L) {
|
||||
const int spellID = Spell::Arg::ResolveSpellID(L, 1);
|
||||
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
|
||||
if (record == nullptr)
|
||||
return 0; // nil for invalid / out-of-range spell IDs
|
||||
|
||||
auto *mechanics = reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_SPELL_RECORD_EFFECT_MECHANIC);
|
||||
|
||||
// Fixed 1-based array of all three effects (0 = effect has no mechanic),
|
||||
// matching the raw EffectMechanic[3] layout so callers can index by slot.
|
||||
Game::Lua::NewTable(L);
|
||||
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(i + 1)); // key
|
||||
Game::Lua::PushNumber(L, static_cast<double>(mechanics[i])); // value
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellEffectMechanics",
|
||||
&Script_GetSpellEffectMechanics);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace Spell::EffectMechanic
|
||||
+33
-20
@@ -443,34 +443,46 @@ static int __fastcall Script_C_IsSpellPassive(void *L) {
|
||||
// arrays and not in the talent tree).
|
||||
// - Same shape modern WoW (5.4.8+) uses for the same function — see
|
||||
// `[0x011C25D8]` in 5.4.8's `Wow.exe`.
|
||||
// Shared read of the engine's known-spell bitmap (the same bit the helper
|
||||
// at `0x0060C740` consults): true iff the local player knows `spellID`.
|
||||
// Bounds-checked; false for out-of-range IDs or before the bitmap exists.
|
||||
static bool PlayerKnowsSpell(int spellID) {
|
||||
if (spellID < 1)
|
||||
return false;
|
||||
const int spellCount = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_SPELL_RECORD_COUNT));
|
||||
if (spellID > spellCount)
|
||||
return false;
|
||||
auto *bitmap = *reinterpret_cast<const uint32_t *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELL_BITMAP));
|
||||
if (bitmap == nullptr)
|
||||
return false;
|
||||
const uint32_t mask = 1u << (spellID & 31);
|
||||
return (bitmap[spellID >> 5] & mask) != 0;
|
||||
}
|
||||
|
||||
static int __fastcall Script_IsPlayerSpell(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: IsPlayerSpell(spellID)");
|
||||
return 0;
|
||||
}
|
||||
const int spellID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (spellID < 1) {
|
||||
Game::Lua::PushBool(L, 0);
|
||||
return 1;
|
||||
}
|
||||
Game::Lua::PushBoolean(L, PlayerKnowsSpell(spellID));
|
||||
return 1;
|
||||
}
|
||||
|
||||
const int spellCount = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_SPELL_RECORD_COUNT));
|
||||
if (spellID > spellCount) {
|
||||
Game::Lua::PushBoolean(L, 0);
|
||||
return 1;
|
||||
}
|
||||
// `CanDualWield()` — true if the local player can equip a weapon in the off
|
||||
// hand. The server tracks this as a plain bool (`Player::m_canDualWield`)
|
||||
// flipped on only by `SPELL_EFFECT_DUAL_WIELD` (effect 40); no class has it
|
||||
// innately. Verified against the client Spell.dbc: exactly ONE spell carries
|
||||
// that effect — 674 "Dual Wield", the trained warrior/rogue/hunter passive —
|
||||
// and it lands in the known-spell bitmap like any learned spell. So there's
|
||||
// no separate client-visible capability flag to read; `IsPlayerSpell(674)`
|
||||
// is the exact equivalent of the server's `CanDualWield()`.
|
||||
static constexpr int kDualWieldSpellID = 674;
|
||||
|
||||
auto *bitmap = *reinterpret_cast<const uint32_t *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELL_BITMAP));
|
||||
if (bitmap == nullptr) {
|
||||
Game::Lua::PushBoolean(L, 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const uint32_t mask = 1u << (spellID & 31);
|
||||
const bool isKnown = (bitmap[spellID >> 5] & mask) != 0;
|
||||
Game::Lua::PushBoolean(L, isKnown);
|
||||
static int __fastcall Script_CanDualWield(void *L) {
|
||||
Game::Lua::PushBoolean(L, PlayerKnowsSpell(kDualWieldSpellID));
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -623,6 +635,7 @@ static void RegisterLuaFunctions() {
|
||||
&Script_FindSpellBookSlotByID);
|
||||
Game::Lua::RegisterGlobalFunction("IsPassiveSpell", &Script_IsPassiveSpell);
|
||||
Game::Lua::RegisterGlobalFunction("IsPlayerSpell", &Script_IsPlayerSpell);
|
||||
Game::Lua::RegisterGlobalFunction("CanDualWield", &Script_CanDualWield);
|
||||
Game::Lua::RegisterGlobalFunction("IsSpellKnown", &Script_IsSpellKnown);
|
||||
Game::Lua::RegisterGlobalFunction("IsHarmfulSpell", &Script_IsHarmfulSpell);
|
||||
Game::Lua::RegisterGlobalFunction("IsHelpfulSpell", &Script_IsHelpfulSpell);
|
||||
|
||||
@@ -55,6 +55,71 @@ static int __fastcall Script_GameTooltipSetSpellByID(void *L) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// `GameTooltip:AddSpellByID(spellID)` — APPENDS a spell's tooltip to the
|
||||
// current one instead of clearing+rebuilding (the `SetSpellByID` behavior).
|
||||
// The natural pair to `SetSpellByID`; lets callers compose tooltips like
|
||||
// `AddLine(talentName) + AddSpellByID(rankSpellID)`.
|
||||
//
|
||||
// `BuildSpellTooltip`'s last arg (`param_7`) gates the clear: `0` calls the
|
||||
// per-tooltip Clear (`FUN_00530050`) first — that's `SetSpellByID`; non-zero
|
||||
// SKIPS the clear and appends. The append path is the engine's talent
|
||||
// "next rank" preview, so it (a) emits a `"\nNext rank:"` header line
|
||||
// instead of the spell name, and (b) does NOT stash the displayed spellID at
|
||||
// `+0x39C` (so `GetSpell` keeps reflecting the base tooltip — correct for an
|
||||
// append). We fix (a) by overwriting that header line — which lands at index
|
||||
// `numLines`-before-the-build (0-based) — with the real spell name via the
|
||||
// line pool's left-text FontString. Verified against `FUN_0052E610`.
|
||||
void AppendByID(void *L, int spellID) {
|
||||
if (spellID <= 0)
|
||||
return;
|
||||
void *tooltipObj = Game::Lua::ResolveObject(L, 1);
|
||||
if (tooltipObj == nullptr)
|
||||
return;
|
||||
auto *tt = static_cast<uint8_t *>(tooltipObj);
|
||||
|
||||
const int before = *reinterpret_cast<const int *>(
|
||||
tt + Offsets::OFF_GAMETOOLTIP_NUM_LINES);
|
||||
|
||||
auto BuildSpellTooltip =
|
||||
reinterpret_cast<BuildSpellTooltip_t>(Offsets::FUN_GAMETOOLTIP_BUILD_SPELL_TOOLTIP);
|
||||
BuildSpellTooltip(tooltipObj, spellID, 0, 0, 0, 0, 0, /*param_7 (append)=*/1);
|
||||
|
||||
const int after = *reinterpret_cast<const int *>(
|
||||
tt + Offsets::OFF_GAMETOOLTIP_NUM_LINES);
|
||||
if (after <= before)
|
||||
return; // nothing appended (bad spellID / missing Spell.dbc record)
|
||||
|
||||
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
|
||||
if (record == nullptr)
|
||||
return;
|
||||
const int locale = *reinterpret_cast<const int *>(Offsets::VAR_LOCALE_INDEX);
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
record + OFF_SPELL_NAME + locale * 4);
|
||||
if (name == nullptr || name[0] == '\0')
|
||||
return;
|
||||
|
||||
// Left-text FontString array: descriptor at +0x324, data ptr at +0x8.
|
||||
auto **textLeft = *reinterpret_cast<void ***>(
|
||||
tt + Offsets::OFF_GAMETOOLTIP_TEXTLEFT_DESC + 8);
|
||||
if (textLeft == nullptr)
|
||||
return;
|
||||
void *fs = textLeft[before];
|
||||
if (fs == nullptr)
|
||||
return;
|
||||
using SetText_t = void(__thiscall *)(void *fs, const char *text, int flag);
|
||||
reinterpret_cast<SetText_t>(Offsets::FUN_FONTSTRING_SET_TEXT)(fs, name, 0);
|
||||
}
|
||||
|
||||
static int __fastcall Script_GameTooltipAddSpellByID(void *L) {
|
||||
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE ||
|
||||
!Game::Lua::IsNumber(L, 2)) {
|
||||
Game::Lua::Error(L, "Usage: GameTooltip:AddSpellByID(spellID)");
|
||||
return 0;
|
||||
}
|
||||
AppendByID(L, static_cast<int>(Game::Lua::ToNumber(L, 2)));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// `GameTooltip:GetSpell()` → (name, rank, spellID) for whichever spell
|
||||
// the tooltip is currently displaying, or nothing if it isn't showing
|
||||
// a spell. BuildSpellTooltip writes the spellID to `tooltip+0x39C`;
|
||||
@@ -126,6 +191,7 @@ static int __fastcall Script_GameTooltipHasSpell(void *L) {
|
||||
|
||||
static const Game::Lua::FrameMethodEntry g_methods[] = {
|
||||
{"SetSpellByID", &Script_GameTooltipSetSpellByID},
|
||||
{"AddSpellByID", &Script_GameTooltipAddSpellByID},
|
||||
{"GetSpell", &Script_GameTooltipGetSpell},
|
||||
{"HasSpell", &Script_GameTooltipHasSpell},
|
||||
};
|
||||
|
||||
@@ -26,4 +26,13 @@ namespace Spell::Tooltip {
|
||||
// so we render the rank-1 spell tooltip instead).
|
||||
void ShowByID(void *L, int spellID);
|
||||
|
||||
// Like `ShowByID` but APPENDS the spell's tooltip to the current one
|
||||
// (no clear), and overwrites the engine's talent "next rank" header with
|
||||
// the real spell name. Backs `GameTooltip:AddSpellByID`. Silent no-op if
|
||||
// `spellID <= 0`, stack[1] isn't a frame object, or the build added no
|
||||
// lines. Does not stash the displayed spellID (so `GetSpell` still
|
||||
// reflects the base tooltip). Useful for cross-class talent tooltips
|
||||
// (`AddLine(talentName) + AppendByID(rankSpellID)`).
|
||||
void AppendByID(void *L, int spellID);
|
||||
|
||||
} // namespace Spell::Tooltip
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// 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 "Game.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
namespace Table::Count {
|
||||
|
||||
// `numTableNodes, numArrayNodes, maxArrayIndex = table.count(tbl)` — a modern
|
||||
// WoW diagnostic (added retail 11.2.5) reporting how a table is populated.
|
||||
// These are COUNTS of live entries, not the table's allocated capacity:
|
||||
//
|
||||
// numTableNodes total number of key/value pairs.
|
||||
// numArrayNodes count of pairs whose key is an integer in [1..numTableNodes]
|
||||
// (the "looks like a contiguous array part" heuristic).
|
||||
// maxArrayIndex the largest positive integral key (>= 1), or 0 if none.
|
||||
// Verified against retail: negative/zero integral keys do
|
||||
// NOT count ({[-3]=x} -> 1, 0, 0).
|
||||
//
|
||||
// Pure iteration — no Lua-internal struct layout needed. Note we only ever
|
||||
// read a key via ToNumber, never ToString: lua_tostring on a NUMERIC key
|
||||
// rewrites it to a string in place and corrupts lua_next's traversal.
|
||||
static int __fastcall Script_table_count(void *L) {
|
||||
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
|
||||
Game::Lua::Error(L, "Usage: table.count(table)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::vector<double> integralKeys;
|
||||
int total = 0;
|
||||
double maxKey = 0.0; // largest positive integral key seen; 0 = none
|
||||
|
||||
Game::Lua::PushNil(L);
|
||||
while (Game::Lua::Next(L, 1) != 0) {
|
||||
++total;
|
||||
// key at -2, value at -1.
|
||||
if (Game::Lua::Type(L, -2) == Game::Lua::TYPE_NUMBER) {
|
||||
const double k = Game::Lua::ToNumber(L, -2);
|
||||
if (k == std::floor(k)) { // integral key
|
||||
integralKeys.push_back(k);
|
||||
if (k >= 1.0 && k > maxKey) // only positive keys count
|
||||
maxKey = k;
|
||||
}
|
||||
}
|
||||
Game::Lua::SetTop(L, -2); // pop value, keep key for the next Next()
|
||||
}
|
||||
|
||||
int numArray = 0;
|
||||
for (double k : integralKeys)
|
||||
if (k >= 1.0 && k <= static_cast<double>(total))
|
||||
++numArray;
|
||||
|
||||
Game::Lua::PushNumber(L, static_cast<double>(total));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(numArray));
|
||||
Game::Lua::PushNumber(L, maxKey);
|
||||
return 3;
|
||||
}
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("table", "count", &Script_table_count);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace Table::Count
|
||||
+137
-11
@@ -18,7 +18,12 @@
|
||||
|
||||
namespace Talent::Info {
|
||||
|
||||
// `GetTalentSpellID(tabIndex, talentIndex[, rank])` — returns the
|
||||
// Cross-class talent record resolver — defined below (before
|
||||
// `GetTalentIDByIndex`), shared by both talent getters' optional classID arg.
|
||||
static const uint8_t *ResolveTalentRecordForClass(int classID, int tabIndex,
|
||||
int talentIndex);
|
||||
|
||||
// `GetTalentSpellID(tabIndex, talentIndex[, rank[, classID]])` — returns the
|
||||
// spellID for the given talent at the requested rank, or `nil` if the
|
||||
// talent isn't allocated (or any arg is out of range).
|
||||
//
|
||||
@@ -34,13 +39,22 @@ namespace Talent::Info {
|
||||
// points in this talent), falls back to rank 1 so the function still
|
||||
// produces the talent's canonical spellID for unallocated talents.
|
||||
//
|
||||
// `classID` is optional (4th arg; pass `rank` as nil to use the default).
|
||||
// When a number, resolves against `Talent.dbc` / `TalentTab.dbc` for that
|
||||
// class instead of the player's runtime tree — same cross-class path as
|
||||
// `GetTalentIDByIndex`. There's no "currentRank" for a class you aren't, so a
|
||||
// cross-class query with no explicit `rank` defaults to rank 1 (the talent's
|
||||
// canonical spellID). Ordering matches the player-class path exactly (see
|
||||
// `ResolveTalentRecordForClass`).
|
||||
//
|
||||
// Returns `nil` when:
|
||||
// - tab/talentIndex/rank are non-numeric or out of range
|
||||
// - tab/talentIndex/rank/classID are non-numeric-when-required or out of range
|
||||
// - the explicit `rank` exceeds the talent's allocated max (e.g. rank
|
||||
// 5 on a 1-rank talent — the SpellRank slot is zero)
|
||||
static int __fastcall Script_GetTalentSpellID(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
|
||||
Game::Lua::Error(L, "Usage: GetTalentSpellID(tabIndex, talentIndex[, rank])");
|
||||
Game::Lua::Error(L,
|
||||
"Usage: GetTalentSpellID(tabIndex, talentIndex[, rank[, classID]])");
|
||||
return 0;
|
||||
}
|
||||
const int tabIndex = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
@@ -48,6 +62,29 @@ static int __fastcall Script_GetTalentSpellID(void *L) {
|
||||
if (tabIndex < 1 || talentIndex < 1)
|
||||
return 0;
|
||||
|
||||
// Explicit classID → cross-class DBC path. `rank` (arg 3) is still honored
|
||||
// when given; without it we can't ask the engine for a currentRank on a
|
||||
// class the player isn't, so default to rank 1.
|
||||
if (Game::Lua::IsNumber(L, 4)) {
|
||||
const int classID = static_cast<int>(Game::Lua::ToNumber(L, 4));
|
||||
int rank = 1;
|
||||
if (Game::Lua::IsNumber(L, 3)) {
|
||||
rank = static_cast<int>(Game::Lua::ToNumber(L, 3));
|
||||
if (rank < 1 || rank > Offsets::TALENT_MAX_RANKS)
|
||||
return 0;
|
||||
}
|
||||
const uint8_t *rec =
|
||||
ResolveTalentRecordForClass(classID, tabIndex, talentIndex);
|
||||
if (rec == nullptr)
|
||||
return 0;
|
||||
const uint32_t spellID = *reinterpret_cast<const uint32_t *>(
|
||||
rec + Offsets::OFF_TALENT_SPELL_RANK + (rank - 1) * sizeof(uint32_t));
|
||||
if (spellID == 0)
|
||||
return 0; // rank slot not populated (rank > maxRank for this talent)
|
||||
Game::Lua::PushNumber(L, static_cast<double>(spellID));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Pre-validate tab/idx so we don't trigger `lua_error` from the
|
||||
// engine's `Script_GetTalentInfo` on out-of-range input. The engine
|
||||
// bails to its error path (lua_error) on bad tab, bad talent index,
|
||||
@@ -109,8 +146,74 @@ static int __fastcall Script_GetTalentSpellID(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `GetTalentIDByIndex(tabIndex, talentIndex)` — returns the Talent.dbc
|
||||
// primary key (the row's ID at `TalentEntry+0x00`) for the talent at
|
||||
// Cross-class talent resolution via the DBC flat arrays — mirrors the engine's
|
||||
// tree builder `FUN_004f2c00` but filtered to an arbitrary class instead of the
|
||||
// local player's. Used by `GetTalentSpellID` / `GetTalentIDByIndex`'s optional
|
||||
// `classID` arg so an addon can inspect any class's tree, not just the one it's
|
||||
// running on.
|
||||
|
||||
// TalentTab.dbc rowID of the `tabIndex`-th (1-based) tab belonging to `classID`,
|
||||
// or 0. Tabs are taken in DBC row order and filtered by `classMask` (mask 0 =
|
||||
// all classes). Race isn't part of "which class owns a tab" — vanilla tabs are
|
||||
// never race-restricted — so we apply only the class half of `FUN_004f2e50`.
|
||||
static uint32_t NthClassTalentTabID(int classID, int tabIndex) {
|
||||
const uint32_t classBit = 1u << ((classID - 1) & 0x1F);
|
||||
auto *base = *reinterpret_cast<const uint8_t *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_TALENTTAB_DBC_FLAT_RECORDS));
|
||||
const int count = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_TALENTTAB_DBC_FLAT_COUNT));
|
||||
if (base == nullptr)
|
||||
return 0;
|
||||
int match = 0;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const uint8_t *rec = base + i * Offsets::TALENTTAB_ENTRY_STRIDE;
|
||||
const uint32_t classMask = *reinterpret_cast<const uint32_t *>(
|
||||
rec + Offsets::OFF_TALENTTAB_CLASS_MASK);
|
||||
if (classMask != 0 && (classMask & classBit) == 0)
|
||||
continue;
|
||||
if (++match == tabIndex)
|
||||
return *reinterpret_cast<const uint32_t *>(
|
||||
rec + Offsets::OFF_TALENTTAB_ID);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Talent.dbc record for the `talentIndex`-th (1-based) talent whose TabID is
|
||||
// `tabID`, in DBC row order — the same order the builder assigns within a tab.
|
||||
// Returns null if there aren't that many.
|
||||
static const uint8_t *NthTabTalentRecord(uint32_t tabID, int talentIndex) {
|
||||
auto *base = *reinterpret_cast<const uint8_t *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_TALENT_DBC_FLAT_RECORDS));
|
||||
const int count = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_TALENT_DBC_FLAT_COUNT));
|
||||
if (base == nullptr)
|
||||
return nullptr;
|
||||
int match = 0;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const uint8_t *rec = base + i * Offsets::TALENT_ENTRY_STRIDE;
|
||||
if (*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_TALENT_DBC_TAB_ID) != tabID)
|
||||
continue;
|
||||
if (++match == talentIndex)
|
||||
return rec;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// The Talent.dbc record for (classID, tabIndex, talentIndex), or null on any
|
||||
// out-of-range input. `classID` is 1-based (1..31; vanilla uses 1..11 with 6/10
|
||||
// unused).
|
||||
static const uint8_t *ResolveTalentRecordForClass(int classID, int tabIndex,
|
||||
int talentIndex) {
|
||||
if (classID < 1 || classID > 31 || tabIndex < 1 || talentIndex < 1)
|
||||
return nullptr;
|
||||
const uint32_t tabID = NthClassTalentTabID(classID, tabIndex);
|
||||
if (tabID == 0)
|
||||
return nullptr;
|
||||
return NthTabTalentRecord(tabID, talentIndex);
|
||||
}
|
||||
|
||||
// `GetTalentIDByIndex(tabIndex, talentIndex[, classID])` — returns the
|
||||
// Talent.dbc primary key (the row's ID at `TalentEntry+0x00`) for the talent at
|
||||
// the given (tab, idx). 1.12's `GetTalentInfo` returns
|
||||
// `(name, icon, tier, column, currentRank, maxRank, ...)` but NOT the
|
||||
// talentID, so addons that want a stable identifier (for SavedVariables
|
||||
@@ -118,14 +221,22 @@ static int __fastcall Script_GetTalentSpellID(void *L) {
|
||||
// encode `(class, tab, tier, column)` and hope the talent tree doesn't
|
||||
// shift.
|
||||
//
|
||||
// Reads from the same per-tab talent arrays at `[VAR_TALENT_TAB_INFO_ARRAY]`
|
||||
// that #36 `GetTalentSpellID` walks. talentID at `+0x00` of the
|
||||
// `TalentEntry` was verified empirically during the GetTalentSpellID
|
||||
// debug pass: `entryFirstDword=174` matched Inner Focus's Talent.dbc
|
||||
// row ID (and `166` for the first Discipline talent).
|
||||
// Without `classID` (or when it's nil) it reads the player's own tree from the
|
||||
// per-tab talent arrays at `[VAR_TALENT_TAB_INFO_ARRAY]` (same source #36
|
||||
// `GetTalentSpellID` walks). talentID at `+0x00` of the `TalentEntry` was
|
||||
// verified empirically during the GetTalentSpellID debug pass:
|
||||
// `entryFirstDword=174` matched Inner Focus's Talent.dbc row ID (and `166` for
|
||||
// the first Discipline talent).
|
||||
//
|
||||
// With an explicit `classID` it resolves against the Talent.dbc / TalentTab.dbc
|
||||
// flat arrays instead, so it works for any class — not just the one the addon
|
||||
// is running on. The (tab, idx) ordering matches the player-class path exactly:
|
||||
// both come from the engine builder's DBC-row-order walk (see
|
||||
// `ResolveTalentRecordForClass` and the flat-array note in Offsets.h).
|
||||
static int __fastcall Script_GetTalentIDByIndex(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
|
||||
Game::Lua::Error(L, "Usage: GetTalentIDByIndex(tabIndex, talentIndex)");
|
||||
Game::Lua::Error(L,
|
||||
"Usage: GetTalentIDByIndex(tabIndex, talentIndex[, classID])");
|
||||
return 0;
|
||||
}
|
||||
const int tabIndex = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
@@ -133,6 +244,21 @@ static int __fastcall Script_GetTalentIDByIndex(void *L) {
|
||||
if (tabIndex < 1 || talentIndex < 1)
|
||||
return 0;
|
||||
|
||||
// Explicit classID → cross-class DBC path. Omitted/nil → player's own class
|
||||
// via the engine's runtime tab-info arrays (below).
|
||||
if (Game::Lua::IsNumber(L, 3)) {
|
||||
const int classID = static_cast<int>(Game::Lua::ToNumber(L, 3));
|
||||
const uint8_t *rec =
|
||||
ResolveTalentRecordForClass(classID, tabIndex, talentIndex);
|
||||
if (rec == nullptr)
|
||||
return 0;
|
||||
const uint32_t talentID = *reinterpret_cast<const uint32_t *>(rec);
|
||||
if (talentID == 0)
|
||||
return 0;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(talentID));
|
||||
return 1;
|
||||
}
|
||||
|
||||
const int tabCount = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_TALENT_TAB_COUNT));
|
||||
if (tabIndex > tabCount)
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
// 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/>.
|
||||
|
||||
// Backports the post-vanilla TargetScript selection functions that 1.12.1
|
||||
// doesn't ship, built on the engine's own tab-targeting internals:
|
||||
//
|
||||
// TargetNearest() — cycle nearest attackable/assistable unit
|
||||
// TargetNearestEnemyPlayer([rev]) — cycle nearest hostile player (PvP)
|
||||
// TargetNearestFriendPlayer([rev]) — cycle nearest friendly player
|
||||
// TargetDirectionEnemy(facing[,cone]) — nearest hostile in a facing cone
|
||||
// TargetDirectionFriend(facing[,cone]) — nearest friendly in a facing cone
|
||||
//
|
||||
// Vanilla already ships `TargetNearestEnemy/Friend/PartyMember/RaidMember`;
|
||||
// they run through `FUN_00493f60(reverse, mode)` — enumerate visible units,
|
||||
// keep those `FUN_00493e40(player, cand, mode)` accepts, sort by distance,
|
||||
// cycle, and commit via `FUN_00493540(guidLo, guidHi)`. We reuse the two
|
||||
// load-bearing pieces of that machinery directly:
|
||||
//
|
||||
// * `FUN_00493e40` (Offsets::FUN_TARGET_CANDIDATE_VALID) — the exact
|
||||
// per-mode validity predicate (mode 1 = enemy: attackable + alive +
|
||||
// not-critter; mode 2 = friend: assistable + alive). Calling it gives
|
||||
// us the engine's real reaction/hostility semantics with zero
|
||||
// reimplementation.
|
||||
// * `FUN_00493540` (Offsets::FUN_SET_SELECTION_BY_GUID) — the canonical
|
||||
// setter the native cycle commits through (CMSG_SET_SELECTION + client
|
||||
// target + event 0xC3), which also maintains the current-selection
|
||||
// globals we read back for cycle continuity.
|
||||
//
|
||||
// So the only genuinely new logic here is (a) an extra player-GUID filter
|
||||
// for the `*Player` variants and (b) the cone geometry for the direction
|
||||
// variants — everything reaction-related defers to the engine.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "guid/Guid.h"
|
||||
#include "unit/Position.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
namespace Target::Nearest {
|
||||
|
||||
namespace {
|
||||
|
||||
// Candidate class each function selects from.
|
||||
enum class Filter { Enemy, Friend, EnemyPlayer, FriendPlayer, Any };
|
||||
constexpr int kFilterCount = 5;
|
||||
|
||||
// Default cone (full width, radians) when `coneAngle` is omitted — 90°, so
|
||||
// 45° to either side of the facing. The engine's own default constant
|
||||
// wasn't cleanly recoverable from the 3.3.5 reference binary; this is a
|
||||
// deliberate, documented choice that matches the "roughly in front of me"
|
||||
// intent of a direction-target keybind.
|
||||
constexpr float kDefaultConeAngle = 1.5707963f; // pi/2
|
||||
|
||||
// Re-cycle window: repeated presses within this many ms advance through the
|
||||
// frozen candidate snapshot (no ping-pong as distances shift); a longer gap
|
||||
// rebuilds and re-targets the nearest. Mirrors the ~1s reset the native
|
||||
// cycle uses.
|
||||
constexpr uint32_t kCycleWindowMs = 1000;
|
||||
|
||||
constexpr int kMaxCandidates = 64;
|
||||
|
||||
// Engine entry points ─────────────────────────────────────────────────
|
||||
using Predicate_t = int(__fastcall *)(void *player, void *cand, int mode);
|
||||
using SetTargetByGuid_t = void(__fastcall *)(const uint64_t *guid);
|
||||
using EnumCallback_t = int(__fastcall *)(void *ctx, void *unusedEdx, uint64_t guid);
|
||||
using EnumVisibleObjects_t = int(__fastcall *)(EnumCallback_t cb, void *ctx);
|
||||
using ObjectPtr_t = void *(__fastcall *)(uint32_t typeMask, const char *dbg,
|
||||
uint32_t guidLo, uint32_t guidHi,
|
||||
int dbgCode);
|
||||
using TickMs_t = uint32_t(__fastcall *)();
|
||||
|
||||
bool PassesFilter(void *player, void *cand, uint64_t guid, Filter f) {
|
||||
auto valid = reinterpret_cast<Predicate_t>(Offsets::FUN_TARGET_CANDIDATE_VALID);
|
||||
switch (f) {
|
||||
case Filter::Enemy: return valid(player, cand, 1) != 0;
|
||||
case Filter::Friend: return valid(player, cand, 2) != 0;
|
||||
case Filter::EnemyPlayer: return Guid::IsPlayer(guid) && valid(player, cand, 1) != 0;
|
||||
case Filter::FriendPlayer: return Guid::IsPlayer(guid) && valid(player, cand, 2) != 0;
|
||||
case Filter::Any: return valid(player, cand, 1) != 0 ||
|
||||
valid(player, cand, 2) != 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
struct Candidate {
|
||||
uint64_t guid;
|
||||
float x, y; // world XY (for the direction cone)
|
||||
float distSq; // 3D squared distance from the player (for nearest)
|
||||
};
|
||||
|
||||
struct CollectCtx {
|
||||
void *player;
|
||||
Filter filter;
|
||||
float px, py, pz;
|
||||
Candidate list[kMaxCandidates];
|
||||
int count;
|
||||
};
|
||||
|
||||
int __fastcall CollectCb(CollectCtx *ctx, void * /*edx*/, uint64_t guid) {
|
||||
if (ctx->count >= kMaxCandidates)
|
||||
return 1;
|
||||
// Players and creatures both resolve under TYPEMASK_UNIT (CGPlayer
|
||||
// derives from CGUnit); non-unit objects resolve to null and drop out.
|
||||
auto ObjectPtr =
|
||||
reinterpret_cast<ObjectPtr_t>(Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
|
||||
void *cand = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
|
||||
static_cast<uint32_t>(guid),
|
||||
static_cast<uint32_t>(guid >> 32), 0);
|
||||
if (cand == nullptr || cand == ctx->player)
|
||||
return 1;
|
||||
if (!PassesFilter(ctx->player, cand, guid, ctx->filter))
|
||||
return 1;
|
||||
|
||||
float pos[3];
|
||||
if (!Unit::Position::Read(cand, pos))
|
||||
return 1;
|
||||
const float dx = pos[0] - ctx->px;
|
||||
const float dy = pos[1] - ctx->py;
|
||||
const float dz = pos[2] - ctx->pz;
|
||||
Candidate &c = ctx->list[ctx->count++];
|
||||
c.guid = guid;
|
||||
c.x = pos[0];
|
||||
c.y = pos[1];
|
||||
c.distSq = dx * dx + dy * dy + dz * dz;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Enumerate visible units matching `f` into `out`. Returns false (empty)
|
||||
// pre-world or on a player-position miss.
|
||||
bool Collect(Filter f, CollectCtx *out) {
|
||||
out->count = 0;
|
||||
// The enumerator derefs the object manager unconditionally; null on the
|
||||
// glue / character-select screen.
|
||||
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) ==
|
||||
nullptr)
|
||||
return false;
|
||||
void *player = Unit::Position::ResolveToken("player");
|
||||
float ppos[3];
|
||||
if (!Unit::Position::Read(player, ppos))
|
||||
return false;
|
||||
|
||||
out->player = player;
|
||||
out->filter = f;
|
||||
out->px = ppos[0];
|
||||
out->py = ppos[1];
|
||||
out->pz = ppos[2];
|
||||
auto Enum = reinterpret_cast<EnumVisibleObjects_t>(
|
||||
Offsets::FUN_CLNT_OBJ_MGR_ENUM_VISIBLE_OBJECTS);
|
||||
Enum(reinterpret_cast<EnumCallback_t>(&CollectCb), out);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Commit the selection through the engine's `Script_TargetUnit` helper
|
||||
// rather than the tab-cycle's raw `FUN_00493540`: this one first checks the
|
||||
// GUID resolves to a live unit and bails otherwise, so a stale GUID left in
|
||||
// a cycle snapshot (unit despawned / went out of range between presses)
|
||||
// gets rejected instead of committed. Committing a dead GUID would leave
|
||||
// `"target"` pointing at freed unit data and crash the next handler that
|
||||
// reads it. `FUN_TARGET_BY_GUID` still routes through `FUN_00493540`
|
||||
// internally, so the current-selection globals we read back are maintained.
|
||||
void SetTarget(uint64_t guid) {
|
||||
reinterpret_cast<SetTargetByGuid_t>(Offsets::FUN_TARGET_BY_GUID)(&guid);
|
||||
}
|
||||
|
||||
uint64_t CurrentSelection() {
|
||||
const uint32_t lo = *reinterpret_cast<volatile uint32_t *>(
|
||||
Offsets::VAR_CURRENT_SELECTION_GUID_LO);
|
||||
const uint32_t hi = *reinterpret_cast<volatile uint32_t *>(
|
||||
Offsets::VAR_CURRENT_SELECTION_GUID_HI);
|
||||
return (static_cast<uint64_t>(hi) << 32) | lo;
|
||||
}
|
||||
|
||||
uint32_t NowMs() {
|
||||
return reinterpret_cast<TickMs_t>(Offsets::FUN_OS_TICKCOUNT_MS)();
|
||||
}
|
||||
|
||||
// Ascending insertion sort by 3D distance (candidate counts are tiny).
|
||||
void SortByDistance(Candidate *a, int n) {
|
||||
for (int i = 1; i < n; ++i) {
|
||||
Candidate key = a[i];
|
||||
int j = i - 1;
|
||||
while (j >= 0 && a[j].distSq > key.distSq) {
|
||||
a[j + 1] = a[j];
|
||||
--j;
|
||||
}
|
||||
a[j + 1] = key;
|
||||
}
|
||||
}
|
||||
|
||||
// Per-filter tab-cycle state. A frozen snapshot of GUIDs (sorted nearest→
|
||||
// farthest) plus a cursor; presses within `kCycleWindowMs` that haven't had
|
||||
// the target changed out from under them advance the cursor over the frozen
|
||||
// list, so the order stays stable through a cycle burst.
|
||||
struct CycleState {
|
||||
bool valid = false;
|
||||
uint64_t snapshot[kMaxCandidates];
|
||||
int count = 0;
|
||||
int index = 0;
|
||||
uint32_t lastPressMs = 0;
|
||||
uint64_t lastSetGuid = 0;
|
||||
};
|
||||
CycleState g_cycle[kFilterCount];
|
||||
|
||||
void DoCycle(void *L, Filter f) {
|
||||
const bool reverse = Game::Lua::ToBoolean(L, 1) != 0;
|
||||
CycleState &st = g_cycle[static_cast<int>(f)];
|
||||
const uint32_t now = NowMs();
|
||||
|
||||
const bool cont = st.valid && st.count > 0 &&
|
||||
(now - st.lastPressMs) <= kCycleWindowMs &&
|
||||
CurrentSelection() == st.lastSetGuid;
|
||||
|
||||
if (cont) {
|
||||
st.index = reverse ? (st.index + st.count - 1) % st.count
|
||||
: (st.index + 1) % st.count;
|
||||
} else {
|
||||
CollectCtx c;
|
||||
if (!Collect(f, &c) || c.count == 0) {
|
||||
st.valid = false;
|
||||
return;
|
||||
}
|
||||
SortByDistance(c.list, c.count);
|
||||
st.count = c.count;
|
||||
for (int i = 0; i < c.count; ++i)
|
||||
st.snapshot[i] = c.list[i].guid;
|
||||
st.index = 0;
|
||||
st.valid = true;
|
||||
}
|
||||
|
||||
const uint64_t guid = st.snapshot[st.index];
|
||||
SetTarget(guid);
|
||||
st.lastSetGuid = guid;
|
||||
st.lastPressMs = now;
|
||||
}
|
||||
|
||||
void DoDirection(void *L, bool hostile, const char *usage) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L, usage);
|
||||
return;
|
||||
}
|
||||
const float facing = static_cast<float>(Game::Lua::ToNumber(L, 1));
|
||||
float cone = Game::Lua::IsNumber(L, 2)
|
||||
? static_cast<float>(Game::Lua::ToNumber(L, 2))
|
||||
: kDefaultConeAngle;
|
||||
if (cone <= 0.0f)
|
||||
cone = kDefaultConeAngle;
|
||||
const float cosHalf = std::cos(cone * 0.5f);
|
||||
const float dirX = std::cos(facing);
|
||||
const float dirY = std::sin(facing);
|
||||
|
||||
CollectCtx c;
|
||||
if (!Collect(hostile ? Filter::Enemy : Filter::Friend, &c) || c.count == 0)
|
||||
return;
|
||||
|
||||
int best = -1;
|
||||
float bestDistSq = 0.0f;
|
||||
for (int i = 0; i < c.count; ++i) {
|
||||
const float vx = c.list[i].x - c.px;
|
||||
const float vy = c.list[i].y - c.py;
|
||||
const float horizSq = vx * vx + vy * vy;
|
||||
if (horizSq <= 0.0001f)
|
||||
continue;
|
||||
// cos(angle between facing and the horizontal vector to the unit).
|
||||
const float cosAngle = (dirX * vx + dirY * vy) / std::sqrt(horizSq);
|
||||
if (cosAngle < cosHalf)
|
||||
continue; // outside the cone
|
||||
if (best < 0 || c.list[i].distSq < bestDistSq) {
|
||||
best = i;
|
||||
bestDistSq = c.list[i].distSq;
|
||||
}
|
||||
}
|
||||
if (best >= 0)
|
||||
SetTarget(c.list[best].guid);
|
||||
}
|
||||
|
||||
// Lua entry points ─────────────────────────────────────────────────────
|
||||
int __fastcall Script_TargetNearest(void *L) {
|
||||
DoCycle(L, Filter::Any);
|
||||
return 0;
|
||||
}
|
||||
int __fastcall Script_TargetNearestEnemyPlayer(void *L) {
|
||||
DoCycle(L, Filter::EnemyPlayer);
|
||||
return 0;
|
||||
}
|
||||
int __fastcall Script_TargetNearestFriendPlayer(void *L) {
|
||||
DoCycle(L, Filter::FriendPlayer);
|
||||
return 0;
|
||||
}
|
||||
int __fastcall Script_TargetDirectionEnemy(void *L) {
|
||||
DoDirection(L, /*hostile*/ true,
|
||||
"Usage: TargetDirectionEnemy(facing [, coneAngle])");
|
||||
return 0;
|
||||
}
|
||||
int __fastcall Script_TargetDirectionFriend(void *L) {
|
||||
DoDirection(L, /*hostile*/ false,
|
||||
"Usage: TargetDirectionFriend(facing [, coneAngle])");
|
||||
return 0;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("TargetNearest", &Script_TargetNearest);
|
||||
Game::Lua::RegisterGlobalFunction("TargetNearestEnemyPlayer",
|
||||
&Script_TargetNearestEnemyPlayer);
|
||||
Game::Lua::RegisterGlobalFunction("TargetNearestFriendPlayer",
|
||||
&Script_TargetNearestFriendPlayer);
|
||||
Game::Lua::RegisterGlobalFunction("TargetDirectionEnemy",
|
||||
&Script_TargetDirectionEnemy);
|
||||
Game::Lua::RegisterGlobalFunction("TargetDirectionFriend",
|
||||
&Script_TargetDirectionFriend);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Target::Nearest
|
||||
@@ -366,6 +366,80 @@ int __fastcall Script_GetAllTaxiNodes(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Direct-edge test — mirrors FUN_004dbad0: a 256x256 [from][to] matrix of
|
||||
// TaxiPath IDs (0 = no direct flight path). IDs outside 1..256 are never direct.
|
||||
bool DirectEdge(int from, int to) {
|
||||
if (from <= 0 || from > Offsets::TAXI_ADJACENCY_DIM ||
|
||||
to <= 0 || to > Offsets::TAXI_ADJACENCY_DIM)
|
||||
return false;
|
||||
const int *adj = reinterpret_cast<const int *>(Offsets::VAR_TAXI_ADJACENCY);
|
||||
return adj[from * Offsets::TAXI_ADJACENCY_DIM + to] != 0;
|
||||
}
|
||||
|
||||
// `C_TaxiMap.GetTaxiRoute(slotIndex)` — the TaxiNodes.dbc ID chain the client
|
||||
// would fly to the taxi-map slot `slotIndex` (1..NumTaxiNodes), in flight
|
||||
// order { currentNodeID, ..., destNodeID }. This mirrors TakeTaxiNode's own
|
||||
// decision (FUN_004dc8d0): a direct hop when a TaxiPath edge exists, otherwise
|
||||
// the client's precomputed multi-hop route array. It is the exact node list
|
||||
// TakeTaxiNode would hand the server, so an addon can identify every hop
|
||||
// without matching flight-map coordinates back to nodes. Returns nothing when
|
||||
// no taxi map is open, the slot is out of range, it is the current node, or no
|
||||
// route exists.
|
||||
int __fastcall Script_GetTaxiRoute(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1))
|
||||
return 0;
|
||||
const int slot1 = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
const int count =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_TAXI_NODE_COUNT);
|
||||
if (count <= 0 || slot1 < 1 || slot1 > count)
|
||||
return 0;
|
||||
|
||||
const auto *arrayBase =
|
||||
reinterpret_cast<const uint8_t *>(Offsets::VAR_TAXI_NODE_ARRAY);
|
||||
const uint8_t *slotRec =
|
||||
arrayBase + (slot1 - 1) * Offsets::TAXI_NODE_STRIDE;
|
||||
|
||||
const uint8_t *curRec = *reinterpret_cast<const uint8_t *const *>(
|
||||
Offsets::VAR_TAXI_CURRENT_REC);
|
||||
const uint8_t *dstRec = *reinterpret_cast<const uint8_t *const *>(
|
||||
slotRec + Offsets::OFF_TAXI_SLOT_RECORD);
|
||||
if (curRec == nullptr || dstRec == nullptr)
|
||||
return 0;
|
||||
const int currentID = IntField(curRec, 0);
|
||||
const int destID = IntField(dstRec, 0);
|
||||
if (currentID == 0 || destID == 0 || currentID == destID)
|
||||
return 0;
|
||||
|
||||
// Collect the chain exactly as FUN_004dc8d0 (TakeTaxiNode) would send it.
|
||||
int ids[64];
|
||||
int n = 0;
|
||||
if (DirectEdge(currentID, destID)) {
|
||||
ids[n++] = currentID;
|
||||
ids[n++] = destID;
|
||||
} else if (*(slotRec + Offsets::OFF_TAXI_SLOT_MULTIHOP) != 0) {
|
||||
const int *route = *reinterpret_cast<const int *const *>(
|
||||
slotRec + Offsets::OFF_TAXI_SLOT_ROUTE);
|
||||
const int rc = *reinterpret_cast<const int *>(
|
||||
slotRec + Offsets::OFF_TAXI_SLOT_ROUTE_COUNT);
|
||||
if (route == nullptr || rc < 2)
|
||||
return 0;
|
||||
const int cap = rc < 64 ? rc : 64;
|
||||
for (int i = 0; i < cap; ++i)
|
||||
ids[n++] = route[i];
|
||||
} else {
|
||||
return 0; // reachable node with no computed route (shouldn't happen)
|
||||
}
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(ids[i]));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
static const Game::Lua::EnumIntegerEntry kFaction[] = {
|
||||
{"Neutral", FACTION_NEUTRAL},
|
||||
@@ -385,6 +459,8 @@ void RegisterLuaFunctions() {
|
||||
&Script_GetTaxiNodesForMap);
|
||||
Game::Lua::RegisterTableFunction("C_TaxiMap", "GetAllTaxiNodes",
|
||||
&Script_GetAllTaxiNodes);
|
||||
Game::Lua::RegisterTableFunction("C_TaxiMap", "GetTaxiRoute",
|
||||
&Script_GetTaxiRoute);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_TaxiMap.GetTaxiPaths()` and `C_TaxiMap.GetTaxiPathWaypoints(pathID)` —
|
||||
// direct readers for `TaxiPath.dbc` (the directed flight-path edge list with
|
||||
// costs) and `TaxiPathNode.dbc` (each path's world-coordinate waypoint
|
||||
// polyline). Vanilla exposes neither; addons can't read DBCs at all, so this
|
||||
// hands them the raw taxi graph + geometry.
|
||||
//
|
||||
// Together they let an addon build the taxi routing graph (nodes + weighted
|
||||
// directed edges) and measure real flight distances (sum the 3D chords of a
|
||||
// path's waypoints). Uses for a flight-path database, a map overlay, or a
|
||||
// travel-time estimator — see the sibling `C_TaxiMap.GetTaxiNodesForMap` for
|
||||
// the node table these edges connect.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace Taxi::Paths {
|
||||
|
||||
namespace {
|
||||
|
||||
int IntField(const uint8_t *rec, int off) {
|
||||
return *reinterpret_cast<const int *>(rec + off);
|
||||
}
|
||||
float FloatField(const uint8_t *rec, int off) {
|
||||
return *reinterpret_cast<const float *>(rec + off);
|
||||
}
|
||||
|
||||
// `C_TaxiMap.GetTaxiPaths()` — every row of TaxiPath.dbc as
|
||||
// { pathID, fromNodeID, toNodeID, cost }. Directed: A->B and B->A are two
|
||||
// separate entries.
|
||||
int __fastcall Script_GetTaxiPaths(void *L) {
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
const int count = *reinterpret_cast<const int *>(Offsets::VAR_TAXIPATH_COUNT);
|
||||
int outIdx = 0;
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_TAXIPATH_RECORDS,
|
||||
Offsets::VAR_TAXIPATH_COUNT,
|
||||
static_cast<uint32_t>(id));
|
||||
if (rec == nullptr)
|
||||
continue;
|
||||
|
||||
outIdx += 1;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(outIdx));
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "pathID", id);
|
||||
Game::Lua::SetFieldNumber(L, "fromNodeID", IntField(rec, Offsets::OFF_TAXIPATH_FROM));
|
||||
Game::Lua::SetFieldNumber(L, "toNodeID", IntField(rec, Offsets::OFF_TAXIPATH_TO));
|
||||
Game::Lua::SetFieldNumber(L, "cost", IntField(rec, Offsets::OFF_TAXIPATH_COST));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_TaxiMap.GetTaxiPathWaypoints(pathID)` — the path's waypoints as
|
||||
// { x, y, z, mapID }, ordered by node index (flight order). Returns nothing
|
||||
// for a missing / unknown pathID or an empty path.
|
||||
int __fastcall Script_GetTaxiPathWaypoints(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1))
|
||||
return 0;
|
||||
const int wantPath = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (wantPath <= 0)
|
||||
return 0;
|
||||
|
||||
struct Waypoint {
|
||||
int index;
|
||||
float x, y, z;
|
||||
int mapID;
|
||||
};
|
||||
std::vector<Waypoint> pts;
|
||||
const int count = *reinterpret_cast<const int *>(Offsets::VAR_TAXIPATHNODE_COUNT);
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_TAXIPATHNODE_RECORDS,
|
||||
Offsets::VAR_TAXIPATHNODE_COUNT,
|
||||
static_cast<uint32_t>(id));
|
||||
if (rec == nullptr)
|
||||
continue;
|
||||
if (IntField(rec, Offsets::OFF_TAXIPATHNODE_PATH_ID) != wantPath)
|
||||
continue;
|
||||
pts.push_back({IntField(rec, Offsets::OFF_TAXIPATHNODE_INDEX),
|
||||
FloatField(rec, Offsets::OFF_TAXIPATHNODE_X),
|
||||
FloatField(rec, Offsets::OFF_TAXIPATHNODE_Y),
|
||||
FloatField(rec, Offsets::OFF_TAXIPATHNODE_Z),
|
||||
IntField(rec, Offsets::OFF_TAXIPATHNODE_MAP)});
|
||||
}
|
||||
if (pts.empty())
|
||||
return 0;
|
||||
|
||||
// DBC rows for a path are stored in index order, but sort to guarantee a
|
||||
// dense 1-based (ipairs-safe) array in flight order regardless.
|
||||
std::sort(pts.begin(), pts.end(),
|
||||
[](const Waypoint &a, const Waypoint &b) { return a.index < b.index; });
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
int outIdx = 0;
|
||||
for (const Waypoint &p : pts) {
|
||||
outIdx += 1;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(outIdx));
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "x", p.x);
|
||||
Game::Lua::SetFieldNumber(L, "y", p.y);
|
||||
Game::Lua::SetFieldNumber(L, "z", p.z);
|
||||
Game::Lua::SetFieldNumber(L, "mapID", p.mapID);
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_TaxiMap", "GetTaxiPaths",
|
||||
&Script_GetTaxiPaths);
|
||||
Game::Lua::RegisterTableFunction("C_TaxiMap", "GetTaxiPathWaypoints",
|
||||
&Script_GetTaxiPathWaypoints);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Taxi::Paths
|
||||
@@ -62,7 +62,7 @@
|
||||
#include "item/Location.h"
|
||||
#include "item/StatAccum.h"
|
||||
#include "item/TooltipItem.h"
|
||||
#include "tooltip/SetItemEvent.h"
|
||||
#include "tooltip/SetEvents.h"
|
||||
#include "ui/ColorData.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -598,7 +598,7 @@ int __fastcall Script_SetHyperlinkCompareItem(void *L) {
|
||||
// this internal build so a handler doesn't run mid-way through the
|
||||
// line-shift below (it fires for the *hovered* item's real Set* instead).
|
||||
{
|
||||
Tooltip::SetItemEvent::Suppressor noEvent;
|
||||
Tooltip::SetEvents::Suppressor noEvent;
|
||||
reinterpret_cast<BuildItemTooltip_t>(Offsets::FUN_GAMETOOLTIP_BUILD_ITEM)(
|
||||
self, static_cast<uint32_t>(equippedID), &equippedGUID, &equippedGUID, 0, 0, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
// 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/>.
|
||||
|
||||
// `GameTooltip:HookScript("OnTooltipSet{Item,Spell,Unit,GameObject}", fn)` —
|
||||
// backport of the modern tooltip scripts that fire whenever a tooltip's item /
|
||||
// spell / unit / gameobject is set, so addons can annotate tooltips by hooking
|
||||
// one script instead of wrapping every Set* method. Fully native — real frame
|
||||
// scripts, settable via the standard SetScript / GetScript / HookScript.
|
||||
//
|
||||
// 1.12 already has the machinery for tooltip scripts, just not these ones:
|
||||
//
|
||||
// - The CGGameTooltip vtable resolver FUN_GAMETOOLTIP_SCRIPT_RESOLVER maps a
|
||||
// script name to its handler slot on the tooltip (OnTooltipCleared etc.,
|
||||
// each an 8-byte {handler, context} pair). SetScript/GetScript/HookScript
|
||||
// all go through it. We co-hook it: for our OnTooltipSet* names (which the
|
||||
// engine doesn't know) we hand back a pointer to a C-side per-tooltip cell
|
||||
// — the tooltip object (alloc size 0x460) has no free 8-byte slot, and the
|
||||
// resolver's only contract is "return the address the script lives at", so
|
||||
// external storage works exactly like an in-object slot.
|
||||
//
|
||||
// - Each object type has a single builder funnel that every Set*/mouseover
|
||||
// path converges on (item FUN_GAMETOOLTIP_BUILD_ITEM, spell _BUILD_SPELL,
|
||||
// unit _BUILD_UNIT, gameobject _BUILD_GAMEOBJECT). We co-hook each and,
|
||||
// after the build, fire the matching handler via FUN_FRAME_INVOKE_SCRIPT
|
||||
// (the same no-arg runner the clear uses for OnTooltipCleared) with the
|
||||
// tooltip as `self`.
|
||||
//
|
||||
// Suppressed during our own Tooltip::Compare equipped-item build (see
|
||||
// Suppressor), and guarded against a handler that re-enters a builder.
|
||||
|
||||
#include "SetEvents.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Tooltip::SetEvents {
|
||||
|
||||
namespace {
|
||||
|
||||
// One entry per script kind. Order is arbitrary but the cell slot array and
|
||||
// the builder hooks index by it.
|
||||
enum ScriptKind { SK_ITEM = 0, SK_SPELL, SK_UNIT, SK_GAMEOBJECT, SK_COUNT };
|
||||
|
||||
// Script names, matched case-insensitively against the resolver's input.
|
||||
constexpr const char *kNamesLower[SK_COUNT] = {
|
||||
"ontooltipsetitem",
|
||||
"ontooltipsetspell",
|
||||
"ontooltipsetunit",
|
||||
"ontooltipsetgameobject",
|
||||
};
|
||||
|
||||
// Per-tooltip storage: one 8-byte {handler, context} slot per script kind.
|
||||
// Tooltips are few and permanent (GameTooltip, ItemRefTooltip,
|
||||
// ShoppingTooltip1/2, AtlasLootTooltip, WorldMapTooltip, plus the odd addon
|
||||
// tooltip), so a small fixed table with a linear scan is plenty and gives each
|
||||
// cell a stable address to hand out.
|
||||
struct Cell {
|
||||
void *tooltip;
|
||||
uint32_t slot[SK_COUNT][2]; // [kind] = {handler, exec context}
|
||||
};
|
||||
constexpr int kMaxTooltips = 32;
|
||||
Cell g_cells[kMaxTooltips];
|
||||
int g_cellCount = 0;
|
||||
|
||||
uint32_t *SlotFor(void *tooltip, int kind, bool create) {
|
||||
for (int i = 0; i < g_cellCount; ++i)
|
||||
if (g_cells[i].tooltip == tooltip)
|
||||
return g_cells[i].slot[kind];
|
||||
if (!create || g_cellCount >= kMaxTooltips)
|
||||
return nullptr;
|
||||
Cell &c = g_cells[g_cellCount++];
|
||||
c.tooltip = tooltip;
|
||||
for (auto &s : c.slot) {
|
||||
s[0] = 0;
|
||||
s[1] = 0;
|
||||
}
|
||||
return c.slot[kind];
|
||||
}
|
||||
|
||||
bool EqualsIgnoreCase(const char *s, const char *literal) {
|
||||
if (s == nullptr)
|
||||
return false;
|
||||
for (;; ++s, ++literal) {
|
||||
unsigned char a = static_cast<unsigned char>(*s);
|
||||
const unsigned char b = static_cast<unsigned char>(*literal);
|
||||
if (a >= 'A' && a <= 'Z')
|
||||
a = static_cast<unsigned char>(a + 32);
|
||||
if (a != b)
|
||||
return false;
|
||||
if (b == 0)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Suppression / recursion state -------------------------------------
|
||||
int g_suppress = 0; // > 0 while our own compare build runs
|
||||
int g_firing = 0; // recursion guard around any handler fire
|
||||
|
||||
// The engine's real script invoker: binds the global `this` = frame and runs
|
||||
// the handler under its own protected lua_pcall. We call it directly rather
|
||||
// than the FUN_00702690 wrapper, whose exec-context mutation is unsafe from the
|
||||
// deep nested Lua->C->Lua stack our co-hook fires within — see Offsets.h.
|
||||
using InvokeScript_t = void(__fastcall *)(uint32_t handler, void *frame);
|
||||
|
||||
// Fire the handler (if any) for `kind` on `self`.
|
||||
//
|
||||
// We fire from inside a builder, which is itself mid-execution of the engine's
|
||||
// Script_Set* (and, in the wild, nested under addon Set* wrappers + several
|
||||
// other DLLs' hooks). The invoker pushes onto the Lua stack and does NOT
|
||||
// restore the top to where the outer, still-running C code left it — harmless
|
||||
// from the engine's top-level dispatch, but from here it shifts the stack and
|
||||
// the outer code (and other DLLs' hooks) then index garbage → deref → crash.
|
||||
// Snapshot and restore the stack top around the fire so the outer code sees
|
||||
// exactly the stack it left.
|
||||
void FireScript(void *self, int kind) {
|
||||
if (g_suppress > 0 || g_firing > 0)
|
||||
return;
|
||||
uint32_t *slot = SlotFor(self, kind, /*create*/ false);
|
||||
if (slot == nullptr || slot[0] == 0)
|
||||
return;
|
||||
void *L = Game::Lua::State();
|
||||
const int savedTop = (L != nullptr) ? Game::Lua::GetTop(L) : 0;
|
||||
++g_firing;
|
||||
reinterpret_cast<InvokeScript_t>(Offsets::FUN_FRAME_INVOKE_SCRIPT)(slot[0], self);
|
||||
--g_firing;
|
||||
if (L != nullptr)
|
||||
Game::Lua::SetTop(L, savedTop);
|
||||
}
|
||||
|
||||
// --- Resolver co-hook (CGGameTooltip vtable script-name → slot) ---------
|
||||
using Resolver_t = int(__fastcall *)(void *self, void *edx, const char *name);
|
||||
Resolver_t g_resolverOriginal = nullptr;
|
||||
|
||||
int __fastcall Resolver_h(void *self, void *edx, const char *name) {
|
||||
const int engineSlot = g_resolverOriginal(self, edx, name);
|
||||
if (engineSlot != 0) // a base-frame or existing tooltip script — leave it
|
||||
return engineSlot;
|
||||
for (int k = 0; k < SK_COUNT; ++k)
|
||||
if (EqualsIgnoreCase(name, kNamesLower[k]))
|
||||
return reinterpret_cast<int>(SlotFor(self, k, /*create*/ true));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --- Builder co-hooks --------------------------------------------------
|
||||
// Each builder is a __thiscall funnel modelled as __fastcall(self /*ecx*/,
|
||||
// edx /*unused reg placeholder*/, ...real stack args...). The declared stack
|
||||
// arg count MUST match the callee's RET N or the caller's stack is corrupted.
|
||||
// Builders whose callers use the return value (spell, unit) are declared to
|
||||
// return it so the co-hook forwards it.
|
||||
|
||||
// Item — FUN_GAMETOOLTIP_BUILD_ITEM, 9 stack args, return unused.
|
||||
using BuildItem_t = void(__fastcall *)(void *self, void *edx, uint32_t itemID,
|
||||
const void *guid, const void *guid2, int a4,
|
||||
int a5, int a6, int headerFlag, int a8, int a9);
|
||||
BuildItem_t g_buildItemOriginal = nullptr;
|
||||
|
||||
void __fastcall BuildItem_h(void *self, void *edx, uint32_t itemID, const void *guid,
|
||||
const void *guid2, int a4, int a5, int a6, int headerFlag,
|
||||
int a8, int a9) {
|
||||
g_buildItemOriginal(self, edx, itemID, guid, guid2, a4, a5, a6, headerFlag, a8, a9);
|
||||
FireScript(self, SK_ITEM);
|
||||
}
|
||||
|
||||
// Spell — FUN_GAMETOOLTIP_BUILD_SPELL_TOOLTIP, 7 stack args, returns an int.
|
||||
using BuildSpell_t = int(__fastcall *)(void *self, void *edx, int spellID, int a2,
|
||||
int a3, int isPet, int a5, int a6, int a7);
|
||||
BuildSpell_t g_buildSpellOriginal = nullptr;
|
||||
|
||||
int __fastcall BuildSpell_h(void *self, void *edx, int spellID, int a2, int a3,
|
||||
int isPet, int a5, int a6, int a7) {
|
||||
const int ret = g_buildSpellOriginal(self, edx, spellID, a2, a3, isPet, a5, a6, a7);
|
||||
FireScript(self, SK_SPELL);
|
||||
return ret;
|
||||
}
|
||||
|
||||
// Unit — FUN_GAMETOOLTIP_BUILD_UNIT, 1 stack arg (guid), returns an int the
|
||||
// caller (Script_GameTooltip_SetUnit) tests.
|
||||
using BuildUnit_t = int(__fastcall *)(void *self, void *edx, const void *guid);
|
||||
BuildUnit_t g_buildUnitOriginal = nullptr;
|
||||
|
||||
int __fastcall BuildUnit_h(void *self, void *edx, const void *guid) {
|
||||
const int ret = g_buildUnitOriginal(self, edx, guid);
|
||||
FireScript(self, SK_UNIT);
|
||||
return ret;
|
||||
}
|
||||
|
||||
// GameObject — FUN_GAMETOOLTIP_BUILD_GAMEOBJECT, 1 stack arg (guid), void.
|
||||
using BuildGO_t = void(__fastcall *)(void *self, void *edx, const void *guid);
|
||||
BuildGO_t g_buildGOOriginal = nullptr;
|
||||
|
||||
void __fastcall BuildGO_h(void *self, void *edx, const void *guid) {
|
||||
g_buildGOOriginal(self, edx, guid);
|
||||
FireScript(self, SK_GAMEOBJECT);
|
||||
}
|
||||
|
||||
static const Game::HookAutoRegister _resolverHook{
|
||||
Offsets::FUN_GAMETOOLTIP_SCRIPT_RESOLVER,
|
||||
reinterpret_cast<void *>(&Resolver_h),
|
||||
reinterpret_cast<void **>(&g_resolverOriginal)};
|
||||
|
||||
static const Game::HookAutoRegister _buildItemHook{
|
||||
Offsets::FUN_GAMETOOLTIP_BUILD_ITEM,
|
||||
reinterpret_cast<void *>(&BuildItem_h),
|
||||
reinterpret_cast<void **>(&g_buildItemOriginal)};
|
||||
|
||||
static const Game::HookAutoRegister _buildSpellHook{
|
||||
Offsets::FUN_GAMETOOLTIP_BUILD_SPELL_TOOLTIP,
|
||||
reinterpret_cast<void *>(&BuildSpell_h),
|
||||
reinterpret_cast<void **>(&g_buildSpellOriginal)};
|
||||
|
||||
static const Game::HookAutoRegister _buildUnitHook{
|
||||
Offsets::FUN_GAMETOOLTIP_BUILD_UNIT,
|
||||
reinterpret_cast<void *>(&BuildUnit_h),
|
||||
reinterpret_cast<void **>(&g_buildUnitOriginal)};
|
||||
|
||||
static const Game::HookAutoRegister _buildGOHook{
|
||||
Offsets::FUN_GAMETOOLTIP_BUILD_GAMEOBJECT,
|
||||
reinterpret_cast<void *>(&BuildGO_h),
|
||||
reinterpret_cast<void **>(&g_buildGOOriginal)};
|
||||
|
||||
} // namespace
|
||||
|
||||
Suppressor::Suppressor() { ++g_suppress; }
|
||||
Suppressor::~Suppressor() {
|
||||
if (g_suppress > 0)
|
||||
--g_suppress;
|
||||
}
|
||||
|
||||
} // namespace Tooltip::SetEvents
|
||||
@@ -13,12 +13,14 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace Tooltip::SetItemEvent {
|
||||
namespace Tooltip::SetEvents {
|
||||
|
||||
// RAII guard that suppresses OnTooltipSetItem firing for the duration of an
|
||||
// engine item-build we invoke *internally* (Tooltip::Compare building the
|
||||
// equipped item into a shopping tooltip). Without it, a handler would run in
|
||||
// the middle of our line-shift / delta rendering and see a half-built tooltip.
|
||||
// RAII guard that suppresses tooltip set-event firing (OnTooltipSetItem etc.)
|
||||
// for the duration of an engine build we invoke *internally* — currently
|
||||
// Tooltip::Compare building the equipped item into a shopping tooltip. Without
|
||||
// it, a handler would run in the middle of our line-shift / delta rendering and
|
||||
// see a half-built tooltip. Only the item builder runs during that internal
|
||||
// build, so in practice this gates OnTooltipSetItem; it's global for safety.
|
||||
// Nestable (ref-counted).
|
||||
struct Suppressor {
|
||||
Suppressor();
|
||||
@@ -27,4 +29,4 @@ struct Suppressor {
|
||||
Suppressor &operator=(const Suppressor &) = delete;
|
||||
};
|
||||
|
||||
} // namespace Tooltip::SetItemEvent
|
||||
} // namespace Tooltip::SetEvents
|
||||
@@ -1,168 +0,0 @@
|
||||
// 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/>.
|
||||
|
||||
// `GameTooltip:HookScript("OnTooltipSetItem", fn)` — backport of the modern
|
||||
// tooltip script that fires whenever a tooltip's item is set, so addons can
|
||||
// annotate item tooltips by hooking one script instead of wrapping every
|
||||
// Set* method (the pattern AtlasLoot resorts to in vanilla, rebuilding the
|
||||
// tooltip and clobbering its item linkage). Fully native — a real frame
|
||||
// script, settable via the standard SetScript / GetScript / HookScript.
|
||||
//
|
||||
// 1.12 already has the machinery for tooltip scripts, just not this one:
|
||||
//
|
||||
// - The CGGameTooltip vtable resolver FUN_GAMETOOLTIP_SCRIPT_RESOLVER maps a
|
||||
// script name to its handler slot on the tooltip (OnTooltipCleared etc.,
|
||||
// each an 8-byte {handler, context} pair). SetScript/GetScript/HookScript
|
||||
// all go through it. We co-hook it: for "OnTooltipSetItem" (a name the
|
||||
// engine doesn't know) we hand back a pointer to a C-side per-tooltip cell
|
||||
// — the tooltip object (alloc size 0x460) has no free 8-byte slot, and the
|
||||
// resolver's only contract is "return the address the script lives at", so
|
||||
// external storage works exactly like an in-object slot.
|
||||
//
|
||||
// - Every item setter (SetHyperlink item:, SetBagItem, SetInventoryItem,
|
||||
// SetMerchantItem, SetAuctionItem, …) funnels through the shared item
|
||||
// builder FUN_GAMETOOLTIP_BUILD_ITEM. We co-hook it and, after the build,
|
||||
// fire the handler via FUN_FRAME_RUN_SCRIPT (the same no-arg runner the
|
||||
// clear uses for OnTooltipCleared) with the tooltip as `self`.
|
||||
//
|
||||
// Suppressed during our own Tooltip::Compare equipped-item build (see
|
||||
// Suppressor), and guarded against a handler that re-enters the builder.
|
||||
|
||||
#include "SetItemEvent.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Tooltip::SetItemEvent {
|
||||
|
||||
namespace {
|
||||
|
||||
// Per-tooltip 8-byte handler slot {handler, context}. Tooltips are few and
|
||||
// permanent (GameTooltip, ItemRefTooltip, ShoppingTooltip1/2, AtlasLootTooltip,
|
||||
// WorldMapTooltip, plus the odd addon tooltip), so a small fixed table with a
|
||||
// linear scan is plenty and gives each cell a stable address to hand out.
|
||||
struct Cell {
|
||||
void *tooltip;
|
||||
uint32_t slot[2]; // [0] = handler, [1] = exec context (written by SetScript)
|
||||
};
|
||||
constexpr int kMaxTooltips = 32;
|
||||
Cell g_cells[kMaxTooltips];
|
||||
int g_cellCount = 0;
|
||||
|
||||
uint32_t *CellFor(void *tooltip, bool create) {
|
||||
for (int i = 0; i < g_cellCount; ++i)
|
||||
if (g_cells[i].tooltip == tooltip)
|
||||
return g_cells[i].slot;
|
||||
if (!create || g_cellCount >= kMaxTooltips)
|
||||
return nullptr;
|
||||
Cell &c = g_cells[g_cellCount++];
|
||||
c.tooltip = tooltip;
|
||||
c.slot[0] = 0;
|
||||
c.slot[1] = 0;
|
||||
return c.slot;
|
||||
}
|
||||
|
||||
bool EqualsIgnoreCase(const char *s, const char *literal) {
|
||||
if (s == nullptr)
|
||||
return false;
|
||||
for (;; ++s, ++literal) {
|
||||
unsigned char a = static_cast<unsigned char>(*s);
|
||||
const unsigned char b = static_cast<unsigned char>(*literal);
|
||||
if (a >= 'A' && a <= 'Z')
|
||||
a = static_cast<unsigned char>(a + 32);
|
||||
if (a != b)
|
||||
return false;
|
||||
if (b == 0)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
constexpr char kScriptNameLower[] = "ontooltipsetitem"; // matched case-insensitively
|
||||
|
||||
// --- Suppression / recursion state -------------------------------------
|
||||
int g_suppress = 0; // > 0 while our own compare build runs
|
||||
int g_firing = 0; // recursion guard around the handler fire
|
||||
|
||||
// --- Resolver co-hook (CGGameTooltip vtable script-name → slot) ---------
|
||||
using Resolver_t = int(__fastcall *)(void *self, void *edx, const char *name);
|
||||
Resolver_t g_resolverOriginal = nullptr;
|
||||
|
||||
int __fastcall Resolver_h(void *self, void *edx, const char *name) {
|
||||
const int engineSlot = g_resolverOriginal(self, edx, name);
|
||||
if (engineSlot != 0) // a base-frame or existing tooltip script — leave it
|
||||
return engineSlot;
|
||||
if (EqualsIgnoreCase(name, kScriptNameLower))
|
||||
return reinterpret_cast<int>(CellFor(self, /*create*/ true));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --- Item-builder co-hook (fires the handler after the item is set) -----
|
||||
using Build_t = void(__fastcall *)(void *self, void *edx, uint32_t itemID, const void *guid,
|
||||
const void *guid2, int a4, int a5, int a6, int headerFlag,
|
||||
int a8, int a9);
|
||||
Build_t g_buildOriginal = nullptr;
|
||||
|
||||
// The engine's real script invoker: binds the global `this` = frame and runs
|
||||
// the handler under its own protected lua_pcall. We call it directly rather
|
||||
// than the FUN_00702690 wrapper, whose exec-context mutation is unsafe from the
|
||||
// deep nested Lua->C->Lua stack our co-hook fires within — see Offsets.h.
|
||||
using InvokeScript_t = void(__fastcall *)(uint32_t handler, void *frame);
|
||||
|
||||
void __fastcall Build_h(void *self, void *edx, uint32_t itemID, const void *guid,
|
||||
const void *guid2, int a4, int a5, int a6, int headerFlag, int a8,
|
||||
int a9) {
|
||||
g_buildOriginal(self, edx, itemID, guid, guid2, a4, a5, a6, headerFlag, a8, a9);
|
||||
if (g_suppress > 0 || g_firing > 0)
|
||||
return;
|
||||
uint32_t *slot = CellFor(self, /*create*/ false);
|
||||
if (slot == nullptr || slot[0] == 0)
|
||||
return;
|
||||
|
||||
// We fire from inside the item builder, which is itself mid-execution of
|
||||
// the engine's Script_Set* (and, in the wild, nested under addon Set*
|
||||
// wrappers + several other DLLs' hooks). The invoker pushes onto the Lua
|
||||
// stack and does NOT restore the top to where the outer, still-running C
|
||||
// code left it — harmless from the engine's top-level dispatch, but from
|
||||
// here it shifts the stack and the outer code (and other DLLs' hooks) then
|
||||
// index garbage → deref → crash. Snapshot and restore the stack top around
|
||||
// the fire so the outer code sees exactly the stack it left.
|
||||
void *L = Game::Lua::State();
|
||||
const int savedTop = (L != nullptr) ? Game::Lua::GetTop(L) : 0;
|
||||
++g_firing;
|
||||
reinterpret_cast<InvokeScript_t>(Offsets::FUN_FRAME_INVOKE_SCRIPT)(slot[0], self);
|
||||
--g_firing;
|
||||
if (L != nullptr)
|
||||
Game::Lua::SetTop(L, savedTop);
|
||||
}
|
||||
|
||||
static const Game::HookAutoRegister _resolverHook{
|
||||
Offsets::FUN_GAMETOOLTIP_SCRIPT_RESOLVER,
|
||||
reinterpret_cast<void *>(&Resolver_h),
|
||||
reinterpret_cast<void **>(&g_resolverOriginal)};
|
||||
|
||||
static const Game::HookAutoRegister _buildHook{
|
||||
Offsets::FUN_GAMETOOLTIP_BUILD_ITEM,
|
||||
reinterpret_cast<void *>(&Build_h),
|
||||
reinterpret_cast<void **>(&g_buildOriginal)};
|
||||
|
||||
} // namespace
|
||||
|
||||
Suppressor::Suppressor() { ++g_suppress; }
|
||||
Suppressor::~Suppressor() {
|
||||
if (g_suppress > 0)
|
||||
--g_suppress;
|
||||
}
|
||||
|
||||
} // namespace Tooltip::SetItemEvent
|
||||
@@ -47,6 +47,7 @@
|
||||
#include "item/Count.h"
|
||||
#include "spell/Lookup.h"
|
||||
#include "tick/WorldTick.h"
|
||||
#include "ui/ColorData.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -499,6 +500,108 @@ int __fastcall Script_TargetTotem(void *L) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---- GameTooltip:SetTotem(slot) -------------------------------------------
|
||||
//
|
||||
// Built with the engine's own tooltip primitives — the per-tooltip clear
|
||||
// and the raw add-line — exactly as retail's `Script_GameTooltip_SetTotem`
|
||||
// does (no Lua method dispatch). Colors are the packed `{b,g,r,a}` values
|
||||
// `FUN_GAMETOOLTIP_ADD_LINE` wants, pulled straight from `UI::ColorData`.
|
||||
|
||||
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
|
||||
|
||||
// Retail's two line colors, read verbatim from the 3.3.5 binary's SetTotem
|
||||
// color data: name = NORMAL_FONT_COLOR (0xFFFFD200), time = white. Both are
|
||||
// already in the 0xAARRGGBB packing the line-color setter consumes.
|
||||
constexpr uint32_t kNameColor = UI::ColorData::ByTag("NORMAL_FONT_COLOR");
|
||||
constexpr uint32_t kTimeColor = UI::ColorData::ByTag("HIGHLIGHT_FONT_COLOR");
|
||||
|
||||
// Left-aligned colored line via the engine's raw add-line. `color` must
|
||||
// outlive the call (its address is passed through).
|
||||
void AddColoredLine(void *self, const char *text, const uint32_t &color) {
|
||||
reinterpret_cast<AddLine_t>(Offsets::FUN_GAMETOOLTIP_ADD_LINE)(
|
||||
self, text, nullptr, &color, nullptr, 0);
|
||||
}
|
||||
|
||||
// `GameTooltip:SetTotem(slot)` — a TBC (2.4.0) tooltip method backported to
|
||||
// 1.12. Fills the tooltip with the slot's active totem, mirroring retail's
|
||||
// two-line shape (3.3.5 `Script_GameTooltip_SetTotem` at 0x006205c0):
|
||||
//
|
||||
// <Totem Name> NORMAL_FONT_COLOR (yellow, 0xFFFFD200)
|
||||
// <Time Left> white — SecondsToTimeAbbrev(SPELL_TIME_REMAINING)
|
||||
//
|
||||
// The time line mirrors retail's `SecondsToTimeAbbrev` with its round-up
|
||||
// flag: raw seconds under a minute, minutes rounded up above. The unit
|
||||
// wording is localized through the project's GlobalString helper (retail's
|
||||
// `SPELL_TIME_REMAINING_SEC`/`_MIN` keys, English fallback). No-op (tooltip
|
||||
// left untouched) when the slot has no active totem — same as retail's
|
||||
// `+8/+0xc` timing guard. Shows itself at the end (via the engine's
|
||||
// Script_Show) — retail's TotemFrame.xml OnEnter calls SetTotem with no
|
||||
// trailing :Show(), so the method is expected to show.
|
||||
int __fastcall Script_GameTooltipSetTotem(void *L) {
|
||||
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE ||
|
||||
!Game::Lua::IsNumber(L, 2)) {
|
||||
Game::Lua::Error(L, "Usage: GameTooltip:SetTotem(slot)");
|
||||
return 0;
|
||||
}
|
||||
const int slot = static_cast<int>(Game::Lua::ToNumber(L, 2));
|
||||
if (slot < 1 || slot > kSlots)
|
||||
return 0;
|
||||
const Slot &t = g_slots[slot - 1];
|
||||
if (!t.active || t.spellID == 0)
|
||||
return 0;
|
||||
|
||||
void *self = Game::Lua::ResolveObject(L, 1);
|
||||
if (self == nullptr)
|
||||
return 0;
|
||||
|
||||
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(t.spellID));
|
||||
if (rec == nullptr)
|
||||
return 0;
|
||||
const int locale =
|
||||
*reinterpret_cast<const int *>(static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
|
||||
const char *name =
|
||||
*reinterpret_cast<const char *const *>(rec + OFF_NAME + locale * 4);
|
||||
if (name == nullptr || name[0] == '\0')
|
||||
return 0;
|
||||
|
||||
// Milliseconds remaining (0 = unknown / expired duration).
|
||||
uint32_t msLeft = 0;
|
||||
if (t.durationMs != 0) {
|
||||
const uint32_t now = NowMs();
|
||||
const uint32_t end = t.startMs + t.durationMs;
|
||||
if (now < end)
|
||||
msLeft = end - now;
|
||||
}
|
||||
|
||||
reinterpret_cast<ClearTooltip_t>(Offsets::FUN_GAMETOOLTIP_CLEAR)(self);
|
||||
AddColoredLine(self, name, kNameColor);
|
||||
|
||||
if (msLeft > 0) {
|
||||
const int savedTop = Game::Lua::GetTop(L);
|
||||
if (msLeft < 60000) {
|
||||
Game::Lua::PushLocalizedFormatInt(L, "SPELL_TIME_REMAINING_SEC",
|
||||
"%d Sec",
|
||||
static_cast<int>(msLeft / 1000));
|
||||
} else {
|
||||
const int minutes = static_cast<int>((msLeft + 59999) / 60000); // ceil
|
||||
Game::Lua::PushLocalizedFormatInt(L, "SPELL_TIME_REMAINING_MIN",
|
||||
"%d Min", minutes);
|
||||
}
|
||||
const char *timeText = Game::Lua::ToString(L, -1);
|
||||
if (timeText != nullptr)
|
||||
AddColoredLine(self, timeText, kTimeColor);
|
||||
Game::Lua::SetTop(L, savedTop);
|
||||
}
|
||||
|
||||
// Show ourselves — self is still at Lua stack index 1, which is what
|
||||
// Script_Show reads.
|
||||
reinterpret_cast<ShowScript_t>(Offsets::FUN_SCRIPT_FRAME_SHOW)(L);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("GetTotemInfo", &Script_GetTotemInfo);
|
||||
Game::Lua::RegisterGlobalFunction("GetTotemTimeLeft",
|
||||
@@ -506,6 +609,14 @@ void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("GetTotemDuration",
|
||||
&Script_GetTotemDuration);
|
||||
Game::Lua::RegisterGlobalFunction("TargetTotem", &Script_TargetTotem);
|
||||
|
||||
static const Game::Lua::FrameMethodEntry kTooltipMethods[] = {
|
||||
{"SetTotem", &Script_GameTooltipSetTotem},
|
||||
};
|
||||
Game::Lua::RegisterFrameMethods(
|
||||
reinterpret_cast<void *>(Offsets::VAR_GAMETOOLTIP_METHOD_REGISTRY),
|
||||
kTooltipMethods,
|
||||
static_cast<int>(sizeof(kTooltipMethods) / sizeof(kTooltipMethods[0])));
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// 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 "ui/FrameObject.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace UI::FrameObject {
|
||||
|
||||
namespace {
|
||||
|
||||
using LuaRawGetI_t = void(__fastcall *)(void *L, int idx, int n);
|
||||
using ScriptRegister_t = void(__fastcall *)(void *this_, void *edx_unused,
|
||||
const char *name);
|
||||
|
||||
} // namespace
|
||||
|
||||
// Delegate wrapper construction to the engine's own
|
||||
// `FrameScript_Object::ScriptRegister`. First call for a never-seen frame
|
||||
// builds a `{[0] = lightuserdata}` table with the framescript metatable,
|
||||
// `luaL_ref`s it into the registry, and writes the refkey to `frame +
|
||||
// 0x08` (`OFF_COBJECT_LUA_REGISTRY_REF`). Subsequent calls find a
|
||||
// populated refkey and just rawgeti.
|
||||
//
|
||||
// Why delegate instead of building our own wrapper: single source of
|
||||
// truth. Every code path that pushes this frame — ours, addons'
|
||||
// `CreateFrame(..., parent)` extracting the frame, the engine's own
|
||||
// pushers — funnels through `rawgeti(REGISTRY, frame + 0x08)` and gets the
|
||||
// same Lua table, so addon fields set on the wrapper don't get orphaned
|
||||
// onto a divergent copy. See the long note in `nameplate/Info.cpp`'s
|
||||
// original implementation for the pfUI-divergence story that motivated
|
||||
// this.
|
||||
//
|
||||
// `ScriptRegister` unconditionally bumps the CObject refcount at `+0x04`,
|
||||
// which pins the frame against GC. That's benign for the pooled/permanent
|
||||
// frames we push here (nameplates, chat bubbles) — we call it at most
|
||||
// once per frame pointer (gated by `refKey <= 0`), so the refcount tops
|
||||
// out at one increment per pool slot.
|
||||
//
|
||||
// Defensive fallback: if the registry slot exists but isn't a table any
|
||||
// more (freed elsewhere), re-register to rebuild a fresh wrapper. This
|
||||
// shouldn't happen in practice — engines only unref on frame destruction.
|
||||
void Push(void *L, void *frame) {
|
||||
auto rawgeti = reinterpret_cast<LuaRawGetI_t>(
|
||||
Offsets::FUN_FRAMESCRIPT_PUSH_OBJECT);
|
||||
auto scriptRegister = reinterpret_cast<ScriptRegister_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER));
|
||||
|
||||
auto *cobj = static_cast<uint8_t *>(frame);
|
||||
int refKey = *reinterpret_cast<const int *>(
|
||||
cobj + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
|
||||
if (refKey <= 0) {
|
||||
scriptRegister(frame, nullptr, nullptr);
|
||||
refKey = *reinterpret_cast<const int *>(
|
||||
cobj + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
|
||||
}
|
||||
|
||||
rawgeti(L, Game::Lua::REGISTRY_INDEX, refKey);
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE)
|
||||
return;
|
||||
Game::Lua::SetTop(L, -2);
|
||||
|
||||
// Slot got freed somewhere — re-register to rebuild a fresh wrapper
|
||||
// and slot. Zero the refcount so `ScriptRegister` re-enters its build
|
||||
// branch.
|
||||
*reinterpret_cast<int *>(cobj + Offsets::OFF_COBJECT_LUA_REFCOUNT) = 0;
|
||||
scriptRegister(frame, nullptr, nullptr);
|
||||
refKey = *reinterpret_cast<const int *>(
|
||||
cobj + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
|
||||
rawgeti(L, Game::Lua::REGISTRY_INDEX, refKey);
|
||||
}
|
||||
|
||||
} // namespace UI::FrameObject
|
||||
@@ -0,0 +1,34 @@
|
||||
// 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 UI::FrameObject {
|
||||
|
||||
// Pushes a C++ `CFrameScriptObject`-derived frame onto the Lua stack as
|
||||
// its canonical wrapper table — the same object every other engine path
|
||||
// resolves via `rawgeti(REGISTRY, frame + 0x08)`, so addon-set fields on
|
||||
// the wrapper survive roundtrips through our API and method dispatch
|
||||
// (`:GetRegions()`, `:GetName()`, …) works normally.
|
||||
//
|
||||
// For frames the engine created purely in C++ without ever going through
|
||||
// `CreateFrame` (default nameplates, chat bubbles), the registry ref at
|
||||
// `+0x08` starts at 0; the first push delegates to the engine's own
|
||||
// `FrameScript_Object::ScriptRegister` to lazily build the wrapper +
|
||||
// registry slot. Subsequent pushes just `rawgeti` the cached slot.
|
||||
//
|
||||
// Leaves exactly one value (the wrapper table, or the re-registered
|
||||
// fallback) on the stack. `frame` must be non-null.
|
||||
void Push(void *L, void *frame);
|
||||
|
||||
} // namespace UI::FrameObject
|
||||
+17
-12
@@ -77,25 +77,30 @@ int __fastcall Script_UnitClassBase(void *L) {
|
||||
// the in-world descriptor exists), so this resolves at addon-load
|
||||
// instead of returning nil until the player object spawns. All other
|
||||
// tokens go through the unit descriptor.
|
||||
uint8_t classByte;
|
||||
uint8_t classByte = 0;
|
||||
if (Unit::Identity::IsPlayerToken(token)) {
|
||||
classByte = *reinterpret_cast<const uint8_t *>(Offsets::VAR_PLAYER_CLASS_BYTE);
|
||||
} else {
|
||||
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
auto *unit = static_cast<const uint8_t *>(resolve(token));
|
||||
if (unit == nullptr) {
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNil(L);
|
||||
return 2;
|
||||
if (unit != nullptr) {
|
||||
auto *desc = *reinterpret_cast<const uint8_t *const *>(
|
||||
unit + Offsets::OFF_UNIT_DESCRIPTOR);
|
||||
if (desc != nullptr)
|
||||
classByte = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
|
||||
}
|
||||
auto *desc = *reinterpret_cast<const uint8_t *const *>(
|
||||
unit + Offsets::OFF_UNIT_DESCRIPTOR);
|
||||
if (desc == nullptr) {
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNil(L);
|
||||
return 2;
|
||||
// No live descriptor (out of range / different map) → fall back to the
|
||||
// engine's player-info cache, matching what UnitClass itself does out
|
||||
// of range (class at OFF_PLAYER_INFO_CLASS). `token` is already valid
|
||||
// here — resolve() raises on a bad token before this — so GuidForToken
|
||||
// won't raise.
|
||||
if (classByte == 0) {
|
||||
const uint8_t *rec = Unit::Identity::PlayerInfoRecord(
|
||||
Unit::Identity::GuidForToken(token));
|
||||
if (rec != nullptr)
|
||||
classByte = static_cast<uint8_t>(*reinterpret_cast<const uint32_t *>(
|
||||
rec + Offsets::OFF_PLAYER_INFO_CLASS));
|
||||
}
|
||||
classByte = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
|
||||
}
|
||||
if (classByte == 0) {
|
||||
Game::Lua::PushNil(L);
|
||||
|
||||
+183
-13
@@ -33,6 +33,17 @@
|
||||
// every world tick — when it returns null, we `Set(0)` which fires
|
||||
// the event. Cost is one hash-table lookup per tick while focus is
|
||||
// set; zero when no focus is active.
|
||||
//
|
||||
// Unit events for `focus` / `focustarget`: `Unit::TokenObserver` watches
|
||||
// the focus unit's descriptor fields so `UNIT_HEALTH` / `UNIT_AURA` / …
|
||||
// fire with `arg1 == "focus"` (see FocusFieldCb). `focustarget` gets the
|
||||
// same treatment via a second observer — but its GUID is the focus unit's
|
||||
// *current target*, which changes with no event to signal it (vanilla's
|
||||
// `UNIT_FIELD_TARGET`, field 10, has no event-table slot). So the WorldTick
|
||||
// watcher re-reads the focus's target each tick and re-points the
|
||||
// focustarget observer only when that identity changes; the focustarget
|
||||
// *events* still fire event-driven through the observer — only the
|
||||
// re-pointing is tick-gated (≤1 tick lag after the focus swaps targets).
|
||||
|
||||
#include "Focus.h"
|
||||
|
||||
@@ -40,6 +51,7 @@
|
||||
#include "Offsets.h"
|
||||
#include "event/Custom.h"
|
||||
#include "tick/WorldTick.h"
|
||||
#include "unit/TokenObserver.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
@@ -52,6 +64,49 @@ const Event::Custom::AutoReserve _reserve{kEventName};
|
||||
|
||||
uint64_t g_focusGUID = 0;
|
||||
|
||||
// True while the `"focus"` descriptor-field observers are currently registered
|
||||
// on a LIVE focus object. Cleared when the object despawns (the nodes die with
|
||||
// it) so `OnWorldTick` re-registers when a groupmate returns to range; guards
|
||||
// against double-registering while the object stays resolvable.
|
||||
bool g_focusObserved = false;
|
||||
|
||||
// Descriptor-field observer callback — fires the changed field's unit event
|
||||
// with the `"focus"` token, making `arg1 == "focus"` work for `UNIT_HEALTH`,
|
||||
// `UNIT_MANA`, `UNIT_AURA`, … exactly like a native token. Registered per
|
||||
// focus unit via `Unit::TokenObserver` (mirrors the engine's own watch of
|
||||
// target/party/raid units). The event id is the field index (`fieldOffset >>
|
||||
// 2`); the engine fires every unit event with format "%s" + the token, so we
|
||||
// match that. Guarded on the live focus GUID so an in-flight field change that
|
||||
// races an unregister can't fire a stale token.
|
||||
int __fastcall FocusFieldCb(uint32_t fieldOffset, uint32_t /*size*/,
|
||||
uint32_t guidLo, uint32_t guidHi,
|
||||
const uint32_t * /*oldValue*/, void * /*userArg*/) {
|
||||
if (guidLo != static_cast<uint32_t>(g_focusGUID) ||
|
||||
guidHi != static_cast<uint32_t>(g_focusGUID >> 32))
|
||||
return 1;
|
||||
using Fire_t = void(__cdecl *)(int eventId, const char *fmt, ...);
|
||||
reinterpret_cast<Fire_t>(static_cast<uintptr_t>(Offsets::FUN_FIRE_EVENT))(
|
||||
static_cast<int>(fieldOffset >> 2), "%s", "focus");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// GUID currently watched as `focustarget` (the focus unit's target), or 0.
|
||||
// Re-pointed by `OnWorldTick`; distinct from `g_focusGUID`.
|
||||
uint64_t g_focusTargetGUID = 0;
|
||||
|
||||
// Same as `FocusFieldCb` but fires the `"focustarget"` token.
|
||||
int __fastcall FocusTargetFieldCb(uint32_t fieldOffset, uint32_t /*size*/,
|
||||
uint32_t guidLo, uint32_t guidHi,
|
||||
const uint32_t * /*oldValue*/, void * /*userArg*/) {
|
||||
if (guidLo != static_cast<uint32_t>(g_focusTargetGUID) ||
|
||||
guidHi != static_cast<uint32_t>(g_focusTargetGUID >> 32))
|
||||
return 1;
|
||||
using Fire_t = void(__cdecl *)(int eventId, const char *fmt, ...);
|
||||
reinterpret_cast<Fire_t>(static_cast<uintptr_t>(Offsets::FUN_FIRE_EVENT))(
|
||||
static_cast<int>(fieldOffset >> 2), "%s", "focustarget");
|
||||
return 1;
|
||||
}
|
||||
|
||||
using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
|
||||
using ResolveByGUID_t = void *(__fastcall *)(int type, const char *debugName,
|
||||
uint32_t guidLo, uint32_t guidHi,
|
||||
@@ -65,21 +120,96 @@ uint64_t ResolveTokenGUID(const char *token) {
|
||||
return fn(token);
|
||||
}
|
||||
|
||||
// Per-tick despawn watcher. Probes the engine's object table for
|
||||
// `g_focusGUID`; when it disappears (out of range, fully despawned),
|
||||
// clear focus and fire PLAYER_FOCUS_CHANGED — matches modern's
|
||||
// "leaves render distance → focus drops" behavior. Won't refocus
|
||||
// when the unit comes back, also matching modern.
|
||||
void OnWorldTick() {
|
||||
if (g_focusGUID == 0)
|
||||
return;
|
||||
// Resolve a GUID to its live CGUnit via the object manager, or null if it
|
||||
// isn't currently in the client's object table (out of range / despawned /
|
||||
// pre-world). Non-throwing — safe from any context.
|
||||
const uint8_t *ResolveObject(uint64_t guid) {
|
||||
if (guid == 0)
|
||||
return nullptr;
|
||||
auto resolve = reinterpret_cast<ResolveByGUID_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID));
|
||||
if (resolve(Offsets::OBJ_TYPE_UNIT, "Focus",
|
||||
static_cast<uint32_t>(g_focusGUID),
|
||||
static_cast<uint32_t>(g_focusGUID >> 32),
|
||||
0x172) == nullptr)
|
||||
Set(0);
|
||||
return static_cast<const uint8_t *>(
|
||||
resolve(Offsets::OBJ_TYPE_UNIT, "Focus", static_cast<uint32_t>(guid),
|
||||
static_cast<uint32_t>(guid >> 32), 0x172));
|
||||
}
|
||||
|
||||
// True if `guid` is a current party or raid member. Reads the roster GUID
|
||||
// storage directly — the 4-slot party GUID array and the 40-slot raid
|
||||
// member-pointer array (GUID at `*member + 0`) — which are populated from the
|
||||
// group roster independent of the object table, so this answers correctly for
|
||||
// a groupmate whose CGUnit has been destroyed by leaving your range. Mirrors
|
||||
// 3.3.5's focus-clear gate `FUN_00512a30` (= `FUN_0052d310` party-member OR
|
||||
// `FUN_00573200` raid-member).
|
||||
bool IsGroupMemberGuid(uint64_t guid) {
|
||||
if (guid == 0)
|
||||
return false;
|
||||
const auto *party = reinterpret_cast<const uint64_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_PARTY_GUIDS));
|
||||
for (int i = 0; i < Offsets::PARTY_MAX_SLOTS; ++i)
|
||||
if (party[i] == guid)
|
||||
return true;
|
||||
const int raidCount = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_RAID_MEMBER_COUNT));
|
||||
if (raidCount > 0) {
|
||||
const auto *const *raid = reinterpret_cast<const uint8_t *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_RAID_MEMBER_PTRS));
|
||||
for (int i = 0; i < Offsets::RAID_MAX_SLOTS; ++i) {
|
||||
const uint8_t *m = raid[i];
|
||||
if (m != nullptr && *reinterpret_cast<const uint64_t *>(m) == guid)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Per-tick focus watcher. Resolving `g_focusGUID` from the object table is
|
||||
// both the despawn check and (when alive) the `focustarget` read.
|
||||
//
|
||||
// When the object is gone (out of range, LoS, despawn) we mirror 3.3.5's focus
|
||||
// gate: a **party/raid member keeps focus** — their CGUnit is destroyed when
|
||||
// they leave your range, but the roster GUID persists, so focus reattaches
|
||||
// when they return — while any **other** unit drops focus, exactly as
|
||||
// vanilla/3.3.5 do on a real despawn. (Verified against 3.3.5's object-teardown
|
||||
// purge `FUN_00524350`, gated by the party-or-raid check `FUN_00512a30`.)
|
||||
// Vanilla fires no re-target event, so this once-per-tick read is also how
|
||||
// `focustarget` follows the focus switching targets; its events stay
|
||||
// observer-driven below.
|
||||
void OnWorldTick() {
|
||||
uint64_t desiredFocusTarget = 0;
|
||||
if (g_focusGUID != 0) {
|
||||
const uint8_t *focusObj = ResolveObject(g_focusGUID);
|
||||
if (focusObj == nullptr) {
|
||||
if (IsGroupMemberGuid(g_focusGUID)) {
|
||||
// Groupmate out of range: keep focus. Its observers died with
|
||||
// the object; OnWorldTick re-registers them when it resolves.
|
||||
g_focusObserved = false;
|
||||
} else {
|
||||
Set(0); // non-group unit left the object table → drop focus
|
||||
}
|
||||
} else {
|
||||
// (Re)attach the unit-event observers on the live object — covers a
|
||||
// groupmate returning to range, and a focus set while out of range.
|
||||
if (!g_focusObserved) {
|
||||
TokenObserver::Register(g_focusGUID, &FocusFieldCb);
|
||||
g_focusObserved = true;
|
||||
}
|
||||
auto *fields = *reinterpret_cast<const uint8_t *const *>(
|
||||
focusObj + Offsets::OFF_CGUNIT_OBJECT_FIELDS);
|
||||
if (fields != nullptr)
|
||||
desiredFocusTarget = *reinterpret_cast<const uint64_t *>(
|
||||
fields + Offsets::OFF_UNIT_FIELD_TARGET);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-point the focustarget observer only when the target identity
|
||||
// changes (focus swapped targets, focus cleared, or focus has no target).
|
||||
if (desiredFocusTarget != g_focusTargetGUID) {
|
||||
if (g_focusTargetGUID != 0)
|
||||
TokenObserver::Unregister(g_focusTargetGUID, &FocusTargetFieldCb);
|
||||
g_focusTargetGUID = desiredFocusTarget;
|
||||
if (g_focusTargetGUID != 0)
|
||||
TokenObserver::Register(g_focusTargetGUID, &FocusTargetFieldCb);
|
||||
}
|
||||
}
|
||||
|
||||
// `FocusUnit(unit)` — sets focus to whatever GUID `unit` currently
|
||||
@@ -111,6 +241,25 @@ void RegisterLuaFunctions() {
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
|
||||
|
||||
// Drop focus when the player logs out to the glue screen. The DLL isn't
|
||||
// reloaded on a logout→character-select→login, so g_focusGUID would
|
||||
// otherwise carry a dead GUID from character A into character B. The
|
||||
// WorldTick despawn probe self-heals it within a tick (ObjectByGUID miss
|
||||
// → Set(0)), but that fires a spurious PLAYER_FOCUS_CHANGED on B's first
|
||||
// tick and can't cover the (astronomically rare) GUID-collision case, so
|
||||
// clearing here is both cleaner and exact retail parity. We use the glue
|
||||
// hook, NOT FrameScript_Initialize, because it fires on the world→glue
|
||||
// return a logout triggers but NOT on /reload — so focus correctly
|
||||
// survives /reload (retail behavior) and drops only on logout. No event /
|
||||
// observer work: char A's focus observers already died with the object
|
||||
// during world teardown, and B starts with focus nil (no event expected).
|
||||
void ClearOnLogout() {
|
||||
g_focusGUID = 0;
|
||||
g_focusTargetGUID = 0;
|
||||
g_focusObserved = false;
|
||||
}
|
||||
const Game::GlueModuleAutoRegister _clearOnLogout{&ClearOnLogout};
|
||||
|
||||
} // namespace
|
||||
|
||||
uint64_t Get() { return g_focusGUID; }
|
||||
@@ -118,7 +267,28 @@ uint64_t Get() { return g_focusGUID; }
|
||||
void Set(uint64_t guid) {
|
||||
if (guid == g_focusGUID)
|
||||
return; // no-op: same target → no event fire (matches 3.3.5)
|
||||
// Stop watching the old focus, start watching the new one, so unit events
|
||||
// (UNIT_HEALTH, …) fire with "focus" for whatever is focused — even a unit
|
||||
// the engine isn't otherwise watching. Unregister is a safe no-op if the
|
||||
// old unit already despawned (nodes died with it).
|
||||
if (g_focusGUID != 0)
|
||||
TokenObserver::Unregister(g_focusGUID, &FocusFieldCb);
|
||||
g_focusObserved = false;
|
||||
// Focus changed → the old focustarget is stale; drop its observer now.
|
||||
// WorldTick re-resolves and re-points to the new focus's target next tick.
|
||||
if (g_focusTargetGUID != 0) {
|
||||
TokenObserver::Unregister(g_focusTargetGUID, &FocusTargetFieldCb);
|
||||
g_focusTargetGUID = 0;
|
||||
}
|
||||
g_focusGUID = guid;
|
||||
// Register the unit-event observers only against a LIVE object. If the new
|
||||
// focus is currently out of range (e.g. /focus a raid member across the
|
||||
// map), OnWorldTick registers once it resolves — keeping the observers tied
|
||||
// to a real object instance rather than an absent GUID.
|
||||
if (guid != 0 && ResolveObject(guid) != nullptr) {
|
||||
TokenObserver::Register(guid, &FocusFieldCb);
|
||||
g_focusObserved = true;
|
||||
}
|
||||
const int slot = Event::Custom::Lookup(kEventName);
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "");
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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/>.
|
||||
|
||||
// `UnitHealthMissing(unit)` — the health deficit, i.e. the equivalent of
|
||||
// `UnitHealthMax(unit) - UnitHealth(unit)`. A convenience for healing addons
|
||||
// (overheal checks, "missing health" bars) that otherwise call both engine
|
||||
// functions and subtract in Lua every frame. Reads the two health fields off
|
||||
// the unit's descriptor directly (same path `Unit::Power` uses for POWER),
|
||||
// so it's a single resolve + two field reads.
|
||||
//
|
||||
// Health carries no display divisor — unlike rage/happiness, HEALTH and
|
||||
// MAXHEALTH are stored at face value — so the deficit is a plain subtraction,
|
||||
// clamped at 0 (a transient current > max should never surface as negative).
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "group/MemberStats.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Unit::Health {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
|
||||
const uint8_t *Descriptor(const uint8_t *unit) {
|
||||
if (unit == nullptr)
|
||||
return nullptr;
|
||||
return *reinterpret_cast<const uint8_t *const *>(
|
||||
unit + Offsets::OFF_CGUNIT_OBJECT_FIELDS);
|
||||
}
|
||||
|
||||
const uint8_t *ResolveUnit(const char *token) {
|
||||
if (token == nullptr)
|
||||
return nullptr;
|
||||
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
return static_cast<const uint8_t *>(fn(token));
|
||||
}
|
||||
|
||||
// `UnitHealthMissing("unit")` — returns `max - current` health (never
|
||||
// negative). Returns 0 for a valid-but-absent unit (e.g. `"target"` with no
|
||||
// target), matching `UnitHealth`'s 0-for-missing convention.
|
||||
static int __fastcall Script_UnitHealthMissing(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: UnitHealthMissing(\"unit\")");
|
||||
return 0;
|
||||
}
|
||||
const uint8_t *desc = Descriptor(ResolveUnit(Game::Lua::ToString(L, 1)));
|
||||
if (desc == nullptr) {
|
||||
// No live object → out-of-range group member; the group roster caches
|
||||
// HP (same fallback the engine's UnitHealth uses). Reached only after
|
||||
// FUN_RESOLVE_UNIT_TOKEN returned null on an already-valid token, so
|
||||
// TokenToGUID can't raise.
|
||||
auto tokenToGuid = reinterpret_cast<uint64_t(__fastcall *)(const char *)>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_TOKEN_TO_GUID));
|
||||
const Group::MemberStats::Stats s =
|
||||
Group::MemberStats::Lookup(tokenToGuid(Game::Lua::ToString(L, 1)));
|
||||
const uint32_t missing =
|
||||
(s.valid && s.maxHealth > s.health) ? s.maxHealth - s.health : 0u;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(missing));
|
||||
return 1;
|
||||
}
|
||||
const uint32_t cur = *reinterpret_cast<const uint32_t *>(
|
||||
desc + Offsets::OFF_UNIT_FIELD_HEALTH);
|
||||
const uint32_t max = *reinterpret_cast<const uint32_t *>(
|
||||
desc + Offsets::OFF_UNIT_FIELD_MAXHEALTH);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(max > cur ? max - cur : 0u));
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("UnitHealthMissing", &Script_UnitHealthMissing);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Unit::Health
|
||||
+26
-10
@@ -164,6 +164,24 @@ uint64_t GuidForToken(const char *token) {
|
||||
return fn(token);
|
||||
}
|
||||
|
||||
const uint8_t *PlayerInfoRecord(uint64_t guid) {
|
||||
if (guid == 0)
|
||||
return nullptr;
|
||||
// Peek the engine's player-info NameCache: a NULL callback means "look up
|
||||
// only, don't fire a query" (see the FUN_PLAYER_INFO_LOOKUP note in
|
||||
// Offsets.h). Returns the entry data block for a cached GUID, else null.
|
||||
using Lookup_t = const uint8_t *(__thiscall *)(void *cache, uint32_t lo,
|
||||
uint32_t hi, void *cookie,
|
||||
void *cb, void *ud, char flag);
|
||||
auto fn = reinterpret_cast<Lookup_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_PLAYER_INFO_LOOKUP));
|
||||
uint32_t cookie[2] = {0, 0};
|
||||
return fn(reinterpret_cast<void *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_PLAYER_NAME_CACHE)),
|
||||
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32),
|
||||
cookie, nullptr, nullptr, 0);
|
||||
}
|
||||
|
||||
// `UnitGUID(unit)` — returns the unit's 64-bit GUID formatted as a
|
||||
// hex string `"0xHHHHHHHHLLLLLLLL"` (16 hex digits, hi dword first).
|
||||
//
|
||||
@@ -289,16 +307,14 @@ const char *TokenFromGUID(uint64_t target, char *buf, size_t bufSize) {
|
||||
if (GuidForToken(buf) == target) return buf;
|
||||
}
|
||||
|
||||
// Visible nameplates. The ordered list (see
|
||||
// `NamePlate::Events::g_orderedGUIDs`) is dense — `GetGUIDByIndex`
|
||||
// returns 0 once we step past the populated count, terminating
|
||||
// the loop. Skips the token-resolver hook + SStrCmpI cost per
|
||||
// index by comparing GUIDs directly.
|
||||
for (int i = 1; ; ++i) {
|
||||
const uint64_t plateGuid = NamePlate::Events::GetGUIDByIndex(i);
|
||||
if (plateGuid == 0)
|
||||
break;
|
||||
if (plateGuid == target) {
|
||||
// Visible nameplates. The slot array is SPARSE (retail-exact slot
|
||||
// assignment — freed middle slots stay as gaps until reused), so we
|
||||
// scan `1..GetSlotCount()` and skip empties rather than stopping at
|
||||
// the first 0. Compares GUIDs directly, skipping the token-resolver
|
||||
// hook + SStrCmpI cost per index.
|
||||
const int plateCount = NamePlate::Events::GetSlotCount();
|
||||
for (int i = 1; i <= plateCount; ++i) {
|
||||
if (NamePlate::Events::GetGUIDByIndex(i) == target) {
|
||||
std::snprintf(buf, bufSize, "nameplate%d", i);
|
||||
return buf;
|
||||
}
|
||||
|
||||
@@ -86,4 +86,13 @@ uint64_t GuidForToken(const char *token);
|
||||
// `buf` should be at least 32 bytes.
|
||||
const char *TokenFromGUID(uint64_t guid, char *buf, size_t bufSize);
|
||||
|
||||
// The engine's cached player-info record for `guid` — the NameCache data block
|
||||
// (name @ `OFF_PLAYER_INFO_NAME`, realm, race @ `_RACE`, sex, class @ `_CLASS`),
|
||||
// or null if the GUID isn't cached. Object-independent: it resolves players who
|
||||
// are out of range / on another map / have left the group, since the cache is
|
||||
// populated on sync (SMSG_NAME_QUERY_RESPONSE). The single primitive for "what
|
||||
// do we know about this player GUID" — read fields via the `OFF_PLAYER_INFO_*`
|
||||
// offsets. Called with a NULL callback, so it only peeks (never fires a query).
|
||||
const uint8_t *PlayerInfoRecord(uint64_t guid);
|
||||
|
||||
} // namespace Unit::Identity
|
||||
|
||||
+93
-9
@@ -8,9 +8,10 @@
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
// Modern power-API polyfill: `UnitPower(unit [, type])`,
|
||||
// `UnitPowerMax(unit [, type])`, and an extended `UnitPowerType(unit)`
|
||||
// that returns the modern 2-tuple `(powerType, powerToken)`.
|
||||
// Modern power-API polyfill: `UnitPower(unit [, type [, unmodified]])`,
|
||||
// `UnitPowerMax(unit [, type [, unmodified]])`, `UnitPowerMissing(unit
|
||||
// [, type [, unmodified]])`, and an extended `UnitPowerType(unit)` that
|
||||
// returns the modern 2-tuple `(powerType, powerToken)`.
|
||||
//
|
||||
// Shape modeled on 3.3.5's `Script_UnitPower` (`0x0060ED40` in the
|
||||
// Frostmourne client) and `Script_UnitPowerMax` (`0x0060EF40`). The
|
||||
@@ -33,6 +34,7 @@
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "group/MemberStats.h"
|
||||
#include "unit/Power.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -139,6 +141,49 @@ bool ResolveArgs(void *L, const uint8_t **outDesc, int *outType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Out-of-range group-member fallback. When a unit token has no live CGUnit —
|
||||
// a party/raid member on a different map or otherwise out of range — the
|
||||
// descriptor is gone, but the group roster still caches the member's PRIMARY
|
||||
// power (`Group::MemberStats`, fed by SMSG_PARTY_MEMBER_STATS). This mirrors
|
||||
// what the engine's Script_UnitMana / Script_UnitManaMax do. Reached only
|
||||
// after ResolveArgs fails (no live object) — the token is already known valid,
|
||||
// so TokenToGUID won't raise. Returns 0 if the GUID isn't rostered, or if an
|
||||
// explicit powerType other than the member's primary was requested (the roster
|
||||
// holds only the one power).
|
||||
enum class RosterKind { Current, Max, Missing };
|
||||
|
||||
double RosterPowerFallback(void *L, RosterKind kind) {
|
||||
if (!Game::Lua::IsString(L, 1))
|
||||
return 0.0;
|
||||
auto tokenToGuid = reinterpret_cast<uint64_t(__fastcall *)(const char *)>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_TOKEN_TO_GUID));
|
||||
const Group::MemberStats::Stats s =
|
||||
Group::MemberStats::Lookup(tokenToGuid(Game::Lua::ToString(L, 1)));
|
||||
if (!s.valid)
|
||||
return 0.0;
|
||||
|
||||
const int reqType = Game::Lua::IsNumber(L, 2)
|
||||
? static_cast<int>(Game::Lua::ToNumber(L, 2))
|
||||
: -1;
|
||||
if (reqType >= Offsets::UNIT_POWER_MIN_TYPE &&
|
||||
reqType <= Offsets::UNIT_POWER_MAX_TYPE && reqType != s.powerType)
|
||||
return 0.0;
|
||||
|
||||
const uint32_t divisor =
|
||||
Game::Lua::ToBoolean(L, 3) ? 1u : PowerDivisor(s.powerType);
|
||||
const uint32_t curDisp = s.power / divisor;
|
||||
const uint32_t maxDisp = s.maxPower / divisor;
|
||||
switch (kind) {
|
||||
case RosterKind::Current:
|
||||
return static_cast<double>(curDisp);
|
||||
case RosterKind::Max:
|
||||
return static_cast<double>(maxDisp);
|
||||
case RosterKind::Missing:
|
||||
return static_cast<double>(maxDisp > curDisp ? maxDisp - curDisp : 0u);
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// `UnitPower("unit" [, powerType])` — returns the current power
|
||||
// value for the given type. Defaults to the unit's primary power
|
||||
// when `powerType` is omitted or set to the modern -1 sentinel.
|
||||
@@ -146,18 +191,21 @@ bool ResolveArgs(void *L, const uint8_t **outDesc, int *outType) {
|
||||
// vanilla).
|
||||
static int __fastcall Script_UnitPower(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: UnitPower(\"unit\" [, type])");
|
||||
Game::Lua::Error(L, "Usage: UnitPower(\"unit\" [, type [, unmodified]])");
|
||||
return 0;
|
||||
}
|
||||
const uint8_t *desc;
|
||||
int type;
|
||||
if (!ResolveArgs(L, &desc, &type)) {
|
||||
Game::Lua::PushNumber(L, 0.0);
|
||||
// No live object → out-of-range group member; try the roster cache.
|
||||
Game::Lua::PushNumber(L, RosterPowerFallback(L, RosterKind::Current));
|
||||
return 1;
|
||||
}
|
||||
const uint32_t raw = *reinterpret_cast<const uint32_t *>(
|
||||
desc + Offsets::OFF_UNIT_FIELD_POWER1 + type * 4);
|
||||
const uint32_t divisor = PowerDivisor(type);
|
||||
// `unmodified` (arg 3, truthy) returns the raw internal value; otherwise
|
||||
// apply the per-type display divisor. Matches retail's third UnitPower arg.
|
||||
const uint32_t divisor = Game::Lua::ToBoolean(L, 3) ? 1u : PowerDivisor(type);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(raw / divisor));
|
||||
return 1;
|
||||
}
|
||||
@@ -167,22 +215,57 @@ static int __fastcall Script_UnitPower(void *L) {
|
||||
// `UnitPower`.
|
||||
static int __fastcall Script_UnitPowerMax(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: UnitPowerMax(\"unit\" [, type])");
|
||||
Game::Lua::Error(L, "Usage: UnitPowerMax(\"unit\" [, type [, unmodified]])");
|
||||
return 0;
|
||||
}
|
||||
const uint8_t *desc;
|
||||
int type;
|
||||
if (!ResolveArgs(L, &desc, &type)) {
|
||||
Game::Lua::PushNumber(L, 0.0);
|
||||
Game::Lua::PushNumber(L, RosterPowerFallback(L, RosterKind::Max));
|
||||
return 1;
|
||||
}
|
||||
const uint32_t raw = *reinterpret_cast<const uint32_t *>(
|
||||
desc + Offsets::OFF_UNIT_FIELD_MAXPOWER1 + type * 4);
|
||||
const uint32_t divisor = PowerDivisor(type);
|
||||
const uint32_t divisor = Game::Lua::ToBoolean(L, 3) ? 1u : PowerDivisor(type);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(raw / divisor));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `UnitPowerMissing("unit" [, powerType [, unmodified]])` — the power
|
||||
// deficit, i.e. `UnitPowerMax - UnitPower` for the given type. Same arg
|
||||
// shape and fallbacks as `UnitPower`; `unmodified` (arg 3, truthy) skips
|
||||
// the display divisor and returns the raw internal deficit, mirroring
|
||||
// retail's third `UnitPower` arg.
|
||||
//
|
||||
// Computed as the difference of the two DISPLAY values (each integer-divided
|
||||
// by the per-type divisor), NOT by dividing the raw difference — so it equals
|
||||
// `UnitPowerMax(...) - UnitPower(...)` exactly. For rage those diverge:
|
||||
// raw 1000/55 displays as 100/5, giving a deficit of 95, whereas
|
||||
// `(1000-55)/10` truncates to 94.
|
||||
static int __fastcall Script_UnitPowerMissing(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1)) {
|
||||
Game::Lua::Error(
|
||||
L, "Usage: UnitPowerMissing(\"unit\" [, powerType [, unmodified]])");
|
||||
return 0;
|
||||
}
|
||||
const uint8_t *desc;
|
||||
int type;
|
||||
if (!ResolveArgs(L, &desc, &type)) {
|
||||
Game::Lua::PushNumber(L, RosterPowerFallback(L, RosterKind::Missing));
|
||||
return 1;
|
||||
}
|
||||
const uint32_t cur = *reinterpret_cast<const uint32_t *>(
|
||||
desc + Offsets::OFF_UNIT_FIELD_POWER1 + type * 4);
|
||||
const uint32_t max = *reinterpret_cast<const uint32_t *>(
|
||||
desc + Offsets::OFF_UNIT_FIELD_MAXPOWER1 + type * 4);
|
||||
const uint32_t divisor = Game::Lua::ToBoolean(L, 3) ? 1u : PowerDivisor(type);
|
||||
const uint32_t curDisp = cur / divisor;
|
||||
const uint32_t maxDisp = max / divisor;
|
||||
Game::Lua::PushNumber(
|
||||
L, static_cast<double>(maxDisp > curDisp ? maxDisp - curDisp : 0u));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `UnitPowerType(unit)` — extended to return `(powerType, powerToken)`.
|
||||
// Vanilla 1.12's implementation pushes only the integer; we chain
|
||||
// to the engine's original (`0x00517940`) to preserve its full
|
||||
@@ -210,6 +293,7 @@ static int __fastcall Script_UnitPowerType(void *L) {
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("UnitPower", &Script_UnitPower);
|
||||
Game::Lua::RegisterGlobalFunction("UnitPowerMax", &Script_UnitPowerMax);
|
||||
Game::Lua::RegisterGlobalFunction("UnitPowerMissing", &Script_UnitPowerMissing);
|
||||
Game::Lua::RegisterGlobalFunction("UnitPowerType", &Script_UnitPowerType);
|
||||
Game::Lua::RegisterIntegerEnum(
|
||||
"Enum", "PowerType",
|
||||
|
||||
+17
-12
@@ -57,25 +57,30 @@ int __fastcall Script_UnitRaceBase(void *L) {
|
||||
// the in-world descriptor exists), so this resolves at addon-load
|
||||
// instead of returning nil until the player object spawns. All other
|
||||
// tokens go through the unit descriptor.
|
||||
uint8_t raceByte;
|
||||
uint8_t raceByte = 0;
|
||||
if (Unit::Identity::IsPlayerToken(token)) {
|
||||
raceByte = *reinterpret_cast<const uint8_t *>(Offsets::VAR_PLAYER_RACE_BYTE);
|
||||
} else {
|
||||
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
auto *unit = static_cast<const uint8_t *>(resolve(token));
|
||||
if (unit == nullptr) {
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNil(L);
|
||||
return 2;
|
||||
if (unit != nullptr) {
|
||||
auto *desc = *reinterpret_cast<const uint8_t *const *>(
|
||||
unit + Offsets::OFF_UNIT_DESCRIPTOR);
|
||||
if (desc != nullptr)
|
||||
raceByte = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_RACE_BYTE);
|
||||
}
|
||||
auto *desc = *reinterpret_cast<const uint8_t *const *>(
|
||||
unit + Offsets::OFF_UNIT_DESCRIPTOR);
|
||||
if (desc == nullptr) {
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNil(L);
|
||||
return 2;
|
||||
// No live descriptor (out of range / different map) → fall back to the
|
||||
// engine's player-info cache, matching what UnitRace itself does out of
|
||||
// range (race at OFF_PLAYER_INFO_RACE). `token` is already valid here —
|
||||
// resolve() raises on a bad token before this — so GuidForToken won't
|
||||
// raise.
|
||||
if (raceByte == 0) {
|
||||
const uint8_t *rec = Unit::Identity::PlayerInfoRecord(
|
||||
Unit::Identity::GuidForToken(token));
|
||||
if (rec != nullptr)
|
||||
raceByte = static_cast<uint8_t>(*reinterpret_cast<const uint32_t *>(
|
||||
rec + Offsets::OFF_PLAYER_INFO_RACE));
|
||||
}
|
||||
raceByte = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_RACE_BYTE);
|
||||
}
|
||||
if (raceByte == 0) {
|
||||
Game::Lua::PushNil(L);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// 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/>.
|
||||
|
||||
// `UnitSpellHaste(unit)` — backport of the TBC+ spell-haste getter to 1.12,
|
||||
// which has no native haste API. It reads UNIT_MOD_CAST_SPEED (the cast-time
|
||||
// multiplier at descriptor +0x22c that the server folds into
|
||||
// `SpellEntry::GetCastTime` — `castTime *= GetFloatValue(UNIT_MOD_CAST_SPEED)`)
|
||||
// and converts it to the modern haste PERCENTAGE:
|
||||
//
|
||||
// haste% = (1 / modCastSpeed - 1) * 100
|
||||
//
|
||||
// so `1.0` (no modifier) -> `0`, `0.5` (half cast time) -> `100`, and a
|
||||
// slowing effect like Curse of Tongues (`modCastSpeed > 1.0`) -> a negative
|
||||
// value. This is the same field nampower surfaces raw as `modCastSpeed`;
|
||||
// `UnitSpellHaste` is the Blizzard-shaped percentage over it, so addons can
|
||||
// drop the `GetUnitField("player", "modCastSpeed")` nampower dependency.
|
||||
// (Consumers that need the raw multiplier can recover it as
|
||||
// `1 / (1 + UnitSpellHaste(unit) / 100)`.)
|
||||
//
|
||||
// Vanilla has very few caster-haste sources, so this reads `0` for most
|
||||
// casters most of the time — the value is meaningful for Curse of Tongues,
|
||||
// Nature's Grace, and the handful of other cast-speed effects (and, as the
|
||||
// cast-time work showed, whatever bleeds into the field for hunter shots).
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Unit::SpellHaste {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
|
||||
const uint8_t *Descriptor(const uint8_t *unit) {
|
||||
if (unit == nullptr)
|
||||
return nullptr;
|
||||
return *reinterpret_cast<const uint8_t *const *>(
|
||||
unit + Offsets::OFF_CGUNIT_OBJECT_FIELDS);
|
||||
}
|
||||
|
||||
const uint8_t *ResolveUnit(const char *token) {
|
||||
if (token == nullptr)
|
||||
return nullptr;
|
||||
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
return static_cast<const uint8_t *>(fn(token));
|
||||
}
|
||||
|
||||
// `UnitSpellHaste("unit")` — spell haste percentage. `0` for an unhasted or
|
||||
// invalid unit; negative for a slowed one (Curse of Tongues).
|
||||
static int __fastcall Script_UnitSpellHaste(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: UnitSpellHaste(\"unit\")");
|
||||
return 0;
|
||||
}
|
||||
const uint8_t *desc = Descriptor(ResolveUnit(Game::Lua::ToString(L, 1)));
|
||||
if (desc == nullptr) {
|
||||
Game::Lua::PushNumber(L, 0.0);
|
||||
return 1;
|
||||
}
|
||||
const float mult = *reinterpret_cast<const float *>(
|
||||
desc + Offsets::OFF_UNIT_MOD_CAST_SPEED);
|
||||
// `mult` is the cast-time multiplier; haste% is its inverse deviation from
|
||||
// 1.0. Guard a non-positive / uninitialized field against div-by-zero
|
||||
// (treat it as "no haste").
|
||||
const double haste = (mult > 0.0f)
|
||||
? (1.0 / static_cast<double>(mult) - 1.0) * 100.0
|
||||
: 0.0;
|
||||
Game::Lua::PushNumber(L, haste);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("UnitSpellHaste", &Script_UnitSpellHaste);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Unit::SpellHaste
|
||||
@@ -0,0 +1,128 @@
|
||||
// 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/>.
|
||||
|
||||
// Replicates the engine's per-unit "watch descriptor fields → fire per-token
|
||||
// unit events" registration (`FUN_0051bbb0` / `FUN_0051bdb0`) so synthetic
|
||||
// tokens can be made first-class. The engine reaches this from
|
||||
// `FUN_0060c520`, which recomputes a unit's token-category mask
|
||||
// (target/party/raid/pet/mouseover) at `unit+0xc90` and, on a 0→nonzero
|
||||
// transition, registers a bank-UNIT observer on every named event field with
|
||||
// callback `FUN_0051bd50` → `FUN_00515e50(guid, eventId)` → the GUID→token
|
||||
// reverse map `FUN_00515c50` → fire the event once per token.
|
||||
//
|
||||
// We register the same field set with a caller-supplied callback instead, so
|
||||
// the caller fires its own token. The field set / sizes are read from the
|
||||
// static event-name table exactly as the engine does; see the
|
||||
// FUN_DESC_OBSERVER_REGISTER block in Offsets.h.
|
||||
//
|
||||
// Cross-checked against nampower's offsets.hpp: `SendUnitSignal` (the
|
||||
// broadcast) = `0x00515e50`, `StringParamFormat` ("%s") = `0x0082E280`,
|
||||
// `SignalEventParam` = `0x00703F50`, `UNIT_HEALTH` = 16 — all consistent with
|
||||
// the field-index-is-event-id derivation here. (nampower left its
|
||||
// `GetNamesFromGUID` reverse-map offset as an unfilled TODO; it is
|
||||
// `FUN_00515c50`.)
|
||||
|
||||
#include "unit/TokenObserver.h"
|
||||
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Unit::TokenObserver {
|
||||
|
||||
namespace {
|
||||
|
||||
using Register_t = void(__fastcall *)(int bank, uint32_t fieldOffset,
|
||||
uint32_t guidLo, uint32_t guidHi, int size,
|
||||
const void *callback, void *userArg1,
|
||||
void *userArg2);
|
||||
using Unregister_t = void(__fastcall *)(int bank, uint32_t fieldOffset,
|
||||
uint32_t guidLo, uint32_t guidHi,
|
||||
const void *callback, void *userArg1);
|
||||
|
||||
const char *const *EventNames() {
|
||||
return reinterpret_cast<const char *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_EVENT_NAME_TABLE_STATIC));
|
||||
}
|
||||
|
||||
// Both register and unregister funnel through `FUN_00464890`, which
|
||||
// dereferences the client/player-rooted observer registry at
|
||||
// `[VAR_LOCAL_PLAYER_PTR]` (reads its `+0x24` mask). That root is NULL out of
|
||||
// world — login screen, loading screens, logout teardown — where the engine
|
||||
// helper null-derefs (crash: `MOV EAX,[EDX+0x24]`, EDX=0). Bail when it isn't
|
||||
// up: there are no live objects or observer nodes to touch anyway (nodes die
|
||||
// with their objects during teardown), and in-world callers re-register/clean
|
||||
// up on the next tick. Unlike `Player::Equipment` (which only ever registers
|
||||
// from the enter-world hook), our consumers register from Lua / WorldTick,
|
||||
// which can run mid-transition — hence the guard here.
|
||||
bool RegistryReady() {
|
||||
return *reinterpret_cast<void *const volatile *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_LOCAL_PLAYER_PTR)) != nullptr;
|
||||
}
|
||||
|
||||
// Bytes the engine watches for the observer on event index `i` — verbatim
|
||||
// from `FUN_0051bbb0`'s size switch. The 8-byte entries are the 64-bit GUID
|
||||
// fields; index 0x29 (`UNIT_FIELD_AURA`) spans the whole 0xD8 aura block.
|
||||
int SizeForIndex(int i) {
|
||||
switch (i) {
|
||||
case 0:
|
||||
case 2:
|
||||
case 4:
|
||||
case 10:
|
||||
return 8;
|
||||
case 0x29:
|
||||
return 0xD8;
|
||||
case 0x6B:
|
||||
return 0x30;
|
||||
case 0xA7:
|
||||
case 0xAE:
|
||||
return 0x1C;
|
||||
default:
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void Register(uint64_t guid, FieldCallback cb) {
|
||||
if (guid == 0 || cb == nullptr || !RegistryReady())
|
||||
return;
|
||||
auto reg = reinterpret_cast<Register_t>(Offsets::FUN_DESC_OBSERVER_REGISTER);
|
||||
const char *const *names = EventNames();
|
||||
const uint32_t lo = static_cast<uint32_t>(guid);
|
||||
const uint32_t hi = static_cast<uint32_t>(guid >> 32);
|
||||
for (int i = 0; i < Offsets::EVENT_NAME_UNIT_MAX; ++i) {
|
||||
if (names[i] == nullptr)
|
||||
continue; // unnamed slot → not a real unit event
|
||||
reg(Offsets::DESC_OBSERVER_BANK_UNIT, static_cast<uint32_t>(i * 4), lo, hi,
|
||||
SizeForIndex(i), reinterpret_cast<const void *>(cb), nullptr, nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
void Unregister(uint64_t guid, FieldCallback cb) {
|
||||
if (guid == 0 || cb == nullptr || !RegistryReady())
|
||||
return;
|
||||
auto unreg =
|
||||
reinterpret_cast<Unregister_t>(Offsets::FUN_DESC_OBSERVER_UNREGISTER);
|
||||
const char *const *names = EventNames();
|
||||
const uint32_t lo = static_cast<uint32_t>(guid);
|
||||
const uint32_t hi = static_cast<uint32_t>(guid >> 32);
|
||||
for (int i = 0; i < Offsets::EVENT_NAME_UNIT_MAX; ++i) {
|
||||
if (names[i] == nullptr)
|
||||
continue;
|
||||
unreg(Offsets::DESC_OBSERVER_BANK_UNIT, static_cast<uint32_t>(i * 4), lo, hi,
|
||||
reinterpret_cast<const void *>(cb), nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace Unit::TokenObserver
|
||||
@@ -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/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Unit::TokenObserver {
|
||||
|
||||
// Descriptor-field observer callback — the engine's __AUCMirrorHandler__ node
|
||||
// ABI (see the FUN_DESC_OBSERVER_REGISTER block in Offsets.h): return 1,
|
||||
// callee cleans 0x10. `fieldOffset` is the bank-relative offset as registered;
|
||||
// the corresponding unit event id is `fieldOffset >> 2` (the descriptor field
|
||||
// index equals the unit event id — health field +0x40 → event 16 =
|
||||
// `UNIT_HEALTH`).
|
||||
using FieldCallback = int(__fastcall *)(uint32_t fieldOffset, uint32_t size,
|
||||
uint32_t guidLo, uint32_t guidHi,
|
||||
const uint32_t *oldValue, void *userArg);
|
||||
|
||||
// Register / unregister the engine's *complete* unit-event descriptor-field
|
||||
// observer set for `guid`, dispatching to `cb` on each watched field change.
|
||||
// Mirrors the engine's own `FUN_0051bbb0` / `FUN_0051bdb0` (identical field
|
||||
// set + per-field watched sizes), so `cb` fires for exactly the fields the
|
||||
// engine treats as token-scoped unit events (`UNIT_HEALTH`, `UNIT_MANA`,
|
||||
// `UNIT_AURA`, `UNIT_LEVEL`, …).
|
||||
//
|
||||
// This makes a synthetic token (focus, nameplateN) a first-class unit-event
|
||||
// source: the engine only registers these observers for its own tokens
|
||||
// (target/party/raid/pet/mouseover via the `unit+0xc90` watch mask), so a
|
||||
// focused mob that isn't otherwise one of them fires no unit events until we
|
||||
// watch it ourselves. The registrar appends a fresh node per (guid, field)
|
||||
// with no dedup, so our observer coexists with the engine's — a unit that is
|
||||
// simultaneously the target keeps firing `"target"` while ours fires its own
|
||||
// token. Unregister is a safe no-op once the object is gone (the engine frees
|
||||
// observer nodes with the object).
|
||||
//
|
||||
// `cb` runs inside SMSG_UPDATE_OBJECT dispatch; firing engine events from it
|
||||
// is sanctioned (the engine's own callback `FUN_0051bd50` does exactly that).
|
||||
void Register(uint64_t guid, FieldCallback cb);
|
||||
void Unregister(uint64_t guid, FieldCallback cb);
|
||||
|
||||
} // namespace Unit::TokenObserver
|
||||
Reference in New Issue
Block a user