mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-14 07:00:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2d25cef8b | ||
|
|
c3c79356be | ||
|
|
6ae3299041 | ||
|
|
f97379795d | ||
|
|
3c6b449df5 | ||
|
|
f000fd6ed7 | ||
|
|
7960266969 | ||
|
|
85ed4eb378 | ||
|
|
7f1e632b49 | ||
|
|
71d193073a | ||
|
|
eda44e6d2d | ||
|
|
81f04c82ad | ||
|
|
54516ad22b | ||
|
|
7e784005b9 | ||
|
|
d12ff75c6c | ||
|
|
76043fcdef | ||
|
|
b9d23fbc8c | ||
|
|
a2c51a1ae6 | ||
|
|
d5c8db9c2e | ||
|
|
9c7c8dd8e5 | ||
|
|
813a10f696 |
@@ -104,6 +104,24 @@ jobs:
|
||||
name: ClassicAPI-${{ github.sha }}
|
||||
path: build/Release/ClassicAPI.dll
|
||||
|
||||
# Bundle both debug artifacts into one zip so a crash from a shipped DLL
|
||||
# can be triaged exactly. The PDB gives WinDbg/x64dbg/VS a line-level
|
||||
# symbolicated call stack from a crash dump; the map resolves the
|
||||
# `0001:offset` addresses in WoW's ERROR #132 crash log by hand when no
|
||||
# dump is available. Both match THIS exact DLL build.
|
||||
# The filename is deliberately off-putting: it is NOT the addon and has no
|
||||
# value to a normal player. The loud all-caps name keeps them from
|
||||
# grabbing it by mistake; the tag suffix lets a dev match symbols to the
|
||||
# exact build. (pdb/map keep their real names inside so the pdb signature
|
||||
# still lines up with the DLL.)
|
||||
- name: Package debug symbols for release
|
||||
if: steps.ver.outputs.is_release == 'true'
|
||||
run: >
|
||||
Compress-Archive
|
||||
-Path build/Release/ClassicAPI.pdb, build/Release/ClassicAPI.map
|
||||
-DestinationPath build/Release/Crash-Symbols-Use-For-Debugging-${{ steps.ver.outputs.tag }}.zip
|
||||
-Force
|
||||
|
||||
- name: Release
|
||||
if: steps.ver.outputs.is_release == 'true'
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -111,5 +129,5 @@ jobs:
|
||||
tag_name: ${{ steps.ver.outputs.tag }}
|
||||
files: |
|
||||
build/Release/ClassicAPI.dll
|
||||
build/Release/ClassicAPI.map
|
||||
build/Release/Crash-Symbols-Use-For-Debugging-${{ steps.ver.outputs.tag }}.zip
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
locales\locales.xml
|
||||
|
||||
SecureTemplates.xml
|
||||
Compat.lua
|
||||
Util\Print.lua
|
||||
Util\Mixin.lua
|
||||
@@ -44,5 +45,8 @@ Util\Vector3D.lua
|
||||
Util\Vector4D.lua
|
||||
Util\UIParent.lua
|
||||
Util\AuraDurationModifiers.lua
|
||||
Util\AuraUtil.lua
|
||||
Util\SecureUnitMenu.lua
|
||||
Util\TextureUtil.lua
|
||||
Util\MacroOptions.lua
|
||||
Util\SecureStateDriver.lua
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
-- SecureTemplates.lua — Lua-side helpers for SecureActionButtonTemplate et al.
|
||||
--
|
||||
-- Backport of the modifier/button attribute-resolution functions from
|
||||
-- Blizzard's 2.0 SecureTemplates.lua that addons call directly
|
||||
-- (SecureButton_GetModifiedAttribute, SecureButton_GetModifierPrefix,
|
||||
-- SecureButton_GetUnit, …), plus the SecureUnitButton_OnLoad convenience.
|
||||
--
|
||||
-- Click dispatch is NOT here: the C++ Frame::Attributes module is the single
|
||||
-- dispatcher. Setting a "type*" attribute on a frame installs a native OnClick
|
||||
-- that resolves the verb (target/spell/item/macro/focus/assist/stop/menu/
|
||||
-- action/pet/click/custom), so the templates carry no <OnClick> and there is no
|
||||
-- parallel Lua dispatcher to drift out of sync.
|
||||
|
||||
-- The "modified attribute" takes the form of: modifier-name-button
|
||||
-- The modifier is one of "shift-", "ctrl-", "alt-", and the button is a
|
||||
-- number from 1 through 5.
|
||||
--
|
||||
-- Setting the attribute by itself is equivalent to *attribute*
|
||||
|
||||
ATTRIBUTE_NOOP = "";
|
||||
|
||||
function SecureButton_GetModifierPrefix()
|
||||
local prefix = "";
|
||||
if IsShiftKeyDown() then
|
||||
prefix = "shift-" .. prefix;
|
||||
end
|
||||
if IsControlKeyDown() then
|
||||
prefix = "ctrl-" .. prefix;
|
||||
end
|
||||
if IsAltKeyDown() then
|
||||
prefix = "alt-" .. prefix;
|
||||
end
|
||||
return prefix;
|
||||
end
|
||||
|
||||
function SecureButton_GetButtonSuffix(button)
|
||||
if button == "LeftButton" then
|
||||
return "1";
|
||||
elseif button == "RightButton" then
|
||||
return "2";
|
||||
elseif button == "MiddleButton" then
|
||||
return "3";
|
||||
elseif button == "Button4" then
|
||||
return "4";
|
||||
elseif button == "Button5" then
|
||||
return "5";
|
||||
elseif button and button ~= "" then
|
||||
return "-" .. tostring(button);
|
||||
end
|
||||
return "";
|
||||
end
|
||||
|
||||
function SecureButton_GetModifiedAttribute(frame, name, button, prefix, suffix)
|
||||
if not prefix then
|
||||
prefix = SecureButton_GetModifierPrefix();
|
||||
end
|
||||
if not suffix then
|
||||
suffix = SecureButton_GetButtonSuffix(button);
|
||||
end
|
||||
local value = frame:GetAttribute(prefix .. name .. suffix) or
|
||||
frame:GetAttribute("*" .. name .. suffix) or
|
||||
frame:GetAttribute(prefix .. name .. "*") or
|
||||
frame:GetAttribute("*" .. name .. "*") or
|
||||
frame:GetAttribute(name);
|
||||
if not value and (frame:GetAttribute("useparent-" .. name) or
|
||||
frame:GetAttribute("useparent*")) then
|
||||
local parent = frame:GetParent();
|
||||
if parent then
|
||||
value = SecureButton_GetModifiedAttribute(parent, name, button, prefix, suffix);
|
||||
end
|
||||
end
|
||||
if value == ATTRIBUTE_NOOP then
|
||||
value = nil;
|
||||
end
|
||||
return value;
|
||||
end
|
||||
|
||||
-- Unmodified attribute lookup (no modifier prefix, no button suffix).
|
||||
-- Overrides the simpler version in SecureStateDriver.lua with the full
|
||||
-- parent-walk + ATTRIBUTE_NOOP semantics.
|
||||
function SecureButton_GetAttribute(frame, name)
|
||||
return SecureButton_GetModifiedAttribute(frame, name, nil, "", "");
|
||||
end
|
||||
|
||||
function SecureButton_GetModifiedUnit(self, button)
|
||||
local unit = SecureButton_GetModifiedAttribute(self, "unit", button);
|
||||
if unit then
|
||||
local unitsuffix = SecureButton_GetModifiedAttribute(self, "unitsuffix", button);
|
||||
if unitsuffix then
|
||||
unit = unit .. unitsuffix;
|
||||
unit = string.gsub(unit, "^([^%d]+)([%d]+)[pP][eE][tT]", "%1pet%2");
|
||||
end
|
||||
return unit;
|
||||
end
|
||||
if SecureButton_GetModifiedAttribute(self, "checkselfcast", button) then
|
||||
if IsAltKeyDown() then
|
||||
return "player";
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function SecureButton_GetUnit(self)
|
||||
local unit = SecureButton_GetAttribute(self, "unit");
|
||||
if unit then
|
||||
local unitsuffix = SecureButton_GetAttribute(self, "unitsuffix");
|
||||
if unitsuffix then
|
||||
unit = unit .. unitsuffix;
|
||||
unit = string.gsub(unit, "^([^%d]+)([%d]+)[pP][eE][tT]", "%1pet%2");
|
||||
end
|
||||
return unit;
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- SecureUnitButton_OnLoad — convenience for the common unit-button setup:
|
||||
-- left-click targets, any other button opens the right-click unit menu. The
|
||||
-- click behavior itself is native: setting these "type*"/"unit" attributes is
|
||||
-- what makes the C++ Frame::Attributes dispatcher act on the clicks.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
function SecureUnitButton_OnLoad(self, unit)
|
||||
self:SetAttribute("type1", "target");
|
||||
self:SetAttribute("type*", "menu");
|
||||
self:SetAttribute("unit", unit);
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
<Ui xmlns="http://www.blizzard.com/wow/ui/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.blizzard.com/wow/ui/
|
||||
..\FrameXML\UI.xsd">
|
||||
<Script file="SecureTemplates.lua"/>
|
||||
|
||||
<!-- A base template for secure frames (no-op in 1.12, no taint system) -->
|
||||
<Frame name="SecureFrameTemplate" virtual="true"/>
|
||||
|
||||
<!-- Secure action button. Click behavior is driven natively by the C++
|
||||
Frame::Attributes module: setting a "type*" attribute installs the
|
||||
OnClick dispatcher, so the template itself needs no <OnClick>. -->
|
||||
<Button name="SecureActionButtonTemplate" inherits="SecureFrameTemplate" virtual="true"/>
|
||||
|
||||
<!-- Unit-button variant (identical to the action button on 1.12; its
|
||||
"type"/"unit" attributes drive it). SecureUnitButton_OnLoad is a Lua
|
||||
convenience for the common target + right-click-menu setup. -->
|
||||
<Button name="SecureUnitButtonTemplate" inherits="SecureFrameTemplate" virtual="true"/>
|
||||
|
||||
<!-- Insecure variant: identical behavior in 1.12 (no combat lockdown), so
|
||||
just inherit the secure template rather than duplicate its OnClick. -->
|
||||
<Button name="InsecureActionButtonTemplate" inherits="SecureActionButtonTemplate" virtual="true"/>
|
||||
</Ui>
|
||||
@@ -84,3 +84,16 @@ if C_AddOns.DoesAddOnExist("Puppeteer") then
|
||||
CAPI_MouseoverClearedCompat(PTEnemyUpdater)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Cartographer_Notes gates a WorldMap release-spirit hook behind an
|
||||
-- `if lua51` probe, treating a 5.1-capable Lua as a modern client that has
|
||||
-- the WorldMapDeathRelease frame. Our 5.1 syntax backport makes the probe
|
||||
-- pass, so Cartographer takes the modern branch and hooks a frame 1.12's
|
||||
-- WorldMap never had (WorldMapDeathRelease:SetScript in OnEnable) -> nil
|
||||
-- index. Provide an inert, hidden stub so the hook succeeds and does nothing
|
||||
-- -- the same outcome vanilla had, where the branch never ran. The embedded
|
||||
-- !!!ClassicAPI addon loads first, so the frame exists before Cartographer's
|
||||
-- OnEnable.
|
||||
if C_AddOns.DoesAddOnExist("Cartographer") and not WorldMapDeathRelease then
|
||||
CreateFrame("Button", "WorldMapDeathRelease", WorldMapButton or UIParent):Hide()
|
||||
end
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
-- AuraUtil: iterate / find auras without allocating a table per aura.
|
||||
--
|
||||
-- Backport of FrameXML's AuraUtil, the helper library that sits on top of
|
||||
-- C_UnitAuras. Its whole value on 1.12 is the zero-allocation path: modern
|
||||
-- addons that scan many units' auras every frame call AuraUtil.ForEachAura (with
|
||||
-- usePackedAura false) instead of C_UnitAuras.GetAuraDataByIndex, so no AuraData
|
||||
-- table is built per aura -- which matters far more under Lua 5.0's mark-sweep
|
||||
-- GC than it does on retail's generational GC. See ClassicAPI issue #30.
|
||||
--
|
||||
-- Ported from the anniversary source (Blizzard_FrameXMLUtil/AuraUtil.lua),
|
||||
-- keeping the exact public shapes:
|
||||
-- * ForEachAura(unit, filter, batchSize, func, usePackedAura)
|
||||
-- * FindAura(predicate, unit, filter, arg1, arg2, arg3)
|
||||
-- * FindAuraByName(auraName, unit, filter)
|
||||
-- * UnpackAuraData(auraData)
|
||||
--
|
||||
-- Two deliberate differences from that source:
|
||||
-- 1. Retail's ForEachAura/FindAura batch through GetAuraSlots / GetAuraDataBySlot
|
||||
-- (continuation tokens) -- 1.12 has no slot-batching API, so the bodies here
|
||||
-- iterate by index (1, 2, ...) until the accessor returns no aura. Same public
|
||||
-- contract, different internals.
|
||||
-- 2. The non-packed ForEachAura and FindAura source each aura from the positional
|
||||
-- C_UnitAuras.UnitAura (no table), so both are zero-allocation here -- FindAura
|
||||
-- in particular, where the retail version allocates a table per aura scanned.
|
||||
--
|
||||
-- The comparator / dispel-priority / border helpers (DefaultAuraCompare,
|
||||
-- UnitFrameDebuffComparator, ShouldDisplayDebuff, SetAuraBorder*, IsPriorityDebuff)
|
||||
-- are NOT ported: they depend on isPriorityAura / auraInstanceID / the aura
|
||||
-- visualization pipeline / secure-call machinery, none of which exist on 1.12.
|
||||
|
||||
local UnitAura = C_UnitAuras.UnitAura
|
||||
|
||||
AuraUtil = AuraUtil or {};
|
||||
|
||||
-- Returns the AuraData table's fields in the classic `UnitAura` positional order,
|
||||
-- for code written against that shape. Retail appends `unpack(auraData.points)`;
|
||||
-- vanilla auras carry no points, so the tuple ends at timeMod (15 values). Field
|
||||
-- #13 is `isFromPlayerOrPlayerPet` (the table's field) -- note the positional
|
||||
-- C_UnitAuras.UnitAura reports the higher-fidelity `castByPlayer` at #13 instead.
|
||||
function AuraUtil.UnpackAuraData(auraData)
|
||||
if not auraData then
|
||||
return nil;
|
||||
end
|
||||
return auraData.name,
|
||||
auraData.icon,
|
||||
auraData.applications,
|
||||
auraData.dispelName,
|
||||
auraData.duration,
|
||||
auraData.expirationTime,
|
||||
auraData.sourceUnit,
|
||||
auraData.isStealable,
|
||||
auraData.nameplateShowPersonal,
|
||||
auraData.spellId,
|
||||
auraData.canApplyAura,
|
||||
auraData.isBossAura,
|
||||
auraData.isFromPlayerOrPlayerPet,
|
||||
auraData.nameplateShowAll,
|
||||
auraData.timeMod;
|
||||
end
|
||||
|
||||
-- Calls `func` for each aura on `unit` matching `filter`, in index order, until
|
||||
-- `func` returns a truthy value or the auras run out. Non-packed (default) passes
|
||||
-- the 15 positional `UnitAura` values (no table); `usePackedAura` passes the
|
||||
-- AuraData table from GetAuraDataByIndex. `batchSize <= 0` does nothing; any other
|
||||
-- value (or nil) visits every matching aura -- `batchSize` is retail's slot-batch
|
||||
-- hint, not a cap on how many auras are visited.
|
||||
function AuraUtil.ForEachAura(unit, filter, batchSize, func, usePackedAura)
|
||||
if batchSize and batchSize <= 0 then
|
||||
return;
|
||||
end
|
||||
local index = 1;
|
||||
while true do
|
||||
local done;
|
||||
if usePackedAura then
|
||||
local auraData = C_UnitAuras.GetAuraDataByIndex(unit, index, filter);
|
||||
if auraData == nil then
|
||||
return;
|
||||
end
|
||||
done = func(auraData);
|
||||
else
|
||||
local name, icon, count, dispelType, duration, expirationTime, source,
|
||||
isStealable, nameplateShowPersonal, spellId, canApplyAura,
|
||||
isBossDebuff, castByPlayer, nameplateShowAll, timeMod =
|
||||
UnitAura(unit, index, filter);
|
||||
if name == nil then
|
||||
return;
|
||||
end
|
||||
done = func(name, icon, count, dispelType, duration, expirationTime,
|
||||
source, isStealable, nameplateShowPersonal, spellId, canApplyAura,
|
||||
isBossDebuff, castByPlayer, nameplateShowAll, timeMod);
|
||||
end
|
||||
if done then
|
||||
return;
|
||||
end
|
||||
index = index + 1;
|
||||
end
|
||||
end
|
||||
|
||||
-- Recursive worker: `...` is the current aura's positional values (nil when the
|
||||
-- index is past the last aura). Tail-recurses to the next index. Lua 5.0 has
|
||||
-- proper tail calls, so depth is constant regardless of aura count.
|
||||
local function FindAuraRecurse(predicate, unit, filter, index, arg1, arg2, arg3, ...)
|
||||
if ... == nil then
|
||||
return nil; -- past the last aura -> not found
|
||||
end
|
||||
if predicate(arg1, arg2, arg3, ...) then
|
||||
return ...;
|
||||
end
|
||||
index = index + 1;
|
||||
return FindAuraRecurse(predicate, unit, filter, index, arg1, arg2, arg3,
|
||||
UnitAura(unit, index, filter));
|
||||
end
|
||||
|
||||
-- Returns the positional values of the first aura for which `predicate` returns
|
||||
-- truthy, or nil. `predicate` receives up to three caller args followed by the
|
||||
-- aura's positional values: predicate(arg1, arg2, arg3, name, icon, ...). Sources
|
||||
-- auras from the positional accessor, so no table is allocated while scanning.
|
||||
function AuraUtil.FindAura(predicate, unit, filter, arg1, arg2, arg3)
|
||||
return FindAuraRecurse(predicate, unit, filter, 1, arg1, arg2, arg3,
|
||||
UnitAura(unit, 1, filter));
|
||||
end
|
||||
|
||||
-- Returns the positional values of the first aura whose name matches `auraName`,
|
||||
-- or nil. Names are localized and not unique -- this finds the first match.
|
||||
function AuraUtil.FindAuraByName(auraName, unit, filter)
|
||||
return AuraUtil.UnpackAuraData(
|
||||
C_UnitAuras.GetAuraDataBySpellName(unit, auraName, filter));
|
||||
end
|
||||
@@ -0,0 +1,488 @@
|
||||
-- SecureCmdOptionParse(options) -> value[, target]
|
||||
--
|
||||
-- Backports the macro-conditional parser (added in 2.0) so a modern option
|
||||
-- string like "[combat,@focus] Foo; [nostealth] Bar; Baz" resolves to the
|
||||
-- value of the first matching clause. Used by Util/SecureStateDriver.lua and
|
||||
-- callable by any macro / click-verb system that wants Blizzard's grammar.
|
||||
--
|
||||
-- Grammar reverse-engineered from the 3.3.5 Wow.exe (functions 0x005f0df0
|
||||
-- clause splitter, 0x005f0bb0 group parser, 0x005f0040 condition parser,
|
||||
-- 0x005ef5c0 evaluator), so it matches Blizzard's own precedence exactly:
|
||||
-- * clauses are split on ';' at bracket depth 0;
|
||||
-- * a clause is zero or more [group] blocks followed by a value (the text
|
||||
-- after the last ']', edge-trimmed -- may be empty);
|
||||
-- * a clause with NO groups always matches; groups are OR'd (first pass
|
||||
-- wins); conditions inside a group are comma-split and AND'd;
|
||||
-- * an '@unit' or 'target=unit' piece (case-insensitive) sets the group's
|
||||
-- target; every other piece is a condition, optionally negated by a 'no'
|
||||
-- prefix (stripped only when the piece is >= 3 chars) and optionally
|
||||
-- carrying ':a/b/c' args (each trimmed, multiple args OR'd);
|
||||
-- * the match returns the clause value plus the passing group's target
|
||||
-- token (nil when the passing group set none).
|
||||
--
|
||||
-- The 33 condition keywords are the 3.3.5 set, matched case-sensitively, plus
|
||||
-- one ClassicAPI extension: [known:spellID] / [known:name].
|
||||
-- Where a keyword's underlying state does not exist on 1.12 (flying, flyable,
|
||||
-- vehicleui, unithasvehicleui) the condition is a constant false; where 1.12
|
||||
-- only has a partial answer (spec is always primary, button/actionbar default
|
||||
-- to the resting state) the closest honest mapping is used. An UNKNOWN keyword
|
||||
-- is a hard false with a one-time warning -- never a silent pass -- so a typo
|
||||
-- can't wrongly show a frame. See docs/API.md for the full contract.
|
||||
--
|
||||
-- Each unique options string is parsed ONCE into a clause structure and cached
|
||||
-- (a plain table, matching the 3.3.5 C hash -- entries are never evicted, and
|
||||
-- the set is bounded by the strings addons actually register). Evaluation then
|
||||
-- just walks the structure calling predicate functions, so the 0.2s driver
|
||||
-- poll re-parses nothing.
|
||||
|
||||
local type = type;
|
||||
local tonumber = tonumber;
|
||||
local strlen = string.len;
|
||||
local strsub = string.sub;
|
||||
local strlower = string.lower;
|
||||
local strmatch = string.match;
|
||||
local gmatch = string.gmatch;
|
||||
|
||||
local function Trim(s)
|
||||
return strmatch(s, "^%s*(.-)%s*$") or "";
|
||||
end
|
||||
|
||||
local function AlwaysTrue()
|
||||
return true;
|
||||
end
|
||||
|
||||
local function AlwaysFalse()
|
||||
return false;
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Query helpers used by more than one predicate
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- The shapeshift-BAR index of the active form (1-based), or 0 when formless.
|
||||
-- [stance:N] / [form:N] number by this bar order (druid 1 = Bear, 3 = Cat),
|
||||
-- which is NOT the DBC form id GetShapeshiftFormID() returns -- so the ':N'
|
||||
-- form deliberately does not use that faster reader.
|
||||
local function GetCurrentFormIndex()
|
||||
for i = 1, GetNumShapeshiftForms() do
|
||||
local _, _, active = GetShapeshiftFormInfo(i);
|
||||
if active then
|
||||
return i;
|
||||
end
|
||||
end
|
||||
return 0;
|
||||
end
|
||||
|
||||
-- True when any equipped item (slots 1..19) matches `want` by localized item
|
||||
-- type, subtype, or equip-location slot name. C_Item.GetItemInfoInstant is
|
||||
-- synchronous and link-accepting; worn gear is always cached, and a nil field
|
||||
-- simply fails the match (fail-safe).
|
||||
local function IsEquippedTypeMatch(want)
|
||||
want = strlower(want);
|
||||
for slot = 1, 19 do
|
||||
local link = GetInventoryItemLink("player", slot);
|
||||
if link then
|
||||
local _, itemType, itemSubType, equipLoc = C_Item.GetItemInfoInstant(link);
|
||||
if (itemType and strlower(itemType) == want)
|
||||
or (itemSubType and strlower(itemSubType) == want) then
|
||||
return true;
|
||||
end
|
||||
if equipLoc and equipLoc ~= "" then
|
||||
local slotName = _G[equipLoc];
|
||||
if slotName and strlower(slotName) == want then
|
||||
return true;
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
|
||||
-- True when a spell named `want` (case-insensitive) is in the player's
|
||||
-- spellbook. This is the name form of [known:...]. There is no name -> id
|
||||
-- resolver, so a name can only resolve against castable spellbook entries;
|
||||
-- talents / passives / profession recipes that IsPlayerSpell reports for a
|
||||
-- spell id are not found by name.
|
||||
local function IsSpellNameKnown(want)
|
||||
want = strlower(want);
|
||||
local i = 1;
|
||||
while true do
|
||||
local name = GetSpellName(i, "spell");
|
||||
if not name then
|
||||
return false;
|
||||
end
|
||||
if strlower(name) == want then
|
||||
return true;
|
||||
end
|
||||
i = i + 1;
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Condition predicates -- signature f(target, args) -> truthy.
|
||||
-- `args` is nil for a bare condition, else { n = <count>, [i] = <trimmed> };
|
||||
-- multiple args are OR'd.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local CONDITIONS = {};
|
||||
|
||||
CONDITIONS[""] = AlwaysTrue; -- empty keyword ([] / [ ]) always matches
|
||||
|
||||
CONDITIONS.combat = function()
|
||||
return UnitAffectingCombat("player");
|
||||
end
|
||||
|
||||
CONDITIONS.exists = function(target)
|
||||
return UnitExists(target);
|
||||
end
|
||||
|
||||
CONDITIONS.dead = function(target)
|
||||
return UnitIsDeadOrGhost(target);
|
||||
end
|
||||
|
||||
CONDITIONS.help = function(target)
|
||||
return UnitCanAssist("player", target);
|
||||
end
|
||||
|
||||
CONDITIONS.harm = function(target)
|
||||
return UnitCanAttack("player", target);
|
||||
end
|
||||
|
||||
CONDITIONS.party = function(target)
|
||||
return UnitPlayerOrPetInParty(target);
|
||||
end
|
||||
|
||||
CONDITIONS.raid = function(target)
|
||||
return UnitInRaid(target);
|
||||
end
|
||||
|
||||
-- [group] = in any group; [group:party] true in a party OR raid (raid members
|
||||
-- occupy party subgroups, matching retail); [group:raid] true only in a raid.
|
||||
CONDITIONS.group = function(target, args)
|
||||
local inParty = GetNumPartyMembers() > 0;
|
||||
local inRaid = GetNumRaidMembers() > 0;
|
||||
if args then
|
||||
for i = 1, args.n do
|
||||
local a = strlower(args[i]);
|
||||
if a == "raid" then
|
||||
if inRaid then return true; end
|
||||
elseif a == "party" then
|
||||
if inParty or inRaid then return true; end
|
||||
end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
return inParty or inRaid;
|
||||
end
|
||||
|
||||
CONDITIONS.stance = function(target, args)
|
||||
if args then
|
||||
local cur = GetCurrentFormIndex();
|
||||
for i = 1, args.n do
|
||||
if tonumber(args[i]) == cur then return true; end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
return GetShapeshiftFormID() ~= 0;
|
||||
end
|
||||
|
||||
CONDITIONS.stealth = function()
|
||||
return IsStealthed();
|
||||
end
|
||||
|
||||
CONDITIONS.mounted = function()
|
||||
return IsMounted();
|
||||
end
|
||||
|
||||
CONDITIONS.swimming = function()
|
||||
return IsSwimming();
|
||||
end
|
||||
|
||||
CONDITIONS.indoors = function()
|
||||
return IsIndoors();
|
||||
end
|
||||
|
||||
CONDITIONS.outdoors = function()
|
||||
return IsOutdoors();
|
||||
end
|
||||
|
||||
-- Vanilla has one permanent spec: [spec] and [spec:1] pass, higher indices fail.
|
||||
CONDITIONS.spec = function(target, args)
|
||||
if args then
|
||||
for i = 1, args.n do
|
||||
if args[i] == "1" then return true; end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
return true;
|
||||
end
|
||||
|
||||
CONDITIONS.modifier = function(target, args)
|
||||
if args then
|
||||
for i = 1, args.n do
|
||||
local a = strlower(args[i]);
|
||||
if a == "shift" then
|
||||
if IsShiftKeyDown() then return true; end
|
||||
elseif a == "ctrl" then
|
||||
if IsControlKeyDown() then return true; end
|
||||
elseif a == "alt" then
|
||||
if IsAltKeyDown() then return true; end
|
||||
end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
return IsShiftKeyDown() or IsControlKeyDown() or IsAltKeyDown();
|
||||
end
|
||||
|
||||
-- Outside a click context the "current" button is the left button.
|
||||
CONDITIONS.button = function(target, args)
|
||||
if args then
|
||||
for i = 1, args.n do
|
||||
local a = args[i];
|
||||
if a == "1" or strlower(a) == "leftbutton" then return true; end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
return true;
|
||||
end
|
||||
|
||||
CONDITIONS.actionbar = function(target, args)
|
||||
if args then
|
||||
local page = CURRENT_ACTIONBAR_PAGE or 1;
|
||||
for i = 1, args.n do
|
||||
if tonumber(args[i]) == page then return true; end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
return true;
|
||||
end
|
||||
|
||||
CONDITIONS.bonusbar = function(target, args)
|
||||
local offset = GetBonusBarOffset();
|
||||
if args then
|
||||
for i = 1, args.n do
|
||||
if tonumber(args[i]) == offset then return true; end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
return offset > 0;
|
||||
end
|
||||
|
||||
CONDITIONS.pet = function(target, args)
|
||||
if args then
|
||||
local petName = UnitName("pet");
|
||||
local petFamily = UnitCreatureFamily("pet");
|
||||
petName = petName and strlower(petName);
|
||||
petFamily = petFamily and strlower(petFamily);
|
||||
for i = 1, args.n do
|
||||
local a = strlower(args[i]);
|
||||
if a == petName or a == petFamily then return true; end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
return UnitExists("pet");
|
||||
end
|
||||
|
||||
-- ClassicAPI cedes the global name UnitChannelInfo/ChannelInfo to addons, so the
|
||||
-- player's channel state is read through the namespaced C_Spell.ChannelInfo().
|
||||
CONDITIONS.channeling = function(target, args)
|
||||
local name = C_Spell.ChannelInfo();
|
||||
if args then
|
||||
if not name then return false; end
|
||||
name = strlower(name);
|
||||
for i = 1, args.n do
|
||||
if strlower(args[i]) == name then return true; end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
return name ~= nil;
|
||||
end
|
||||
|
||||
CONDITIONS.equipped = function(target, args)
|
||||
if not args then return false; end -- [equipped] with no type never matches
|
||||
for i = 1, args.n do
|
||||
if IsEquippedTypeMatch(args[i]) then return true; end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
|
||||
CONDITIONS.cursor = function()
|
||||
return GetCursorInfo() ~= nil;
|
||||
end
|
||||
|
||||
-- ClassicAPI extension (not a 3.3.5 conditional). [known:spellID] uses
|
||||
-- IsPlayerSpell (broad knowledge -- spellbook, talents, recipes, racials);
|
||||
-- [known:name] falls back to a spellbook name scan (see IsSpellNameKnown).
|
||||
CONDITIONS.known = function(target, args)
|
||||
if not args then return false; end
|
||||
for i = 1, args.n do
|
||||
local a = args[i];
|
||||
local id = tonumber(a);
|
||||
if id then
|
||||
if IsPlayerSpell(id) then return true; end
|
||||
elseif IsSpellNameKnown(a) then
|
||||
return true;
|
||||
end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
|
||||
-- No such state on 1.12.
|
||||
CONDITIONS.flying = AlwaysFalse;
|
||||
CONDITIONS.flyable = AlwaysFalse;
|
||||
CONDITIONS.vehicleui = AlwaysFalse;
|
||||
CONDITIONS.unithasvehicleui = AlwaysFalse;
|
||||
|
||||
-- Aliases (share the function reference).
|
||||
CONDITIONS.form = CONDITIONS.stance;
|
||||
CONDITIONS.mod = CONDITIONS.modifier;
|
||||
CONDITIONS.btn = CONDITIONS.button;
|
||||
CONDITIONS.bar = CONDITIONS.actionbar;
|
||||
CONDITIONS.worn = CONDITIONS.equipped;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Parser
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local warned = {};
|
||||
|
||||
-- piece -> { pred = fn, neg = bool, args = { n, [i] } or nil }
|
||||
local function ParseCondition(piece)
|
||||
local neg = false;
|
||||
if strlen(piece) >= 3 and strsub(piece, 1, 2) == "no" then
|
||||
neg = true;
|
||||
piece = strsub(piece, 3);
|
||||
end
|
||||
|
||||
local keyword, argstr = strmatch(piece, "^([^:]*):?(.*)$");
|
||||
keyword = Trim(keyword);
|
||||
|
||||
local args;
|
||||
if argstr ~= "" then
|
||||
args = { n = 0 };
|
||||
for a in gmatch(argstr .. "/", "([^/]*)/") do
|
||||
args.n = args.n + 1;
|
||||
args[args.n] = Trim(a);
|
||||
end
|
||||
end
|
||||
|
||||
local pred = CONDITIONS[keyword];
|
||||
if not pred then
|
||||
if not warned[keyword] then
|
||||
warned[keyword] = true;
|
||||
print("SecureCmdOptionParse: unknown condition '" .. keyword .. "'");
|
||||
end
|
||||
pred = AlwaysFalse;
|
||||
neg = false; -- an unknown keyword is a hard false
|
||||
end
|
||||
|
||||
return { pred = pred, neg = neg, args = args };
|
||||
end
|
||||
|
||||
-- group body -> { target = str or nil, n, [i] = cond }
|
||||
local function ParseGroup(body)
|
||||
local group = { n = 0 };
|
||||
-- Sentinel comma so a run ending at end-of-body (and an empty body) yields
|
||||
-- a piece; each piece is (non-comma)* up to a comma.
|
||||
for piece in gmatch(body .. ",", "([^,]*),") do
|
||||
piece = Trim(piece);
|
||||
if strsub(piece, 1, 1) == "@" then
|
||||
group.target = Trim(strsub(piece, 2));
|
||||
elseif strlower(strsub(piece, 1, 7)) == "target=" then
|
||||
group.target = Trim(strsub(piece, 8));
|
||||
else
|
||||
group.n = group.n + 1;
|
||||
group[group.n] = ParseCondition(piece);
|
||||
end
|
||||
end
|
||||
return group;
|
||||
end
|
||||
|
||||
-- clause text -> { value = str, groups = { n, [i] = group } }
|
||||
local function ParseClause(text)
|
||||
local groups = { n = 0 };
|
||||
-- Greedy up to the LAST ']' splits the [group] blocks from the value.
|
||||
local body, value = strmatch(text, "^%s*(.*%])(.*)$");
|
||||
if not body then
|
||||
return { value = Trim(text), groups = groups };
|
||||
end
|
||||
for groupBody in gmatch(body, "%[([^%]]*)%]") do
|
||||
groups.n = groups.n + 1;
|
||||
groups[groups.n] = ParseGroup(groupBody);
|
||||
end
|
||||
return { value = Trim(value), groups = groups };
|
||||
end
|
||||
|
||||
-- options -> { n, [i] = clause }. Splits on ';' at bracket depth 0.
|
||||
local function ParseOptions(options)
|
||||
local clauses = { n = 0 };
|
||||
local len = strlen(options);
|
||||
local pos = 1;
|
||||
while pos <= len + 1 do
|
||||
local i = pos;
|
||||
local depth = 0;
|
||||
while i <= len do
|
||||
local c = strsub(options, i, i);
|
||||
if c == "[" then
|
||||
depth = 1;
|
||||
elseif c == "]" then
|
||||
depth = 0;
|
||||
elseif c == ";" and depth == 0 then
|
||||
break;
|
||||
end
|
||||
i = i + 1;
|
||||
end
|
||||
clauses.n = clauses.n + 1;
|
||||
clauses[clauses.n] = ParseClause(strsub(options, pos, i - 1));
|
||||
pos = i + 1;
|
||||
end
|
||||
return clauses;
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Evaluation
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local cache = {};
|
||||
|
||||
local function GroupPasses(group)
|
||||
local target = group.target or "target";
|
||||
for i = 1, group.n do
|
||||
local cond = group[i];
|
||||
local ok = cond.pred(target, cond.args);
|
||||
if cond.neg then
|
||||
ok = not ok;
|
||||
end
|
||||
if not ok then
|
||||
return false;
|
||||
end
|
||||
end
|
||||
return true;
|
||||
end
|
||||
|
||||
function SecureCmdOptionParse(options)
|
||||
if type(options) ~= "string" then
|
||||
error("Usage: SecureCmdOptionParse(\"options\")");
|
||||
return nil; -- WoW's error() may not unwind
|
||||
end
|
||||
|
||||
local clauses = cache[options];
|
||||
if not clauses then
|
||||
clauses = ParseOptions(options);
|
||||
cache[options] = clauses;
|
||||
end
|
||||
|
||||
for i = 1, clauses.n do
|
||||
local clause = clauses[i];
|
||||
local groups = clause.groups;
|
||||
if groups.n == 0 then
|
||||
return clause.value;
|
||||
end
|
||||
for j = 1, groups.n do
|
||||
if GroupPasses(groups[j]) then
|
||||
return clause.value, groups[j].target;
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil;
|
||||
end
|
||||
@@ -0,0 +1,222 @@
|
||||
-- SecureStateDriver: automatic frame state / visibility from macro conditionals.
|
||||
--
|
||||
-- Backports FrameXML's SecureStateDriverManager. A registered driver re-runs
|
||||
-- SecureCmdOptionParse (Util/MacroOptions.lua) on a 0.2s poll (plus an instant
|
||||
-- rescan on the events below) and applies the result:
|
||||
-- * RegisterStateDriver(frame, "visibility", "[combat] hide; show") -> the
|
||||
-- value "show"/"hide" drives frame:Show()/Hide();
|
||||
-- * RegisterStateDriver / RegisterAttributeDriver for any other state ->
|
||||
-- frame:SetAttribute("state-"..state, value), so an OnAttributeChanged
|
||||
-- handler reacts to it;
|
||||
-- * RegisterUnitWatch(frame [, asState]) -> shows/hides (or sets
|
||||
-- "state-unitexists") as the frame's `unit` attribute comes and goes.
|
||||
--
|
||||
-- Base: 3.3.5 FrameXML/SecureStateDriver.lua, with the anniversary refinements
|
||||
-- grafted in (the resolveDriver() consolidation, tonumber() coercion of driver
|
||||
-- values, the literal string 'nil' -> nil, and the "statehidden" attribute).
|
||||
--
|
||||
-- Three deliberate deviations from upstream, all because 1.12 has no taint and
|
||||
-- our attribute system fires OnAttributeChanged SYNCHRONOUSLY:
|
||||
-- 1. No attribute-bootstrap manager. Upstream drives itself through
|
||||
-- SecureStateDriverManager:SetAttribute("setframe"/"setstate"/...) -- a
|
||||
-- taint-boundary trick. Our SetAttribute has a recursion guard (a nested
|
||||
-- SetAttribute stores the value but does NOT fire the handler, see
|
||||
-- src/frame/Attributes.cpp), so doing the registration-time resolve from
|
||||
-- inside that handler would silently swallow the target frame's own
|
||||
-- OnAttributeChanged. Instead the public Register* functions write plain
|
||||
-- local tables and resolve immediately at top level. Public signatures are
|
||||
-- unchanged from retail.
|
||||
-- 2. GetTime()-deadline throttle instead of the OnUpdate `elapsed` arg (1.12
|
||||
-- handlers take no parameters).
|
||||
-- 3. UpdateUnitWatch guards its "statehidden"/"state-unitexists" writes on a
|
||||
-- real change, so a watched frame's handler is not fired at 5 Hz.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- SecureButton_* (minimal -- no taint, no modifier/button suffix machinery)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- SecureButton_GetAttribute and SecureButton_GetUnit are defined in
|
||||
-- SecureTemplates.lua (loaded earlier via SecureTemplates.xml).
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Private state
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local secureAttributeDrivers = {}; -- [frame][attribute] = optionsString
|
||||
local unitExistsWatchers = {}; -- [frame] = doState (boolean)
|
||||
local unitExistsCache = setmetatable({}, {
|
||||
__index = function(t, k)
|
||||
local v = (UnitExists(k) and true) or false;
|
||||
t[k] = v;
|
||||
return v;
|
||||
end,
|
||||
});
|
||||
|
||||
local STATE_DRIVER_UPDATE_THROTTLE = 0.2;
|
||||
local nextUpdate = 0;
|
||||
|
||||
local pairs = pairs;
|
||||
|
||||
function UnitWatchRegistered(frame)
|
||||
return unitExistsWatchers[frame] ~= nil;
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Resolvers
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function UpdateUnitWatch(frame, doState)
|
||||
local unit = SecureButton_GetUnit(frame);
|
||||
local exists = (unit and unitExistsCache[unit]) or false;
|
||||
if doState then
|
||||
if frame:GetAttribute("state-unitexists") ~= exists then
|
||||
frame:SetAttribute("state-unitexists", exists);
|
||||
end
|
||||
else
|
||||
if exists then
|
||||
frame:Show();
|
||||
else
|
||||
frame:Hide();
|
||||
end
|
||||
local hidden = (not exists) or nil;
|
||||
if frame:GetAttribute("statehidden") ~= hidden then
|
||||
frame:SetAttribute("statehidden", hidden);
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function resolveDriver(frame, attribute, values)
|
||||
local newValue = SecureCmdOptionParse(values);
|
||||
|
||||
if attribute == "state-visibility" then
|
||||
if newValue == "show" then
|
||||
frame:Show();
|
||||
if frame:GetAttribute("statehidden") ~= nil then
|
||||
frame:SetAttribute("statehidden", nil);
|
||||
end
|
||||
elseif newValue == "hide" then
|
||||
frame:Hide();
|
||||
if frame:GetAttribute("statehidden") ~= true then
|
||||
frame:SetAttribute("statehidden", true);
|
||||
end
|
||||
end
|
||||
elseif newValue then
|
||||
if newValue == "nil" then
|
||||
newValue = nil;
|
||||
else
|
||||
newValue = tonumber(newValue) or newValue;
|
||||
end
|
||||
if frame:GetAttribute(attribute) ~= newValue then
|
||||
frame:SetAttribute(attribute, newValue);
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Manager frame
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
SecureStateDriverManager = CreateFrame("Frame", "SecureStateDriverManager");
|
||||
SecureStateDriverManager:Hide(); -- hidden -> no OnUpdate until a driver exists
|
||||
|
||||
SecureStateDriverManager:SetScript("OnUpdate", function()
|
||||
local now = GetTime();
|
||||
if now < nextUpdate then
|
||||
return;
|
||||
end
|
||||
nextUpdate = now + STATE_DRIVER_UPDATE_THROTTLE;
|
||||
|
||||
for frame, drivers in pairs(secureAttributeDrivers) do
|
||||
for attribute, values in pairs(drivers) do
|
||||
resolveDriver(frame, attribute, values);
|
||||
end
|
||||
end
|
||||
|
||||
for k in pairs(unitExistsCache) do
|
||||
unitExistsCache[k] = nil;
|
||||
end
|
||||
for frame, doState in pairs(unitExistsWatchers) do
|
||||
UpdateUnitWatch(frame, doState);
|
||||
end
|
||||
end);
|
||||
|
||||
SecureStateDriverManager:SetScript("OnEvent", function()
|
||||
nextUpdate = 0; -- force a rescan on the next frame
|
||||
end);
|
||||
|
||||
SecureStateDriverManager:SetScript("OnAttributeChanged", function()
|
||||
if arg1 == "updatetime" and tonumber(arg2) then
|
||||
STATE_DRIVER_UPDATE_THROTTLE = tonumber(arg2);
|
||||
end
|
||||
end);
|
||||
|
||||
-- Events that warrant an early rescan. Each is guarded so a name this client
|
||||
-- doesn't define (e.g. UPDATE_STEALTH) is simply skipped.
|
||||
local rescanEvents = {
|
||||
"MODIFIER_STATE_CHANGED", -- ClassicAPI-fired
|
||||
"PLAYER_FOCUS_CHANGED", -- ClassicAPI-fired
|
||||
"UPDATE_SHAPESHIFT_FORM", -- ClassicAPI-fired (singular)
|
||||
"UPDATE_SHAPESHIFT_FORMS", -- native 1.12 (plural)
|
||||
"ACTIONBAR_PAGE_CHANGED",
|
||||
"UPDATE_BONUS_ACTIONBAR",
|
||||
"PLAYER_ENTERING_WORLD",
|
||||
"UPDATE_STEALTH",
|
||||
"PLAYER_TARGET_CHANGED",
|
||||
"PLAYER_REGEN_DISABLED",
|
||||
"PLAYER_REGEN_ENABLED",
|
||||
"UNIT_PET",
|
||||
"RAID_ROSTER_UPDATE",
|
||||
"PARTY_MEMBERS_CHANGED",
|
||||
};
|
||||
for _, event in ipairs(rescanEvents) do
|
||||
if C_EventUtils.IsEventValid(event) then
|
||||
SecureStateDriverManager:RegisterEvent(event);
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Public API
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
function RegisterAttributeDriver(frame, attribute, values)
|
||||
if attribute and values and string.sub(attribute, 1, 1) ~= "_" then
|
||||
local drivers = secureAttributeDrivers[frame];
|
||||
if not drivers then
|
||||
drivers = {};
|
||||
secureAttributeDrivers[frame] = drivers;
|
||||
end
|
||||
drivers[attribute] = values;
|
||||
SecureStateDriverManager:Show();
|
||||
resolveDriver(frame, attribute, values);
|
||||
end
|
||||
end
|
||||
|
||||
function UnregisterAttributeDriver(frame, attribute)
|
||||
if attribute then
|
||||
local drivers = secureAttributeDrivers[frame];
|
||||
if drivers then
|
||||
drivers[attribute] = nil;
|
||||
end
|
||||
else
|
||||
secureAttributeDrivers[frame] = nil;
|
||||
end
|
||||
end
|
||||
|
||||
function RegisterStateDriver(frame, state, values)
|
||||
RegisterAttributeDriver(frame, "state-" .. state, values);
|
||||
end
|
||||
|
||||
function UnregisterStateDriver(frame, state)
|
||||
UnregisterAttributeDriver(frame, state and ("state-" .. state));
|
||||
end
|
||||
|
||||
function RegisterUnitWatch(frame, asState)
|
||||
local doState = (asState and true) or false;
|
||||
unitExistsWatchers[frame] = doState;
|
||||
SecureStateDriverManager:Show();
|
||||
UpdateUnitWatch(frame, doState);
|
||||
end
|
||||
|
||||
function UnregisterUnitWatch(frame)
|
||||
unitExistsWatchers[frame] = nil;
|
||||
end
|
||||
+21
-7
@@ -142,15 +142,29 @@ target_include_directories(
|
||||
set_target_properties(${PROJECT_NAME} PROPERTIES OUTPUT_NAME "ClassicAPI")
|
||||
|
||||
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /MT")
|
||||
set(CMAKE_CXX_FLAGS_RELEASE "/MT /O2 /Ob2 /DNDEBUG")
|
||||
# /Zi retains full debug info (source file + line numbers, locals, types) in a
|
||||
# separate PDB WITHOUT disabling optimization — the DLL stays a fully optimized
|
||||
# /O2 release build; the symbols just live in ClassicAPI.pdb on the side. Feeds
|
||||
# the linker's /DEBUG below.
|
||||
set(CMAKE_CXX_FLAGS_RELEASE "/MT /O2 /Ob2 /DNDEBUG /Zi")
|
||||
set(CMAKE_SHARED_LINKER_FLAGS
|
||||
# /MAP emits build/Release/ClassicAPI.map — a linker-only output (no effect
|
||||
# on code layout) that maps `0001:offset` addresses from WoW's ERROR #132
|
||||
# crash dumps back to functions. Published as a build/release artifact so
|
||||
# any crash from a shipped DLL can be triaged exactly. (/OPT:ICF folds
|
||||
# identical functions, so a few map symbols share an address — harmless.)
|
||||
# Two debug artifacts for triaging crashes from a SHIPPED DLL. Both are
|
||||
# published as release artifacts, match this exact build, and have no effect
|
||||
# on code layout:
|
||||
# /MAP -> build/Release/ClassicAPI.map: a text symbol->address table
|
||||
# that resolves the `0001:offset` addresses in WoW's ERROR #132
|
||||
# crash log back to functions by hand (no debugger/dump needed).
|
||||
# /DEBUG -> build/Release/ClassicAPI.pdb: full symbols (paired with the
|
||||
# compiler's /Zi) so WinDbg/x64dbg/VS produce a line-level
|
||||
# symbolicated call stack from a crash dump. A SEPARATE file —
|
||||
# it is NOT embedded in the DLL, only a small signature record
|
||||
# linking the two is.
|
||||
# /DEBUG implicitly disables /OPT:REF and /OPT:ICF (dead-code strip +
|
||||
# identical-COMDAT fold); they are listed explicitly below so they stay ON
|
||||
# and the shipped DLL does not grow. (/OPT:ICF folds identical functions, so
|
||||
# a few map symbols share an address — harmless.)
|
||||
# /DEF exports `Load` (VanillaFixes' post-injection entry point) undecorated
|
||||
# so its GetProcAddress(module, "Load") resolves. See src/ClassicAPI.def and
|
||||
# the loader-lock rationale in src/DllMain.cpp.
|
||||
"/OPT:REF /OPT:ICF /MERGE:.rdata=.text /MERGE:.pdata=.text /SECTION:.text,EWR /NODEFAULTLIB:MSVCRT /MAP /DEF:${CMAKE_CURRENT_SOURCE_DIR}/src/ClassicAPI.def"
|
||||
"/OPT:REF /OPT:ICF /MERGE:.rdata=.text /MERGE:.pdata=.text /SECTION:.text,EWR /NODEFAULTLIB:MSVCRT /MAP /DEBUG /DEF:${CMAKE_CURRENT_SOURCE_DIR}/src/ClassicAPI.def"
|
||||
)
|
||||
|
||||
@@ -1,25 +1,55 @@
|
||||
# ClassicAPI
|
||||
|
||||
A small DLL for World of Warcraft 1.12.1 (Vanilla / Turtle WoW) that adds a
|
||||
collection of Lua API calls Blizzard never exposed in 1.12 but which make
|
||||
addon authoring noticeably less painful — primarily for backporting addons
|
||||
written against later API versions (3.3.5+) where these calls do exist.
|
||||
ClassicAPI is a DLL for World of Warcraft 1.12.1 (Vanilla / Turtle WoW). It
|
||||
backports a large part of the modern WoW API — 550+ functions and 50+ events,
|
||||
plus much of Lua 5.1 — into the 1.12 client, so addons written for later
|
||||
versions (3.3.5+ or Classic Era) run with little or no change.
|
||||
|
||||
The DLL hooks the FrameScript engine after WoW boots and registers its
|
||||
extensions through the same in-engine mechanisms WoW uses for its own Lua
|
||||
functions. No companion addon is required.
|
||||
It hooks the engine after WoW boots and registers everything the same way WoW
|
||||
registers its own Lua functions. Nothing else to install — the DLL is
|
||||
self-contained.
|
||||
|
||||
## What's added
|
||||
## Highlights
|
||||
|
||||
Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
The headline features. Each one takes effect as soon as the DLL loads, so your
|
||||
existing addons benefit without any code changes. The complete per-function
|
||||
reference is in **[docs/API.md](docs/API.md)**.
|
||||
|
||||
### Calls
|
||||
### Lua 5.1 compatibility
|
||||
|
||||
The flagship feature: run modern Lua 5.1 addon code on 1.12's Lua 5.0 VM.
|
||||
|
||||
| Feature | Effect |
|
||||
|---------|--------|
|
||||
| Syntax | Compiles the Lua 5.1 length (`#`), modulo (`%`), `...`-expression, `0x` hex-literal, and leveled long bracket (`[=[ ]=]`) syntax that vanilla's Lua 5.0 rejects, by rewriting addon source before it compiles. Each addon file also receives its `(name, table)` through `...` (`local name, tbl = ...`). See [Lua 5.1 syntax](docs/API.md#lua-51-syntax). |
|
||||
| String methods | Every string value accepts method calls — `("asd"):upper()`, `("%d gold"):format(n)`, `msg:match("^!(%w+)")` — resolving through the `string` table, the way Lua 5.1 works. Vanilla's Lua 5.0 errors with `attempt to index a string value`. Works on literals and variables, in-world and on the login screen, and inside coroutines. See [String methods](docs/API.md#string-methods-supper-sformat). |
|
||||
| Script-handler arguments | Frame-script handlers receive their values as positional arguments — `OnMouseWheel(self, delta)`, `OnClick(self, button)`, `OnEvent(self, event, ...)`, etc. — the way 5.1+ clients do, so modern addon ports work unmodified. The `this` / `arg1` globals stay set. A handler that declares no parameters is unaffected. A handler that declared a parameter and expected nil now receives its real value. On by default. `SetModernScriptArgs(false)` restores exact vanilla dispatch. See [SetModernScriptArgs](docs/API.md#setmodernscriptargsenable--getmodernscriptargs). |
|
||||
|
||||
### Modern client behaviors
|
||||
|
||||
| Feature | Effect |
|
||||
|---------|--------|
|
||||
| Inline textures | Draws inline texture markup (`\|T…\|t`) as icons in FontStrings, chat, and tooltips, the way 4.3.4+ clients do. Vanilla 1.12 shows the raw escape as literal text instead. This covers item and spell icons, raid-target markers, and the coin icons in money strings. `GetStringWidth` and `GetStringHeight` count the icons, so measured width and text wrapping stay correct. Done in pure C++ by hooking the engine's text pipeline — no addon. |
|
||||
| Tooltip line cap | Lifts `GameTooltip`'s hard 30-line limit to 60 for every `GameTooltipTemplate` frame (`GameTooltip`, `ShoppingTooltip1/2`, `ItemRefTooltip`, AtlasLoot, …). Stat-heavy tooltips and comparison blocks (e.g. pfUI's eqcompare) no longer have their extra lines silently dropped. Done in pure C++ by growing the engine's FontString pool at tooltip-creation time. |
|
||||
| Event-driven nameplates | The modern `C_NamePlate` API, driven by real events instead of vanilla frame-scraping. `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` fire as plates appear and vanish, `nameplate1`..`nameplateN` tokens resolve with every `UnitX` function (and fire `UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "nameplateN"`), and `C_NamePlate.GetNamePlateForUnit` / `GetNamePlates` hand back the live frames. See [NamePlate](docs/API.md#nameplate). |
|
||||
| Focus target | A sticky focus unit, like later clients. `FocusUnit("target")` pins it and `ClearFocus()` drops it, with `PLAYER_FOCUS_CHANGED` on every change. The `focus` / `focustarget` tokens resolve with every `UnitX` function and fire unit events (`UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "focus"`), and the predefined `FOCUSTARGET` / `TARGETFOCUS` keybinds are ready to set. See [Focus](docs/API.md#focus). |
|
||||
| Multi-flavor & conditional TOC | Loads modern multi-flavor addons that ship one folder. Selects a version-specific TOC (`<Name>_ClassicAPI.toc` or `<Name>_Turtle.toc`) and the matching keybinding file (`Bindings_ClassicAPI.xml` / `Bindings_Turtle.xml`), accepts a comma-separated `## Interface:` version list (compatible when it includes the client version `11200`), and honors per-line `[AllowLoadGameType]` / `[AllowLoadTextLocale]` conditions and `[Family]` / `[Game]` / `[TextLocale]` path variables inside a TOC. See [Conditional and multi-flavor TOC loading](docs/API.md#conditional-and-multi-flavor-toc-loading). |
|
||||
|
||||
## Full API reference
|
||||
|
||||
Everything ClassicAPI adds, in full. Each section is collapsed — click a
|
||||
heading to expand it. For signatures and return values, see the per-function
|
||||
reference in **[docs/API.md](docs/API.md)**.
|
||||
|
||||
<details>
|
||||
<summary><b>In-game Lua calls</b> — 550+ functions across ~60 namespaces</summary>
|
||||
|
||||
| Namespace | Calls |
|
||||
|-----------|-------|
|
||||
| [Action](docs/API.md#action) | `GetActionInfo` |
|
||||
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnLocalTable`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
|
||||
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem` |
|
||||
| [AuraUtil](docs/API.md#aurautil) | `AuraUtil.FindAura`, `AuraUtil.FindAuraByName`, `AuraUtil.ForEachAura`, `AuraUtil.UnpackAuraData` |
|
||||
| [Bindings](docs/API.md#bindings) | `SetBindingSpell`, `SetBindingItem`, `SetBindingMacro`, `SetBindingClick`, `SetOverrideBinding`, `SetOverrideBindingSpell`, `SetOverrideBindingItem`, `SetOverrideBindingMacro`, `SetOverrideBindingClick`, `ClearOverrideBindings` |
|
||||
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
|
||||
| [ChatBubbles](docs/API.md#chatbubbles) | `C_ChatBubbles.GetAllChatBubbles` |
|
||||
@@ -37,7 +67,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
|
||||
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
|
||||
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
|
||||
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `GetMouseFoci`, `frame:SetShown`, `fontstring:GetStringHeight`, `fontstring:GetUnboundedStringWidth`, `fontstring:GetWrappedWidth`, `fontstring:GetNumLines`, `fontstring:GetLineHeight`, `fontstring:IsTruncated`, `fontstring:SetMaxLines`, `fontstring:GetMaxLines`, `fontstring:SetFormattedText`, `texture:SetRotation`, `texture:GetRotation`, `texture:SetVertexOffset`, `texture:GetVertexOffset`, `texture:SetColorTexture`, `fontstring:SetRotation`, `fontstring:GetRotation`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs` |
|
||||
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `GetMouseFoci`, `frame:SetShown`, `fontstring:GetStringHeight`, `fontstring:GetUnboundedStringWidth`, `fontstring:GetWrappedWidth`, `fontstring:GetNumLines`, `fontstring:GetLineHeight`, `fontstring:IsTruncated`, `fontstring:SetMaxLines`, `fontstring:GetMaxLines`, `fontstring:SetFormattedText`, `texture:SetRotation`, `texture:GetRotation`, `texture:SetVertexOffset`, `texture:GetVertexOffset`, `texture:SetColorTexture`, `texture:SetMask`, `frame:CreateMaskTexture`, `texture:AddMaskTexture`, `texture:RemoveMaskTexture`, `texture:GetNumMaskTextures`, `texture:GetMaskTexture`, `fontstring:SetRotation`, `fontstring:GetRotation`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs`, `SecureCmdOptionParse`, `RegisterStateDriver`, `UnregisterStateDriver`, `RegisterAttributeDriver`, `UnregisterAttributeDriver`, `RegisterUnitWatch`, `UnregisterUnitWatch`, `UnitWatchRegistered`, `SecureButton_GetAttribute`, `SecureButton_GetUnit` |
|
||||
| [FriendList](docs/API.md#friendlist) | `C_FriendList.GetFriendInfo`, `C_FriendList.GetFriendInfoByIndex`, `C_FriendList.GetNumFriends`, `C_FriendList.GetNumOnlineFriends`, `C_FriendList.GetNumWhoResults`, `C_FriendList.GetWhoInfo`, `C_FriendList.IsFriend`, `C_FriendList.IsIgnored`, `C_FriendList.IsIgnoredByGuid`, `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName`, `C_FriendList.SetFriendNotes`, `C_FriendList.SetFriendNotesByIndex` |
|
||||
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID`, `ClosestGameObjectPosition` |
|
||||
| [Glue](docs/API.md#glue) | `C_Glue.IsOnGlueScreen` |
|
||||
@@ -73,11 +103,14 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
|
||||
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
|
||||
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `UnitSpellTargetName`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
|
||||
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration` |
|
||||
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration`, `C_UnitAuras.UnitAura`, `C_UnitAuras.UnitBuff`, `C_UnitAuras.UnitDebuff` |
|
||||
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
|
||||
| [XMLUtil](docs/API.md#xmlutil) | `C_XMLUtil.DoesTemplateExist`, `C_XMLUtil.GetTemplateInfo`, `C_XMLUtil.GetTemplates` |
|
||||
|
||||
### GlueXML calls
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>GlueXML calls</b> — login / realm-select / character-select screens</summary>
|
||||
|
||||
Registered on the **glue** Lua state (the engine that runs the login,
|
||||
realm-select, and character-select screens). The persistence entries
|
||||
@@ -97,7 +130,10 @@ the glue state because GlueXML had no way to reach them otherwise.
|
||||
| Script | `RunScript` (compile and run a Lua chunk in the glue state's globals — useful for slash-command-style helpers in GlueXML) |
|
||||
| State | `IsLoggedIn` |
|
||||
|
||||
### Macros
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Macros</b> — engine-level parsing extensions</summary>
|
||||
|
||||
Engine-level extensions to macro parsing and dispatch — no new Lua
|
||||
functions, just behavior the stock 1.12 engine didn't have. See the
|
||||
@@ -109,7 +145,10 @@ functions, just behavior the stock 1.12 engine didn't have. See the
|
||||
| `CastSpellByName("<spellID>")` | Same — numeric strings resolve through the engine's name resolver |
|
||||
| `CastSpellNoToggle("<name>")` in a macro | Engine's macro parser now recognizes it as a primary-spell line, so the macro slot in an addon like pfUI highlights when its spell is auto-repeating or its self-aura is active |
|
||||
|
||||
### Console commands
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Console commands</b> — developer console (<code>-console</code>)</summary>
|
||||
|
||||
Registered on the engine's developer console (the `~` console available
|
||||
when launching with `-console`), not as Lua functions. See the
|
||||
@@ -121,7 +160,10 @@ when launching with `-console`), not as Lua functions. See the
|
||||
| `ExportInterfaceFiles art` | Extracts Blizzard's UI art (`.blp`/`.tga`) from the MPQs to `BlizzardInterfaceArt\` |
|
||||
| `ExportDBCFiles` | Extracts the client's `.dbc` tables from the MPQs to `DBFilesClient\` |
|
||||
|
||||
### Events
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Events</b> — new events fired to addons</summary>
|
||||
|
||||
| Event | Payload |
|
||||
|-------|---------|
|
||||
@@ -177,7 +219,10 @@ when launching with `-console`), not as Lua functions. See the
|
||||
| `VOICE_CHAT_TTS_PLAYBACK_FAILED` | `status, utteranceID, destination` |
|
||||
| `VOICE_CHAT_TTS_VOICES_UPDATE` | *(none)* |
|
||||
|
||||
### Globals
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Globals & enums</b></summary>
|
||||
|
||||
| Group | Constants |
|
||||
|-------|-----------|
|
||||
@@ -193,7 +238,10 @@ when launching with `-console`), not as Lua functions. See the
|
||||
| Spellbook bank | `Enum.SpellBookSpellBank.{Player,Pet}` (0–1) |
|
||||
| Spellbook item type | `Enum.SpellBookItemType.{None,Spell,FutureSpell,PetAction,Flyout}` (0–4; 1.12 only yields `Spell`/`PetAction`) |
|
||||
|
||||
### Unit tokens
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Unit tokens</b> — <code>nameplateN</code>, <code>focus</code>, <code>markN</code></summary>
|
||||
|
||||
| Token | Resolves to |
|
||||
|-------|-------------|
|
||||
@@ -201,7 +249,10 @@ when launching with `-console`), not as Lua functions. See the
|
||||
| `focus` / `focustarget` | Sticky target set via [`FocusUnit`](docs/API.md#focusunitunit), cleared via [`ClearFocus`](docs/API.md#clearfocus). Same `UnitX` coverage as `nameplateN`. Fires [`PLAYER_FOCUS_CHANGED`](docs/API.md#player_focus_changed-event) on transition. See [Focus](docs/API.md#focus). |
|
||||
| `mark1`..`mark8` | Unit currently wearing the Nth raid-target marker (`mark1` = star … `mark8` = skull). Same `UnitX` coverage as `nameplateN`, and fires unit events (`UNIT_HEALTH`, `UNIT_AURA`, …) with `arg1 == "markN"`. `UnitExists("markN")` is `false` when the marker is unset, out of range, or on a non-unit. Suffix chains (`mark1target`) compose. See [Unit tokens (`markN`)](docs/API.md#unit-tokens-markn). |
|
||||
|
||||
### Bindings
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Bindings</b> — direct-action and override binding families</summary>
|
||||
|
||||
ClassicAPI backports the later-client direct-action and temporary override
|
||||
binding families. Permanent bindings use the standard `SPELL`, `ITEM`,
|
||||
@@ -212,11 +263,10 @@ See the [Bindings API reference](docs/API.md#bindings) for signatures,
|
||||
precedence, execution behavior, macro-text usage, and 1.12 compatibility
|
||||
notes.
|
||||
|
||||
#### Predefined focus bindings
|
||||
|
||||
Injected into the engine's **Targeting Functions** group at FrameXML
|
||||
Bindings.xml load time, so they appear in the keybind UI alongside
|
||||
native targeting bindings instead of orphaned at the bottom.
|
||||
**Predefined focus bindings.** Injected into the engine's **Targeting
|
||||
Functions** group at FrameXML Bindings.xml load time, so they appear in the
|
||||
keybind UI alongside native targeting bindings instead of orphaned at the
|
||||
bottom.
|
||||
|
||||
| Binding | Action |
|
||||
|---------|--------|
|
||||
@@ -226,20 +276,7 @@ native targeting bindings instead of orphaned at the bottom.
|
||||
See [Predefined focus bindings](docs/API.md#predefined-focus-bindings-focustarget--targetfocus)
|
||||
for the implementation note.
|
||||
|
||||
### Behaviors
|
||||
|
||||
Transparent engine tweaks — no API to call, they just fix a vanilla limitation:
|
||||
|
||||
| Tweak | Effect |
|
||||
|-------|--------|
|
||||
| Tooltip line cap | Lifts `GameTooltip`'s hard 30-line limit to 60 for every `GameTooltipTemplate` frame (`GameTooltip`, `ShoppingTooltip1/2`, `ItemRefTooltip`, AtlasLoot, …). Stat-heavy tooltips and comparison blocks (e.g. pfUI's eqcompare) no longer have their extra lines silently dropped. Done in pure C++ by growing the engine's FontString pool at tooltip-creation time. |
|
||||
| Inline textures | Draws inline texture markup (`\|T…\|t`) as icons in FontStrings, chat, and tooltips, the way 4.3.4+ clients do. Vanilla 1.12 shows the raw escape as literal text instead. This covers item and spell icons, raid-target markers, and the coin icons in money strings. `GetStringWidth` and `GetStringHeight` count the icons, so measured width and text wrapping stay correct. Done in pure C++ by hooking the engine's text pipeline — no addon. |
|
||||
| Lua 5.1 syntax | Compiles the Lua 5.1 length (`#`), modulo (`%`), `...`-expression, `0x` hex-literal, and leveled long bracket (`[=[ ]=]`) syntax that vanilla's Lua 5.0 rejects, by rewriting addon source before it compiles. Each addon file also receives its `(name, table)` through `...` (`local name, tbl = ...`). See [Lua 5.1 syntax](docs/API.md#lua-51-syntax). |
|
||||
| Lua 5.1 string methods | Every string value accepts method calls — `("asd"):upper()`, `("%d gold"):format(n)`, `msg:match("^!(%w+)")` — resolving through the `string` table, the way Lua 5.1 works. Vanilla's Lua 5.0 errors with `attempt to index a string value`. Works on literals and variables, in-world and on the login screen, and inside coroutines. See [String methods](docs/API.md#string-methods-supper-sformat). |
|
||||
| Lua 5.1 environment protection | `getfenv` / `setfenv` honor a `__environment` metatable field — the Lua 5.1 sandbox form — in addition to vanilla's raw `__fenv`. See [getfenv / setfenv environment protection](docs/API.md#getfenv--setfenv-environment-protection). |
|
||||
| Multi-flavor & conditional TOC loading | Loads modern multi-flavor addons that ship one folder. Selects a version-specific TOC (`<Name>_ClassicAPI.toc` or `<Name>_Turtle.toc`) and the matching keybinding file (`Bindings_ClassicAPI.xml` / `Bindings_Turtle.xml`), accepts a comma-separated `## Interface:` version list (compatible when it includes the client version `11200`), and honors per-line `[AllowLoadGameType]` / `[AllowLoadTextLocale]` conditions and `[Family]` / `[Game]` / `[TextLocale]` path variables inside a TOC. See [Conditional and multi-flavor TOC loading](docs/API.md#conditional-and-multi-flavor-toc-loading). |
|
||||
| SavedVariables loaded first | Honors the modern `## LoadSavedVariablesFirst` TOC directive: a flagged addon's SavedVariables load before its Lua runs, so file-scope code sees restored config (instead of vanilla's `nil`). See [SavedVariables loaded first](docs/API.md#savedvariables-loaded-first). |
|
||||
| Modern script-handler arguments | Frame-script handlers receive their values as positional arguments — `OnMouseWheel(self, delta)`, `OnClick(self, button)`, `OnEvent(self, event, ...)`, etc. — the way 5.1+ clients do, so modern addon ports work unmodified. The `this` / `arg1` globals stay set. A handler that declares no parameters is unaffected. A handler that declared a parameter and expected nil now receives its real value. On by default. `SetModernScriptArgs(false)` restores exact vanilla dispatch. See [SetModernScriptArgs](docs/API.md#setmodernscriptargsenable--getmodernscriptargs). |
|
||||
</details>
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
+361
@@ -28,6 +28,12 @@ build instructions.
|
||||
- [AuctionHouse](#auctionhouse)
|
||||
- [`C_AuctionHouse.PostItem(itemLocation, duration, quantity, numStacks, bid, buyout)`](#c_auctionhousepostitemitemlocation-duration-quantity-numstacks-bid-buyout)
|
||||
|
||||
- [AuraUtil](#aurautil)
|
||||
- [`AuraUtil.ForEachAura`](#aurautilforeachaura)
|
||||
- [`AuraUtil.FindAura`](#aurautilfindaura)
|
||||
- [`AuraUtil.FindAuraByName`](#aurautilfindaurabyname)
|
||||
- [`AuraUtil.UnpackAuraData`](#aurautilunpackauradata)
|
||||
|
||||
- [CharacterList](#characterlist)
|
||||
- [`GetSavedCharacterOrder(realm)` / `SetSavedCharacterOrder(realm, order)` — GlueXML only](#getsavedcharacterorderrealm--setsavedcharacterorderrealm-order--gluexml-only)
|
||||
|
||||
@@ -178,6 +184,12 @@ build instructions.
|
||||
- [`fontstring:SetFormattedText(format [, ...])`](#fontstringsetformattedtextformat--)
|
||||
- [`texture:SetRotation(angle [, cx, cy])`](#texturesetrotationangle--cx-cy)
|
||||
- [`texture:SetVertexOffset(vertexIndex, offsetX, offsetY)`](#texturesetvertexoffsetvertexindex-offsetx-offsety)
|
||||
- [`texture:SetMask(path)`](#texturesetmaskpath)
|
||||
- [`frame:CreateMaskTexture([name, layer, ...])`](#framecreatemasktexturename-layer-)
|
||||
- [`texture:AddMaskTexture(mask)`](#textureaddmasktexturemask)
|
||||
- [`texture:RemoveMaskTexture([mask])`](#textureremovemasktexturemask)
|
||||
- [`texture:GetNumMaskTextures()`](#texturegetnummasktextures)
|
||||
- [`texture:GetMaskTexture(index)`](#texturegetmasktextureindex)
|
||||
- [`texture:SetColorTexture(colorR, colorG, colorB [, a])`](#texturesetcolortexturecolorr-colorg-colorb--a)
|
||||
- [`fontstring:SetRotation(angle [, cx, cy])`](#fontstringsetrotationangle--cx-cy)
|
||||
- [`frame:SetResizeBounds(minWidth, minHeight [, maxWidth, maxHeight])`](#framesetresizeboundsminwidth-minheight--maxwidth-maxheight)
|
||||
@@ -186,6 +198,11 @@ build instructions.
|
||||
- [`frame:GetEffectiveAlpha()`](#framegeteffectivealpha)
|
||||
- [`frame:SetAttribute` / `SetAttributeNoHandler` / `ClearAttribute` / `GetAttribute` (+ unit-frame mouseover)](#framesetattributename-value--framesetattributenohandlername-value--frameclearattributename--framegetattribute)
|
||||
- [`SetModernScriptArgs(enable)` / `GetModernScriptArgs()`](#setmodernscriptargsenable--getmodernscriptargs)
|
||||
- [`SecureCmdOptionParse(options)`](#securecmdoptionparseoptions)
|
||||
- [`RegisterStateDriver` / `UnregisterStateDriver`](#registerstatedriver--unregisterstatedriver)
|
||||
- [`RegisterAttributeDriver` / `UnregisterAttributeDriver`](#registerattributedriver--unregisterattributedriver)
|
||||
- [`RegisterUnitWatch` / `UnregisterUnitWatch` / `UnitWatchRegistered`](#registerunitwatch--unregisterunitwatch--unitwatchregistered)
|
||||
- [`SecureButton_GetAttribute` / `SecureButton_GetUnit`](#securebutton_getattribute--securebutton_getunit)
|
||||
|
||||
- [FriendList](#friendlist)
|
||||
- [`C_FriendList.SendWhoQueryByName(name)`](#c_friendlistsendwhoquerybynamename)
|
||||
@@ -352,6 +369,7 @@ build instructions.
|
||||
- [`table.wipe(t)`](#tablewipet)
|
||||
- [`table.count(tbl)`](#tablecounttbl)
|
||||
- [`table.maxn(t)`](#tablemaxnt)
|
||||
- [Stale table lengths (5.1 healing)](#stale-table-lengths-51-healing)
|
||||
- [`Mixin(object, ...)` / `CreateFromMixins(...)`](#mixinobject--createfrommixins)
|
||||
- [`string.match` / `string.gmatch`](#stringmatch--stringgmatch)
|
||||
- [`string.gsub` table replacement](#stringgsub-table-replacement)
|
||||
@@ -609,6 +627,8 @@ build instructions.
|
||||
- [UnitAuras](#unitauras)
|
||||
- [`C_UnitAuras.GetAuraDataByIndex(unit, index [, filter])`](#c_unitaurasgetauradatabyindexunit-index--filter)
|
||||
- [`C_UnitAuras.GetBuffDataByIndex(unit, index)` / `GetDebuffDataByIndex(unit, index)`](#c_unitaurasgetbuffdatabyindexunit-index--getdebuffdatabyindexunit-index)
|
||||
- [`C_UnitAuras.UnitAura(unit, index [, filter])`](#c_unitaurasunitauraunit-index--filter)
|
||||
- [`C_UnitAuras.UnitBuff(unit, index [, filter])` / `UnitDebuff(unit, index [, filter])`](#c_unitaurasunitbuffunit-index--filter--unitdebuffunit-index--filter)
|
||||
- [`C_UnitAuras.GetUnitAuraBySpellID(unit, spellID [, filter])`](#c_unitaurasgetunitaurabyspellidunit-spellid--filter)
|
||||
- [`C_UnitAuras.GetPlayerAuraBySpellID(spellID)`](#c_unitaurasgetplayeraurabyspellidspellid)
|
||||
- [`C_UnitAuras.GetAuraDataBySpellName(unit, spellName [, filter])`](#c_unitaurasgetauradatabyspellnameunit-spellname--filter)
|
||||
@@ -1132,6 +1152,60 @@ stack); temp splits use general-purpose bags only, so family-restricted items
|
||||
(arrows/shards that live in specialty bags) aren't supported; one job runs at
|
||||
a time.
|
||||
|
||||
## AuraUtil
|
||||
|
||||
Helper library over [`C_UnitAuras`](#unitauras), backported from FrameXML. Its
|
||||
purpose on 1.12 is the allocation-free aura scan: the non-packed path builds no
|
||||
table per aura, which matters under Lua's garbage collector when you scan many
|
||||
units each frame.
|
||||
|
||||
### AuraUtil.ForEachAura
|
||||
|
||||
`AuraUtil.ForEachAura(unit, filter, batchSize, func [, usePackedAura])` calls
|
||||
`func` for each aura on `unit` matching `filter`, in order, until `func` returns a
|
||||
truthy value or the auras run out.
|
||||
|
||||
- Non-packed (default): `func` receives the 15 positional values of
|
||||
[`C_UnitAuras.UnitAura`](#c_unitaurasunitauraunit-index--filter) — no table is
|
||||
built per aura.
|
||||
- `usePackedAura` true: `func` receives the `AuraData` table from
|
||||
`GetAuraDataByIndex`.
|
||||
|
||||
`batchSize` of `0` or less does nothing; any other value (or `nil`) visits every
|
||||
matching aura — `batchSize` is retail's slot-batch hint, not a cap on the count.
|
||||
|
||||
```lua
|
||||
AuraUtil.ForEachAura("target", "HARMFUL", nil, function(name, icon, count)
|
||||
print(name, count)
|
||||
end)
|
||||
```
|
||||
|
||||
### AuraUtil.FindAura
|
||||
|
||||
`AuraUtil.FindAura(predicate, unit, filter [, arg1, arg2, arg3])` returns the
|
||||
positional values of the first aura for which `predicate` returns truthy, or
|
||||
`nil`. `predicate` receives the (up to three) caller arguments, then the aura's
|
||||
positional values:
|
||||
|
||||
```lua
|
||||
local function castByMe(_, _, _, name, icon, count, dispelType, duration, exp, source)
|
||||
return source == "player"
|
||||
end
|
||||
local name = AuraUtil.FindAura(castByMe, "target", "HARMFUL")
|
||||
```
|
||||
|
||||
### AuraUtil.FindAuraByName
|
||||
|
||||
`AuraUtil.FindAuraByName(auraName, unit, filter)` returns the positional values of
|
||||
the first aura whose name matches `auraName`, or `nil`. Names are localized and
|
||||
not unique, so this returns the first match.
|
||||
|
||||
### AuraUtil.UnpackAuraData
|
||||
|
||||
`AuraUtil.UnpackAuraData(auraData)` returns an `AuraData` table's fields as 15
|
||||
positional values in the classic `UnitAura` order, or `nil` when `auraData` is
|
||||
`nil`. (Position 13 is the table's `isFromPlayerOrPlayerPet`.)
|
||||
|
||||
## CharacterList
|
||||
|
||||
### `GetSavedCharacterOrder(realm)` / `SetSavedCharacterOrder(realm, order)` — GlueXML only
|
||||
@@ -4378,6 +4452,96 @@ t:SetAllPoints(frame)
|
||||
t:SetColorTexture(0.1, 0.6, 1.0, 0.8) -- semi-transparent blue fill
|
||||
```
|
||||
|
||||
### `texture:SetMask(path)`
|
||||
|
||||
Clips a texture to the shape of a mask. Later clients added this for round
|
||||
portraits, round minimap buttons, and other shaped art. Vanilla has no general
|
||||
masking — only the special-cased minimap.
|
||||
|
||||
`path` is a mask texture. The mask's ALPHA channel is the shape: where the mask
|
||||
is opaque the texture shows, where the mask is transparent the texture is
|
||||
hidden. The mask is sampled across the texture's own texture coordinates, so it
|
||||
lines up with the texture. Pass `nil` or `""` to remove the mask; the texture
|
||||
returns to its full rectangle.
|
||||
|
||||
A mask file must be one the client can load and must hold its shape in the alpha
|
||||
channel:
|
||||
|
||||
- A BLP, or an UNCOMPRESSED 32-bit TGA. The client cannot decode an
|
||||
RLE-compressed TGA.
|
||||
- The shape must be in the alpha channel, with white color. A mask that is fully
|
||||
opaque does nothing; a mask whose shape is only in its color does not clip.
|
||||
- Bottom-origin. A top-origin TGA loads upside down.
|
||||
|
||||
Two masks ship in the client and need no file of your own: `Textures\MinimapMask`
|
||||
(round) and `Interface\CharacterFrame\TempPortraitAlphaMask` (round portrait).
|
||||
For any other shape, put your own mask texture in your addon folder and pass its
|
||||
path, the same way you ship any custom texture.
|
||||
|
||||
```lua
|
||||
local t = frame:CreateTexture(nil, "ARTWORK")
|
||||
t:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark")
|
||||
t:SetMask("Textures\\MinimapMask") -- clip the icon to a circle
|
||||
t:SetMask("Interface\\AddOns\\MyAddon\\round") -- or your own mask texture
|
||||
t:SetMask(nil) -- remove the mask
|
||||
```
|
||||
|
||||
> One difference from later clients: the mask follows the texture's own
|
||||
> coordinates. If you crop the texture with `SetTexCoord`, the mask follows the
|
||||
> cropped region rather than the full display area. For a normal, uncropped
|
||||
> texture the two are the same.
|
||||
|
||||
### `frame:CreateMaskTexture([name, layer, ...])`
|
||||
|
||||
Creates a MaskTexture and returns it. A MaskTexture is a texture that never draws
|
||||
itself. Instead it clips other textures to its shape. Later clients added this
|
||||
object; vanilla has only the special-cased minimap mask.
|
||||
|
||||
Give the mask a shape with `SetTexture`, place and size it like any texture
|
||||
(`SetPoint`, `SetSize`, `SetAllPoints`), then attach it with
|
||||
`texture:AddMaskTexture`. Because the mask has its own position and size, it can
|
||||
cover only part of the target, sit to one side, or move each frame.
|
||||
|
||||
The arguments match `frame:CreateTexture`. The mask shape lives in the texture's
|
||||
alpha channel and must load from a BLP or an uncompressed 32-bit TGA — the same
|
||||
rule as [`texture:SetMask`](#texturesetmaskpath).
|
||||
|
||||
```lua
|
||||
local mask = frame:CreateMaskTexture()
|
||||
mask:SetTexture("Interface\\AddOns\\MyAddon\\circle")
|
||||
mask:SetAllPoints(icon) -- cover the whole icon → a round icon
|
||||
icon:AddMaskTexture(mask)
|
||||
```
|
||||
|
||||
### `texture:AddMaskTexture(mask)`
|
||||
|
||||
Clips this texture to `mask`'s shape and position. Where the mask is opaque the
|
||||
texture shows; where the mask is transparent the texture is hidden.
|
||||
|
||||
Call it again with a different mask to add another. A texture with several masks
|
||||
shows only where ALL of them are opaque — the masks intersect. So two circles
|
||||
show their overlapping lens, and a square plus a circle show a square with
|
||||
rounded corners. Up to 7 masks apply at once. Masks cannot subtract, so you
|
||||
cannot cut a hole with this.
|
||||
|
||||
A mask honors [`texture:SetRotation`](#texturesetrotationangle--cx-cy): rotate
|
||||
the mask and its clip shape turns with it. The mask can also move or resize each
|
||||
frame (drive it from an `OnUpdate`), so the clip animates.
|
||||
|
||||
### `texture:RemoveMaskTexture([mask])`
|
||||
|
||||
Removes `mask` from this texture. With no argument, removes every mask, so the
|
||||
texture returns to its full rectangle.
|
||||
|
||||
### `texture:GetNumMaskTextures()`
|
||||
|
||||
Returns the number of masks attached to this texture.
|
||||
|
||||
### `texture:GetMaskTexture(index)`
|
||||
|
||||
Returns the mask at `index` (1 is the first one added), or `nil` when there is
|
||||
no mask at that index.
|
||||
|
||||
### `fontstring:SetRotation(angle [, cx, cy])`
|
||||
|
||||
Rotates a FontString's text by `angle` radians. A positive angle turns the text
|
||||
@@ -4651,6 +4815,146 @@ end)
|
||||
GetModernScriptArgs() -- true (default)
|
||||
```
|
||||
|
||||
### `SecureCmdOptionParse(options)`
|
||||
|
||||
Parses a macro conditional string. Returns the value of the first clause that
|
||||
matches. This is the parser behind `/cast [combat] Spell`-style options.
|
||||
|
||||
The `options` string is a list of clauses, separated by `;`. A clause is zero
|
||||
or more `[...]` condition groups, then a value:
|
||||
|
||||
```lua
|
||||
SecureCmdOptionParse("[combat] Attack; [nostealth] Prowl; Rest")
|
||||
```
|
||||
|
||||
Rules:
|
||||
- A clause with no group always matches.
|
||||
- Groups in one clause are OR'd. The first group that passes wins.
|
||||
- Conditions in a group are separated by `,` and are AND'd.
|
||||
- A `no` prefix negates a condition. A `:a/b/c` suffix adds arguments, which
|
||||
are OR'd.
|
||||
- A `@unit` or `target=unit` piece sets the group's target. Conditions that
|
||||
need a unit use it, and default to `"target"`.
|
||||
|
||||
Returns the matched value, plus the passing group's target token as a second
|
||||
value (nil when the group set no target). Returns nil when no clause matches.
|
||||
|
||||
```lua
|
||||
SecureCmdOptionParse("hello") -- "hello"
|
||||
SecureCmdOptionParse("[combat] a; b") -- "a" in combat, else "b"
|
||||
SecureCmdOptionParse("[@focus,harm] a; b") -- "a", "focus" when focus is hostile
|
||||
SecureCmdOptionParse("[nocombat] rest") -- "rest" out of combat, else nil
|
||||
```
|
||||
|
||||
**Supported conditions.** Each maps to real 1.12 state:
|
||||
|
||||
| Condition | Meaning |
|
||||
|---|---|
|
||||
| `combat` | the player is in combat |
|
||||
| `exists` / `dead` | the target exists / is dead or a ghost |
|
||||
| `help` / `harm` | the target is friendly / hostile |
|
||||
| `party` / `raid` | the target is in your party / raid |
|
||||
| `group` / `group:party` / `group:raid` | you are in a group / party or raid / raid |
|
||||
| `stance` / `stance:N` | you are shapeshifted / in bar form N (`form` is the same) |
|
||||
| `stealth` / `mounted` / `swimming` / `indoors` / `outdoors` | player state |
|
||||
| `mod` / `mod:shift` / `mod:ctrl` / `mod:alt` | a modifier key is held (`modifier` is the same) |
|
||||
| `button:N` | the current button is N (defaults to the left button; `btn` is the same) |
|
||||
| `bar:N` / `actionbar:N` | the current action bar page is N |
|
||||
| `bonusbar` / `bonusbar:N` | a bonus bar is active / bonus bar N is active |
|
||||
| `pet` / `pet:name` | you have a pet / a pet with that name or family |
|
||||
| `channeling` / `channeling:spell` | you are channeling / channeling that spell |
|
||||
| `equipped:type` | an item of that type, subtype, or slot is equipped (`worn` is the same) |
|
||||
| `cursor` | the cursor holds an item, a spell, or money |
|
||||
| `spec` / `spec:1` | always true (vanilla has one spec) |
|
||||
| `known:spellID` / `known:name` | you know that spell |
|
||||
|
||||
**Always false.** `flying`, `flyable`, `vehicleui`, and `unithasvehicleui`
|
||||
describe state that 1.12 does not have, so they never match.
|
||||
|
||||
**Unknown condition.** An unrecognized keyword never matches, and prints a
|
||||
warning once. Keywords are case-sensitive: `[Combat]` is unknown, `[combat]` is
|
||||
not.
|
||||
|
||||
The `[stance:N]` number is the shapeshift bar slot, not the form ID that
|
||||
[`GetShapeshiftFormID()`](#getshapeshiftformid) returns. On a druid, bar slot 1
|
||||
is Bear and slot 3 is Cat.
|
||||
|
||||
`known` is a ClassicAPI extension, not a Blizzard conditional. A numeric
|
||||
argument uses [`IsPlayerSpell`](#isplayerspellspellid), so it matches any known
|
||||
spell — a spellbook spell, a talent, a profession recipe, or a racial. A name
|
||||
argument matches only a spellbook spell, because there is no name-to-ID
|
||||
resolver. Use the spell ID for a talent or a recipe.
|
||||
|
||||
### `RegisterStateDriver` / `UnregisterStateDriver`
|
||||
|
||||
`RegisterStateDriver(frame, state, values)` drives a frame's state from a macro
|
||||
conditional string. The driver re-runs `SecureCmdOptionParse(values)` on a
|
||||
0.2 second poll and applies the result.
|
||||
|
||||
For the state `"visibility"`, the value `"show"` or `"hide"` shows or hides the
|
||||
frame:
|
||||
|
||||
```lua
|
||||
RegisterStateDriver(myFrame, "visibility", "[combat] hide; show")
|
||||
```
|
||||
|
||||
For any other state, the driver sets the attribute `"state-"..state` to the
|
||||
value. An `OnAttributeChanged` handler then reacts to it.
|
||||
|
||||
`UnregisterStateDriver(frame, state)` removes one driver.
|
||||
`UnregisterStateDriver(frame)` with no state removes every driver on the frame.
|
||||
|
||||
The driver also rescans at once on combat, target, focus, form, pet, group,
|
||||
action-bar, and modifier-key changes, so state that a poll would lag catches up
|
||||
without delay.
|
||||
|
||||
### `RegisterAttributeDriver` / `UnregisterAttributeDriver`
|
||||
|
||||
`RegisterAttributeDriver(frame, attribute, values)` is the same as the
|
||||
state-driver pair, but it sets `attribute` directly instead of adding the
|
||||
`"state-"` prefix. An attribute name that starts with `_` is ignored.
|
||||
|
||||
The driver coerces a numeric value to a number, and the literal string `"nil"`
|
||||
to nil, before it sets the attribute.
|
||||
|
||||
`UnregisterAttributeDriver(frame, attribute)` removes one driver.
|
||||
`UnregisterAttributeDriver(frame)` with no attribute removes every driver on the
|
||||
frame.
|
||||
|
||||
### `RegisterUnitWatch` / `UnregisterUnitWatch` / `UnitWatchRegistered`
|
||||
|
||||
`RegisterUnitWatch(frame [, asState])` shows or hides a frame as its `unit`
|
||||
attribute comes into and out of existence. Set the `unit` attribute first:
|
||||
|
||||
```lua
|
||||
myUnitFrame:SetAttribute("unit", "target")
|
||||
RegisterUnitWatch(myUnitFrame) -- shown only while you have a target
|
||||
```
|
||||
|
||||
With `asState` true, the watch sets the boolean attribute `"state-unitexists"`
|
||||
instead of calling Show/Hide, so an `OnAttributeChanged` handler drives the
|
||||
visibility.
|
||||
|
||||
The Show/Hide form also sets a `"statehidden"` attribute (true when hidden), so
|
||||
unit-frame code can tell a driver-hidden frame from one the user hid.
|
||||
|
||||
`UnregisterUnitWatch(frame)` stops the watch. The frame stays in its last shown
|
||||
or hidden state. `UnitWatchRegistered(frame)` returns whether a watch is active.
|
||||
|
||||
### `SecureButton_GetAttribute` / `SecureButton_GetUnit`
|
||||
|
||||
`SecureButton_GetAttribute(frame, name)` reads an attribute. When the frame opts
|
||||
in with a `"useparent-"..name` or `"useparent*"` attribute, it walks up to the
|
||||
parent frame to find the value.
|
||||
|
||||
`SecureButton_GetUnit(frame)` returns the frame's `unit` attribute, with an
|
||||
optional `unitsuffix` attribute appended. The unit-watch and click systems use
|
||||
it to find a frame's unit.
|
||||
|
||||
**No taint.** These are functional copies of the modern secure templates. 1.12
|
||||
has no combat lockdown or taint, so nothing here is protected — the names exist
|
||||
for addon compatibility.
|
||||
|
||||
## FriendList
|
||||
|
||||
### `C_FriendList.SendWhoQueryByName(name)`
|
||||
@@ -8754,6 +9058,29 @@ table.maxn({ a = 1 }) -- 0
|
||||
|
||||
Errors on non-table input.
|
||||
|
||||
### Stale table lengths (5.1 healing)
|
||||
|
||||
Lua 5.0 stores a table's `table.insert` / `table.getn` length **out of
|
||||
band**. Clearing a table's keys (`for k in pairs(t) do t[k] = nil end`)
|
||||
does not reset it — 5.0 code must also call `table.setn(t, 0)`. Lua 5.1
|
||||
removed `setn` entirely; lengths are computed from the table itself.
|
||||
|
||||
ClassicAPI gives you the 5.1 behavior — code written for Lua 5.1 (or
|
||||
that detects it) can skip `setn` and still get correct lengths.
|
||||
|
||||
When the stored length points at a nil slot
|
||||
and the table has no explicit `n` field, `table.getn` — and everything
|
||||
built on it: `table.insert`, `table.remove`, `table.concat`,
|
||||
`table.sort`, `table.foreachi`, `unpack` — returns the true border, the
|
||||
same answer Lua 5.1 gives. Two things deliberately keep their old
|
||||
behavior:
|
||||
|
||||
- A table with an explicit numeric `n` field (the vararg `arg` contract)
|
||||
keeps its stored count, so trailing nils survive
|
||||
(`unpack({10, nil, 30, n = 3})` still returns all three slots).
|
||||
- `table.setn` still works; the heal only changes the answer when the
|
||||
stored length points past the last populated slot.
|
||||
|
||||
### `Mixin(object, ...)` / `CreateFromMixins(...)`
|
||||
|
||||
The FrameXML table-mixin primitives, provided as engine C functions.
|
||||
@@ -14996,6 +15323,40 @@ Convenience wrappers locking the filter to `HELPFUL` or `HARMFUL`
|
||||
respectively. Equivalent to `GetAuraDataByIndex(unit, index,
|
||||
"HELPFUL")` / `"HARMFUL"`.
|
||||
|
||||
### `C_UnitAuras.UnitAura(unit, index [, filter])`
|
||||
|
||||
Returns the same aura as `GetAuraDataByIndex`, but as 15 positional values
|
||||
instead of a table — so it allocates nothing. Use it in an `OnUpdate` loop that
|
||||
scans many auras, where one table per aura strains Lua's garbage collector.
|
||||
Prefer [`AuraUtil.ForEachAura`](#aurautilforeachaura) for iteration.
|
||||
|
||||
The values, in order:
|
||||
|
||||
```
|
||||
name, icon, count, dispelType, duration, expirationTime, source, isStealable,
|
||||
nameplateShowPersonal, spellId, canApplyAura, isBossDebuff, castByPlayer,
|
||||
nameplateShowAll, timeMod
|
||||
```
|
||||
|
||||
Returns a single `nil` when the unit has fewer than `index` matching auras.
|
||||
`dispelType` is the dispel-type string (`"Magic"`, `"Curse"`, `"Disease"`,
|
||||
`"Poison"`, or `nil`). `source` is the caster's unit token, or `nil` when the cast
|
||||
was not seen this session. `castByPlayer` is true when the local player cast the
|
||||
aura. `filter` takes the same tokens as `GetAuraDataByIndex`.
|
||||
|
||||
The values are the `GetAuraDataByIndex` fields in the classic `UnitAura` order,
|
||||
with two field-name differences from the table: position 12 is `isBossDebuff`
|
||||
(the table field is `isBossAura`) and position 13 is `castByPlayer` (the table
|
||||
field is `isFromPlayerOrPlayerPet`). On 1.12 the boss field is always false;
|
||||
`castByPlayer` is reported truthfully.
|
||||
|
||||
### `C_UnitAuras.UnitBuff(unit, index [, filter])` / `UnitDebuff(unit, index [, filter])`
|
||||
|
||||
The `UnitAura` positional form with the range locked to `HELPFUL` or `HARMFUL`.
|
||||
The `filter` still honors the `PLAYER`, `DISPELLABLE`, and `CROWD_CONTROL`
|
||||
predicates. Namespaced under `C_UnitAuras` so they never clash with the native
|
||||
global `UnitBuff` / `UnitDebuff` (which keep their vanilla texture-first return).
|
||||
|
||||
### `C_UnitAuras.GetUnitAuraBySpellID(unit, spellID [, filter])`
|
||||
|
||||
Linear-searches `unit`'s aura array for the first populated slot
|
||||
|
||||
@@ -30,6 +30,21 @@ The DLL hooks the engine's own text pipeline — no companion addon:
|
||||
- **Rendering** — the paint-pass co-hook `FUN_005c8fe0` walks the icon records
|
||||
and queues each to `Text::InlineTexturePool`, which places the region on the
|
||||
next frame tick (never mid-render).
|
||||
- **Chat has no one-frame lag** — a ScrollingMessageFrame re-`SetText`s every
|
||||
visible line on each new message, and the engine builds each line lazily at
|
||||
paint, so a line's icons would land one frame after its glyphs (glyphs draw
|
||||
synchronously; the icon regions are separate objects whose placement is only
|
||||
known once the line builds mid-paint, after the frame's texture pass already
|
||||
drew). A co-hook on the SMF display refresh (`FUN_00788750`, which runs
|
||||
pre-render from the frame's own methods) forces each icon-bearing line to
|
||||
resolve+build and applies its icon placement synchronously — before the frame
|
||||
draws — so icons move in lockstep with their text. It reuses the engine's own
|
||||
`Realize(0)` → `RebuildString` → ensure-built path and the same placement math
|
||||
the paint flush uses (`ComputePlacementsForNode`), so the later flush dedups to
|
||||
a no-op. Additive and best-effort: any line whose rect isn't resolvable in time
|
||||
falls back to the paint + tick pipeline (correct, one-frame lagged). A pure
|
||||
scroll or drag never lagged — the icons are anchored to the line and the engine
|
||||
moves them for free; only content changes on a reused line needed this.
|
||||
- **Placement coordinates** — text pen space IS render-target pixels. The
|
||||
engine stores node positions NORMALIZED (fs-local anchor ÷
|
||||
`[0x832A44]`/`[0x832A48]` per axis, `FUN_0041ade0`), and the origin finalize
|
||||
@@ -85,9 +100,11 @@ Every fs-level measure path now counts icons: width (`FUN_00772890`), height
|
||||
refute that: the GameTooltip auto-size measures each WRAPPED SEGMENT of a
|
||||
wrap-enabled line through it, so icon-bearing wrapped tooltip lines
|
||||
undersized their tooltip until it was hooked too). The Lua control surface is
|
||||
one kill switch, `_classicapi_InlineTexEnable` (the SEH latch trips it on a
|
||||
flush fault); the bring-up tune / stat / probe functions were removed
|
||||
(`5e5677b`, `6800a9a`).
|
||||
two toggles: `_classicapi_InlineTexEnable` (the whole-feature kill switch; the
|
||||
SEH latch trips it on a flush fault) and `_classicapi_InlineTexEarly` (the
|
||||
SMF-refresh early apply above — on by default, toggle off to A/B the chat lag
|
||||
against the baseline paint+tick pipeline, which keeps running either way). The
|
||||
bring-up tune / stat / probe functions were removed (`5e5677b`, `6800a9a`).
|
||||
|
||||
## REMOVED (bring-up record) — the raw-GxU-quad rendering primitive
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@
|
||||
#include "player/NameCache.h"
|
||||
#include "text/InlineTexture.h"
|
||||
#include "text/InlineTexturePool.h"
|
||||
#include "texture/Mask.h"
|
||||
#include "texture/Transform.h"
|
||||
#include "tooltip/SetEvents.h"
|
||||
|
||||
static Game::FrameScript_Initialize_t FrameScript_Initialize_o = nullptr;
|
||||
static Game::LoadScriptFunctions_t LoadScriptFunctions_o = nullptr;
|
||||
@@ -62,10 +64,19 @@ static bool __fastcall FrameScript_Initialize_h() {
|
||||
// reload destroys every addon texture and the region pool reuses their pointers.
|
||||
Texture::Transform::PrepareForReload();
|
||||
|
||||
// Drop per-region texture masks for the same reason (SetMask).
|
||||
Texture::Mask::PrepareForReload();
|
||||
|
||||
// Drop pending character-dress jobs and their engine compositors — the
|
||||
// reload destroys every Model frame the jobs point at.
|
||||
Model::DisplayInfo::PrepareForReload();
|
||||
|
||||
// Drop the per-tooltip OnTooltipSet* handler cells — the reload destroys
|
||||
// and recreates every tooltip object, and the handler refs die with the
|
||||
// Lua reset (issue #33: the cell table filled after ~18 reloads, then
|
||||
// recycled tooltip addresses fired dead refs).
|
||||
Tooltip::SetEvents::PrepareForReload();
|
||||
|
||||
// Forget the captured `string` table before the world Lua state resets
|
||||
// (this hook also fires on /logout, where the state's tables die) —
|
||||
// string-method resolution fails closed (stock index error) until
|
||||
@@ -103,6 +114,7 @@ static void __stdcall LoadGlueScriptFunctions_h() {
|
||||
Text::InlineTexture::PrepareForReload();
|
||||
Text::InlineTexturePool::PrepareForReload();
|
||||
Texture::Transform::PrepareForReload();
|
||||
Texture::Mask::PrepareForReload();
|
||||
}
|
||||
|
||||
// Every Lua-side `frame:RegisterEvent(...)` is a chance to claim a slot
|
||||
|
||||
+183
@@ -419,6 +419,14 @@ enum Offsets {
|
||||
VAR_SIMPLETEXTURE_POOL = 0x00CF4CE0, // &DAT_00cf4ce0 CSimpleTexture free-list pool (`this`)
|
||||
VAR_SIMPLETEXTURE_CLASS_TAG = 0x00846588, // ".?AVCSimpleTexture@@" (alloc debug tag)
|
||||
FUN_SIMPLETEXTURE_CTOR = 0x0076FC40, // __thiscall(mem, parent, layer, sublayer) -> tex
|
||||
// --- MaskTexture object API (frame:CreateMaskTexture / Texture:AddMaskTexture) ---
|
||||
// CreateMaskTexture mints its mask region by calling the engine's own
|
||||
// Script_CreateTexture (reads (frame,name,layer,template) off the Lua stack and
|
||||
// pushes the new CSimpleTexture), then hides it — the mask is a source, never drawn.
|
||||
// The draw hook reads the mask's HTEXTURE (OFF_SIMPLETEXTURE_HTEXTURE, +0xCC, set
|
||||
// by SetTexture and valid while hidden) + its rect (FUN_REGION_GET_RECT); the
|
||||
// +0xC4 shown flag is OFF_REGION_DESIRED_SHOWN (defined below).
|
||||
FUN_SCRIPT_CREATE_TEXTURE = 0x00773A20, // Script frame:CreateTexture, __fastcall(L)->int
|
||||
// CSimpleTexture::SetTexture(path). Loads via FUN_00449D90, stores the owned
|
||||
// HTEXTURE at +0xCC (releases the old via DecRef FUN_0041AED0), marks dirty.
|
||||
// `__thiscall(tex, path, 0, *VAR_TEXTURE_BLEND_DEFAULT, 0) -> u32`. The
|
||||
@@ -482,6 +490,10 @@ enum Offsets {
|
||||
// rect (see FUN_00770410's SetTexCoord path); texture/Rotation.cpp reads it to
|
||||
// rebuild axis-aligned corners before rotating.
|
||||
FUN_REGION_GET_RECT = 0x00768320,
|
||||
// __thiscall(region+OFF_REGION_ANCHOR) -> int (nonzero = layout dirty, needs a
|
||||
// resolve). GetLeft gates its resolve on this. Paired with the flag-1 resolve
|
||||
// below to read a hidden region's rect synchronously.
|
||||
FUN_REGION_LAYOUT_DIRTY = 0x00768310,
|
||||
|
||||
// __thiscall(region+OFF_REGION_ANCHOR, int flag /*stack; pass 0*/) — force a
|
||||
// synchronous layout resolve: recomputes the region's rect (+0x64) from its
|
||||
@@ -557,6 +569,34 @@ enum Offsets {
|
||||
// stale-owner bugs (ghost icons, orphaned records) structurally impossible
|
||||
// instead of heuristically guarded.
|
||||
FUN_TEXT_NODE_FREE = 0x005CD950,
|
||||
// Ensure a text node is laid out: `__fastcall(node)` -> FUN_005cd3f0 + the
|
||||
// draw builder FUN_005CDC20 (runs the glyph emitter and finalizes the node
|
||||
// origin +0x70/+0x74), then clears node+0xc0. Same "ensure built" the paint
|
||||
// pass (FUN_005c8fe0) calls per node; safe to call directly (the builder
|
||||
// re-lays the node — a clean node just re-emits the same glyphs). Verified
|
||||
// callers: the paint pass and FUN_005cd4d0 (the width/resize helper).
|
||||
// Text::InlineTexture drives it PRE-RENDER from the SMF-refresh early-apply
|
||||
// so a chat line's icons are recorded before the frame draws.
|
||||
FUN_TEXT_ENSURE_BUILT = 0x005CD6A0,
|
||||
|
||||
// CScrollingMessageFrame display refresh — `__thiscall(smf, int newestIdx)`.
|
||||
// Rebuilds the visible line set bottom-up: per slot it SetTexts the line
|
||||
// fontstring (FUN_00788af0 -> SetText FUN_00771d80 + enqueue-resolve
|
||||
// FUN_007680e0 flag 1), (re)anchors newly-allocated lines, shows/hides by
|
||||
// message. Every new message shifts the newest index, so EVERY visible slot
|
||||
// is re-SetText'd -> its inline icons must be recomputed; the engine builds
|
||||
// the line lazily at paint, so icons land one frame after the glyphs (the
|
||||
// one-frame lag). Text::InlineTexture co-hooks this and, post-refresh,
|
||||
// forces each icon-bearing line to resolve+build and applies its icon
|
||||
// placement synchronously — pre-render (the refresh runs from the SMF's
|
||||
// Lua/event-driven methods, never mid-render), so icons draw with their
|
||||
// glyphs the same frame. Line-entry layout verified from the decompile:
|
||||
// count@+0x3A0, stride-0x10 entry array@+0x3A4, entry+8 = the line fs.
|
||||
FUN_SMF_DISPLAY_REFRESH = 0x00788750,
|
||||
OFF_SMF_LINE_COUNT = 0x3A0, // int: allocated visible-line count
|
||||
OFF_SMF_LINE_ARRAY = 0x3A4, // base of the stride-0x10 line-entry array
|
||||
SMF_LINE_STRIDE = 0x10, // per-line-entry stride
|
||||
OFF_SMF_LINE_FONTSTRING = 0x8, // entry+8 = the line's CSimpleFontString
|
||||
// THE pen↔anchor unit bridge. RebuildString (0x7724A0) multiplies every
|
||||
// text-unit quantity by region+0x7C when crossing into node creation:
|
||||
// nodePos = inset×s + rect corner, nodeFontH = fontPx×s, spacing = +0xF4×s,
|
||||
@@ -6860,6 +6900,31 @@ enum Offsets {
|
||||
// (case-insensitive, hashed). See FUN_006ee6f0 in Templates.cpp notes.
|
||||
FUN_XML_TEMPLATE_LOOKUP = 0x006EE6F0,
|
||||
|
||||
// `CreateFrame(type [, name, parent, template])` — the global FrameScript
|
||||
// function at table 0x00872E74 slot 3. Standard `int __fastcall(void *L)`
|
||||
// shape. Vanilla accepts only a SINGLE template name; the hook in
|
||||
// `Frame::CreateFrame` splits comma-separated inherits strings so modern
|
||||
// multi-template calls work (e.g. "UIPanelButtonTemplate,
|
||||
// SecureActionButtonTemplate"). It builds the frame with the first
|
||||
// resolved template through the engine, then applies each remaining
|
||||
// template onto the created object with the engine's OWN inherit
|
||||
// primitive: `frameObj->vtable[+8](templateDefNode, status)` for content
|
||||
// (FUN_00769820) and `(frameObj+0x24)->vtable[+8](...)` for the template's
|
||||
// `<Frames>` children (FUN_0076a060) — exactly the two `__thiscall`
|
||||
// recursion calls the builder FUN_006ee280 emits when resolving an
|
||||
// `inherits=`. Finalize/OnLoad (`vtable[+0x24]`) runs once during the
|
||||
// engine build and is NOT re-run.
|
||||
FUN_SCRIPT_CREATEFRAME = 0x007060B0,
|
||||
|
||||
// XML-build "status" object — a printf-style error/warning accumulator the
|
||||
// appliers log through (`status->vtable[+0xc](status, level, fmt, ...)`,
|
||||
// __cdecl varargs). Script_CreateFrame builds one on its stack as the
|
||||
// 5-dword `{PTR_TEXLOAD_DESC_VTBL, 8, &self+8, (&self+8)|1, 0}` (same shape
|
||||
// the texture loader reuses; see PTR_TEXLOAD_DESC_VTBL). Reset/emptied by
|
||||
// `FUN_FRAMESCRIPT_STATUS_RESET` (`__thiscall(this)`), which frees any
|
||||
// accumulated message nodes and re-stamps the vtable.
|
||||
FUN_FRAMESCRIPT_STATUS_RESET = 0x00419E30,
|
||||
|
||||
// --- Inline texture escape (`|Tpath:h:w:...|t`) backport -------------------
|
||||
// See src/text/InlineTexture.cpp and docs/InlineTextureEscapes.md. The
|
||||
// 1.12 text pipeline is a shared `|`-tokenizer feeding per-purpose loops.
|
||||
@@ -6881,6 +6946,124 @@ enum Offsets {
|
||||
// Script_Texture_SetTexture's own call site does.
|
||||
VAR_TEXTURE_BLEND_DEFAULT = 0x00878CF0,
|
||||
|
||||
// --- Texture masking (`Texture:SetMask` backport) ---------------------------
|
||||
// Mirrors the engine's own canonical masked-quad draw: the minimap BLIP mask
|
||||
// FUN_004eae10 (a sibling of the disc FUN_004ec440; both fed by the mask that
|
||||
// Minimap:SetMaskTexture at 0x004EE4A0 loads into DAT_00bc7968). FUN_004eae10
|
||||
// is the exact template — bind base on unit 0, mask on unit 1, force each
|
||||
// unit's combiner to MODULATE, submit with SEPARATE uv0/uv1, then unbind
|
||||
// unit 1:
|
||||
// GxRs(0x17, base); GxRs(0x1F, 1); // unit 0: bind + MODULATE preset
|
||||
// GxRs(0x18, mask); GxRs(0x20, 1); // unit 1: bind + MODULATE preset
|
||||
// FUN_0058a2a0(4, corners, …, uv0, uv1); // explicit second UV stream
|
||||
// GxRs(0x18, 0); // unbind unit 1
|
||||
// Unit 1's MODULATE combiner multiplies the mask's alpha into the base's
|
||||
// alpha, so a white-with-alpha mask clips the base to the mask's shape.
|
||||
//
|
||||
// The recipe was also pinned by an in-game probe (git history's
|
||||
// MaskProbe.cpp) on the live backend — findings, load-bearing:
|
||||
// * The COMBINER PRESET selectors are 0x1F+unit (GXRS_COMBINER0), value 1 =
|
||||
// MODULATE/MODULATE (the .rdata preset tables at 0x0080a25c/0x0080a274,
|
||||
// consumed by the D3D9 combiner apply FUN_005a2dd0: COLOROP table
|
||||
// {4,4,13,7,5,12}, ALPHAOP {3,4,3,7,5,3} — value 1 is the only preset
|
||||
// that MODULATEs the alpha channel too, which is what masking rides).
|
||||
// Binding a texture on unit 1 alone happens to give MODULATE by default,
|
||||
// but FUN_004eae10 sets it EXPLICITLY — we mirror that.
|
||||
// * The SECOND vertex UV stream (uv1) DOES feed unit 1 (FUN_004eae10
|
||||
// passes a distinct uv1 = this+0x50), so uv1 = uv0 makes unit 1 sample
|
||||
// the mask at the base's own texcoords — exactly SetMask semantics.
|
||||
// uv1 is the LAST explicit UV slot that exists: the vertex-format
|
||||
// component table at 0x008097A8 (13 formats × 13 components) has no
|
||||
// format with a third UV set, so masks past the first CANNOT ride an
|
||||
// explicit stream — they use texgen (below).
|
||||
GXRS_TEXTURE0 = 0x17, // texture bind, +unit 0..7 (via FUN_GX_RS_SET_PTR)
|
||||
GXRS_COMBINER0 = 0x1F, // combiner preset, +unit 0..7 (via FUN_GX_RS_SET)
|
||||
GXRS_COMBINE_MODULATE = 1, // preset value: COLOROP=MODULATE, ALPHAOP=MODULATE
|
||||
// GxRs setters — `__fastcall(selector, value)`. The int form (0x00589E60)
|
||||
// writes combiner presets and the like; the pointer form (0x00589E80) writes
|
||||
// texture binds.
|
||||
FUN_GX_RS_SET = 0x00589E60,
|
||||
FUN_GX_RS_SET_PTR = 0x00589E80,
|
||||
|
||||
// --- Texgen + per-unit texture matrices (multi-mask / rotated masks) -------
|
||||
// Decoded from the D3D9 backend's draw-time state flush FUN_005a2f00 (the
|
||||
// selector → SetTextureStageState/SetTransform Rosetta stone) and the
|
||||
// minimap DISC draw FUN_004ec440, which is the engine's own texgen-mask
|
||||
// template (it submits positions+colors ONLY and feeds BOTH units via
|
||||
// texgen + a per-unit texture matrix). Selector namespace is five per-unit
|
||||
// families, each 8 units wide (device supports 8 fixed-function stages;
|
||||
// live cap at [VAR_GX_DEVICE]+OFF_GXDEV_STAGE_CAP):
|
||||
// 0x17+u bind 0x1F+u combiner 0x27+u mip-bias 0x2F+u texgen
|
||||
// 0x37+u coord-transform mode
|
||||
// Texgen values (FUN_005a2b40): 0 = passthrough (stage reads vertex UV set
|
||||
// #u — the default; why stage 1 sees the explicit uv1 stream), 1 = OBJECT-
|
||||
// space position (D3D TCI_CAMERASPACEPOSITION + an auto inv(view)·inv(model)
|
||||
// basis matrix — UV becomes a pure function of the RAW vertex position,
|
||||
// immune to whatever model/view the pass set), 2 = world-space position,
|
||||
// 3 = camera-space position (identity basis; what the disc draw uses),
|
||||
// 5/4/6 = normal/reflection.
|
||||
// Coord-transform values (FUN_005a14e0): 0 = off, 1 = apply the unit's
|
||||
// texture matrix (basis × user matrix, D3DTTFF_COUNT3), 2 = projected.
|
||||
// Per-unit texture-matrix STACKS (4 levels, base ctx+0xFD0+unit*0x118;
|
||||
// slot 8 = model, 10 = view) — the disc draw's push/translate/scale/pop:
|
||||
GXRS_TEXGEN0 = 0x2F, // texgen mode, +unit (int form)
|
||||
GXRS_TEXGEN_OBJECT_POS = 1,
|
||||
GXRS_TEXXFORM0 = 0x37, // coord-transform mode, +unit (int form)
|
||||
GXRS_TEXXFORM_MATRIX = 1,
|
||||
// Texture-matrix stack ops — `__fastcall(int unit, ...)`. PUSH duplicates
|
||||
// the top (max depth 4); PUSH_LOAD pushes then loads an arbitrary 4×4
|
||||
// (row-vector D3D layout: out = pos·M, translation in row 3). POP restores.
|
||||
// (PUSH/POP were previously mislabeled FUN_GX_TEXUNIT_ENABLE/_DISABLE —
|
||||
// they never enabled anything; binding a texture is what enables a stage,
|
||||
// binding null disables it, per FUN_005a29d0.)
|
||||
FUN_GX_TEXMTX_PUSH = 0x0058B200, // (unit)
|
||||
FUN_GX_TEXMTX_PUSH_LOAD = 0x0058B210, // (unit, const float m[16])
|
||||
FUN_GX_TEXMTX_POP = 0x0058B220, // (unit)
|
||||
// Gx render-state journal push/pop — brackets a state scope; pop replays
|
||||
// every selector written since the push back to its prior value. The engine
|
||||
// wraps each region-layer draw (FUN_0076fb00) and the disc draw in a pair.
|
||||
FUN_GX_STATE_PUSH = 0x00589F40,
|
||||
FUN_GX_STATE_POP = 0x00589F50,
|
||||
// The indexed submit that actually queues the draw for the verts staged by
|
||||
// FUN_GX_PRIM_STREAMS — `__fastcall(int primType, int indexCount, const
|
||||
// void *indices)`. Every FUN_GX_PRIM_STREAMS caller invokes it immediately
|
||||
// after (region layer: FUN_0076fb00; disc: FUN_004ec440). State/matrix
|
||||
// RESTORES must run AFTER this call, not after the stream call — the disc
|
||||
// draw pops its matrices/state only past this point, and restoring earlier
|
||||
// reverts the state the queued draw will be flushed with.
|
||||
FUN_GX_PRIM_INDEXED = 0x0058A2E0,
|
||||
// The device/context singleton the Gx wrappers route through (set at
|
||||
// backend creation, FUN_00589ac0). +0x23C holds the live texture-stage cap
|
||||
// the per-stage applies clamp against (FUN_005a29d0/FUN_005a2b40).
|
||||
VAR_GX_DEVICE = 0x00C0ED38,
|
||||
OFF_GXDEV_STAGE_CAP = 0x23C,
|
||||
// The vertex-stream primitive every textured region draws through
|
||||
// (FUN_0076fb00 calls it per region). Decompiler signature (13 params):
|
||||
// (count, positions, posStride, s3, s3Stride, colors, colorStride, drop8,
|
||||
// drop9, uv0, uv0Stride, uv1, uv1Stride); RET 0x2C. The inner FUN_0058a3d0
|
||||
// builds an interleaved vertex from whichever of {s3, colors, uv0, uv1} are
|
||||
// non-null — so uv1 is a real, buildable second UV slot no engine caller
|
||||
// populates. `positions` for a region draw is the region's corner array
|
||||
// (region + OFF_SIMPLETEXTURE_CORNERS), which is how the mask co-hook
|
||||
// recovers the owning region: region = positions - OFF_SIMPLETEXTURE_CORNERS
|
||||
// (verified: FUN_00772fd0 appends the batch entry with positions =
|
||||
// region+0xD4, and FUN_0076fb00 forwards that pointer here).
|
||||
FUN_GX_PRIM_STREAMS = 0x0058A2A0,
|
||||
// Path → HTEXTURE loader trio (re-adds of the quad-era offsets removed in
|
||||
// 56c2670, with the same derivations — see docs/InlineTextureEscapes.md):
|
||||
// FUN_TEXTURE_LOAD_BY_PATH(path, &desc, flags, 0, 1) __fastcall, desc =
|
||||
// the 5-dword {PTR_TEXLOAD_DESC_VTBL, 8, &self8, (&self8)|1, 0} on-stack
|
||||
// node FUN_00770200 builds; flags via FUN_GX_TEXFLAGS_INIT(&flags, blend,
|
||||
// 0,0, 0,0,0, 1, 0) __thiscall. Never returns null (engine substitutes a
|
||||
// fallback texture). FUN_TEXTURE_GET_RENDERABLE(hTex, 1, 0) __fastcall
|
||||
// returns the bindable CGxTex at [hTex+0x140] and drives the streaming
|
||||
// load — calling it each frame IS the residency reference (what the
|
||||
// minimap's masked draw does for its mask at 0x004ec65c).
|
||||
FUN_TEXTURE_LOAD_BY_PATH = 0x00449D90,
|
||||
PTR_TEXLOAD_DESC_VTBL = 0x007FFA10,
|
||||
FUN_GX_TEXFLAGS_INIT = 0x0058A980,
|
||||
FUN_TEXTURE_GET_RENDERABLE = 0x0044ACF0,
|
||||
|
||||
// --- Inline-texture positioning (slice 1: measure/emit integration) --------
|
||||
// The 1.12 text pipeline builds a layout as a list of render "nodes" (one
|
||||
// per wrapped line); each node owns per-font-page glyph vertex batches and a
|
||||
|
||||
@@ -183,6 +183,31 @@ int PushAuraByIndex(void *L, const char *unitToken, int index,
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Positional (multi-return) sibling of `PushAuraByIndex` for the
|
||||
// `C_UnitAuras.UnitAura` family: pushes the Classic-Era `UnitAura` 15-tuple with
|
||||
// NO table allocation (the zero-GC path). Returns 15 on a hit, or 1 (a single
|
||||
// nil) on a miss. The bool-returning push helpers leave exactly the 15-tuple on a
|
||||
// hit and nothing on a miss, so the miss `PushNil` + `return 1` needs no cleanup.
|
||||
int PushUnitAuraPositional(void *L, const char *unitToken, int index,
|
||||
Data::Filter filter, Data::Match match = {}) {
|
||||
const uint8_t *unit = ResolveUnit(unitToken);
|
||||
if (unit != nullptr) {
|
||||
const int slot = Data::FindNthSlot(unit, index, filter, match);
|
||||
if (slot >= 0) {
|
||||
Data::Push(L, unit, slot, Data::Emit::Positional);
|
||||
return 15;
|
||||
}
|
||||
if (Data::PushNthCacheFallback(L, unit, index, filter, match,
|
||||
Data::Emit::Positional))
|
||||
return 15;
|
||||
} else if (Data::PushNthGroupAura(L, GuidForOutOfRange(unitToken), index,
|
||||
filter, match, Data::Emit::Positional)) {
|
||||
return 15;
|
||||
}
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetAuraDataByIndex(void *L) {
|
||||
const char *unit = ArgUnit(L, 1);
|
||||
const int index = ArgInt(L, 2);
|
||||
@@ -215,6 +240,50 @@ int __fastcall Script_GetDebuffDataByIndex(void *L) {
|
||||
return PushAuraByIndex(L, unit, index, Data::Filter::Harmful);
|
||||
}
|
||||
|
||||
// Zero-allocation positional accessors — Classic-Era `UnitAura`/`UnitBuff`/
|
||||
// `UnitDebuff` shape, namespaced under `C_UnitAuras` to avoid clashing with the
|
||||
// native `UnitBuff`/`UnitDebuff` globals (which keep their vanilla short return).
|
||||
// `UnitAura` reads the range from the filter (helpful by default); `UnitBuff` /
|
||||
// `UnitDebuff` lock the range and still honor the filter's PLAYER/DISPELLABLE/
|
||||
// CROWD_CONTROL predicates.
|
||||
int __fastcall Script_UnitAura(void *L) {
|
||||
const char *unit = ArgUnit(L, 1);
|
||||
const int index = ArgInt(L, 2);
|
||||
const char *filterStr = ArgOptString(L, 3);
|
||||
if (unit == nullptr || index < 1) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
const ParsedFilter pf = ParseFilters(filterStr);
|
||||
return PushUnitAuraPositional(L, unit, index, RangeFilter(pf), pf.ToMatch());
|
||||
}
|
||||
|
||||
int __fastcall Script_UnitBuff(void *L) {
|
||||
const char *unit = ArgUnit(L, 1);
|
||||
const int index = ArgInt(L, 2);
|
||||
const char *filterStr = ArgOptString(L, 3);
|
||||
if (unit == nullptr || index < 1) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
const ParsedFilter pf = ParseFilters(filterStr);
|
||||
return PushUnitAuraPositional(L, unit, index, Data::Filter::Helpful,
|
||||
pf.ToMatch());
|
||||
}
|
||||
|
||||
int __fastcall Script_UnitDebuff(void *L) {
|
||||
const char *unit = ArgUnit(L, 1);
|
||||
const int index = ArgInt(L, 2);
|
||||
const char *filterStr = ArgOptString(L, 3);
|
||||
if (unit == nullptr || index < 1) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
const ParsedFilter pf = ParseFilters(filterStr);
|
||||
return PushUnitAuraPositional(L, unit, index, Data::Filter::Harmful,
|
||||
pf.ToMatch());
|
||||
}
|
||||
|
||||
int __fastcall Script_GetUnitAuraBySpellID(void *L) {
|
||||
const char *unitToken = ArgUnit(L, 1);
|
||||
const int spellID = ArgInt(L, 2);
|
||||
@@ -426,6 +495,12 @@ static void RegisterLuaFunctions() {
|
||||
&Script_GetBuffDataByIndex);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetDebuffDataByIndex",
|
||||
&Script_GetDebuffDataByIndex);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "UnitAura",
|
||||
&Script_UnitAura);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "UnitBuff",
|
||||
&Script_UnitBuff);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "UnitDebuff",
|
||||
&Script_UnitDebuff);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetUnitAuraBySpellID",
|
||||
&Script_GetUnitAuraBySpellID);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetPlayerAuraBySpellID",
|
||||
|
||||
+94
-25
@@ -449,23 +449,44 @@ const char *DispelName(uint32_t dispelTypeID) {
|
||||
return (name == nullptr || *name == '\0') ? nullptr : name;
|
||||
}
|
||||
|
||||
// The display fields resolved from a spell record + caster, shared by the table
|
||||
// emitter (`BuildTable`) and the positional emitter (`PushPositional`) so the
|
||||
// name / icon / dispel-name lookups live in exactly one place. `castByPlayer` is
|
||||
// the positional tuple's field #13; the table uses `isFromPlayerOrPlayerPet`
|
||||
// instead, mirroring retail's own divergence between the two aura shapes.
|
||||
struct Display {
|
||||
const char *name;
|
||||
const char *icon;
|
||||
const char *dispelName;
|
||||
bool castByPlayer;
|
||||
};
|
||||
|
||||
static Display ResolveDisplay(uint32_t spellID, uint64_t casterGuid) {
|
||||
const uint8_t *spellRecord = SpellRecord(spellID);
|
||||
uint32_t dispelTypeID = 0;
|
||||
if (spellRecord != nullptr) {
|
||||
dispelTypeID = *reinterpret_cast<const uint32_t *>(
|
||||
spellRecord + Offsets::OFF_SPELL_DISPEL_TYPE);
|
||||
}
|
||||
Display d;
|
||||
d.name = LocalizedSpellName(spellRecord);
|
||||
d.icon = SpellIconPath(spellRecord);
|
||||
d.dispelName = DispelName(dispelTypeID);
|
||||
d.castByPlayer =
|
||||
casterGuid != 0 && casterGuid == Unit::Identity::PlayerGuid();
|
||||
return d;
|
||||
}
|
||||
|
||||
// Writes the full modern AuraData table on top of the Lua stack from
|
||||
// already-resolved values. Shared by the descriptor path (`Push`) and the
|
||||
// cache-fallback path (`PushFromCache`). Net stack effect: +1 (the table).
|
||||
static void BuildTable(void *L, uint32_t spellID, int applications,
|
||||
bool isHelpful, double duration, double expirationTime,
|
||||
uint64_t casterGuid) {
|
||||
const uint8_t *spellRecord = SpellRecord(spellID);
|
||||
|
||||
uint32_t dispelTypeID = 0;
|
||||
if (spellRecord != nullptr) {
|
||||
dispelTypeID = *reinterpret_cast<const uint32_t *>(
|
||||
spellRecord + Offsets::OFF_SPELL_DISPEL_TYPE);
|
||||
}
|
||||
|
||||
const char *name = LocalizedSpellName(spellRecord);
|
||||
const char *icon = SpellIconPath(spellRecord);
|
||||
const char *dispel = DispelName(dispelTypeID);
|
||||
const Display disp = ResolveDisplay(spellID, casterGuid);
|
||||
const char *name = disp.name;
|
||||
const char *icon = disp.icon;
|
||||
const char *dispel = disp.dispelName;
|
||||
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
@@ -518,6 +539,45 @@ static void BuildTable(void *L, uint32_t spellID, int applications,
|
||||
// yields nil, so the table doesn't need an explicit entry.
|
||||
}
|
||||
|
||||
// Emits the Classic-Era `UnitAura` 15-value tuple onto the Lua stack from the
|
||||
// same already-resolved values `BuildTable` takes. Net stack effect: +15 — NO
|
||||
// table allocation, the zero-GC path behind `C_UnitAuras.UnitAura`. Field order
|
||||
// matches Classic-Era `UnitAura`: name, icon, count, dispelType(string),
|
||||
// duration, expirationTime, source, isStealable, nameplateShowPersonal, spellId,
|
||||
// canApplyAura, isBossDebuff, castByPlayer, nameplateShowAll, timeMod. Values
|
||||
// mirror the AuraData table exactly so packed / non-packed callers agree: name /
|
||||
// icon / dispelType push `""` when absent (as `SetFieldString` does), while
|
||||
// `source` pushes nil when there's no caster (as the table omits `sourceUnit`).
|
||||
// `isHelpful` is unused (the tuple has no isHelpful/isHarmful) — kept so the leaf
|
||||
// dispatch calls either emitter with a uniform arg list.
|
||||
static void PushPositional(void *L, uint32_t spellID, int applications,
|
||||
bool /*isHelpful*/, double duration,
|
||||
double expirationTime, uint64_t casterGuid) {
|
||||
const Display disp = ResolveDisplay(spellID, casterGuid);
|
||||
|
||||
const char *source = nullptr;
|
||||
char tokenBuf[32];
|
||||
if (casterGuid != 0)
|
||||
source = Unit::Identity::TokenFromGUID(casterGuid, tokenBuf,
|
||||
sizeof tokenBuf);
|
||||
|
||||
Game::Lua::PushString(L, disp.name ? disp.name : ""); // 1 name
|
||||
Game::Lua::PushString(L, disp.icon ? disp.icon : ""); // 2 icon
|
||||
Game::Lua::PushNumber(L, static_cast<double>(applications)); // 3 count
|
||||
Game::Lua::PushString(L, disp.dispelName ? disp.dispelName : ""); // 4 dispelType
|
||||
Game::Lua::PushNumber(L, duration); // 5 duration
|
||||
Game::Lua::PushNumber(L, expirationTime); // 6 expirationTime
|
||||
Game::Lua::PushString(L, source); // 7 source (nil if none)
|
||||
Game::Lua::PushBool(L, false); // 8 isStealable
|
||||
Game::Lua::PushBool(L, false); // 9 nameplateShowPersonal
|
||||
Game::Lua::PushNumber(L, static_cast<double>(spellID)); // 10 spellId
|
||||
Game::Lua::PushBool(L, false); // 11 canApplyAura
|
||||
Game::Lua::PushBool(L, false); // 12 isBossDebuff
|
||||
Game::Lua::PushBool(L, disp.castByPlayer); // 13 castByPlayer
|
||||
Game::Lua::PushBool(L, false); // 14 nameplateShowAll
|
||||
Game::Lua::PushNumber(L, 1); // 15 timeMod
|
||||
}
|
||||
|
||||
// Enriches a bare `(guid, spellID)` into a full AuraData table — the shared
|
||||
// core behind every push path (descriptor, cache fallback, group-array). No
|
||||
// dependence on a live descriptor: the descriptor path passes what it read
|
||||
@@ -546,7 +606,8 @@ static void BuildTable(void *L, uint32_t spellID, int applications,
|
||||
static void PushEnriched(void *L, uint64_t guid, uint32_t spellID,
|
||||
bool isHelpful, int applications, int unitLevel,
|
||||
bool isPlayer, int slot,
|
||||
const Attribution *known = nullptr) {
|
||||
const Attribution *known = nullptr,
|
||||
Emit emit = Emit::Table) {
|
||||
double duration = 0.0;
|
||||
double expirationTime = 0.0;
|
||||
uint64_t casterGuid = 0;
|
||||
@@ -578,8 +639,12 @@ static void PushEnriched(void *L, uint64_t guid, uint32_t spellID,
|
||||
duration = static_cast<double>(a.durationMs) * 0.001;
|
||||
casterGuid = a.caster;
|
||||
}
|
||||
BuildTable(L, spellID, applications, isHelpful, duration, expirationTime,
|
||||
casterGuid);
|
||||
if (emit == Emit::Positional)
|
||||
PushPositional(L, spellID, applications, isHelpful, duration,
|
||||
expirationTime, casterGuid);
|
||||
else
|
||||
BuildTable(L, spellID, applications, isHelpful, duration, expirationTime,
|
||||
casterGuid);
|
||||
}
|
||||
|
||||
// Attribution held directly by an `Aura::Source` cache entry (from `Enumerate`),
|
||||
@@ -588,12 +653,13 @@ static Attribution CachedAttribution(const Aura::Source::CachedAura &c) {
|
||||
return {c.casterGuid, c.expirationMs, c.durationMs};
|
||||
}
|
||||
|
||||
void Push(void *L, const uint8_t *unit, int slot) {
|
||||
void Push(void *L, const uint8_t *unit, int slot, Emit emit) {
|
||||
const uint32_t spellID = ReadSpellID(unit, slot);
|
||||
const bool isHelpful = slot < Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
|
||||
PushEnriched(L, UnitGuid(unit), spellID, isHelpful, ReadStacks(unit, slot),
|
||||
unitLevel, unit != nullptr && unit == LocalPlayer(), slot);
|
||||
unitLevel, unit != nullptr && unit == LocalPlayer(), slot,
|
||||
/*known*/ nullptr, emit);
|
||||
}
|
||||
|
||||
namespace {
|
||||
@@ -603,7 +669,8 @@ namespace {
|
||||
// in SMSG_SPELL_GO, so `applications` defaults to 1. `duration` uses the
|
||||
// applied (caster-modified) ms when known, else the Spell.dbc base.
|
||||
void PushFromCache(void *L, const uint8_t *unit,
|
||||
const Aura::Source::CachedAura &c, bool isHelpful) {
|
||||
const Aura::Source::CachedAura &c, bool isHelpful,
|
||||
Emit emit = Emit::Table) {
|
||||
// `c` already carries this exact instance's caster + timing (Enumerate
|
||||
// copied it out), so hand it to `PushEnriched` as `known` rather than
|
||||
// re-resolving by (guid, spell, slot) — an unbound entry sharing its spell
|
||||
@@ -612,7 +679,7 @@ void PushFromCache(void *L, const uint8_t *unit,
|
||||
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
|
||||
const Attribution a = CachedAttribution(c);
|
||||
PushEnriched(L, UnitGuid(unit), c.spellId, isHelpful, 1, unitLevel, false,
|
||||
c.slot, &a);
|
||||
c.slot, &a, emit);
|
||||
}
|
||||
|
||||
// Reconciles the `Aura::Source` cache against `unit`'s descriptor: when the
|
||||
@@ -698,7 +765,7 @@ bool FallbackEligible(const uint8_t *unit, const Aura::Source::CachedAura &c,
|
||||
} // namespace
|
||||
|
||||
bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
|
||||
Filter filter, Match match) {
|
||||
Filter filter, Match match, Emit emit) {
|
||||
if (unit == nullptr)
|
||||
return false;
|
||||
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
|
||||
@@ -721,7 +788,7 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
|
||||
if (!FallbackEligible(unit, buf[i], filter, match))
|
||||
continue;
|
||||
if (++seen == target) {
|
||||
PushFromCache(L, unit, buf[i], filter == Filter::Helpful);
|
||||
PushFromCache(L, unit, buf[i], filter == Filter::Helpful, emit);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -828,7 +895,8 @@ bool GroupFallbackEligible(const uint16_t *arr, const Aura::Source::CachedAura &
|
||||
// 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, Match match) {
|
||||
int oneBasedIndex, Filter filter, Match match,
|
||||
Emit emit = Emit::Table) {
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
const int n =
|
||||
Aura::Source::Enumerate(guid, filter == Filter::Harmful, buf, kFallbackMax);
|
||||
@@ -839,7 +907,7 @@ bool PushNthGroupCacheFallback(void *L, uint64_t guid, const uint16_t *arr,
|
||||
if (++seen == oneBasedIndex) {
|
||||
const Attribution a = CachedAttribution(buf[i]);
|
||||
PushEnriched(L, guid, buf[i].spellId, filter == Filter::Helpful, 1,
|
||||
GroupMemberLevel(guid), false, buf[i].slot, &a);
|
||||
GroupMemberLevel(guid), false, buf[i].slot, &a, emit);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -893,7 +961,7 @@ bool PushGroupCacheFallbackMatch(void *L, uint64_t guid, const uint16_t *arr,
|
||||
} // namespace
|
||||
|
||||
bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
|
||||
Match match) {
|
||||
Match match, Emit emit) {
|
||||
if (guid == 0 || oneBasedIndex < 1)
|
||||
return false;
|
||||
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
|
||||
@@ -909,14 +977,15 @@ bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
|
||||
if (++matches == oneBasedIndex) {
|
||||
PushEnriched(L, guid, GroupSpellID(arr, slot),
|
||||
filter == Filter::Helpful, 1, GroupMemberLevel(guid),
|
||||
false, Aura::Source::SLOT_UNBOUND);
|
||||
false, Aura::Source::SLOT_UNBOUND, /*known*/ nullptr,
|
||||
emit);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Empty array (server delta-resend gap): supplement from the cache.
|
||||
if (!GroupArrayHasVisibleAura(arr))
|
||||
return PushNthGroupCacheFallback(L, guid, arr, oneBasedIndex, filter,
|
||||
match);
|
||||
match, emit);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
+19
-7
@@ -33,6 +33,14 @@ namespace Aura::Data {
|
||||
// to helpful when no filter is specified.
|
||||
enum class Filter { Helpful, Harmful };
|
||||
|
||||
// How a resolved aura is emitted onto the Lua stack by the single-aura push
|
||||
// paths. `Table` builds the modern `AuraData` table (net stack +1); `Positional`
|
||||
// pushes the Classic-Era `UnitAura` 15-value tuple (net stack +15) with NO table
|
||||
// allocation, for the `C_UnitAuras.UnitAura/UnitBuff/UnitDebuff` accessors that
|
||||
// avoid the per-call GC churn. Selection + timing resolution are identical for
|
||||
// both — only the terminal leaf differs.
|
||||
enum class Emit { Table, Positional };
|
||||
|
||||
// Caster restriction from the `PLAYER` / `!PLAYER` aura filter tokens.
|
||||
// `Any` = no restriction; `PlayerOnly` = only auras the local player cast
|
||||
// (`PLAYER`); `NotPlayer` = only auras NOT cast by the local player
|
||||
@@ -154,8 +162,9 @@ const char *DispelName(uint32_t dispelTypeID);
|
||||
// nameplateShowPersonal=false, canApplyAura=false,
|
||||
// shouldConsolidate=false, isRaid=false
|
||||
//
|
||||
// Net stack effect: +1 (the table).
|
||||
void Push(void *L, const uint8_t *unit, int slot);
|
||||
// Net stack effect: +1 for `Emit::Table` (the table), +15 for `Emit::Positional`
|
||||
// (the Classic-Era `UnitAura` tuple — see `Emit`).
|
||||
void Push(void *L, const uint8_t *unit, int slot, Emit emit = Emit::Table);
|
||||
|
||||
// Fallback for the index path when the descriptor has dropped an aura's slot
|
||||
// (rogue stealth, party range fluctuation) but it's still active server-side.
|
||||
@@ -165,9 +174,11 @@ void Push(void *L, const uint8_t *unit, int slot);
|
||||
// order). On a hit it pushes the AuraData table and returns true; on a miss it
|
||||
// pushes nothing and returns false (caller pushes nil). Only entries whose
|
||||
// helpful/harmful classification matches `filter` and that aren't already in a
|
||||
// populated descriptor slot are considered.
|
||||
// populated descriptor slot are considered. `emit` selects the table vs the
|
||||
// positional tuple leaf (see `Emit`); the pushed count follows `Push`.
|
||||
bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
|
||||
Filter filter, Match match = {});
|
||||
Filter filter, Match match = {},
|
||||
Emit emit = Emit::Table);
|
||||
|
||||
// Appends every eligible `Aura::Source` cache fallback for `unit` matching
|
||||
// `filter` into the array table at `outerIdx`, continuing from `nextKey`
|
||||
@@ -196,10 +207,11 @@ void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
|
||||
// to dedup against.
|
||||
|
||||
// Pushes AuraData for the `oneBasedIndex`-th group-array aura on `guid`
|
||||
// matching `filter`. On a hit pushes the table and returns true; otherwise
|
||||
// pushes nothing and returns false (caller pushes nil).
|
||||
// matching `filter`. On a hit pushes the aura and returns true; otherwise
|
||||
// pushes nothing and returns false (caller pushes nil). `emit` selects the
|
||||
// table vs the positional tuple leaf (see `Emit`).
|
||||
bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
|
||||
Match match = {});
|
||||
Match match = {}, Emit emit = Emit::Table);
|
||||
|
||||
// Pushes AuraData for the group-array aura with `spellID` on `guid`, optionally
|
||||
// restricted to one filter range (nullptr = both, helpful first). Returns true
|
||||
|
||||
+130
-12
@@ -69,7 +69,14 @@
|
||||
// `macrotext`/`macro` attribute — prefers an addon RunMacro, else runs natively
|
||||
// via the stock ChatEdit_ParseText), `stopcasting`, `menu`/`togglemenu` (pops
|
||||
// the standard unit dropdown at the cursor via the addon's
|
||||
// ClassicAPI_ToggleUnitMenu). Actions call ordinary Lua globals where the entry
|
||||
// ClassicAPI_ToggleUnitMenu, or cancels spell-targeting when a spell is on the
|
||||
// cursor), `action` (UseAction of the `action` slot), `pet` (CastPetAction of
|
||||
// the `action` index), `click` (forwards to the frame in the `clickbutton`
|
||||
// attribute via delegate:Click), and any other verb as a custom action — a
|
||||
// function stored as a raw field on the frame named by the verb, called as
|
||||
// func(self, unit, button). This is the complete secure-button verb set, so the
|
||||
// !!!ClassicAPI addon ships no parallel Lua dispatcher. Actions call ordinary
|
||||
// Lua globals where the entry
|
||||
// is the engine's (TargetUnit, IsAltKeyDown, …); where we own a C++ module the
|
||||
// dispatch goes straight to it, no Lua round-trip (Spell::AtUnit for `spell`,
|
||||
// Unit::Focus for `focus`). All run under the engine's protected OnClick.
|
||||
@@ -211,7 +218,12 @@ bool CopyAttr(void *L, int frameIdx, const char *lname, char *buf, size_t n) {
|
||||
const int si = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushString(L, lname);
|
||||
Game::Lua::RawGet(L, si); // [.., sub, val]
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_STRING) {
|
||||
const int t = Game::Lua::Type(L, -1);
|
||||
if (t == Game::Lua::TYPE_STRING || t == Game::Lua::TYPE_NUMBER) {
|
||||
// lua_tostring coerces a number to its digits in place — on our
|
||||
// temporary stack copy, so the stored attribute is untouched. This
|
||||
// lets a numeric attribute value (e.g. SetAttribute("spell", 12345)
|
||||
// or a numeric itemID) read as a string.
|
||||
const char *s = Game::Lua::ToString(L, -1);
|
||||
if (s) {
|
||||
BoundedCopy(buf, s, n);
|
||||
@@ -514,10 +526,79 @@ bool CallGlobalNum2(void *L, const char *name, double a, double b) {
|
||||
return called;
|
||||
}
|
||||
|
||||
// _G[name](a) — one number arg, no results. Returns true iff called.
|
||||
bool CallGlobalNum1(void *L, const char *name, double a) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
const bool called = Game::Lua::PushGlobalFunction(L, name);
|
||||
if (called) {
|
||||
Game::Lua::PushNumber(L, a);
|
||||
Game::Lua::Call(L, 1, 0);
|
||||
}
|
||||
Game::Lua::SetTop(L, top);
|
||||
return called;
|
||||
}
|
||||
|
||||
// `click` verb: the `clickbutton` attribute holds a FRAME (not a string).
|
||||
// Resolve it with the same modified-key precedence as ReadModAttr, then forward
|
||||
// the click via delegate:Click(button). Returns true iff a delegate was called.
|
||||
bool ClickDelegate(void *L, int fi, const char *prefix, const char *suffix,
|
||||
const char *button) {
|
||||
char key[128];
|
||||
Compose3Lower(key, sizeof key, prefix, "clickbutton", suffix);
|
||||
bool got = TryPushValue(L, fi, key);
|
||||
if (!got) {
|
||||
Compose3Lower(key, sizeof key, "", "clickbutton", suffix);
|
||||
got = TryPushValue(L, fi, key);
|
||||
}
|
||||
if (!got) {
|
||||
Compose3Lower(key, sizeof key, "", "clickbutton", "");
|
||||
got = TryPushValue(L, fi, key);
|
||||
}
|
||||
if (!got) return false; // no delegate configured
|
||||
|
||||
const int di = Game::Lua::GetTop(L); // the delegate value
|
||||
bool called = false;
|
||||
if (Game::Lua::Type(L, di) == Game::Lua::TYPE_TABLE) {
|
||||
Game::Lua::PushString(L, "Click");
|
||||
Game::Lua::GetTable(L, di); // delegate.Click
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_FUNCTION) {
|
||||
Game::Lua::PushValue(L, di); // self = delegate
|
||||
Game::Lua::PushString(L, button); // button name
|
||||
Game::Lua::Call(L, 2, 0);
|
||||
called = true;
|
||||
}
|
||||
}
|
||||
Game::Lua::SetTop(L, di - 1); // drop the delegate (+leftovers)
|
||||
return called;
|
||||
}
|
||||
|
||||
// Custom verb: a function stored as a raw field on the frame's own table named
|
||||
// by the verb (retail's escape hatch), invoked as func(self, unit, button).
|
||||
// Returns true iff such a function existed and was called.
|
||||
bool CallCustomAction(void *L, int fi, const char *verb, const char *unit,
|
||||
const char *button) {
|
||||
if (Game::Lua::Type(L, fi) != Game::Lua::TYPE_TABLE) return false;
|
||||
Game::Lua::PushString(L, verb);
|
||||
Game::Lua::RawGet(L, fi); // frame[verb]
|
||||
bool called = false;
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_FUNCTION) {
|
||||
Game::Lua::PushValue(L, fi); // self
|
||||
Game::Lua::PushString(L, unit); // unit (nil if null)
|
||||
Game::Lua::PushString(L, button); // button name
|
||||
Game::Lua::Call(L, 3, 0);
|
||||
called = true;
|
||||
} else {
|
||||
Game::Lua::SetTop(L, Game::Lua::GetTop(L) - 1);
|
||||
}
|
||||
return called;
|
||||
}
|
||||
|
||||
// Performs the resolved `verb` on `unit` (a token attribute value, may be null).
|
||||
// Returns true if it owned the click (so the chained handler is skipped).
|
||||
// `button` is the raw click-button name ("LeftButton", …), needed by the
|
||||
// `click` and custom verbs. Returns true if it owned the click (so the chained
|
||||
// handler is skipped).
|
||||
bool DispatchVerb(void *L, int fi, const char *prefix, const char *suffix,
|
||||
const char *verb, const char *unit) {
|
||||
const char *verb, const char *unit, const char *button) {
|
||||
if (Ascii::EqualCI(verb, "target")) {
|
||||
if (!unit) return false;
|
||||
// `unit="none"` clears the target (retail's SecureActionButton behavior).
|
||||
@@ -549,14 +630,28 @@ bool DispatchVerb(void *L, int fi, const char *prefix, const char *suffix,
|
||||
return true;
|
||||
}
|
||||
if (Ascii::EqualCI(verb, "spell")) {
|
||||
// A numeric spell ID (modern addons do SetAttribute("spell", 12345),
|
||||
// stored as a Lua number; a numeric string is accepted too). Cast the
|
||||
// EXACT rank by ID through the spellbook-slot resolver — at the unit, or
|
||||
// with no unit on the current target (targetGuid 0). NOT name
|
||||
// resolution, which casts the highest known rank regardless of the rank
|
||||
// the caller asked for.
|
||||
int spellID;
|
||||
if (ReadModAttrInt(L, fi, prefix, "spell", suffix, &spellID)) {
|
||||
if (unit)
|
||||
Spell::AtUnit::CastByID(spellID, unit);
|
||||
else
|
||||
Spell::AtCursor::DispatchSpellCast(spellID);
|
||||
return true;
|
||||
}
|
||||
// Otherwise a spell name. With a `unit`, cast straight at it (GUID →
|
||||
// dispatcher, no target juggling; a ground-target spell lands at its
|
||||
// feet). With no `unit`, fall back to the plain global cast on the
|
||||
// current target — a `type="spell"` button that sets no `unit`
|
||||
// attribute (issue #18) works like `/cast <spell>`.
|
||||
char spell[128];
|
||||
if (!ReadModAttr(L, fi, prefix, "spell", suffix, spell, sizeof spell))
|
||||
return false;
|
||||
// With a `unit`, cast straight at it (GUID → dispatcher, no target
|
||||
// juggling; a ground-target spell lands at its feet). With no `unit`,
|
||||
// fall back to the plain global cast on the current target — a
|
||||
// `type="spell"` button that sets no `unit` attribute (issue #18) now
|
||||
// works like `/cast <spell>` instead of doing nothing.
|
||||
if (unit)
|
||||
Spell::AtUnit::CastByName(spell, unit);
|
||||
else
|
||||
@@ -638,15 +733,38 @@ bool DispatchVerb(void *L, int fi, const char *prefix, const char *suffix,
|
||||
Game::Lua::CallGlobal(L, "SpellStopCasting");
|
||||
return true;
|
||||
}
|
||||
if (Ascii::EqualCI(verb, "action")) {
|
||||
int slot;
|
||||
if (!ReadModAttrInt(L, fi, prefix, "action", suffix, &slot)) return false;
|
||||
CallGlobalNum1(L, "UseAction", slot);
|
||||
return true;
|
||||
}
|
||||
if (Ascii::EqualCI(verb, "pet")) {
|
||||
int index;
|
||||
if (!ReadModAttrInt(L, fi, prefix, "action", suffix, &index)) return false;
|
||||
CallGlobalNum1(L, "CastPetAction", index);
|
||||
return true;
|
||||
}
|
||||
if (Ascii::EqualCI(verb, "click")) {
|
||||
return ClickDelegate(L, fi, prefix, suffix, button);
|
||||
}
|
||||
if (Ascii::EqualCI(verb, "menu") || Ascii::EqualCI(verb, "togglemenu")) {
|
||||
// While a spell is on the cursor, cancel targeting instead of opening
|
||||
// the menu (retail's SecureUnitButton behavior).
|
||||
if (CallBoolGlobal(L, "SpellIsTargeting")) {
|
||||
Game::Lua::CallGlobal(L, "SpellStopTargeting");
|
||||
return true;
|
||||
}
|
||||
if (!unit) return false;
|
||||
// The unit dropdown is pure FrameXML work (UnitPopup + ToggleDropDown),
|
||||
// so it lives in the !!!ClassicAPI addon; we just pop it at the cursor.
|
||||
Game::Lua::CallGlobalString(L, "ClassicAPI_ToggleUnitMenu", unit);
|
||||
return true;
|
||||
}
|
||||
// Unknown verb → not handled here; the chained handler runs.
|
||||
return false;
|
||||
// Unknown verb → a custom action: a function stored as a raw field on the
|
||||
// frame named by the verb (retail's escape hatch). If none exists it isn't
|
||||
// handled here and the frame's own chained OnClick runs.
|
||||
return CallCustomAction(L, fi, verb, unit, button);
|
||||
}
|
||||
|
||||
// The chained OnClick handler. Upvalues: 1 = previous handler (or nil), 2 = the
|
||||
@@ -681,7 +799,7 @@ int __fastcall OnClick_c(void *L) {
|
||||
const bool haveUnit =
|
||||
ReadModAttr(L, fi, prefix, "unit", suffix, unit, sizeof unit);
|
||||
handled = DispatchVerb(L, fi, prefix, suffix, verb,
|
||||
haveUnit ? unit : nullptr);
|
||||
haveUnit ? unit : nullptr, btn);
|
||||
}
|
||||
}
|
||||
Game::Lua::SetTop(L, top);
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
// 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/>.
|
||||
|
||||
// Multi-template `CreateFrame` support.
|
||||
//
|
||||
// Modern WoW (2.0+) allows a comma-separated template list in CreateFrame's
|
||||
// 4th argument:
|
||||
//
|
||||
// CreateFrame("Button", nil, parent,
|
||||
// "UIPanelButtonTemplate, SecureActionButtonTemplate")
|
||||
//
|
||||
// Vanilla 1.12's `Script_CreateFrame` (0x007060B0) resolves the 4th arg as a
|
||||
// SINGLE template name — the whole comma string is one lookup, which fails.
|
||||
// 1.12 has no native multi-inheritance: the frame builder `FUN_006ee280`
|
||||
// applies exactly one `inherits` name.
|
||||
//
|
||||
// This hook adds real multi-inheritance by mirroring the engine's OWN inherit
|
||||
// mechanism. It:
|
||||
// 1. Splits the 4th arg on commas and trims each entry.
|
||||
// 2. Resolves each name in the XML template registry, keeping the resolvable
|
||||
// ones in list order (typos / not-yet-loaded optional deps are skipped).
|
||||
// 3. Builds the frame through the original `Script_CreateFrame` using the
|
||||
// FIRST resolved template — the engine does the object creation, that
|
||||
// template's full inherit chain, ref/name setup, and OnLoad.
|
||||
// 4. Grafts each REMAINING resolved template onto the created frame with the
|
||||
// two `__thiscall` calls the builder `FUN_006ee280` emits per inherited
|
||||
// node (verified at 0x006ee4ca):
|
||||
// content : frameObj->vtable[+8](templateDefNode, status) (FUN_00769820)
|
||||
// child <Frames> : (frameObj+0x24)->vtable[+8](templateDefNode, status) (FUN_0076a060)
|
||||
// Both recurse the grafted template's own `inherits`, add its regions /
|
||||
// backdrop / attributes / script handlers, and create its child frames —
|
||||
// exactly as if the frame had inherited it. Later templates override
|
||||
// earlier ones, matching modern left-to-right inherit order.
|
||||
//
|
||||
// OnLoad runs once, during the engine build of the first template (step 3).
|
||||
// Secondary templates' `<OnLoad>` handlers are registered (they fire on the
|
||||
// next relevant event) but not re-run at creation, and the frame's OnLoad is
|
||||
// not re-fired. `status` is the engine's XML-build error accumulator, built
|
||||
// and reset exactly as `Script_CreateFrame` does.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace Frame::CreateFrame {
|
||||
|
||||
namespace {
|
||||
|
||||
using ScriptFn_t = int(__fastcall *)(void *L);
|
||||
using TemplateLookup_t = const uint8_t *(__fastcall *)(const char *name);
|
||||
|
||||
// XML-node applier vmethod: applies `node` (and its `inherits` chain) to
|
||||
// `self`. __thiscall(self /*ecx*/, node, status). Reached at vtable byte
|
||||
// offset +8 on both the frame's main object (content applier, FUN_00769820)
|
||||
// and its child-<Frames> sub-object at frame+0x24 (FUN_0076a060).
|
||||
using ApplyNodeFn = void(__thiscall *)(void *self, const void *node, void *status);
|
||||
|
||||
// Empties / re-initializes the XML-build status object. __thiscall(this).
|
||||
using StatusReset_t = void(__thiscall *)(void *self);
|
||||
|
||||
// The original Script_CreateFrame function, populated by MinHook.
|
||||
ScriptFn_t g_origCreateFrame = nullptr;
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
// Returns the definition node if `name` resolves in the engine's XML template
|
||||
// registry, else nullptr.
|
||||
const uint8_t *LookupTemplate(const char *name) {
|
||||
return reinterpret_cast<TemplateLookup_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_XML_TEMPLATE_LOOKUP))(name);
|
||||
}
|
||||
|
||||
// Byte offset of the child-<Frames> applier sub-object within a frame object,
|
||||
// and the vtable slot of the "apply XML node" vmethod. Both read straight from
|
||||
// the builder FUN_006ee280's apply sequence at 0x006ee4ca:
|
||||
// MOV EDX,[EDI] CALL [EDX+0x20] ; top-level content
|
||||
// MOV EAX,[EDI+0x24] LEA ECX,[EDI+0x24] CALL [EAX+0x8] ; child <Frames>
|
||||
// MOV EDX,[EDI] CALL [EDX+0x24] ; finalize / OnLoad
|
||||
// The inherit-recursion inside FUN_00769820 / FUN_0076a060 uses vtable[+8] —
|
||||
// that is the applier we invoke to graft an extra template.
|
||||
constexpr int kChildApplierSubObj = 0x24;
|
||||
constexpr int kApplyNodeVtableSlot = 2; // byte offset +8 / sizeof(void*)
|
||||
|
||||
// The XML-build status object: the 5-dword error accumulator Script_CreateFrame
|
||||
// stack-builds ({vtable, 8, &self+8, (&self+8)|1, 0}). The appliers only ever
|
||||
// call its vtable[+0xc] (a __cdecl printf-style logger); building the engine's
|
||||
// real one absorbs any sub-node warning exactly as the engine does and is safe
|
||||
// against every slot.
|
||||
struct BuildStatus {
|
||||
const void *vtable;
|
||||
int kind;
|
||||
void *head;
|
||||
uintptr_t tail;
|
||||
int count;
|
||||
};
|
||||
|
||||
void InitStatus(BuildStatus *s) {
|
||||
s->vtable = reinterpret_cast<const void *>(
|
||||
static_cast<uintptr_t>(Offsets::PTR_TEXLOAD_DESC_VTBL));
|
||||
s->kind = 8;
|
||||
s->head = &s->head;
|
||||
s->tail = reinterpret_cast<uintptr_t>(&s->head) | 1u;
|
||||
s->count = 0;
|
||||
}
|
||||
|
||||
void ResetStatus(BuildStatus *s) {
|
||||
reinterpret_cast<StatusReset_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_FRAMESCRIPT_STATUS_RESET))(s);
|
||||
}
|
||||
|
||||
// Grafts template definition `node` onto the already-created frame `frameObj`,
|
||||
// applying its content and its child <Frames> the same way the engine applies
|
||||
// an inherited node.
|
||||
void ApplyTemplateNode(void *frameObj, const void *node, void *status) {
|
||||
void **vtbl = *reinterpret_cast<void ***>(frameObj);
|
||||
reinterpret_cast<ApplyNodeFn>(vtbl[kApplyNodeVtableSlot])(frameObj, node, status);
|
||||
|
||||
void *childSub = reinterpret_cast<char *>(frameObj) + kChildApplierSubObj;
|
||||
void **childVtbl = *reinterpret_cast<void ***>(childSub);
|
||||
reinterpret_cast<ApplyNodeFn>(childVtbl[kApplyNodeVtableSlot])(childSub, node, status);
|
||||
}
|
||||
|
||||
// Maximum templates in a single comma-separated list.
|
||||
constexpr int kMaxTemplates = 8;
|
||||
|
||||
// Splits `input` on commas into `out[]`, writing NUL terminators into the
|
||||
// mutable buffer `buf`. Each entry is whitespace-trimmed. Returns the count.
|
||||
int SplitTemplates(char *buf, size_t bufLen, const char *input,
|
||||
const char *out[kMaxTemplates]) {
|
||||
size_t len = std::strlen(input);
|
||||
if (len >= bufLen) len = bufLen - 1;
|
||||
std::memcpy(buf, input, len);
|
||||
buf[len] = '\0';
|
||||
|
||||
int count = 0;
|
||||
char *p = buf;
|
||||
while (*p && count < kMaxTemplates) {
|
||||
while (*p == ' ' || *p == '\t') ++p;
|
||||
if (*p == '\0') break;
|
||||
|
||||
char *start = p;
|
||||
while (*p && *p != ',') ++p;
|
||||
|
||||
// Note whether we stopped on a comma BEFORE writing the terminator:
|
||||
// when there is no trailing whitespace, `end` lands on the comma
|
||||
// itself, so `*end = '\0'` would clobber it and the advance below
|
||||
// would then miss it — dropping every entry after the first.
|
||||
const bool atComma = (*p == ',');
|
||||
|
||||
char *end = p;
|
||||
while (end > start && (*(end - 1) == ' ' || *(end - 1) == '\t'))
|
||||
--end;
|
||||
*end = '\0';
|
||||
|
||||
if (*start != '\0')
|
||||
out[count++] = start;
|
||||
|
||||
if (atComma) ++p;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ---- the hook --------------------------------------------------------------
|
||||
|
||||
int __fastcall CreateFrame_h(void *L) {
|
||||
// No 4th arg or not a string → pass through.
|
||||
if (Game::Lua::GetTop(L) < 4 || !Game::Lua::IsString(L, 4))
|
||||
return g_origCreateFrame(L);
|
||||
|
||||
const char *inherits = Game::Lua::ToString(L, 4);
|
||||
if (inherits == nullptr || *inherits == '\0')
|
||||
return g_origCreateFrame(L);
|
||||
|
||||
// Fast path: no comma → single template, engine handles it natively.
|
||||
if (std::strchr(inherits, ',') == nullptr)
|
||||
return g_origCreateFrame(L);
|
||||
|
||||
// ---- comma-delimited: split, resolve, combine --------------------------
|
||||
|
||||
char buf[1024];
|
||||
const char *names[kMaxTemplates];
|
||||
const int count = SplitTemplates(buf, sizeof buf, inherits, names);
|
||||
if (count == 0)
|
||||
return g_origCreateFrame(L);
|
||||
|
||||
// Resolve each token; keep the resolvable definition nodes in list order.
|
||||
const char *resolvedNames[kMaxTemplates];
|
||||
const void *resolvedNodes[kMaxTemplates];
|
||||
int resolved = 0;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const uint8_t *node = LookupTemplate(names[i]);
|
||||
if (node != nullptr) {
|
||||
resolvedNames[resolved] = names[i];
|
||||
resolvedNodes[resolved] = node;
|
||||
++resolved;
|
||||
}
|
||||
}
|
||||
|
||||
// 0 or 1 resolvable: nothing to combine. Hand the engine a single name —
|
||||
// the first resolved one, or (if none resolved) the first token so the
|
||||
// engine emits its normal "couldn't find inherited node" error rather than
|
||||
// silently building a template-less frame.
|
||||
if (resolved <= 1) {
|
||||
Game::Lua::SetTop(L, 3);
|
||||
Game::Lua::PushString(L, resolved == 1 ? resolvedNames[0] : names[0]);
|
||||
return g_origCreateFrame(L);
|
||||
}
|
||||
|
||||
// Build with the first resolved template through the engine (object
|
||||
// creation + its full inherit chain + ref/name setup + OnLoad).
|
||||
Game::Lua::SetTop(L, 3);
|
||||
Game::Lua::PushString(L, resolvedNames[0]);
|
||||
const int rc = g_origCreateFrame(L);
|
||||
if (rc != 1)
|
||||
return rc; // creation failed (e.g. unknown frame type) — leave as-is.
|
||||
|
||||
// Recover the created frame object (top of stack) and graft the remaining
|
||||
// templates onto it with the engine's own inherit primitive. Snapshot the
|
||||
// stack the engine left (frame at `savedTop`) and restore it afterward, so
|
||||
// anything a grafted template's appliers touch on the Lua stack (a child
|
||||
// frame's OnLoad, `$parent` name resolution) can't shift the return value
|
||||
// the caller reads back — the Tooltip::SetEvents save/restore discipline,
|
||||
// which matters in this hook-saturated environment.
|
||||
const int savedTop = Game::Lua::GetTop(L);
|
||||
void *frameObj = Game::Lua::ResolveObject(L, savedTop);
|
||||
if (frameObj != nullptr) {
|
||||
BuildStatus status;
|
||||
InitStatus(&status);
|
||||
for (int i = 1; i < resolved; ++i)
|
||||
ApplyTemplateNode(frameObj, resolvedNodes[i], &status);
|
||||
ResetStatus(&status);
|
||||
Game::Lua::SetTop(L, savedTop);
|
||||
}
|
||||
|
||||
return rc;
|
||||
}
|
||||
|
||||
// ---- registration ----------------------------------------------------------
|
||||
|
||||
const Game::HookAutoRegister _createFrameHook{
|
||||
Offsets::FUN_SCRIPT_CREATEFRAME,
|
||||
reinterpret_cast<void *>(&CreateFrame_h),
|
||||
reinterpret_cast<void **>(&g_origCreateFrame)};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Frame::CreateFrame
|
||||
@@ -252,6 +252,16 @@ size_t SkipNumber(const char *src, size_t len, size_t pos) {
|
||||
}
|
||||
|
||||
void Tokenize(const char *src, size_t len, std::vector<Token> &out) {
|
||||
// Clear first — RunPasses re-tokenizes into the SAME vector after a pass
|
||||
// rewrites the buffer. Without this, the re-lex APPENDED the new tokens
|
||||
// after the stale ones, and the next pass walked a mixed stream: stale
|
||||
// positions from the old buffer, then new tokens starting back at 0,
|
||||
// whose `t.start` sits BEHIND the splice cursor — the `t.start - p`
|
||||
// size_t underflow then threw std::length_error("string too long")
|
||||
// through the loadbuffer hook into the engine = fatal ERROR #132.
|
||||
// Field-reproduced with WeakAuras' Chomp Internal.lua (hex pass fires,
|
||||
// then the vararg pass walks the doubled stream).
|
||||
out.clear();
|
||||
size_t i = 0;
|
||||
while (i < len) {
|
||||
unsigned char c = static_cast<unsigned char>(src[i]);
|
||||
@@ -1250,9 +1260,22 @@ int __fastcall LoadBuffer_h(void *L, const char *buff, unsigned size, const char
|
||||
|
||||
// Syntax transpile: hex literals first, then vararg, then # / % — one shared
|
||||
// tokenization (see RunPasses). `didVararg` / `didOps` drive the preamble.
|
||||
//
|
||||
// GUARDED: a C++ exception from the rewrite machinery (std::length_error
|
||||
// from an underflowed splice length, bad_alloc, anything) must NEVER cross
|
||||
// into the engine — the engine has no handler, so it surfaces as a fatal
|
||||
// ERROR #132 at RaiseException (seen in the field: a WeakAuras Chomp
|
||||
// chunk killed the client from inside this hook). On any exception the
|
||||
// chunk falls through to the ORIGINAL buffer untouched: worst case it
|
||||
// fails to compile exactly as stock 1.12 would — never a crash.
|
||||
std::string transpiled;
|
||||
bool didVararg = false, didOps = false;
|
||||
const bool changed = RunPasses(buff, size, transpiled, &didVararg, &didOps);
|
||||
bool changed = false;
|
||||
try {
|
||||
changed = RunPasses(buff, size, transpiled, &didVararg, &didOps);
|
||||
} catch (...) {
|
||||
return g_origLoadBuffer(L, buff, size, name);
|
||||
}
|
||||
const char *body = changed ? transpiled.data() : buff;
|
||||
size_t bodyLen = changed ? transpiled.size() : size;
|
||||
|
||||
@@ -1349,7 +1372,13 @@ int __fastcall Script_Transpile(void *L) {
|
||||
}
|
||||
unsigned n = Game::Lua::StrLen(L, 1);
|
||||
std::string out;
|
||||
if (RewriteChunk(s, n, out))
|
||||
bool rewrote = false;
|
||||
try {
|
||||
rewrote = RewriteChunk(s, n, out);
|
||||
} catch (...) {
|
||||
rewrote = false; // same guard as LoadBuffer_h — never throw into the VM
|
||||
}
|
||||
if (rewrote)
|
||||
Game::Lua::PushLString(L, out.data(), static_cast<unsigned>(out.size()));
|
||||
else
|
||||
Game::Lua::PushLString(L, s, n);
|
||||
|
||||
@@ -17,7 +17,17 @@
|
||||
|
||||
namespace Object {
|
||||
|
||||
bool RegistryReady() {
|
||||
return *reinterpret_cast<void *const volatile *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_LOCAL_PLAYER_PTR)) != nullptr;
|
||||
}
|
||||
|
||||
void *ByGuid(int typeMask, uint64_t guid, const char *debugName, int priority) {
|
||||
// Out of world the engine resolver null-derefs its registry root (see
|
||||
// RegistryReady in Resolve.h); with no world there are no live objects, so
|
||||
// "not found" is also the correct answer.
|
||||
if (!RegistryReady())
|
||||
return nullptr;
|
||||
using Fn = void *(__fastcall *)(int typeMask, const char *debugName,
|
||||
uint32_t guidLo, uint32_t guidHi,
|
||||
int priority);
|
||||
|
||||
+17
-3
@@ -22,14 +22,28 @@
|
||||
|
||||
namespace Object {
|
||||
|
||||
// True while the engine's client/player-rooted object registry is up. The
|
||||
// resolver (`FUN_OBJECT_RESOLVE_BY_GUID`) and the descriptor-observer
|
||||
// register/unregister all funnel through `FUN_00464890`, whose FIRST action
|
||||
// dereferences the registry root at `[VAR_LOCAL_PLAYER_PTR]` (reads its
|
||||
// `+0x24` mask) with no null check. That root is NULL out of world — login
|
||||
// screen, loading screens, logout teardown — where the deref is a fatal
|
||||
// ERROR #132 (`MOV EAX,[EDX+0x24]`, EDX=0). Anything that can run
|
||||
// mid-transition (packet hooks, Lua, WorldTick) must gate on this before
|
||||
// touching the registry. See issue #34 for a live crash (SMSG_SPELL_GO
|
||||
// dispatched during a world transition).
|
||||
bool RegistryReady();
|
||||
|
||||
// Resolve `guid` to its live client object. `typeMask` is a bitmask of
|
||||
// `Offsets::TYPEMASK_*` (e.g. `TYPEMASK_UNIT | TYPEMASK_PLAYER`, or
|
||||
// `TYPEMASK_UNIT` / `TYPEMASK_OBJECT`). Returns nullptr when the GUID isn't
|
||||
// currently in the object table (out of range, despawned, pre-world).
|
||||
// Non-throwing — safe from any context (Lua callback, world tick, packet
|
||||
// hook). `priority` mirrors the engine's call-site priority argument: 0x172
|
||||
// for a normal resolve, 0 on the loot/enumeration paths. `debugName` is an
|
||||
// engine diagnostic label (may be nullptr).
|
||||
// hook): internally gated on `RegistryReady`, so an out-of-world call
|
||||
// resolves to nullptr instead of null-derefing in the engine. `priority`
|
||||
// mirrors the engine's call-site priority argument: 0x172 for a normal
|
||||
// resolve, 0 on the loot/enumeration paths. `debugName` is an engine
|
||||
// diagnostic label (may be nullptr).
|
||||
//
|
||||
// Returns `void *` — callers static_cast to the concrete object type they
|
||||
// expect (the engine hands back the same pointer regardless of type).
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// 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/>.
|
||||
|
||||
// Stale table-length healing — Lua 5.1 border semantics for 5.0's
|
||||
// out-of-band lengths.
|
||||
//
|
||||
// Lua 5.0 stores a table's `table.insert`/`getn` length OUT of band (a
|
||||
// `t.n` field, else the registry's weak LUA_SIZES table). Clearing a
|
||||
// table's keys (`for k in pairs(t) do t[k] = nil end`) does NOT reset it —
|
||||
// 5.0 code must call `table.setn(t, 0)`. Lua 5.1 removed all of that:
|
||||
// lengths are computed borders, and a cleared table is length 0
|
||||
// automatically.
|
||||
//
|
||||
// That difference breaks the standard Ace2-era dual-compatibility idiom
|
||||
// the moment our 5.1 syntax backport makes their probe pass:
|
||||
//
|
||||
// local lua51 = loadstring("return function(...) return ... end")
|
||||
// and true or false
|
||||
// local table_setn = lua51 and function() end or table.setn
|
||||
//
|
||||
// With `lua51` true (the `...`-expression now compiles via
|
||||
// LuaSyntax::Transpile), `table_setn` is a NO-OP — correct on real 5.1,
|
||||
// wrong on this VM, whose table functions still read the out-of-band
|
||||
// length. Cleared-and-reused tables keep stale lengths: `table.insert`
|
||||
// appends past the nil'd slots and `table.getn` counts entries that no
|
||||
// longer exist. Verified live: Dewdrop-2.0's menus (the SuperAPI /
|
||||
// NampowerSettings minimap buttons) break exactly this way, and work
|
||||
// again when the probe is forced back to 5.0.
|
||||
//
|
||||
// Since we made the client PASS 5.1 probes, 5.1 length semantics must
|
||||
// hold. The engine funnels every length read through ONE function —
|
||||
// `luaL_getn` (LUAL_GETN, consulted by table.insert / getn / remove /
|
||||
// concat / sort / foreachi, plus our `unpack`) — so a single co-hook
|
||||
// closes the gap for every consumer at once:
|
||||
//
|
||||
// 1. Reported slot populated (`t[n] ~= nil`) → return n unchanged. The
|
||||
// healthy path costs one rawgeti.
|
||||
// 2. `t[n]` nil but `t.n` is a number → return n unchanged. An explicit
|
||||
// `n` field is the 5.0 vararg-table contract (`arg` = {n=3, holes}),
|
||||
// where trailing nils are intentional — healing it would corrupt
|
||||
// vararg counts.
|
||||
// 3. Otherwise the out-of-band length is stale (keys cleared without
|
||||
// setn): scan down for the true border, 5.1-style, and return that.
|
||||
// Read-only — no write-back, so a length read never mutates state;
|
||||
// the engine's own `table.insert` calls `luaL_setn(n+1)` on the next
|
||||
// append, which re-syncs the stored length by itself.
|
||||
//
|
||||
// `table.setn` itself still works for code that calls it — the heal only
|
||||
// changes the answer when the stored length points past the border, which
|
||||
// is the same answer real 5.1 would give.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
namespace Table::Length {
|
||||
|
||||
namespace {
|
||||
|
||||
using LuaLGetN_t = int(__fastcall *)(void *L, int idx);
|
||||
using RawGetI_t = void(__fastcall *)(void *L, int idx, int n);
|
||||
|
||||
LuaLGetN_t g_getnOriginal = nullptr;
|
||||
|
||||
// True if t[k] (rawgeti) is nil. Balances the stack.
|
||||
bool SlotIsNil(void *L, int absIdx, int k) {
|
||||
reinterpret_cast<RawGetI_t>(Offsets::LUA_RAWGETI)(L, absIdx, k);
|
||||
const bool isNil = Game::Lua::Type(L, -1) == Game::Lua::TYPE_NIL;
|
||||
Game::Lua::SetTop(L, -2);
|
||||
return isNil;
|
||||
}
|
||||
|
||||
int __fastcall LuaLGetN_h(void *L, int idx) {
|
||||
const int n = g_getnOriginal(L, idx);
|
||||
if (n <= 0)
|
||||
return n;
|
||||
|
||||
// Normalize a relative index once — the checks below push/pop around
|
||||
// the access, and the `t.n` probe has a key on the stack at its
|
||||
// access point, which would shift a negative index. Pseudo-indices
|
||||
// (registry and below, <= -10000) pass through unchanged.
|
||||
int absIdx = idx;
|
||||
if (idx < 0 && idx > -10000)
|
||||
absIdx = Game::Lua::GetTop(L) + idx + 1;
|
||||
|
||||
if (!SlotIsNil(L, absIdx, n))
|
||||
return n; // healthy — the reported last slot is populated
|
||||
|
||||
// Explicit `t.n` count (vararg-table contract): trailing nils are
|
||||
// intentional, keep the stored length.
|
||||
Game::Lua::PushString(L, "n");
|
||||
Game::Lua::RawGet(L, absIdx);
|
||||
const bool hasExplicitN = Game::Lua::Type(L, -1) == Game::Lua::TYPE_NUMBER;
|
||||
Game::Lua::SetTop(L, -2);
|
||||
if (hasExplicitN)
|
||||
return n;
|
||||
|
||||
// Stale out-of-band length — heal to the true border.
|
||||
int b = n - 1;
|
||||
while (b > 0 && SlotIsNil(L, absIdx, b))
|
||||
--b;
|
||||
return b;
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hook{Offsets::LUAL_GETN,
|
||||
reinterpret_cast<void *>(&LuaLGetN_h),
|
||||
reinterpret_cast<void **>(&g_getnOriginal)};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Table::Length
|
||||
+211
-92
@@ -1629,6 +1629,78 @@ static const Game::HookAutoRegister _emitterHook{Offsets::FUN_TEXT_EMITTER,
|
||||
using Paint_t = void(__fastcall *)(void *layout);
|
||||
Paint_t g_paintOriginal = nullptr;
|
||||
|
||||
// Builds the region placements for `node`'s recorded icons, expressed RELATIVE
|
||||
// to the owning fontstring `fs`'s rect min corner (the pool anchors them there,
|
||||
// so the engine moves them with the line). Fills `out` and returns true ONLY
|
||||
// when everything the placement needs is ready this instant — K live (raster
|
||||
// globals written) and the fs rect resolved. Returns false otherwise, and the
|
||||
// caller must NOT queue an empty set (that would hide the icons and the dedup
|
||||
// would freeze them hidden — the scroll-landing bug's second head). Shared by
|
||||
// the paint flush and the SMF-refresh early apply so both produce
|
||||
// bit-identical placements (the pool's dedup depends on that). The per-icon
|
||||
// geometry mirrors exactly what the emitter reserves — see IconAdvancePen.
|
||||
bool ComputePlacementsForNode(void *node, void *fs,
|
||||
std::vector<Text::InlineTexturePool::Placement> &out) {
|
||||
if (!LooksReadable(node) || !LooksReadable(fs))
|
||||
return false;
|
||||
auto itIcons = g_nodeIcons.find(node);
|
||||
if (itIcons == g_nodeIcons.end() || itIcons->second.empty())
|
||||
return false;
|
||||
const PenScale K = PenPerAnchor();
|
||||
if (!(K.x > 1.0f) || !(K.y > 1.0f))
|
||||
return false;
|
||||
auto *n = reinterpret_cast<uint8_t *>(node);
|
||||
const float ox = Game::Read<float>(n, Offsets::OFF_TEXT_NODE_ORIGIN_X);
|
||||
const float oy = Game::Read<float>(n, Offsets::OFF_TEXT_NODE_ORIGIN_Y);
|
||||
// Same pixel-snap mode the emitter honours (bit-7 clear) — gates the
|
||||
// drawn-rect snap in the placement loop below.
|
||||
const bool snapNode = (Game::Read<uint32_t>(n, Offsets::OFF_TEXT_NODE_FLAGS) & 0x80u) == 0;
|
||||
const float *rc = Game::Ptr<const float>(fs, Offsets::OFF_REGION_RECT);
|
||||
const float fsBottom = (rc[0] < rc[2]) ? rc[0] : rc[2];
|
||||
const float fsLeft = (rc[1] < rc[3]) ? rc[1] : rc[3];
|
||||
if (rc[1] == rc[3])
|
||||
return false; // unresolved rect reads 0-width
|
||||
for (const IconRecord &r : itIcons->second) {
|
||||
// Screen left = pen + the FULL lead pad (the emitter reserves
|
||||
// w + 1.5×pad in the advance: lead 1×, trail 0.5×). Pad is
|
||||
// fontH-relative — must match IconAdvancePen exactly.
|
||||
const float cx = r.x + ox + r.offsetX + r.fontH * g_iconPadFrac + r.outlineInk * 0.5f;
|
||||
// Centre on the line: penY sits near the text top. offsetY shifts up
|
||||
// (WoW convention), so subtract it. Tall icons centre in the grown line
|
||||
// (the string-height co-hook added the overflow).
|
||||
const float cy = r.y + r.fontH * g_centerFrac + oy + g_vBias - r.offsetY;
|
||||
float rx = cx + g_regionCalX;
|
||||
float y0 = cy + g_regionCalY - r.h * 0.5f;
|
||||
float w = r.w, h = r.h;
|
||||
if (snapNode) {
|
||||
// Land the drawn rect on WHOLE render-target pixels, like the
|
||||
// engine's own glyphs (crisp texel sampling). Size snaps
|
||||
// independently of position so a :16 icon is EXACTLY 16px.
|
||||
rx = std::floor(rx + 0.5f);
|
||||
y0 = std::floor(y0 + 0.5f);
|
||||
w = std::floor(w + 0.5f);
|
||||
h = std::floor(h + 0.5f);
|
||||
if (w < 1.0f)
|
||||
w = 1.0f;
|
||||
if (h < 1.0f)
|
||||
h = 1.0f;
|
||||
}
|
||||
Text::InlineTexturePool::Placement p;
|
||||
p.path = r.path;
|
||||
p.x0 = rx / K.x - fsLeft;
|
||||
p.y0 = y0 / K.y - fsBottom;
|
||||
p.x1 = (rx + w) / K.x - fsLeft;
|
||||
p.y1 = (y0 + h) / K.y - fsBottom;
|
||||
p.color = r.color;
|
||||
p.u0 = r.u0;
|
||||
p.v0 = r.v0;
|
||||
p.u1 = r.u1;
|
||||
p.v1 = r.v1;
|
||||
out.push_back(std::move(p));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Walks the layout's live render nodes and, for each recorded icon, computes
|
||||
// its screen geometry (node-local pen coords translated by the node's screen
|
||||
// origin +0x70/+0x74 — the SAME transform the paint pass applies to glyph
|
||||
@@ -1768,99 +1840,17 @@ void FlushLayout(void *layout) {
|
||||
}
|
||||
}
|
||||
}
|
||||
const float ox = Game::Read<float>(n, Offsets::OFF_TEXT_NODE_ORIGIN_X);
|
||||
const float oy = Game::Read<float>(n, Offsets::OFF_TEXT_NODE_ORIGIN_Y);
|
||||
// Same pixel-snap mode the emitter honours (bit-7 clear) — gates
|
||||
// the drawn-rect snap in the placement loop below.
|
||||
const bool snapNode =
|
||||
(Game::Read<uint32_t>(n, Offsets::OFF_TEXT_NODE_FLAGS) & 0x80u) == 0;
|
||||
// The fs rect, read HERE in the same flush as the icon coords — a
|
||||
// coherent snapshot. Placements are stored FS-RELATIVE: an
|
||||
// apply-time rect read raced the chat relayout (SetText invalidates
|
||||
// the rect briefly), and a placement applied against a mid-relayout
|
||||
// rect parked the icon off the line — then the dedup (unchanged
|
||||
// absolute want) froze it there. Relative offsets are also
|
||||
// position-invariant, so scrolling no longer re-places anything.
|
||||
const PenScale K = PenPerAnchor();
|
||||
float fsLeft = 0.0f, fsBottom = 0.0f;
|
||||
bool fsRectValid = false;
|
||||
if (fs != nullptr) {
|
||||
const float *rc = Game::Ptr<const float>(fs, Offsets::OFF_REGION_RECT);
|
||||
fsBottom = (rc[0] < rc[2]) ? rc[0] : rc[2];
|
||||
fsLeft = (rc[1] < rc[3]) ? rc[1] : rc[3];
|
||||
fsRectValid = rc[1] != rc[3]; // unresolved rect reads 0-width
|
||||
}
|
||||
// Geometry + readiness gate factored into ComputePlacementsForNode
|
||||
// (shared with the SMF-refresh early apply so both produce
|
||||
// bit-identical placements — the pool dedup depends on it). It
|
||||
// returns false when K or the fs rect isn't ready; SKIP the queue
|
||||
// then and retry next paint. An empty queue here would HIDE the
|
||||
// line's icons and the dedup would freeze it hidden forever
|
||||
// (identical empty re-queues never dirty) — the scroll-landing
|
||||
// bug's second head, where lines painted before K was available
|
||||
// queued {} and stayed iconless until their text changed.
|
||||
std::vector<Text::InlineTexturePool::Placement> places;
|
||||
for (const IconRecord &r : it->second) {
|
||||
// Screen left = pen + the FULL lead pad. The emitter reserves
|
||||
// w + 1.5×pad in the advance (lead 1×, trail 0.5×) — drawing at
|
||||
// the raw pen put all of that gap AFTER the icon, which is why
|
||||
// a string-final icon (money-string copper) looked jammed
|
||||
// against its digits and grew per-coin offset hacks. The pad is
|
||||
// fontH-relative (NOT r.w) — must match IconAdvancePen exactly.
|
||||
const float cx =
|
||||
r.x + ox + r.offsetX + r.fontH * g_iconPadFrac + r.outlineInk * 0.5f;
|
||||
// Centre on the line: penY sits near the text top, so add a
|
||||
// fraction of the font height (plus the fine-tune bias). offsetY
|
||||
// shifts up (WoW convention), so subtract it.
|
||||
//
|
||||
// TALL icons (h > fontH) centre too: the string-height co-hook
|
||||
// grew the line by the overflow, and the text centres itself
|
||||
// inside the grown rect (verified in-game — a bottom-aligned
|
||||
// icon clipped the neighbouring line while retail shows tall
|
||||
// emotes centred with no clip), so the extra space appears half
|
||||
// above and half below the text. A centred icon fills exactly
|
||||
// that — the retail look.
|
||||
const float cy = r.y + r.fontH * g_centerFrac + oy + g_vBias - r.offsetY;
|
||||
if (K.x > 1.0f && K.y > 1.0f && fs != nullptr && fsRectValid) {
|
||||
float rx = cx + g_regionCalX;
|
||||
float y0 = cy + g_regionCalY - r.h * 0.5f;
|
||||
float w = r.w, h = r.h;
|
||||
if (snapNode) {
|
||||
// Land the drawn rect on WHOLE render-target pixels,
|
||||
// like the engine's own glyphs (rounded origin +
|
||||
// truncated advances). Pen px = render px, the fsLeft/
|
||||
// fsBottom terms cancel exactly through the anchor
|
||||
// round-trip (the engine adds the same rect corner
|
||||
// back at resolve), and the pool's width convergence
|
||||
// preserves the linear factor — so an integral pen
|
||||
// rect lands integral on screen: 1:1-sized icons
|
||||
// sample texel centres (crisp) instead of blending
|
||||
// four neighbours (soft). Size snaps independently of
|
||||
// position so a :16 icon is EXACTLY 16px, never 15/17
|
||||
// from the two edges rounding apart.
|
||||
rx = std::floor(rx + 0.5f);
|
||||
y0 = std::floor(y0 + 0.5f);
|
||||
w = std::floor(w + 0.5f);
|
||||
h = std::floor(h + 0.5f);
|
||||
if (w < 1.0f)
|
||||
w = 1.0f;
|
||||
if (h < 1.0f)
|
||||
h = 1.0f;
|
||||
}
|
||||
Text::InlineTexturePool::Placement p;
|
||||
p.path = r.path;
|
||||
p.x0 = rx / K.x - fsLeft;
|
||||
p.y0 = y0 / K.y - fsBottom;
|
||||
p.x1 = (rx + w) / K.x - fsLeft;
|
||||
p.y1 = (y0 + h) / K.y - fsBottom;
|
||||
p.color = r.color;
|
||||
p.u0 = r.u0;
|
||||
p.v0 = r.v0;
|
||||
p.u1 = r.u1;
|
||||
p.v1 = r.v1;
|
||||
places.push_back(std::move(p));
|
||||
}
|
||||
}
|
||||
// The queue gate must match the per-icon build gate EXACTLY —
|
||||
// including K. If any input wasn't ready this paint (unresolved fs
|
||||
// rect, raster globals not yet written), SKIP the queue and retry
|
||||
// next paint: an empty queue here would HIDE the line's icons and
|
||||
// the dedup would freeze it hidden forever (identical empty
|
||||
// re-queues never dirty). That was the scroll-landing bug's second
|
||||
// head: lines painted before K was available queued {} and stayed
|
||||
// iconless until their text changed.
|
||||
if (fs != nullptr && fsRectValid && K.x > 1.0f && K.y > 1.0f)
|
||||
if (ComputePlacementsForNode(node, fs, places))
|
||||
Text::InlineTexturePool::QueuePlacements(fs, std::move(places));
|
||||
} else if (fs != nullptr) {
|
||||
Text::InlineTexturePool::QueuePlacements(fs, {});
|
||||
@@ -1890,6 +1880,121 @@ static const Game::HookAutoRegister _paintHook{Offsets::FUN_TEXT_PAINT,
|
||||
reinterpret_cast<void *>(&Paint_h),
|
||||
reinterpret_cast<void **>(&g_paintOriginal)};
|
||||
|
||||
// --- SMF-refresh early apply (kills the one-frame chat-icon lag) -------------
|
||||
//
|
||||
// The lag: a ScrollingMessageFrame re-SetTexts every visible line slot on each
|
||||
// new message (FUN_00788750), but the engine builds each line lazily at PAINT.
|
||||
// So a line's icons are recorded — and its regions placed — one frame AFTER its
|
||||
// glyphs draw, because a frame's textures (our icon regions) draw before its
|
||||
// fontstring glyphs and the placement isn't computed until the glyph build runs
|
||||
// mid-paint. This co-hook closes it: after the refresh sets up the lines (still
|
||||
// PRE-RENDER — the refresh runs from the SMF's Lua/event-driven methods, never
|
||||
// mid-render), force each icon-bearing line to resolve+build and apply its icon
|
||||
// placement synchronously, so the icons draw with their glyphs the same frame.
|
||||
//
|
||||
// Additive and best-effort: any line we can't resolve/build in time (rect still
|
||||
// pending) falls back to the existing paint-flush + FrameTick pipeline (correct,
|
||||
// one-frame lagged). Gated by its own toggle (default on) so it can be A/B'd and
|
||||
// disabled independently of the whole feature; the SEH latch trips it on a fault.
|
||||
bool g_earlyApply = true;
|
||||
|
||||
using SmfRefresh_t = void(__fastcall *)(void *smf, void *edx, int newestIdx);
|
||||
SmfRefresh_t g_smfRefreshOriginal = nullptr;
|
||||
|
||||
using EnsureBuilt_t = int(__fastcall *)(void *node);
|
||||
using RealizeAnchor_t = void(__thiscall *)(void *anchor, int flag);
|
||||
using RebuildStringFs_t = void(__fastcall *)(void *fs);
|
||||
|
||||
// Force `fs` to resolve its rect and lay out its text node NOW (pre-render),
|
||||
// mirroring what the engine's pending-layout pass + paint would do later.
|
||||
// Returns the live text node, or nullptr if it couldn't be built yet (rect
|
||||
// still unresolved — caller falls back to the lagged pipeline). Uses only the
|
||||
// engine's own supported force paths (the same Realize/RebuildString/ensure-
|
||||
// built the Show and paint paths call).
|
||||
void *ForceBuildFontString(void *fs) {
|
||||
// 1. Synchronous rect resolve (Realize flag 0 — the call Show itself uses;
|
||||
// it unlinks the anchor from the pending-layout list and resolves now).
|
||||
// The caller walks lines bottom-up, so this line's relativeTo (the line
|
||||
// below it / the frame) is already resolved when we get here.
|
||||
reinterpret_cast<RealizeAnchor_t>(Offsets::FUN_REGION_LAYOUT_REALIZE)(
|
||||
reinterpret_cast<uint8_t *>(fs) + Offsets::OFF_REGION_ANCHOR, 0);
|
||||
// 2. Rebuild the text block — creates the fresh node (but only if the rect
|
||||
// is now resolved). Internally gated on the fs dirty bit, so a clean
|
||||
// line is a no-op and we avoid depending on reading that bit ourselves.
|
||||
reinterpret_cast<RebuildStringFs_t>(Offsets::FUN_FONTSTRING_REBUILD_STRING)(fs);
|
||||
void *block = Game::Read<void *>(fs, Offsets::OFF_FONTSTRING_TEXT_BLOCK);
|
||||
if (!LooksReadable(block))
|
||||
return nullptr; // rect still unresolved — no node created
|
||||
void *node = Game::Read<void *>(block, Offsets::OFF_TEXTBLOCK_NODE);
|
||||
if (!LooksReadable(node))
|
||||
return nullptr;
|
||||
// 3. Lay the node out now: runs the glyph emitter (our Emitter_h records the
|
||||
// icons) and finalizes the node origin. Idempotent — paint's own ensure-
|
||||
// built re-runs later and the flush dedups the identical placement.
|
||||
reinterpret_cast<EnsureBuilt_t>(Offsets::FUN_TEXT_ENSURE_BUILT)(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
// Walk the SMF's visible line array and early-apply each icon-bearing line.
|
||||
// The C++-object-holding body is kept out of the SEH frame (see
|
||||
// SafeSmfEarlyApply) — MSVC won't allow __try where objects need unwinding.
|
||||
void SmfEarlyApplyImpl(void *smf) {
|
||||
if (!LooksReadable(smf))
|
||||
return;
|
||||
const int count = Game::Read<int>(smf, Offsets::OFF_SMF_LINE_COUNT);
|
||||
if (count <= 0 || count > 1024)
|
||||
return;
|
||||
auto *arrayBase = Game::Read<uint8_t *>(smf, Offsets::OFF_SMF_LINE_ARRAY);
|
||||
if (!LooksReadable(arrayBase))
|
||||
return;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
uint8_t *entry = arrayBase + i * Offsets::SMF_LINE_STRIDE;
|
||||
if (!LooksReadable(entry))
|
||||
break;
|
||||
void *fs = Game::Read<void *>(entry, Offsets::OFF_SMF_LINE_FONTSTRING);
|
||||
if (!LooksReadable(fs))
|
||||
continue;
|
||||
// Only shown lines (the desired-shown latch the SMF just set) that carry
|
||||
// inline markup — a plain chat line costs nothing but the text scan.
|
||||
if (Game::Read<uint32_t>(fs, Offsets::OFF_REGION_DESIRED_SHOWN) == 0)
|
||||
continue;
|
||||
const uint8_t *text = Game::Read<const uint8_t *>(fs, Offsets::OFF_FONTSTRING_TEXT);
|
||||
if (!LooksReadable(text))
|
||||
continue;
|
||||
int len = 0;
|
||||
while (len < 0x4000 && text[len] != '\0')
|
||||
++len;
|
||||
if (!HasInlineTexture(text, len))
|
||||
continue;
|
||||
void *node = ForceBuildFontString(fs);
|
||||
if (node == nullptr || NodeEditable(node))
|
||||
continue; // couldn't build in time, or editable (raw markup)
|
||||
std::vector<Text::InlineTexturePool::Placement> places;
|
||||
if (ComputePlacementsForNode(node, fs, places))
|
||||
Text::InlineTexturePool::ApplyNow(fs, std::move(places));
|
||||
}
|
||||
}
|
||||
|
||||
// SEH latch mirroring SafeFlush — a fault disables the early path for the
|
||||
// session (the baseline paint+tick pipeline still runs) instead of crashing.
|
||||
void SafeSmfEarlyApply(void *smf) {
|
||||
__try {
|
||||
SmfEarlyApplyImpl(smf);
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
g_earlyApply = false;
|
||||
}
|
||||
}
|
||||
|
||||
void __fastcall SmfRefresh_h(void *smf, void *edx, int newestIdx) {
|
||||
g_smfRefreshOriginal(smf, edx, newestIdx);
|
||||
if (g_inlineEnabled && g_earlyApply)
|
||||
SafeSmfEarlyApply(smf);
|
||||
}
|
||||
|
||||
static const Game::HookAutoRegister _smfRefreshHook{
|
||||
Offsets::FUN_SMF_DISPLAY_REFRESH, reinterpret_cast<void *>(&SmfRefresh_h),
|
||||
reinterpret_cast<void **>(&g_smfRefreshOriginal)};
|
||||
|
||||
// --- Lua control surface ---------------------------------------------------
|
||||
|
||||
// _classicapi_InlineTexEnable([on]) -> enabled. The feature kill switch (also
|
||||
@@ -1903,8 +2008,22 @@ int __fastcall Script_InlineTexEnable(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// _classicapi_InlineTexEarly([on]) -> enabled. Toggles the SMF-refresh
|
||||
// pre-render early apply (the chat-icon-lag fix) independently of the whole
|
||||
// feature — for A/B'ing the lag against the baseline paint+tick pipeline, which
|
||||
// keeps running either way. Also what the SMF SEH latch trips on a fault.
|
||||
int __fastcall Script_InlineTexEarly(void *L) {
|
||||
if (Game::Lua::GetTop(L) == 0)
|
||||
g_earlyApply = true;
|
||||
else
|
||||
g_earlyApply = Game::Lua::ToBoolean(L, 1) != 0;
|
||||
Game::Lua::PushBool(L, g_earlyApply);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("_classicapi_InlineTexEnable", &Script_InlineTexEnable);
|
||||
Game::Lua::RegisterGlobalFunction("_classicapi_InlineTexEarly", &Script_InlineTexEarly);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
@@ -282,6 +282,43 @@ void ApplyPlacement(IconRegion &r, void *fs, const Placement &p, uint32_t fsColo
|
||||
Show(r.tex);
|
||||
}
|
||||
|
||||
// Apply the queued `want` for one fs to its regions: create/reparent/configure
|
||||
// each, hide surplus from a previously-larger set, and clear the dirty flag.
|
||||
// Reads the fs's live parent at call time. Shared by MaintainImpl's dirty
|
||||
// branch and the synchronous ApplyNow path (pre-render). Caller supplies the
|
||||
// folded fs colour (FsColorKey) so both paths fold the same fade.
|
||||
void ApplyDirtyBranch(void *fs, FsPlacements &np, uint32_t fsColor) {
|
||||
np.dirty = false;
|
||||
void *parent = *reinterpret_cast<void **>(reinterpret_cast<uint8_t *>(fs) +
|
||||
Offsets::OFF_REGION_PARENT);
|
||||
if (!LooksReadable(parent))
|
||||
return;
|
||||
const int want = static_cast<int>(np.want.size());
|
||||
for (int i = 0; i < want; ++i) {
|
||||
if (i >= static_cast<int>(np.regions.size())) {
|
||||
void *tex = CreateRegion(parent);
|
||||
if (tex == nullptr)
|
||||
break;
|
||||
np.regions.push_back(IconRegion{tex, parent, std::string()});
|
||||
}
|
||||
IconRegion &r = np.regions[static_cast<size_t>(i)];
|
||||
if (r.parent != parent) {
|
||||
reinterpret_cast<SetParentAndLayer_t>(Offsets::FUN_REGION_SET_PARENT_AND_LAYER)(
|
||||
r.tex, parent, Offsets::DRAWLAYER_ARTWORK, 0);
|
||||
r.parent = parent;
|
||||
}
|
||||
ApplyPlacement(r, fs, np.want[static_cast<size_t>(i)], fsColor);
|
||||
}
|
||||
// Hide surplus pooled regions from a previous, larger icon set.
|
||||
for (int i = want; i < np.shown; ++i)
|
||||
if (i < static_cast<int>(np.regions.size()))
|
||||
Hide(np.regions[static_cast<size_t>(i)].tex);
|
||||
np.shown = (want < static_cast<int>(np.regions.size()))
|
||||
? want
|
||||
: static_cast<int>(np.regions.size());
|
||||
np.appliedColor = fsColor;
|
||||
}
|
||||
|
||||
// Per-frame tick (FrameTick — glue AND world): apply queued placements
|
||||
// (deferred out of the text paint — regions must never be mutated mid-paint).
|
||||
// Wrapped by Maintain() below; kept destructor-holding C++ objects out of the
|
||||
@@ -359,37 +396,7 @@ void MaintainImpl() {
|
||||
const uint32_t fsColor = FsColorKey(fs);
|
||||
|
||||
if (np.dirty) {
|
||||
np.dirty = false;
|
||||
void *parent = *reinterpret_cast<void **>(reinterpret_cast<uint8_t *>(fs) +
|
||||
Offsets::OFF_REGION_PARENT);
|
||||
if (!LooksReadable(parent)) {
|
||||
++it;
|
||||
continue;
|
||||
}
|
||||
const int want = static_cast<int>(np.want.size());
|
||||
for (int i = 0; i < want; ++i) {
|
||||
if (i >= static_cast<int>(np.regions.size())) {
|
||||
void *tex = CreateRegion(parent);
|
||||
if (tex == nullptr)
|
||||
break;
|
||||
np.regions.push_back(IconRegion{tex, parent, std::string()});
|
||||
}
|
||||
IconRegion &r = np.regions[static_cast<size_t>(i)];
|
||||
if (r.parent != parent) {
|
||||
reinterpret_cast<SetParentAndLayer_t>(Offsets::FUN_REGION_SET_PARENT_AND_LAYER)(
|
||||
r.tex, parent, Offsets::DRAWLAYER_ARTWORK, 0);
|
||||
r.parent = parent;
|
||||
}
|
||||
ApplyPlacement(r, fs, np.want[static_cast<size_t>(i)], fsColor);
|
||||
}
|
||||
// Hide surplus pooled regions from a previous, larger icon set.
|
||||
for (int i = want; i < np.shown; ++i)
|
||||
if (i < static_cast<int>(np.regions.size()))
|
||||
Hide(np.regions[static_cast<size_t>(i)].tex);
|
||||
np.shown = (want < static_cast<int>(np.regions.size()))
|
||||
? want
|
||||
: static_cast<int>(np.regions.size());
|
||||
np.appliedColor = fsColor;
|
||||
ApplyDirtyBranch(fs, np, fsColor);
|
||||
} else if (np.shown > 0 && fsColor != np.appliedColor) {
|
||||
// FADE MIRROR: the ScrollingMessageFrame fader animates the line
|
||||
// fontstring's color alpha every frame, and the glue AddonList
|
||||
@@ -462,6 +469,44 @@ void Maintain() {
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronous apply for ONE fs (the pre-render early path). Applies only when
|
||||
// the fs is a live, actually-shown fontstring and its entry is still dirty
|
||||
// (i.e. QueuePlacements did NOT dedup the re-queue). Otherwise it leaves the
|
||||
// entry for the normal tick path — best-effort, so a not-yet-shown or
|
||||
// deduped line simply falls back to the one-frame-lagged behaviour.
|
||||
void ApplyNowImpl(void *fs) {
|
||||
auto it = g_fsIcons.find(fs);
|
||||
if (it == g_fsIcons.end())
|
||||
return;
|
||||
FsPlacements &np = it->second;
|
||||
if (!np.dirty)
|
||||
return; // deduped re-queue: already applied, nothing to do
|
||||
if (!IsLiveFontString(fs))
|
||||
return;
|
||||
// Match Maintain's own visibility gate (actually-shown latch). The SMF's
|
||||
// per-line Show realizes this synchronously before we run, so a visible
|
||||
// line passes; a line not yet shown is left dirty for the tick path.
|
||||
const bool fsShown =
|
||||
*reinterpret_cast<const uint32_t *>(reinterpret_cast<const uint8_t *>(fs) +
|
||||
Offsets::OFF_REGION_ACTUALLY_SHOWN) != 0;
|
||||
if (!fsShown)
|
||||
return;
|
||||
ApplyDirtyBranch(fs, np, FsColorKey(fs));
|
||||
}
|
||||
|
||||
// SEH split mirroring Maintain/MaintainImpl — the __try body holds no C++
|
||||
// objects needing unwinding (see MaintainImpl). A fault latches the same
|
||||
// maintenance kill switch as the tick path.
|
||||
void SafeApplyNow(void *fs) {
|
||||
if (g_maintainDisabled)
|
||||
return;
|
||||
__try {
|
||||
ApplyNowImpl(fs);
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
g_maintainDisabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// FrameTick, not WorldTick: icon regions are UI objects that also exist on the
|
||||
// glue screen (addon-list titles with |T emotes), and FrameTick fires wherever
|
||||
// the UI renders — glue and world. (An earlier FrameTick attempt was blamed
|
||||
@@ -498,6 +543,15 @@ void QueuePlacements(void *fs, std::vector<Placement> &&icons) {
|
||||
np.dirty = true;
|
||||
}
|
||||
|
||||
void ApplyNow(void *fs, std::vector<Placement> &&icons) {
|
||||
// Queue first (learns the fs vtable, sets want+dirty+touch, dedups an
|
||||
// unchanged re-queue), then apply synchronously for this fs. Deduped
|
||||
// re-queues leave the entry not-dirty, so SafeApplyNow no-ops — a
|
||||
// per-paint steady state costs one compare, same as QueuePlacements.
|
||||
QueuePlacements(fs, std::move(icons));
|
||||
SafeApplyNow(fs);
|
||||
}
|
||||
|
||||
void PrepareForReload() {
|
||||
// The reload teardown frees the icon regions (they die with their parent
|
||||
// frames); only forget the pointers — never Hide/free them.
|
||||
|
||||
@@ -58,6 +58,15 @@ inline bool operator==(const Placement &a, const Placement &b) {
|
||||
// hidden fontstring's regions are hidden automatically each tick.
|
||||
void QueuePlacements(void *fs, std::vector<Placement> &&icons);
|
||||
|
||||
// Like QueuePlacements, but ALSO applies the placements to their regions
|
||||
// immediately (synchronously) instead of waiting for the next FrameTick. Only
|
||||
// safe to call PRE-RENDER (no frame mid-draw) — Text::InlineTexture calls it
|
||||
// from the SMF-refresh hook so a chat line's icons are placed before the frame
|
||||
// draws, killing the one-frame lag. Deduped against the current want, so a
|
||||
// no-change re-apply is a cheap no-op. Faults are caught (latches the same
|
||||
// maintenance kill switch as the tick path).
|
||||
void ApplyNow(void *fs, std::vector<Placement> &&icons);
|
||||
|
||||
// Called from FrameScript_Initialize_h BEFORE the /reload teardown: the reload
|
||||
// destroys every icon region (they die with their parent frames), so drop the
|
||||
// pointers without touching them — regions rebuild lazily as icons re-draw.
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
// 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/>.
|
||||
|
||||
// `Texture:SetMask(path)` — apply an alpha-mask texture to a Texture region, the
|
||||
// way the modern client does (a white-with-alpha mask clips the texture to the
|
||||
// mask's shape). 1.12 has no generic masking, only the special-cased minimap
|
||||
// disc (Minimap:SetMaskTexture). This backports it to any Texture.
|
||||
//
|
||||
// Mechanism (see the Offsets.h "Texture masking" + "Texgen" blocks for the full
|
||||
// findings): every textured region draws through the vertex-stream primitive
|
||||
// FUN_0058a2a0, which is passed the region's corner array as its `positions`.
|
||||
// We co-hook it; when the positions belong to a region that has a mask set, we
|
||||
// bind the mask(s) on texture units 1..N (the MODULATE combiner multiplies each
|
||||
// mask's alpha into the base's alpha) and re-run the engine's own submit.
|
||||
//
|
||||
// Two mask sources, two sampling paths:
|
||||
// * SetMask("path") — the mask samples at the base's own texcoords, so it
|
||||
// rides the explicit second UV stream (uv1 = uv0). One mask max: the
|
||||
// engine's vertex-format table has no third UV set.
|
||||
// * AddMaskTexture(maskRegion) — positioned masks. These use TEXGEN, the same
|
||||
// mechanism as the engine's own minimap-disc mask (FUN_004ec440): each
|
||||
// unit's UV is generated from the raw vertex POSITION and mapped through a
|
||||
// per-unit texture matrix built from the mask's rect (plus its SetRotation,
|
||||
// which is just a rotation term in the matrix). No vertex stream needed, so
|
||||
// up to 7 simultaneous masks (device-stage-cap-clamped), each independently
|
||||
// positioned, sized, rotated, and animated.
|
||||
//
|
||||
// State discipline: the queued draw is only flushed at the indexed submit
|
||||
// (FUN_GX_PRIM_INDEXED) the engine issues right after this stream call, so the
|
||||
// texgen/matrix/bind restores CANNOT run inside the stream hook — a second
|
||||
// co-hook on the indexed submit pops the matrices and the state journal after
|
||||
// the draw, exactly the op order of the engine's own disc draw.
|
||||
//
|
||||
// A cold co-hook on the region ctor clears a region's mask state when its
|
||||
// pooled memory is reused, so a recycled region can never inherit (or serve) a
|
||||
// stale mask. Kill switch `_classicapi_TextureMaskEnable`; an SEH latch trips
|
||||
// it on a draw fault.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "texture/Transform.h"
|
||||
#include "ui/FrameObject.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace Texture::Mask {
|
||||
|
||||
namespace {
|
||||
|
||||
bool g_enabled = true;
|
||||
|
||||
// A region's mask: the loaded HTEXTURE plus the path (so a re-SetMask with the
|
||||
// same path is a cheap no-op and the value is inspectable). Keyed by the
|
||||
// CSimpleTexture object pointer — the same pointer the Lua object resolves to
|
||||
// AND the base of the corner array the draw hook recovers.
|
||||
struct MaskEntry {
|
||||
void *handle = nullptr; // HTEXTURE from the path loader (never null once set)
|
||||
std::string path;
|
||||
};
|
||||
std::unordered_map<void *, MaskEntry> g_regionMask;
|
||||
|
||||
// --- object mask API: MaskTexture regions ------------------------------------
|
||||
// A base region -> the mask regions it's clipped by, in Add order. Each mask is
|
||||
// a real (hidden) CSimpleTexture minted by CreateMaskTexture; the draw hook
|
||||
// reads its live rect + texture + rotation and clips the base to it. Keyed by
|
||||
// the base region pointer, same as g_regionMask. The draw applies the first
|
||||
// (stage cap - 1) masks — up to 7 on this device.
|
||||
std::unordered_map<void *, std::vector<void *>> g_baseMasks;
|
||||
// Regions minted by CreateMaskTexture — a mask-source allow-list.
|
||||
std::unordered_set<void *> g_maskRegions;
|
||||
|
||||
// --- path -> HTEXTURE loader (mirrors FUN_00770200's SetTexture load) --------
|
||||
|
||||
using TexFlagsInit_t = void *(__thiscall *)(void *self, uint32_t blend, int, int, int, int, int,
|
||||
uint32_t, int);
|
||||
using TextureLoad_t = uint32_t(__fastcall *)(const char *path, void *desc, uint32_t flags, int,
|
||||
int);
|
||||
using GetRenderable_t = void *(__fastcall *)(void *hTex, int force, void *);
|
||||
|
||||
struct TexLoadDesc {
|
||||
void *vtbl;
|
||||
int32_t field4;
|
||||
void *self8;
|
||||
uint32_t fieldC;
|
||||
int32_t field10;
|
||||
};
|
||||
|
||||
void *LoadByPath(const char *path) {
|
||||
TexLoadDesc desc;
|
||||
desc.vtbl = reinterpret_cast<void *>(Offsets::PTR_TEXLOAD_DESC_VTBL);
|
||||
desc.field4 = 8;
|
||||
desc.self8 = &desc.self8;
|
||||
desc.fieldC = reinterpret_cast<uintptr_t>(&desc.self8) | 1u;
|
||||
desc.field10 = 0;
|
||||
uint32_t flags = 0;
|
||||
const uint32_t blend = Game::Read<uint32_t>(Offsets::VAR_TEXTURE_BLEND_DEFAULT);
|
||||
reinterpret_cast<TexFlagsInit_t>(Offsets::FUN_GX_TEXFLAGS_INIT)(&flags, blend, 0, 0, 0, 0, 0,
|
||||
1, 0);
|
||||
return reinterpret_cast<void *>(reinterpret_cast<TextureLoad_t>(
|
||||
Offsets::FUN_TEXTURE_LOAD_BY_PATH)(path, &desc, flags, 0, 1));
|
||||
}
|
||||
|
||||
// --- stream-primitive co-hook: apply the mask on unit 1 ----------------------
|
||||
|
||||
using GxRsSet_t = void(__fastcall *)(int selector, int value);
|
||||
using GxRsSetPtr_t = void(__fastcall *)(int selector, const void *value);
|
||||
using GxTexMtxOp_t = void(__fastcall *)(int unit);
|
||||
using PrimStreams_t = void(__fastcall *)(int count, const void *pos, int posStride, const void *s3,
|
||||
int s3Stride, const void *colors, int colorStride,
|
||||
int drop8, int drop9, const void *uv0, int uv0Stride,
|
||||
const void *uv1, int uv1Stride);
|
||||
|
||||
PrimStreams_t g_primStreamsOriginal = nullptr;
|
||||
|
||||
// Runs the engine's own submit with the mask bound on unit 1 and uv1 = uv0.
|
||||
// Returns true if it drew; false if the mask isn't resident yet (caller draws
|
||||
// the base unmasked this frame and retries next). POD-only body for the SEH
|
||||
// wrapper.
|
||||
bool MaskedDrawImpl(void *maskH, int count, const void *pos, int posStride, const void *s3,
|
||||
int s3Stride, const void *colors, int colorStride, int drop8, int drop9,
|
||||
const void *uv0, int uv0Stride) {
|
||||
// Per-frame residency reference (force=1), exactly like the minimap's mask.
|
||||
void *maskCGx =
|
||||
reinterpret_cast<GetRenderable_t>(Offsets::FUN_TEXTURE_GET_RENDERABLE)(maskH, 1, nullptr);
|
||||
if (maskCGx == nullptr)
|
||||
return false;
|
||||
auto rs = reinterpret_cast<GxRsSet_t>(Offsets::FUN_GX_RS_SET);
|
||||
auto rsPtr = reinterpret_cast<GxRsSetPtr_t>(Offsets::FUN_GX_RS_SET_PTR);
|
||||
// Historical note: this push/pop pair was believed to be a per-unit
|
||||
// enable/disable; it's actually unit 1's texture-matrix push/pop (binding
|
||||
// is what enables a stage). The pair is a net no-op here but its
|
||||
// dirty-marking is part of the exact sequence this path shipped and was
|
||||
// verified with — left untouched.
|
||||
reinterpret_cast<GxTexMtxOp_t>(Offsets::FUN_GX_TEXMTX_PUSH)(1);
|
||||
rsPtr(Offsets::GXRS_TEXTURE0 + 1, maskCGx); // bind mask on unit 1
|
||||
// Force unit 1's combiner to MODULATE, exactly as the engine's own masked
|
||||
// draw FUN_004eae10 does (GxRs(0x20, 1)) — robust vs. relying on the
|
||||
// backend default.
|
||||
rs(Offsets::GXRS_COMBINER0 + 1, Offsets::GXRS_COMBINE_MODULATE);
|
||||
// uv1 = uv0 → unit 1 samples the mask at the base's texcoords.
|
||||
g_primStreamsOriginal(count, pos, posStride, s3, s3Stride, colors, colorStride, drop8, drop9,
|
||||
uv0, uv0Stride, uv0, uv0Stride);
|
||||
reinterpret_cast<GxTexMtxOp_t>(Offsets::FUN_GX_TEXMTX_POP)(1);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SafeMaskedDraw(void *maskH, int count, const void *pos, int posStride, const void *s3,
|
||||
int s3Stride, const void *colors, int colorStride, int drop8, int drop9,
|
||||
const void *uv0, int uv0Stride) {
|
||||
__try {
|
||||
return MaskedDrawImpl(maskH, count, pos, posStride, s3, s3Stride, colors, colorStride,
|
||||
drop8, drop9, uv0, uv0Stride);
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
g_enabled = false; // disable the feature for the session
|
||||
return true; // may have partly drawn — don't double-draw
|
||||
}
|
||||
}
|
||||
|
||||
// --- object mask: clip the base to a positioned mask region ------------------
|
||||
|
||||
using RegionGetRect_t = int(__thiscall *)(void *anchor, float *outTLBR);
|
||||
using RegionResolve_t = void(__thiscall *)(void *anchor, int flag);
|
||||
using RegionDirty_t = int(__thiscall *)(void *anchor);
|
||||
|
||||
void *AnchorOf(void *region) {
|
||||
return reinterpret_cast<void *>(reinterpret_cast<uintptr_t>(region) +
|
||||
Offsets::OFF_REGION_ANCHOR);
|
||||
}
|
||||
|
||||
// Reads a region's resolved rect {top,left,bottom,right}. Mirrors the engine's
|
||||
// own GetLeft: if the layout is dirty, force a synchronous resolve with flag 1
|
||||
// (the read-time path that resolves NOW; flag 0 instead defers to the render's
|
||||
// layout pass, which never runs for a hidden mask). Only resolves when dirty, so
|
||||
// steady-state frames are a pure read. Returns false if it still can't resolve.
|
||||
bool RegionRect(void *region, float outTLBR[4]) {
|
||||
void *anchor = AnchorOf(region);
|
||||
if (reinterpret_cast<RegionDirty_t>(Offsets::FUN_REGION_LAYOUT_DIRTY)(anchor) != 0)
|
||||
reinterpret_cast<RegionResolve_t>(Offsets::FUN_FONTSTRING_LAYOUT_INVALIDATE)(anchor, 1);
|
||||
return reinterpret_cast<RegionGetRect_t>(Offsets::FUN_REGION_GET_RECT)(anchor, outTLBR) != 0;
|
||||
}
|
||||
|
||||
// --- texgen path: positioned masks on units 1..N -----------------------------
|
||||
//
|
||||
// Each mask unit samples via texgen (object-space position) through a texture
|
||||
// matrix mapping the raw vertex position to the mask's UV — the engine's own
|
||||
// minimap-disc-mask mechanism. Outside the mask's rect the UV leaves [0,1] and
|
||||
// the sampler clamps to the mask's transparent border, clipping the base
|
||||
// (verified live on the uv1 path; same sampler state).
|
||||
//
|
||||
// The position→UV map is affine, derived from two verified facts:
|
||||
// * corner positions and the layout rect share the same anchor-unit space
|
||||
// (the engine's corner store writes rect values verbatim — Transform.cpp);
|
||||
// * the draw renders a stored y at (rectTop + rectBottom) − y (the y-mirror
|
||||
// established by the 2026-08-18 texprobe fixtures — Transform.cpp
|
||||
// WriteCorners). Texgen samples the STORED position, so the matrix folds
|
||||
// the mirror in: rendered-space y_r = midY − y.
|
||||
// In rendered space (y-down): un-rotate about the mask's pivot (a mask rotated
|
||||
// +a CCW-on-screen shows texel R(−a) about the pivot), then map the mask's rect
|
||||
// to [0,1]². Composed into one row-vector 4×4 (translation in row 3):
|
||||
// u = ( c·x + s·y + Tx − maskLeft) / maskW
|
||||
// v = ( s·x − c·y + Ty − maskTop ) / maskHd [maskHd = bottom − top]
|
||||
// with c = cos a, s = sin a, pivot P in rendered space, and
|
||||
// Tx = Px·(1−c) − s·(midY−Py), Ty = Py + c·(midY−Py) − s·Px
|
||||
// (a = 0 ⇒ u = (x − left)/W, v = (midY − y − top)/Hd — exactly the shipped and
|
||||
// verified uv1-path formula re-expressed through positions).
|
||||
bool BuildMaskMatrix(const float mr[4], float midY, bool rotated, float angle, float cxN,
|
||||
float cyN, float out[16]) {
|
||||
const float mW = mr[3] - mr[1]; // left→right
|
||||
const float mHd = mr[2] - mr[0]; // top→bottom (y-down: positive)
|
||||
if (mW == 0.0f || mHd == 0.0f)
|
||||
return false;
|
||||
float c = 1.0f, s = 0.0f, tx = 0.0f, ty = midY;
|
||||
if (rotated) {
|
||||
c = std::cos(angle);
|
||||
s = std::sin(angle);
|
||||
const float px = mr[1] + cxN * mW;
|
||||
// cyN is measured from the BOTTOM edge on screen (rendered bottom =
|
||||
// mr[2], numerically larger in this y-down space).
|
||||
const float py = mr[2] + cyN * (mr[0] - mr[2]);
|
||||
tx = px * (1.0f - c) - s * (midY - py);
|
||||
ty = py + c * (midY - py) - s * px;
|
||||
}
|
||||
for (int i = 0; i < 16; ++i)
|
||||
out[i] = 0.0f;
|
||||
out[0] = c / mW;
|
||||
out[1] = s / mHd;
|
||||
out[4] = s / mW;
|
||||
out[5] = -c / mHd;
|
||||
out[10] = 1.0f;
|
||||
out[12] = (tx - mr[1]) / mW;
|
||||
out[13] = (ty - mr[0]) / mHd;
|
||||
out[15] = 1.0f;
|
||||
return true;
|
||||
}
|
||||
|
||||
using GxTexMtxPushLoad_t = void(__fastcall *)(int unit, const float *m16);
|
||||
using PrimIndexed_t = void(__fastcall *)(int primType, int indexCount, const void *indices);
|
||||
using Crop_t = void(__fastcall *)(void *region, void *edx, float *rect);
|
||||
|
||||
PrimIndexed_t g_primIndexedOriginal = nullptr;
|
||||
|
||||
// Pending post-draw restores: bit 0 = the Gx state journal was pushed, bits
|
||||
// 1..7 = that unit's texture matrix was pushed. Set by the masked draw,
|
||||
// consumed AFTER the engine's indexed submit queues the draw (PrimIndexed_h) —
|
||||
// restoring inside the stream hook would revert the state the queued draw is
|
||||
// flushed with (the disc draw pops only past its indexed submit; see
|
||||
// FUN_GX_PRIM_INDEXED in Offsets.h).
|
||||
uint32_t g_pendingRestore = 0;
|
||||
|
||||
void RestoreAfterDraw() {
|
||||
const uint32_t bits = g_pendingRestore;
|
||||
if (bits == 0)
|
||||
return;
|
||||
g_pendingRestore = 0;
|
||||
for (int u = 1; u <= 7; ++u)
|
||||
if ((bits & (1u << u)) != 0)
|
||||
reinterpret_cast<GxTexMtxOp_t>(Offsets::FUN_GX_TEXMTX_POP)(u);
|
||||
if ((bits & 1u) != 0)
|
||||
reinterpret_cast<void(__fastcall *)()>(Offsets::FUN_GX_STATE_POP)();
|
||||
}
|
||||
|
||||
void __fastcall PrimIndexed_h(int primType, int indexCount, const void *indices) {
|
||||
g_primIndexedOriginal(primType, indexCount, indices);
|
||||
if (g_pendingRestore != 0)
|
||||
RestoreAfterDraw();
|
||||
}
|
||||
|
||||
// Runs the engine submit with each mask bound on its own unit via texgen. All
|
||||
// fallible resolution happens before any Gx state is touched; the restores run
|
||||
// in PrimIndexed_h. POD-only body for the SEH wrapper.
|
||||
bool MaskedRegionDrawImpl(void *base, void *const *masks, int maskCount, int count,
|
||||
const void *pos, int posStride, const void *s3, int s3Stride,
|
||||
const void *colors, int colorStride, int drop8, int drop9,
|
||||
const void *uv0, int uv0Stride) {
|
||||
// Stage budget: units 1..(cap−1), 7 max. Extra masks are dropped, first-
|
||||
// added wins — the base still draws with every mask that fits.
|
||||
void *dev = Game::Read<void *>(Offsets::VAR_GX_DEVICE);
|
||||
if (dev == nullptr)
|
||||
return false;
|
||||
int usable = Game::Read<int>(dev, Offsets::OFF_GXDEV_STAGE_CAP) - 1;
|
||||
if (usable > 7)
|
||||
usable = 7;
|
||||
if (usable < 1)
|
||||
return false;
|
||||
if (maskCount > usable)
|
||||
maskCount = usable;
|
||||
|
||||
float br[4]; // base rect {top, left, bottom, right}
|
||||
if (!RegionRect(base, br))
|
||||
return false;
|
||||
// Mirror the engine corner store's input rect: the texcoord crop applies
|
||||
// only when the region opted in (SetTexCoordModifiesRect) — same gate as
|
||||
// Transform.cpp's ApplyFromRect, so midY matches the store's midline.
|
||||
if (Game::Read<int>(base, Offsets::OFF_REGION_TEXCOORD_MODIFIES_RECT) != 0)
|
||||
reinterpret_cast<Crop_t>(Offsets::FUN_REGION_TEXCOORD_CROP)(base, nullptr, br);
|
||||
const float midY = br[0] + br[2];
|
||||
|
||||
void *cgx[7];
|
||||
float mtx[7][16];
|
||||
for (int i = 0; i < maskCount; ++i) {
|
||||
// The mask's owned HTEXTURE (stored by SetTexture at +0xCC, present
|
||||
// even while hidden — the draw-entry array only exists after a render,
|
||||
// which a hidden mask never gets). Resolving to a renderable each frame
|
||||
// IS the residency reference, exactly like the path-mask path.
|
||||
void *hTex = Game::Read<void *>(masks[i], Offsets::OFF_SIMPLETEXTURE_HTEXTURE);
|
||||
if (hTex == nullptr)
|
||||
return false;
|
||||
cgx[i] = reinterpret_cast<GetRenderable_t>(Offsets::FUN_TEXTURE_GET_RENDERABLE)(hTex, 1,
|
||||
nullptr);
|
||||
if (cgx[i] == nullptr)
|
||||
return false;
|
||||
float mr[4];
|
||||
if (!RegionRect(masks[i], mr))
|
||||
return false;
|
||||
float angle = 0.0f, cxN = 0.5f, cyN = 0.5f;
|
||||
const bool rotated = Texture::Transform::GetRotation(masks[i], &angle, &cxN, &cyN);
|
||||
if (!BuildMaskMatrix(mr, midY, rotated, angle, cxN, cyN, mtx[i]))
|
||||
return false;
|
||||
}
|
||||
|
||||
// Infallible tail. Track each push as it lands so a fault mid-sequence
|
||||
// never pops something that wasn't pushed.
|
||||
if (g_pendingRestore != 0)
|
||||
RestoreAfterDraw(); // stale leftover (shouldn't happen) — clear first
|
||||
auto rs = reinterpret_cast<GxRsSet_t>(Offsets::FUN_GX_RS_SET);
|
||||
auto rsPtr = reinterpret_cast<GxRsSetPtr_t>(Offsets::FUN_GX_RS_SET_PTR);
|
||||
reinterpret_cast<void(__fastcall *)()>(Offsets::FUN_GX_STATE_PUSH)();
|
||||
g_pendingRestore = 1u;
|
||||
for (int i = 0; i < maskCount; ++i) {
|
||||
const int unit = 1 + i;
|
||||
rsPtr(Offsets::GXRS_TEXTURE0 + unit, cgx[i]);
|
||||
rs(Offsets::GXRS_COMBINER0 + unit, Offsets::GXRS_COMBINE_MODULATE);
|
||||
rs(Offsets::GXRS_TEXGEN0 + unit, Offsets::GXRS_TEXGEN_OBJECT_POS);
|
||||
rs(Offsets::GXRS_TEXXFORM0 + unit, Offsets::GXRS_TEXXFORM_MATRIX);
|
||||
reinterpret_cast<GxTexMtxPushLoad_t>(Offsets::FUN_GX_TEXMTX_PUSH_LOAD)(unit, mtx[i]);
|
||||
g_pendingRestore |= 1u << unit;
|
||||
}
|
||||
g_primStreamsOriginal(count, pos, posStride, s3, s3Stride, colors, colorStride, drop8, drop9,
|
||||
uv0, uv0Stride, nullptr, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SafeMaskedRegionDraw(void *base, void *const *masks, int maskCount, int count,
|
||||
const void *pos, int posStride, const void *s3, int s3Stride,
|
||||
const void *colors, int colorStride, int drop8, int drop9,
|
||||
const void *uv0, int uv0Stride) {
|
||||
__try {
|
||||
return MaskedRegionDrawImpl(base, masks, maskCount, count, pos, posStride, s3, s3Stride,
|
||||
colors, colorStride, drop8, drop9, uv0, uv0Stride);
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
RestoreAfterDraw(); // pop exactly what was pushed before the fault
|
||||
g_enabled = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
void __fastcall PrimStreams_h(int count, const void *pos, int posStride, const void *s3,
|
||||
int s3Stride, const void *colors, int colorStride, int drop8,
|
||||
int drop9, const void *uv0, int uv0Stride, const void *uv1,
|
||||
int uv1Stride) {
|
||||
// Fast path: nothing masked, feature off, or not a plain textured quad.
|
||||
if (g_enabled && count == 4 && uv0 != nullptr && uv1 == nullptr && pos != nullptr &&
|
||||
(!g_regionMask.empty() || !g_baseMasks.empty())) {
|
||||
// The region draw passes the region's corner array as `positions`, so
|
||||
// the owning region is positions - OFF_SIMPLETEXTURE_CORNERS. A false
|
||||
// match needs a non-region draw whose positions pointer numerically
|
||||
// equals a masked region's corner array — an exact-pointer collision,
|
||||
// effectively impossible.
|
||||
void *region = reinterpret_cast<void *>(reinterpret_cast<uintptr_t>(pos) -
|
||||
Offsets::OFF_SIMPLETEXTURE_CORNERS);
|
||||
// Positioned mask regions (AddMaskTexture) win over a path mask.
|
||||
if (!g_baseMasks.empty()) {
|
||||
auto mit = g_baseMasks.find(region);
|
||||
if (mit != g_baseMasks.end() && !mit->second.empty()) {
|
||||
if (SafeMaskedRegionDraw(region, mit->second.data(),
|
||||
static_cast<int>(mit->second.size()), count, pos,
|
||||
posStride, s3, s3Stride, colors, colorStride, drop8,
|
||||
drop9, uv0, uv0Stride))
|
||||
return;
|
||||
}
|
||||
}
|
||||
auto it = g_regionMask.find(region);
|
||||
if (it != g_regionMask.end() && it->second.handle != nullptr) {
|
||||
if (SafeMaskedDraw(it->second.handle, count, pos, posStride, s3, s3Stride, colors,
|
||||
colorStride, drop8, drop9, uv0, uv0Stride))
|
||||
return;
|
||||
}
|
||||
}
|
||||
g_primStreamsOriginal(count, pos, posStride, s3, s3Stride, colors, colorStride, drop8, drop9,
|
||||
uv0, uv0Stride, uv1, uv1Stride);
|
||||
}
|
||||
|
||||
static const Game::HookAutoRegister _primHook{Offsets::FUN_GX_PRIM_STREAMS,
|
||||
reinterpret_cast<void *>(&PrimStreams_h),
|
||||
reinterpret_cast<void **>(&g_primStreamsOriginal)};
|
||||
|
||||
// The indexed submit paired with every stream call — where the texgen-path
|
||||
// restores run (see g_pendingRestore).
|
||||
static const Game::HookAutoRegister _primIdxHook{
|
||||
Offsets::FUN_GX_PRIM_INDEXED, reinterpret_cast<void *>(&PrimIndexed_h),
|
||||
reinterpret_cast<void **>(&g_primIndexedOriginal)};
|
||||
|
||||
// --- region ctor co-hook: drop a stale mask on pooled reuse ------------------
|
||||
|
||||
// __thiscall(mem, parent, layer, sublayer) -> region; dummy-EDX __fastcall.
|
||||
using Ctor_t = void *(__fastcall *)(void *mem, void *edx, void *parent, int layer, int sublayer);
|
||||
Ctor_t g_ctorOriginal = nullptr;
|
||||
|
||||
void *__fastcall Ctor_h(void *mem, void *edx, void *parent, int layer, int sublayer) {
|
||||
if (mem != nullptr) {
|
||||
// A (re)constructed region owns no mask and is no longer a mask source.
|
||||
g_regionMask.erase(mem);
|
||||
g_baseMasks.erase(mem);
|
||||
// If the recycled memory WAS a mask region, detach it from every base
|
||||
// still holding it — otherwise the draw hook would read the recycled
|
||||
// region as a mask (stale rect/texture at best, garbage at worst).
|
||||
if (g_maskRegions.erase(mem) != 0 && !g_baseMasks.empty()) {
|
||||
for (auto it = g_baseMasks.begin(); it != g_baseMasks.end();) {
|
||||
auto &v = it->second;
|
||||
v.erase(std::remove(v.begin(), v.end(), mem), v.end());
|
||||
if (v.empty())
|
||||
it = g_baseMasks.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
return g_ctorOriginal(mem, edx, parent, layer, sublayer);
|
||||
}
|
||||
|
||||
static const Game::HookAutoRegister _ctorHook{Offsets::FUN_SIMPLETEXTURE_CTOR,
|
||||
reinterpret_cast<void *>(&Ctor_h),
|
||||
reinterpret_cast<void **>(&g_ctorOriginal)};
|
||||
|
||||
// --- Lua surface -------------------------------------------------------------
|
||||
|
||||
// texture:SetMask("path") sets the mask; SetMask(nil) / SetMask("") clears it.
|
||||
int __fastcall Script_SetMask(void *L) {
|
||||
void *region = Game::Lua::ResolveObject(L, 1);
|
||||
if (region == nullptr)
|
||||
return 0;
|
||||
const char *path = (Game::Lua::GetTop(L) >= 2 && Game::Lua::Type(L, 2) == Game::Lua::TYPE_STRING)
|
||||
? Game::Lua::ToString(L, 2)
|
||||
: nullptr;
|
||||
if (path == nullptr || path[0] == '\0') {
|
||||
g_regionMask.erase(region);
|
||||
return 0;
|
||||
}
|
||||
auto it = g_regionMask.find(region);
|
||||
if (it != g_regionMask.end() && it->second.path == path)
|
||||
return 0; // unchanged
|
||||
MaskEntry e;
|
||||
e.handle = LoadByPath(path); // never null (engine substitutes a fallback)
|
||||
e.path = path;
|
||||
g_regionMask[region] = std::move(e);
|
||||
return 0;
|
||||
}
|
||||
|
||||
using ScriptFn_t = int(__fastcall *)(void *L);
|
||||
using RegionHide_t = void(__fastcall *)(void *region);
|
||||
|
||||
// frame:CreateMaskTexture([name, layer, ...]) — mint a MaskTexture. Delegates to
|
||||
// the engine's own CreateTexture (it reads the args off this same stack and
|
||||
// pushes the new region), then hides it (a mask is a source, never drawn) and
|
||||
// records it as a valid mask source.
|
||||
int __fastcall Script_CreateMaskTexture(void *L) {
|
||||
const int rc = reinterpret_cast<ScriptFn_t>(Offsets::FUN_SCRIPT_CREATE_TEXTURE)(L);
|
||||
if (rc != 1)
|
||||
return rc; // engine raised an error, or pushed nothing
|
||||
void *region = Game::Lua::ResolveObject(L, Game::Lua::GetTop(L));
|
||||
if (region != nullptr) {
|
||||
Game::Ref<uint32_t>(region, Offsets::OFF_REGION_DESIRED_SHOWN) = 0;
|
||||
reinterpret_cast<RegionHide_t>(Offsets::FUN_FONTSTRING_HIDE)(region);
|
||||
g_maskRegions.insert(region);
|
||||
}
|
||||
return 1; // the region object the engine pushed is our return value
|
||||
}
|
||||
|
||||
// texture:AddMaskTexture(mask) — clip this texture to the mask region. Repeat
|
||||
// calls add further masks (drawn on units 1..7; extras past the device's stage
|
||||
// budget are stored but not applied). Adding the same mask twice is a no-op.
|
||||
int __fastcall Script_AddMaskTexture(void *L) {
|
||||
void *base = Game::Lua::ResolveObject(L, 1);
|
||||
void *mask = Game::Lua::ResolveObject(L, 2);
|
||||
if (base == nullptr || mask == nullptr || base == mask)
|
||||
return 0;
|
||||
auto &v = g_baseMasks[base];
|
||||
for (void *m : v)
|
||||
if (m == mask)
|
||||
return 0;
|
||||
v.push_back(mask);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// texture:RemoveMaskTexture([mask]) — drop the given mask, or all if omitted.
|
||||
int __fastcall Script_RemoveMaskTexture(void *L) {
|
||||
void *base = Game::Lua::ResolveObject(L, 1);
|
||||
if (base == nullptr)
|
||||
return 0;
|
||||
void *mask = Game::Lua::ResolveObject(L, 2);
|
||||
auto it = g_baseMasks.find(base);
|
||||
if (it == g_baseMasks.end())
|
||||
return 0;
|
||||
if (mask == nullptr) {
|
||||
g_baseMasks.erase(it);
|
||||
return 0;
|
||||
}
|
||||
auto &v = it->second;
|
||||
v.erase(std::remove(v.begin(), v.end(), mask), v.end());
|
||||
if (v.empty())
|
||||
g_baseMasks.erase(it);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// texture:GetNumMaskTextures() -> count.
|
||||
int __fastcall Script_GetNumMaskTextures(void *L) {
|
||||
void *base = Game::Lua::ResolveObject(L, 1);
|
||||
int n = 0;
|
||||
if (base != nullptr) {
|
||||
auto it = g_baseMasks.find(base);
|
||||
if (it != g_baseMasks.end())
|
||||
n = static_cast<int>(it->second.size());
|
||||
}
|
||||
Game::Lua::PushNumber(L, n);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// texture:GetMaskTexture(index) -> the index-th mask region object (or nil).
|
||||
int __fastcall Script_GetMaskTexture(void *L) {
|
||||
void *base = Game::Lua::ResolveObject(L, 1);
|
||||
const int index =
|
||||
(Game::Lua::GetTop(L) >= 2) ? static_cast<int>(Game::Lua::ToNumber(L, 2)) : 1;
|
||||
if (base != nullptr && index >= 1) {
|
||||
auto it = g_baseMasks.find(base);
|
||||
if (it != g_baseMasks.end() && static_cast<size_t>(index) <= it->second.size()) {
|
||||
UI::FrameObject::Push(L, it->second[index - 1]);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// _classicapi_TextureMaskEnable([on]) -> enabled. Kill switch (also what the
|
||||
// SEH latch trips on a draw fault).
|
||||
int __fastcall Script_TextureMaskEnable(void *L) {
|
||||
if (Game::Lua::GetTop(L) == 0)
|
||||
g_enabled = true;
|
||||
else
|
||||
g_enabled = Game::Lua::ToBoolean(L, 1) != 0;
|
||||
Game::Lua::PushBool(L, g_enabled);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const Game::Lua::FrameMethodEntry g_methods[] = {
|
||||
{"SetMask", &Script_SetMask},
|
||||
{"AddMaskTexture", &Script_AddMaskTexture},
|
||||
{"RemoveMaskTexture", &Script_RemoveMaskTexture},
|
||||
{"GetNumMaskTextures", &Script_GetNumMaskTextures},
|
||||
{"GetMaskTexture", &Script_GetMaskTexture},
|
||||
};
|
||||
|
||||
const Game::Lua::FrameMethodEntry g_frameMethods[] = {
|
||||
{"CreateMaskTexture", &Script_CreateMaskTexture},
|
||||
};
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterFrameMethods(
|
||||
reinterpret_cast<void *>(Offsets::VAR_TEXTURE_METHOD_REGISTRY), g_methods,
|
||||
static_cast<int>(sizeof(g_methods) / sizeof(g_methods[0])));
|
||||
Game::Lua::RegisterFrameMethods(
|
||||
reinterpret_cast<void *>(Offsets::VAR_FRAME_METHOD_REGISTRY), g_frameMethods,
|
||||
static_cast<int>(sizeof(g_frameMethods) / sizeof(g_frameMethods[0])));
|
||||
Game::Lua::RegisterGlobalFunction("_classicapi_TextureMaskEnable", &Script_TextureMaskEnable);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
void PrepareForReload() {
|
||||
// /reload frees every region; forget the mask maps so a recycled address
|
||||
// can't be mistaken for a still-masked region before its ctor re-clears it.
|
||||
g_regionMask.clear();
|
||||
g_baseMasks.clear();
|
||||
g_maskRegions.clear();
|
||||
}
|
||||
|
||||
} // namespace Texture::Mask
|
||||
@@ -0,0 +1,26 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
// Texture:SetMask backport — see texture/Mask.cpp.
|
||||
|
||||
namespace Texture::Mask {
|
||||
|
||||
// Called from the reload/teardown hooks (FrameScript_Initialize + world->glue):
|
||||
// the reload destroys every addon Texture and the region pool reuses their
|
||||
// pointers, so forget the per-region mask map. (The region ctor co-hook also
|
||||
// clears an individual entry on reuse; this drops them all in one shot.)
|
||||
void PrepareForReload();
|
||||
|
||||
} // namespace Texture::Mask
|
||||
@@ -679,4 +679,14 @@ void PrepareForReload() {
|
||||
// ApplyToNode / Transform.h for the timing contract.
|
||||
void RotateFontStringNode(void *node, void *fs) { ApplyToNode(node, fs, /*fresh=*/true); }
|
||||
|
||||
bool GetRotation(void *region, float *angleRad, float *cxN, float *cyN) {
|
||||
auto it = g_xf.find(region);
|
||||
if (it == g_xf.end() || it->second.angle == 0.0f)
|
||||
return false;
|
||||
*angleRad = it->second.angle;
|
||||
*cxN = it->second.cxN;
|
||||
*cyN = it->second.cyN;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace Texture::Transform
|
||||
|
||||
@@ -34,4 +34,12 @@ void PrepareForReload();
|
||||
// Offsets.h (OFF_TEXT_NODE_PAGE_BUFFERS …).
|
||||
void RotateFontStringNode(void *node, void *fs);
|
||||
|
||||
// The stored rotation for `region` (from SetRotation), if any: writes the angle
|
||||
// (radians, positive = counter-clockwise on screen) and the normalized pivot,
|
||||
// returns true. False when the region has no rotation. Texture::Mask reads a
|
||||
// MaskTexture's rotation through this to rotate its clip shape — a mask never
|
||||
// renders, so its corner rewrite is moot; the {angle, pivot} record is the
|
||||
// entire effect.
|
||||
bool GetRotation(void *region, float *angleRad, float *cxN, float *cyN);
|
||||
|
||||
} // namespace Texture::Transform
|
||||
|
||||
@@ -62,10 +62,19 @@ constexpr const char *kNamesLower[SK_COUNT] = {
|
||||
};
|
||||
|
||||
// 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.
|
||||
// Tooltips are few (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.
|
||||
//
|
||||
// Few — but NOT permanent: every /reload (and logout) destroys and recreates
|
||||
// the tooltip objects, usually at new addresses. `PrepareForReload` clears the
|
||||
// table at each UI teardown; without it the table gained ~2 dead cells per
|
||||
// reload until full (≈18 reloads — SetScript then failed with "doesn't have a
|
||||
// 'OnTooltipSetItem' script") and a stale cell whose address the allocator
|
||||
// recycled into a new tooltip fired its dead handler ref → crash (issue #33).
|
||||
// The clear is also semantically right: handler refs never survive the Lua
|
||||
// reset, so post-reload every cell held garbage regardless.
|
||||
struct Cell {
|
||||
void *tooltip;
|
||||
uint32_t slot[SK_COUNT][2]; // [kind] = {handler, exec context}
|
||||
@@ -238,4 +247,10 @@ Suppressor::~Suppressor() {
|
||||
--g_suppress;
|
||||
}
|
||||
|
||||
void PrepareForReload() {
|
||||
// Forget every cell — count gates the scan, so resetting it is a full
|
||||
// clear; the create path re-initializes a cell's slots on reuse.
|
||||
g_cellCount = 0;
|
||||
}
|
||||
|
||||
} // namespace Tooltip::SetEvents
|
||||
|
||||
@@ -29,4 +29,14 @@ struct Suppressor {
|
||||
Suppressor &operator=(const Suppressor &) = delete;
|
||||
};
|
||||
|
||||
// Drop every per-tooltip handler cell. Must run at each UI teardown
|
||||
// (FrameScript_Initialize_h — /reload and /logout): the engine destroys and
|
||||
// recreates the tooltip objects, so the cells' tooltip pointers go stale and
|
||||
// their handler refs die with the reset Lua state. Without this the fixed
|
||||
// cell table leaks one entry per recreated tooltip per reload until it fills
|
||||
// (SetScript then errors "doesn't have a 'OnTooltipSetX' script") and a
|
||||
// recycled tooltip address can match a stale cell and fire a dead handler
|
||||
// ref — issue #33.
|
||||
void PrepareForReload();
|
||||
|
||||
} // namespace Tooltip::SetEvents
|
||||
|
||||
+10
-14
@@ -35,6 +35,7 @@
|
||||
#include "unit/TokenObserver.h"
|
||||
|
||||
#include "Offsets.h"
|
||||
#include "object/Resolve.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
@@ -55,20 +56,15 @@ const char *const *EventNames() {
|
||||
static_cast<uintptr_t>(Offsets::VAR_EVENT_NAME_TABLE_STATIC));
|
||||
}
|
||||
|
||||
// Both register and unregister funnel through `FUN_00464890`, which
|
||||
// dereferences the client/player-rooted observer registry at
|
||||
// `[VAR_LOCAL_PLAYER_PTR]` (reads its `+0x24` mask). That root is NULL out of
|
||||
// world — login screen, loading screens, logout teardown — where the engine
|
||||
// helper null-derefs (crash: `MOV EAX,[EDX+0x24]`, EDX=0). Bail when it isn't
|
||||
// up: there are no live objects or observer nodes to touch anyway (nodes die
|
||||
// with their objects during teardown), and in-world callers re-register/clean
|
||||
// up on the next tick. Unlike `Player::Equipment` (which only ever registers
|
||||
// from the enter-world hook), our consumers register from Lua / WorldTick,
|
||||
// which can run mid-transition — hence the guard here.
|
||||
bool RegistryReady() {
|
||||
return *reinterpret_cast<void *const volatile *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_LOCAL_PLAYER_PTR)) != nullptr;
|
||||
}
|
||||
// Both register and unregister funnel through `FUN_00464890`, whose registry
|
||||
// root at `[VAR_LOCAL_PLAYER_PTR]` is NULL out of world — the shared
|
||||
// `Object::RegistryReady` (object/Resolve.h) documents the crash mechanics.
|
||||
// Bail when it isn't up: there are no live objects or observer nodes to touch
|
||||
// anyway (nodes die with their objects during teardown), and in-world callers
|
||||
// re-register/clean up on the next tick. Unlike `Player::Equipment` (which
|
||||
// only ever registers from the enter-world hook), our consumers register from
|
||||
// Lua / WorldTick, which can run mid-transition — hence the guard here.
|
||||
using Object::RegistryReady;
|
||||
|
||||
// Bytes the engine watches for the observer on event index `i` — verbatim
|
||||
// from `FUN_0051bbb0`'s size switch. The 8-byte entries are the 64-bit GUID
|
||||
|
||||
Reference in New Issue
Block a user