Compare commits

...
16 Commits
Author SHA1 Message Date
Brues a5b6cf367d aura: cache fallback for out-of-range group auras (server delta gap)
The server resends out-of-range party/raid stats as deltas only, so a
member seen in range and then moved away again leaves an empty party-
stats aura array (it's cleared when they become visible and never re-
sent), and C_UnitAuras showed nothing until a full roster resync (relog).
Their auras still sit in the Aura::Source cache from when we observed them
in range, so the four group enumerate paths now fall back to the cache
when the group array is empty -- gated by GroupArrayHasVisibleAura, the
same "container non-empty => authoritative" rule the descriptor path uses
(DescriptorHasVisibleAura). A populated array is trusted as-is (no
phantoms); only the empty-array gap consults the cache, deduped and
visibility/playerOnly-filtered.
2026-07-26 18:19:47 -05:00
Brues b496568670 Add C_Spell.GetSpellEffectMechanics(spellID)
Returns the spell's three per-effect SpellMechanic ids (Spell.dbc
EffectMechanic[3], +0x13C) as a 1-based array, or nil for an invalid spell.
Complements GetSpellMechanicByID, which reads only the spell-level Mechanic
field (+0x14): vanilla stores some mechanics on an effect instead -- e.g.
Rake's bleed is effect-level (spell-level 0, EffectMechanic[2]=15), so
GetSpellMechanicByID misses it while this returns {0,15,0}.

Add OFF_SPELL_RECORD_EFFECT_MECHANIC offset, API.md section + TOC entry, and
README index entry.
2026-07-26 15:42:25 -05:00
Brues a37c590a72 aura: clamp elapsed player-buff expiration to 0 (permanent auras)
Permanent auras (paladin auras, passives, racials) carry an infinite
SpellDuration (base -1); the server stores their fixed cast-time in the
player buff-expiration table, so it reads as already-elapsed and drifts
into the past. We were reporting that stale timestamp, giving a permanent
aura like Devotion Aura a negative time-left. Mirror the engine's own
GetPlayerBuffTimeLeft (Script at 0x004e4930 -> FUN_004e4450), which reads
the same table and returns 0 once now >= expiration. Signed compare
absorbs the GetTickCount wrap, matching the engine.
2026-07-26 12:16:48 -05:00
Brues 60479677c1 aura: best-effort duration guess for out-of-range group/raid auras
An out-of-range party/raid member has no CGUnit and never yields
SMSG_SPELL_GO, so the caster/duration cache never learned their auras'
timing and expirationTime stayed 0. But SMSG_PARTY_MEMBER_STATS carries
their aura spell IDs and the server sends it on change
(SendUpdateToOutOfRangeGroupMembers flushes the moment an aura add/remove
dirties the group mask), so a spell ID that newly appears in the group
array did so ~one tick + latency after the real application.

Aura::Source::ObserveGroupAuras (called from the group enumerate paths in
Aura::Data) keeps a per-member snapshot of the last-seen spell-ID set and,
on a genuine absent->present transition, stamps now + base duration into
the same cache PushEnriched already reads. Covers party and raid alike
(AuraArray reads the raid slot too; snapshot table sized for a 40-man).
Base duration only (no remote caster mods) and no caster recorded
(sourceUnit/GUID stay nil). Auras already present at first sight have
unknown age and are not stamped; snapshots reset on map change and after
30s unpolled. The Store guard keeps any real SpellGo timing, so this
never downgrades better data.
2026-07-26 03:05:54 -05:00
Brues b5038dc3fd util: add VectorLib (Vector2D / Vector3D / Vector4D)
Verbatim backport of retail's Blizzard_SharedXML vector mixins
(CreateVector2D/3D/4D + Vector*DMixin + the free Vector*_ helpers).
Pure math on CreateFromMixins and Lua 5.0 math.*, so no adaptation was
needed. Registered the three files in the TOC (we list .lua directly
rather than via retail's VectorLib.xml include).
2026-07-26 03:00:03 -05:00
Brues 0bbf802316 Use BASE_MOVEMENT_SPEED for speed calculation
Replace the magic divisor 7 with the BASE_MOVEMENT_SPEED constant
2026-07-26 02:53:34 -05:00
Brues 0fcaa2e064 util: add RectangleMixin / CreateRectangle
Backport of retail's Rectangle helper (sides, stretch/move/adjust,
contains/intersects, center, width/height accessors), built on
CreateFromMixins. Registered in the TOC.
2026-07-26 02:49:02 -05:00
Brues 6724ddd2bd mathutil: CreateCounter takes an optional step
Retail added a second `step` parameter (default 1); mirror it. Backward
compatible -- existing no-step callers are unchanged.
2026-07-26 02:46:58 -05:00
Brues 50d001a71f mathutil: add CreateAccumulator / AccumulatorMixin
Backport of retail's AccumulatorMixin (Init/Add/Subtract/Count/Reset)
and the CreateAccumulator factory, built on CreateAndInitFromMixin.
2026-07-26 02:42:40 -05:00
Brues 3aad341a91 table: add table.count(tbl)
Backport of retail's table.count (added 11.2.5): returns
(numTableNodes, numArrayNodes, maxArrayIndex) -- total entries, entries
with an integer key in [1..numTableNodes], and the largest positive
integral key (0 if none). Pure lua_next walk, no Lua-internal layout
dependency; keys read via ToNumber only (ToString on a numeric key
would corrupt traversal). All five cases verified to match retail,
including negative-key handling ({[-3]=x} -> 1, 0, 0).
2026-07-26 02:24:19 -05:00
Brues 3141acf547 baselib: provide Mixin and CreateFromMixins as C natives
Modern WoW implements these FrameXML table primitives as engine C
functions (TableUtil); do the same so they exist whenever the DLL is
injected -- even if the !!!ClassicAPI addon is disabled. Behaviour
matches the Lua form exactly: shallow key/value copy from each table
arg, non-table args skipped, returns the object / new table. Registered
on both the in-game and glue states.

Slimmed the addon Mixin.lua to just CreateAndInitFromMixin (calls a Lua
:Init, so it stays Lua), mirroring retail's Mixin.lua. The taint-based
SecureMixin family is omitted -- vanilla 1.12 has no execution taint.
2026-07-26 02:12:22 -05:00
Brues 05e7bda5da color: add C_ColorUtil and wire the addon Color files to it
New C_ColorUtil native module (src/color/Util.cpp) with the modern color
helpers, all pure math/string: ConvertRGBToHSV, ConvertHSVToRGB,
ConvertHSVToHSL, ConvertHSLToHSV, ConvertHSLToRGB, GenerateTextColorCode,
WrapTextInColor, WrapTextInColorCode. Conventions verified in-game against
a retail-family client: hue in degrees [0,360), -1 achromatic sentinel,
bare AARRGGBB from GenerateTextColorCode, |c..|r wrapping.

Ported the addon Color.lua / ColorUtil.lua to match retail now that the
natives exist: ColorMixin:GetHSL/GenerateHexColor/WrapText* delegate to
C_ColorUtil (GetHSL now returns degrees), SetRGBA defaults alpha to 1,
byte getters use Round; added COLOR_FORMAT_* constants and
CreateColorFromBestRGBHexString. Kept 1.12-Lua-5.0-safe syntax and the
ClassicAPI-only CAPI_ApplyStandardColorGlobals / ITEM_QUALITY_COLORS bits.
2026-07-26 01:57:56 -05:00
Brues c4b48f7f42 spell: add CanDualWield()
New global returning whether the player can equip an off-hand weapon.
The server tracks this as Player::m_canDualWield, flipped on only by
SPELL_EFFECT_DUAL_WIELD (effect 40); exactly one client Spell.dbc row
carries that effect -- 674 "Dual Wield" -- so CanDualWield() is exactly
IsPlayerSpell(674). Factored the known-spell bitmap read into a shared
PlayerKnowsSpell() helper used by both.
2026-07-26 01:30:11 -05:00
Brues 4d97b1c449 clipboard: add CopyToClipboard(text [, removeMarkup])
New Win32-backed global that copies text to the Windows clipboard and
returns the byte length copied. Optional removeMarkup strips WoW UI
escape sequences (color codes, hyperlinks keeping visible text,
textures, |n, || -> |) so the clipboard gets plain text. UTF-8 in,
CF_UNICODETEXT out. Registered on both the in-game and glue states.

Docs: folded the lone Screen section and this into a single System
section rather than adding a one-function Clipboard section.
2026-07-26 01:18:42 -05:00
Brues e447c70ca3 aura: recover player-talented DoT duration in the application hooks
A player DoT with a duration talent (e.g. Improved Shadow Word: Pain,
2/2 -> 24s vs 18s base) intermittently showed the base duration. The
displayed value is the Aura::Source cache entry's durationMs, which is
computed two ways: SpellGo knows caster == player so it applies the
player's duration mods (skipMod 0 -> 24s), while the OnAuraAdded /
OnAuraStacksChanged application hooks have no caster and take the
unmodified base (skipMod 1 -> 18s).

Normally SpellGo owns the entry and the application hooks can't clobber
it, but the base value wins whenever SpellGo can't land its talented
value on THIS target's entry: an empty SMSG_SPELL_GO hit list files it
under the caster instead (numTargets == 0), or a slot reshuffle
evicts + refills the entry after SpellGo ran. Both are packet-order
dependent, hence the flicker.

Fix: SpellGo (which knows caster == player) records recent player aura
casts by spellId; the application hooks recompute with skipMod 0 and
attribute the caster when the spellId matches a recent player cast.
Robust against both the attribution miss and the refill race, and
leaves other units' auras on the base path (we lack their mod tables).
2026-07-26 00:00:42 -05:00
Brues 72fa36956e tooltip: add OnTooltipSetSpell/Unit/GameObject scripts
Generalize the item-only Tooltip::SetItemEvent into Tooltip::SetEvents,
backing all four object tooltip scripts through one shared fire path
(stack save/restore + recursion guard + Suppressor). One resolver
co-hook recognizes all four OnTooltipSet* names (the per-tooltip cell
now holds a slot per kind), plus one builder co-hook per kind firing its
script after the original build:

- Spell  -> FUN_0052E610 (SetSpell/SetSpellByID/SetTalent/SetShapeshift;
  7 stack args, RET 0x1c)
- Unit   -> FUN_00529fe0 (SetUnit + mouseover; 1 arg, returns an int the
  caller tests -> co-hook forwards it)
- GameObject -> FUN_0052aa20 (GO hover populator; 1 arg)

Each hook's declared stack-arg count matches its callee RET N (verified
from disassembly) so the caller's stack isn't corrupted.

Auras deliberately do NOT fire OnTooltipSetSpell: SetUnitBuff/Debuff/
PlayerBuff funnel through a separate aura-tooltip builder (FUN_0052f880)
that bypasses the spell builder -- matching retail, where unit auras are
a distinct tooltip data type.

Verified in-game: all three new scripts fire (spell hover, unit
mouseover, gameobject mouseover), item still fires, no crash.
2026-07-25 22:57:32 -05:00
27 changed files with 2321 additions and 338 deletions
+4
View File
@@ -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
+17 -40
View File
@@ -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)
+26 -13
View File
@@ -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)
+38 -3
View File
@@ -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
+7 -20
View File
@@ -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)
+1 -1
View File
@@ -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()
+141
View File
@@ -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
+142
View File
@@ -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
+173
View File
@@ -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
+143
View File
@@ -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
View File
@@ -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
+5 -4
View File
@@ -23,6 +23,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [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` |
@@ -38,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: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` (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` |
@@ -46,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` |
@@ -57,10 +58,10 @@ 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` |
| [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` |
+299 -58
View File
@@ -34,6 +34,16 @@ build instructions.
- [Class](#class)
- [`FillLocalizedClassList(table [, isFemale])`](#filllocalizedclasslisttable-isfemale)
- [ColorUtil](#colorutil)
- [`C_ColorUtil.ConvertRGBToHSV(r, g, b)`](#c_colorutilconvertrgbtohsvr-g-b)
- [`C_ColorUtil.ConvertHSVToRGB(h, s, v)`](#c_colorutilconverthsvtorgbh-s-v)
- [`C_ColorUtil.ConvertHSVToHSL(h, s, v)`](#c_colorutilconverthsvtohslh-s-v)
- [`C_ColorUtil.ConvertHSLToHSV(h, s, l)`](#c_colorutilconverthsltohsvh-s-l)
- [`C_ColorUtil.ConvertHSLToRGB(h, s, l)`](#c_colorutilconverthsltorgbh-s-l)
- [`C_ColorUtil.GenerateTextColorCode(color)`](#c_colorutilgeneratetextcolorcodecolor)
- [`C_ColorUtil.WrapTextInColor(text, color)`](#c_colorutilwraptextincolortext-color)
- [`C_ColorUtil.WrapTextInColorCode(text, colorCode)`](#c_colorutilwraptextincolorcodetext-colorcode)
- [Combat](#combat)
- [`InCombatLockdown()`](#incombatlockdown)
@@ -166,7 +176,7 @@ build instructions.
- [`GameTooltip:SetInventoryItemByID(itemID)`](#gametooltipsetinventoryitembyiditemid)
- [`GameTooltip:SetHyperlinkCompareItem("itemLink" [, offset, shiftButton, comparisonTooltip])`](#gametooltipsethyperlinkcompareitemitemlink--offset-shiftbutton-comparisontooltip)
- [`GameTooltip:IsEquippedItem()`](#gametooltipisequippeditem)
- [`OnTooltipSetItem` script](#ontooltipsetitem-script)
- [`OnTooltipSet*` scripts (Item / Spell / Unit / GameObject)](#ontooltipset-scripts)
- [`GameTooltip:SetItemByGUID(itemGUID)`](#gametooltipsetitembyguiditemguid)
- [`GameTooltip:SetEquipmentSet(name)`](#gametooltipsetequipmentsetname)
- [`GameTooltip:SetTotem(slot)`](#gametooltipsettotemslot)
@@ -290,6 +300,8 @@ build instructions.
- [Lua](#lua)
- [`select(index, ...)`](#selectindex-)
- [`table.wipe(t)`](#tablewipet)
- [`table.count(tbl)`](#tablecounttbl)
- [`Mixin(object, ...)` / `CreateFromMixins(...)`](#mixinobject--createfrommixins)
- [`string.match` / `string.gmatch`](#stringmatch--stringgmatch)
- [`strsplit(sep, str [, pieces])`](#strsplitsep-str--pieces)
- [`strjoin(delimiter, ...)`](#strjoindelimiter-)
@@ -379,9 +391,6 @@ build instructions.
- [`C_QuestLog.IsQuestDataCachedByID(questID)`](#c_questlogisquestdatacachedbyidquestid)
- [`GetQuestLogLeaderBoardID(objectiveIndex [, questIndex])`](#getquestlogleaderboardidobjectiveindex--questindex)
- [Screen](#screen)
- [`GetPhysicalScreenSize()`](#getphysicalscreensize)
- [Spell](#spell)
- [`C_Spell.DoesSpellExist(spellID)`](#c_spelldoesspellexistspellid)
- [`C_Spell.GetSchoolString(schoolMask)`](#c_spellgetschoolstringschoolmask)
@@ -393,6 +402,7 @@ build instructions.
- [`C_Spell.GetSpellLink(spellID)`](#c_spellgetspelllinkspellid)
- [`C_Spell.GetSpellDescription(spellID)`](#c_spellgetspelldescriptionspellid)
- [`C_Spell.GetSpellMechanicByID(spellID)`](#c_spellgetspellmechanicbyidspellid)
- [`C_Spell.GetSpellEffectMechanics(spellID)`](#c_spellgetspelleffectmechanicsspellid)
- [`C_Spell.GetSpellRadius(spellID)` / `GetSpellRadius(slot, bookType)`](#c_spellgetspellradiusspellid--getspellradiusslot-booktype)
- [`C_Spell.GetSpellPowerCost(spellIdentifier)`](#c_spellgetspellpowercostspellidentifier)
- [`C_Spell.GetSpellReagents(spellID)`](#c_spellgetspellreagentsspellid)
@@ -400,6 +410,7 @@ build instructions.
- [`IsPassiveSpell(spellID)` / `IsPassiveSpell(slot, bookType)`](#ispassivespellspellid--ispassivespellslot-booktype)
- [`C_Spell.IsSpellPassive(spellID)`](#c_spellisspellpassivespellid)
- [`IsPlayerSpell(spellID)`](#isplayerspellspellid)
- [`CanDualWield()`](#candualwield)
- [`IsSpellKnown(spellID, [isPet])`](#isspellknownspellid-ispet)
- [`GetSpellBonusDamage(school)`](#getspellbonusdamageschool)
- [`GetSpellBonusHealing()`](#getspellbonushealing)
@@ -447,6 +458,10 @@ build instructions.
- [`GetShapeshiftFormID()`](#getshapeshiftformid)
- [`CancelShapeshiftForm()`](#cancelshapeshiftform)
- [System](#system)
- [`GetPhysicalScreenSize()`](#getphysicalscreensize)
- [`CopyToClipboard(text [, removeMarkup])`](#copytoclipboardtext--removemarkup)
- [Talent](#talent)
- [`GetTalentSpellID(tabIndex, talentIndex, [rank[, classID]])`](#gettalentspellidtabindex-talentindex-rank-classid)
- [`GetTalentIDByIndex(tabIndex, talentIndex[, classID])`](#gettalentidbyindextabindex-talentindex-classid)
@@ -1058,6 +1073,80 @@ Sparse class IDs (vanilla skips classID 6 — Death Knight didn't
exist yet — and a few others) have NULL records and are silently
skipped.
## ColorUtil
The modern `C_ColorUtil` color-space and text-color-code helpers. All are
pure functions (no engine state); conventions match a retail-family client
that ships them natively, verified in-game.
**Ranges:** hue is in **degrees**, `[0, 360)`; saturation / value / lightness
are `[0, 1]`. RGB channels are `[0, 1]` (not 0–255). Achromatic (gray) inputs
return a hue of **`-1`** — a sentinel, not `0` — which the reverse conversions
accept as "no hue" (equivalent to `saturation == 0`).
The `color` argument is a table with `r`, `g`, `b` fields in `[0, 1]`
(`ColorMixin`-shaped). An optional `a` is honored (default `1`), though the
color code always encodes 8 hex digits with alpha.
### `C_ColorUtil.ConvertRGBToHSV(r, g, b)`
Returns `(h, s, v)`. Achromatic input → `h == -1`.
```lua
C_ColorUtil.ConvertRGBToHSV(0, 1, 0) -- 120, 1, 1 (pure green)
C_ColorUtil.ConvertRGBToHSV(0.5, 0.5, 0.5) -- -1, 0, 0.5 (gray)
```
### `C_ColorUtil.ConvertHSVToRGB(h, s, v)`
Returns `(r, g, b)`. A negative hue (or `s == 0`) is treated as achromatic
and yields `r == g == b == v`.
```lua
C_ColorUtil.ConvertHSVToRGB(120, 1, 1) -- 0, 1, 0
```
### `C_ColorUtil.ConvertHSVToHSL(h, s, v)`
Returns `(h, s, l)`. Hue passes through unchanged.
### `C_ColorUtil.ConvertHSLToHSV(h, s, l)`
Returns `(h, s, v)`. Hue passes through unchanged.
### `C_ColorUtil.ConvertHSLToRGB(h, s, l)`
Returns `(r, g, b)`. A negative hue (or `s == 0`) is achromatic → `r == g ==
b == l`.
### `C_ColorUtil.GenerateTextColorCode(color)`
Returns the bare 8-hex color string `"AARRGGBB"` (alpha first), **not**
including the `|c` escape.
```lua
C_ColorUtil.GenerateTextColorCode({r = 1, g = 0, b = 0}) -- "ffff0000"
```
### `C_ColorUtil.WrapTextInColor(text, color)`
Wraps `text` in the color's escape sequence: `"|c" .. AARRGGBB .. text ..
"|r"`.
```lua
C_ColorUtil.WrapTextInColor("Hi", {r = 1, g = 0, b = 0}) -- "|cffff0000Hi|r"
```
### `C_ColorUtil.WrapTextInColorCode(text, colorCode)`
Wraps `text` in the given bare color code: `"|c" .. colorCode .. text ..
"|r"`. Pair with `GenerateTextColorCode` (which produces `colorCode`).
```lua
local code = C_ColorUtil.GenerateTextColorCode({r = 1, g = 0, b = 0})
C_ColorUtil.WrapTextInColorCode("Hi", code) -- "|cffff0000Hi|r"
```
## Combat
### `InCombatLockdown()`
@@ -4158,18 +4247,27 @@ GameTooltip:SetBagItem(0, 1)
if GameTooltip:IsEquippedItem() then ... end -- false unless that item is also worn
```
### `OnTooltipSetItem` script
### `OnTooltipSet*` scripts
A real frame script — settable with the standard `SetScript` / `GetScript` /
`HookScript` — that fires whenever a tooltip's **item** is set. Backports the
modern tooltip script so addons annotate item tooltips by hooking one script
instead of wrapping every `Set*` method.
Fires for every item-setting path (`SetBagItem`, `SetInventoryItem`,
`SetHyperlink` for an `item:` link, `SetMerchantItem`, `SetAuctionItem`,
`SetItemByID`, …). Available on all GameTooltip-type frames (`GameTooltip`,
`OnTooltipSetItem` / `OnTooltipSetSpell` / `OnTooltipSetUnit` /
`OnTooltipSetGameObject` — real frame scripts, settable with the standard
`SetScript` / `GetScript` /
`HookScript` — that fire whenever a tooltip's **item**, **spell**, **unit**, or
**gameobject** is set. Backport the modern tooltip scripts so addons annotate
tooltips by hooking one script per object type instead of wrapping every
`Set*` method. Available on all GameTooltip-type frames (`GameTooltip`,
`ItemRefTooltip`, `ShoppingTooltip1/2`, `AtlasLootTooltip`, …).
| Script | Fires after any of |
|---|---|
| `OnTooltipSetItem` | `SetBagItem`, `SetInventoryItem`, `SetHyperlink` (`item:`), `SetMerchantItem`, `SetAuctionItem`, `SetItemByID`, … |
| `OnTooltipSetSpell` | `SetSpell`, `SetSpellByID`, `SetTalent`, `SetShapeshift` |
| `OnTooltipSetUnit` | `SetUnit` and unit mouseover |
| `OnTooltipSetGameObject` | gameobject mouseover (herb/ore nodes, chests, mailboxes, signs) |
Vanilla only shipped `OnTooltipAddMoney` / `OnTooltipCleared` /
`OnTooltipSetDefaultAnchor`; these four are the backported set.
The handler receives the tooltip as the **global `this`**, the 1.12
frame-script convention — *not* a `self` argument (like every built-in vanilla
script: `OnShow`, `OnEvent`, `OnTooltipCleared`, …). Modern-style
@@ -4178,21 +4276,25 @@ script: `OnShow`, `OnEvent`, `OnTooltipCleared`, …). Modern-style
```lua
GameTooltip:HookScript("OnTooltipSetItem", function()
local name, link, id = this:GetItem() -- the tooltip is the global `this`
if id then
this:AddLine("ID: " .. id, 0.6, 0.6, 0.6)
end
if id then this:AddLine("ID: " .. id, 0.6, 0.6, 0.6) end
end)
GameTooltip:HookScript("OnTooltipSetUnit", function()
local name, unit = this:GetUnit()
if unit then this:AddLine(UnitClassBase(unit), 0.6, 0.6, 0.6) end
end)
```
Vanilla only shipped `OnTooltipAddMoney` / `OnTooltipCleared` /
`OnTooltipSetDefaultAnchor`; `OnTooltipSetSpell` / `OnTooltipSetUnit` are not
yet provided.
**Auras don't fire `OnTooltipSetSpell`** — `SetUnitBuff` / `SetUnitDebuff` /
`SetPlayerBuff` / `SetUnitAura` use a separate aura-tooltip builder, so hovering
a buff/debuff does not trigger the spell script. This matches retail, where unit
auras are a distinct tooltip data type rather than a spell.
**Caveat — the event fires from inside the item-tooltip build.** Lightweight
handler work is fine: reading `this:GetItem()`, `this:AddLine(...)`, printing,
etc. But avoid *re-entrant* tooltip rebuilds from the handler — e.g.
`GameTooltip_ShowCompareItem()`, or a `Set*` call on another tooltip. Because
the handler runs mid-build, re-entering the tooltip / FrameScript machinery can
**Caveat — the event fires from inside the tooltip build.** Lightweight handler
work is fine: reading `this:GetItem()` / `this:GetUnit()`, `this:AddLine(...)`,
printing, etc. But avoid *re-entrant* tooltip rebuilds from the handler — e.g.
`GameTooltip_ShowCompareItem()`, or a `Set*` call on another tooltip. Because the
handler runs mid-build, re-entering the tooltip / FrameScript machinery can
collide with other DLLs that hook the same Lua paths (nampower, SuperWoW,
weirdutils, …) and crash the client. If you need heavy work like that, defer it
to the next frame:
@@ -7115,6 +7217,55 @@ undefined per the Lua reference manual.
Errors on non-table input.
### `table.count(tbl)`
Returns `(numTableNodes, numArrayNodes, maxArrayIndex)` describing how a table
is populated (a modern WoW diagnostic, added retail 11.2.5). These are counts
of live entries, **not** the table's allocated capacity:
- **`numTableNodes`** — total number of key/value pairs.
- **`numArrayNodes`** — how many of those have an integer key in the range
`[1..numTableNodes]` (the "contiguous array part" heuristic).
- **`maxArrayIndex`** — the largest positive integral key (`>= 1`), or `0` if
there is none. Negative/zero integral keys don't count.
```lua
table.count({ 10, 20, 30 }) -- 3, 3, 3
table.count({ a = 1, b = 2 }) -- 2, 0, 0
table.count({ [1] = "x", foo = "y" }) -- 2, 1, 1
table.count({ [100] = "x" }) -- 1, 0, 100
table.count({ [-3] = "x" }) -- 1, 0, 0
```
Pure iteration over the table (`lua_next`), so no dependency on Lua's internal
storage layout. Errors on non-table input.
### `Mixin(object, ...)` / `CreateFromMixins(...)`
The FrameXML table-mixin primitives, provided as engine C functions.
- **`Mixin(object, ...)`** — shallow-copies every key/value from each table
argument onto `object` (later arguments win on key collisions) and returns
`object`. Non-table arguments are skipped.
- **`CreateFromMixins(...)`** — `Mixin({}, ...)`: returns a new table seeded
from the given mixins.
```lua
local Greeter = {}
function Greeter:Hello() return "hi, "..self.name end
local obj = CreateFromMixins(Greeter)
obj.name = "Bob"
obj:Hello() -- "hi, Bob"
```
Modern WoW provides these as C functions in `TableUtil`; ClassicAPI does 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(mixin, ...)` (which calls a `:Init` method) remains a
Lua helper in the addon, mirroring retail's `Mixin.lua`. The taint-based
`SecureMixin` family is intentionally omitted — vanilla 1.12 has no execution
taint system, so there is nothing for it to guard.
### `string.match` / `string.gmatch`
The Lua 5.1 pattern helpers, backported to 1.12's Lua 5.0 (which ships only
@@ -9181,40 +9332,6 @@ A separate function keeps the existing call wire-compatible.
> first, skip zero slots, then the item array. 1-based `objectiveIndex`
> counts only non-empty slots.
## Screen
### `GetPhysicalScreenSize()`
Returns `(widthPixels, heightPixels)` — the display's physical
resolution in pixels. Modern WoW added this as a native in 7.0
(Legion); FrameXML's `PixelUtil` builds its entire pixel↔UI-unit
conversion on it (`768.0 / physicalHeight`), which is what lets addons
snap frames to whole pixels for crisp borders at fractional UI scales.
```lua
local w, h = GetPhysicalScreenSize() -- e.g. 1920, 1080
```
Vanilla 1.12 has no such native — and no pre-parsed pixel-dimension
global. `GetScreenWidth()`/`GetScreenHeight()` return UI-space units
(derived from the UIParent aspect ratio at `[UIParent + 0x7c]`), not
pixels. The engine only ever holds the current mode as the
`gxResolution` CVar string (`"WIDTHxHEIGHT"`); both `SetScreenResolution`
(`0x0048bfd0`) and the display-init path sscanf it on demand rather
than caching dimensions anywhere. So we read the `gxResolution` CVar
directly through the engine's by-name lookup (`FUN_FIND_CVAR`, value at
`+0x20`) and parse it — the same source of truth the engine uses.
Returns `1024, 768` (→ factor 1.0, making PixelUtil a 1:1 no-op) when
the CVar is missing or unparseable. Caveat: in windowed mode
`gxResolution` reflects the configured render resolution, which may
differ from the OS window's client size — vanilla exposes no separate
window-pixel query.
The companion `PixelUtil` table (`PixelUtil.SetPoint`/`SetSize`/
`SetStatusBarValue`/…) is provided Lua-side by the ClassicAPI addon and
consumes this function.
## Spell
> **All `C_Spell.*` functions in this section accept any spell
@@ -9523,6 +9640,41 @@ column is exactly what this function returns):
> `14` (incapacitated) in 1.12 — the `30` ("sapped") value used by some
> addon tables is a later-expansion addition and has no row here.
### `C_Spell.GetSpellEffectMechanics(spellID)`
Returns the spell's three per-effect `SpellMechanic` ids (`Spell.dbc`
`EffectMechanic[3]`) as a 1-based array table, or `nil` for an invalid /
out-of-range spell ID. Each entry uses the same numbering as
[`GetSpellMechanicByID`](#c_spellgetspellmechanicbyidspellid) (`0` = that
effect carries no mechanic).
```lua
C_Spell.GetSpellEffectMechanics(1822) -- Rake → { 0, 15, 0 }
C_Spell.GetSpellEffectMechanics(703) -- Garrote → { 0, 0, 0 }
C_Spell.GetSpellEffectMechanics(133) -- Fireball → { 0, 0, 0 }
```
Complements [`GetSpellMechanicByID`](#c_spellgetspellmechanicbyidspellid),
which reads only the **spell-level** `Mechanic` field (`+0x14`). Vanilla
frequently stores a spell's mechanic on an **effect** instead — periodic
damage such as bleeds is the common case. Garrote/Rupture/Rend/Rip tag
`bleeding` (15) at the spell level, but **Rake** has spell-level `0` and
`EffectMechanic[2] = 15`, so effect-mechanic-aware callers (e.g. bleed
classification for immunity tracking) need this array:
```lua
local function IsBleed(spellID)
if C_Spell.GetSpellMechanicByID(spellID) == 15 then return true end
local em = C_Spell.GetSpellEffectMechanics(spellID)
if em then for i = 1, 3 do if em[i] == 15 then return true end end end
return false
end
```
Reads `Spell.dbc` directly (`EffectMechanic[3]` at `+0x13C`), so it covers
every spell the client knows — not just the player's spellbook — with no
caching or network round-trip.
### `C_Spell.GetSpellRadius(spellID)` / `GetSpellRadius(slot, bookType)`
Returns `(baseRadius, modifiedRadius)` — the spell's AOE radius in yards,
@@ -9703,6 +9855,23 @@ Reads a single bit from the engine's spell-knowledge bitmap at
The same lookup the engine itself does internally. No spellbook walk,
no talent walk, no profession-window dependency.
### `CanDualWield()`
Returns `true` if the player can equip a weapon in the off hand.
```lua
if CanDualWield() then ... end -- true for a warrior/rogue past level 10, etc.
```
Server-side this is a plain flag (`Player::m_canDualWield`) that starts
`false` and is turned on **only** by the `SPELL_EFFECT_DUAL_WIELD` spell
effect — no class has it innately. Exactly one spell in the 1.12 client's
`Spell.dbc` carries that effect: `674` ("Dual Wield"), the trained
warrior / rogue / hunter passive, which lands in the known-spell bitmap
like any learned spell. So `CanDualWield()` is precisely
`IsPlayerSpell(674)` — there is no separate client-visible capability
field to read.
### `IsSpellKnown(spellID, [isPet])`
Returns `true` if the given spellID is currently in the player's (or
@@ -11019,6 +11188,78 @@ Mirrors 3.3.5a's `Script_CancelShapeshiftForm` inner (`FUN_00726CE0`),
which does the same effect-array scan + form-id match before issuing
its cancel packet. Vanilla just lacks the public Lua surface.
## System
Host/OS-level helpers that aren't tied to a game domain.
### `GetPhysicalScreenSize()`
Returns `(widthPixels, heightPixels)` — the display's physical
resolution in pixels. Modern WoW added this as a native in 7.0
(Legion); FrameXML's `PixelUtil` builds its entire pixel↔UI-unit
conversion on it (`768.0 / physicalHeight`), which is what lets addons
snap frames to whole pixels for crisp borders at fractional UI scales.
```lua
local w, h = GetPhysicalScreenSize() -- e.g. 1920, 1080
```
Vanilla 1.12 has no such native — and no pre-parsed pixel-dimension
global. `GetScreenWidth()`/`GetScreenHeight()` return UI-space units
(derived from the UIParent aspect ratio at `[UIParent + 0x7c]`), not
pixels. The engine only ever holds the current mode as the
`gxResolution` CVar string (`"WIDTHxHEIGHT"`); both `SetScreenResolution`
(`0x0048bfd0`) and the display-init path sscanf it on demand rather
than caching dimensions anywhere. So we read the `gxResolution` CVar
directly through the engine's by-name lookup (`FUN_FIND_CVAR`, value at
`+0x20`) and parse it — the same source of truth the engine uses.
Returns `1024, 768` (→ factor 1.0, making PixelUtil a 1:1 no-op) when
the CVar is missing or unparseable. Caveat: in windowed mode
`gxResolution` reflects the configured render resolution, which may
differ from the OS window's client size — vanilla exposes no separate
window-pixel query.
The companion `PixelUtil` table (`PixelUtil.SetPoint`/`SetSize`/
`SetStatusBarValue`/…) is provided Lua-side by the ClassicAPI addon and
consumes this function.
### `CopyToClipboard(text [, removeMarkup])`
Copies `text` to the Windows clipboard and returns the number of bytes
copied. Vanilla has no clipboard access at all, so this is a ClassicAPI
addition backed by the Win32 clipboard API. Available on both the in-game
and login/character-select screens.
- `text` — the string to copy (numbers are coerced, like Lua does).
- `removeMarkup` — optional; when truthy, strip WoW UI escape sequences
before copying so the clipboard gets plain text:
- `|cAARRGGBB … |r` color codes → removed
- `|H…|h text |h` hyperlinks → keeps only the visible text
- `|T…|t` textures → removed
- `|n` → newline, `||` → literal `|`
- unknown `|x` sequences are left verbatim, so real text that merely
contains a pipe survives
- returns the byte length of the copied (possibly stripped) string —
consistent with Lua's `#s`. `0` for an empty string or if the clipboard
couldn't be opened.
```lua
CopyToClipboard("hello world") -- 11
local link = "\124cffff0000Red\124r" -- real single-pipe markup
CopyToClipboard(link, true) -- 3 (clipboard gets "Red")
CopyToClipboard(link) -- 15 (raw escapes copied)
```
> The string is treated as UTF-8 (matching the credential and TTS modules)
> and written as `CF_UNICODETEXT`; Windows synthesizes `CF_TEXT` for legacy
> consumers.
>
> Note when testing from the chat box: the edit box escapes every typed `|`
> into `||`, so a literal `|cff…` you type is no longer a color code by the
> time it reaches Lua. Build real markup with `\124` (as above) to exercise
> `removeMarkup`.
## Talent
### `GetTalentSpellID(tabIndex, talentIndex, [rank[, classID]])`
+23
View File
@@ -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
@@ -5321,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
+145
View File
@@ -140,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;
}
@@ -677,6 +693,101 @@ 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,
@@ -686,6 +797,7 @@ bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
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;
@@ -699,6 +811,10 @@ bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
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;
}
@@ -709,6 +825,7 @@ bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
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;
@@ -724,6 +841,17 @@ bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
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;
}
@@ -734,6 +862,7 @@ bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
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;
@@ -753,6 +882,17 @@ bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
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;
}
@@ -763,6 +903,7 @@ void AppendGroupAuras(void *L, uint64_t guid, Filter filter, bool playerOnly,
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);
@@ -774,6 +915,10 @@ void AppendGroupAuras(void *L, uint64_t guid, Filter filter, bool playerOnly,
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
+157 -2
View File
@@ -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
+15
View File
@@ -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
+83
View File
@@ -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
+170
View File
@@ -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
+292
View File
@@ -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
+76
View File
@@ -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
View File
@@ -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);
+77
View File
@@ -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
+2 -2
View File
@@ -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);
}
+241
View File
@@ -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
-168
View File
@@ -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