mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-13 22:50:44 +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 | ||
|
|
7225eeb32c | ||
|
|
5a1d94c001 | ||
|
|
1536741594 | ||
|
|
9b9d80d807 | ||
|
|
bac27b9b2b | ||
|
|
31e85731b6 | ||
|
|
58ddcace32 | ||
|
|
58ee86447b | ||
|
|
a4d6290b80 | ||
|
|
998deeac4a | ||
|
|
af02ddadb1 | ||
|
|
93f3131307 | ||
|
|
653a2c6e3a |
@@ -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>
|
||||
@@ -15,12 +15,15 @@ if C_AddOns.DoesAddOnExist('pfUI') then
|
||||
-- written for on their pfActionBar buttons, which breaks them. While
|
||||
-- the actionbar module builds those buttons, temporarily wrap
|
||||
-- CreateFrame to shadow HookScript with a falsy field so those forks
|
||||
-- fall back to their vanilla SetScript path. The maintained "brues"
|
||||
-- fork handles HookScript itself, so it's detected via the TOC
|
||||
-- X-Website tag and left alone. The wrapper is removed as soon as the
|
||||
-- actionbar module finishes loading so it affects nothing else.
|
||||
-- fall back to their vanilla SetScript path. The wrapper is removed as
|
||||
-- soon as the actionbar module finishes loading so it affects nothing
|
||||
-- else. A pfUI is trusted to handle HookScript itself if EITHER signal
|
||||
-- is present: the maintained "brues" X-Website tag OR the
|
||||
-- `pfUI.handlesHookScript` capability flag. Without the website the
|
||||
-- flag is required; if neither is present we apply the shim.
|
||||
local website = GetAddOnMetadata("pfUI", "X-Website")
|
||||
if not website or not strfind(website, 'brues') then
|
||||
local hasBruesWebsite = website and strfind(website, 'brues')
|
||||
if not hasBruesWebsite and not pfUI.handlesHookScript then
|
||||
local _createFrame = CreateFrame
|
||||
local HookedCreateFrame = function(frameType, name, parent, template)
|
||||
local frame = _createFrame(frameType, name, parent, template)
|
||||
@@ -81,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.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
|
||||
| [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`, `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` |
|
||||
@@ -50,7 +80,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootAllCorpses`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
|
||||
| [LootHistory](docs/API.md#loothistory) | `C_LootHistory.GetNumItems`, `C_LootHistory.GetItem`, `C_LootHistory.GetPlayerInfo`, `C_LootHistory.Clear` |
|
||||
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount` |
|
||||
| [Lua](docs/API.md#lua) | `coroutine.create`, `coroutine.resume`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `string.gmatch`, `string.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `table.wipe` |
|
||||
| [Lua](docs/API.md#lua) | `collectgarbage` (5.1 options), `coroutine.create`, `coroutine.resume`, `coroutine.running`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `string.gmatch`, `string.gsub` (table replacement), `string.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `table.maxn`, `table.wipe`, `unpack` (range args) |
|
||||
| [Macros](docs/API.md#macros) | `C_Macro.CreateMacro`, `C_Macro.EditMacro`, `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
|
||||
| [Mail](docs/API.md#mail) | `GetInboxItemLink`, `GetSendMailItemLink` |
|
||||
| [Map](docs/API.md#map) | `C_Map.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapOverlays`, `C_Map.GetMapWorldSize` |
|
||||
@@ -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,14 +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. |
|
||||
</details>
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -3815,3 +3815,42 @@ estimate. Anyone who wants vanilla threat should run an existing threat
|
||||
addon. ClassicAPI's only worthwhile contribution in this space is an
|
||||
unrelated precise-combat-log event feed — filed as its own thing, not as
|
||||
"threat".
|
||||
|
||||
## 99. `C_AddOns.GetAddOnLocalTable(name)` — gate on loaded + opt-in TOC flag — DONE
|
||||
|
||||
Shipped gated (branch `feat/lua-syntax-transpile`,
|
||||
[src/addons/LocalTable.cpp](src/addons/LocalTable.cpp)). Returns the addon's
|
||||
private namespace table **only** when BOTH hold, else `nil`:
|
||||
1. the addon `name` is **loaded** (`FUN_ADDON_IS_LOADED`, by-name), and
|
||||
2. its `.toc` declares `## AllowAddOnTableAccess: <nonzero>` (scanned via
|
||||
`FUN_FILE_READ` on `Interface\AddOns\<name>\<name>.toc`, read as a TOC
|
||||
boolean flag; approach (a) from the notes below — no parser hook).
|
||||
|
||||
The two paths stay separate, as required:
|
||||
- **Internal** `__addonns` (the `local name, ns = ...` preamble) stays
|
||||
**ungated** — an addon always gets its own namespace. Still in
|
||||
`luasyntax/Transpile.cpp`.
|
||||
- **Public** `C_AddOns.GetAddOnLocalTable` carries the gate, in
|
||||
`addons/LocalTable.cpp`.
|
||||
|
||||
Both resolve to the SAME table per addon via `LuaSyntax::PushAddonNamespace`
|
||||
(the one owner of the registry-backed name→table map, exposed by
|
||||
[src/luasyntax/AddonNamespace.h](src/luasyntax/AddonNamespace.h)) — so an addon
|
||||
reads back exactly what it (or its files' `...`) stored.
|
||||
|
||||
Directive spelling + function name verified present in the 1.15.8 Classic Era
|
||||
binary (`AllowAddOnTableAccess:` TOC key, `GetAddOnLocalTable`).
|
||||
|
||||
Deliberate scope choices (revisit only if an addon needs otherwise):
|
||||
- **Name (string) input only.** A numeric index returns `nil` — cross-addon
|
||||
table access is by name, and the map is name-keyed. Every other `C_AddOns.*`
|
||||
wrapper accepts name-or-index; this one does not.
|
||||
- **`nil` on denial, not a Lua error.** Matches the project's modern-API
|
||||
convention (nil over `lua_error`). Retail may raise instead; switch the
|
||||
denial branches to `Game::Lua::Error` if strict parity is wanted.
|
||||
- **No per-name cache** on the TOC scan — GetAddOnLocalTable is not a hot path.
|
||||
|
||||
Not shipped by default: no addon opts in yet. The embedded `!!!ClassicAPI`
|
||||
addon could add `## AllowAddOnTableAccess: 1` to its `.toc` to expose its own
|
||||
namespace as a worked example, but that's a product/security decision left to
|
||||
the maintainer.
|
||||
|
||||
+744
-20
@@ -21,10 +21,19 @@ build instructions.
|
||||
- [`C_AddOns.GetAddOnSecurity(indexOrName)`](#c_addonsgetaddonsecurityindexorname)
|
||||
- [`C_AddOns.DoesAddOnExist(indexOrName)`](#c_addonsdoesaddonexistindexorname)
|
||||
- [`C_AddOns.GetAddOnOptionalDependencies(indexOrName)`](#c_addonsgetaddonoptionaldependenciesindexorname)
|
||||
- [`C_AddOns.GetAddOnLocalTable(name)`](#c_addonsgetaddonlocaltablename)
|
||||
- [Conditional and multi-flavor TOC loading](#conditional-and-multi-flavor-toc-loading)
|
||||
- [SavedVariables loaded first](#savedvariables-loaded-first)
|
||||
|
||||
- [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)
|
||||
|
||||
@@ -175,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)
|
||||
@@ -183,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)
|
||||
@@ -340,11 +360,19 @@ build instructions.
|
||||
- [`C_LossOfControl.GetActiveLossOfControlData(index)`](#c_lossofcontrolgetactivelossofcontroldataindex)
|
||||
|
||||
- [Lua](#lua)
|
||||
- [Lua 5.1 syntax](#lua-51-syntax)
|
||||
- [String methods (`s:upper()`, `s:format(...)`)](#string-methods-supper-sformat)
|
||||
- [`getfenv` / `setfenv` environment protection](#getfenv--setfenv-environment-protection)
|
||||
- [`select(index, ...)`](#selectindex-)
|
||||
- [`unpack(list [, i [, j]])`](#unpacklist--i--j)
|
||||
- [`collectgarbage(opt [, arg])`](#collectgarbageopt--arg)
|
||||
- [`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)
|
||||
- [`strsplit(sep, str [, pieces])`](#strsplitsep-str--pieces)
|
||||
- [`strjoin(delimiter, ...)`](#strjoindelimiter-)
|
||||
- [`strtrim(str [, chars])`](#strtrimstr--chars)
|
||||
@@ -358,6 +386,7 @@ build instructions.
|
||||
- [`coroutine.yield(...)`](#coroutineyield)
|
||||
- [`coroutine.status(co)`](#coroutinestatusco)
|
||||
- [`coroutine.wrap(fn)`](#coroutinewrapfn)
|
||||
- [`coroutine.running()`](#coroutinerunning)
|
||||
- [Async pattern (RunAsync + C_Timer.After)](#async-pattern-runasync--c_timerafter)
|
||||
|
||||
- [Macros](#macros)
|
||||
@@ -598,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)
|
||||
@@ -926,6 +957,141 @@ C_AddOns.GetAddOnOptionalDependencies("MyAddon") -- "Atlas", "pfQuest"
|
||||
C_AddOns.GetAddOnOptionalDependencies("Atlas") -- (nothing — no OptionalDeps)
|
||||
```
|
||||
|
||||
### `C_AddOns.GetAddOnLocalTable(name)`
|
||||
|
||||
Returns the private table of the addon named `name`. This is the same
|
||||
table the addon's own files receive as the second value of `...`:
|
||||
|
||||
```lua
|
||||
-- inside MyAddon's files
|
||||
local addonName, addonTable = ...
|
||||
```
|
||||
|
||||
Another addon reads that table only when both conditions are true:
|
||||
|
||||
1. `MyAddon` is loaded, and
|
||||
2. `MyAddon`'s `.toc` file declares `## AllowAddOnTableAccess: 1`.
|
||||
|
||||
If either condition is false, the function returns `nil`. An addon
|
||||
that does not opt in keeps its table private. The check protects each
|
||||
addon's table from access it did not permit.
|
||||
|
||||
```lua
|
||||
-- MyAddon.toc contains: ## AllowAddOnTableAccess: 1
|
||||
local t = C_AddOns.GetAddOnLocalTable("MyAddon") -- MyAddon's table
|
||||
t.sharedValue = 5 -- MyAddon sees this too
|
||||
|
||||
C_AddOns.GetAddOnLocalTable("DebugTools") -- nil (no opt-in directive)
|
||||
C_AddOns.GetAddOnLocalTable("DoesNotExist") -- nil (not loaded)
|
||||
```
|
||||
|
||||
Pass the addon name as a string. A numeric index returns `nil` — one
|
||||
addon refers to another by name, not by load order.
|
||||
|
||||
### Conditional and multi-flavor TOC loading
|
||||
|
||||
Modern addons often support several game versions from one folder.
|
||||
ClassicAPI backports two TOC mechanisms so these addons load on 1.12.
|
||||
This client is the **Vanilla** game type of the **Classic** family.
|
||||
|
||||
**Flavor TOC files.** Some addons ship no plain `<Name>.toc`. They ship
|
||||
one TOC per version instead. ClassicAPI loads such an addon from a
|
||||
version-specific TOC, and prefers it even when a plain `<Name>.toc` also
|
||||
exists:
|
||||
|
||||
- `<Name>_ClassicAPI.toc` — used on any client, because ClassicAPI is
|
||||
always present.
|
||||
- `<Name>_Turtle.toc` — used only on a Turtle client. It wins over the
|
||||
`_ClassicAPI` file.
|
||||
|
||||
Both suffixes are ClassicAPI conventions. Name a TOC this way to target
|
||||
this client on purpose. ClassicAPI does not use `_Vanilla` or `_Classic`
|
||||
files — those target the 1.15 Classic Era client, which runs a modern
|
||||
engine this build does not match.
|
||||
|
||||
**Flavor Bindings files.** The same selection applies to an addon's
|
||||
keybinding file. An addon can ship `Bindings_ClassicAPI.xml` (used on any
|
||||
client) or `Bindings_Turtle.xml` (used only on a Turtle client, and it
|
||||
wins), in place of or alongside a plain `Bindings.xml`:
|
||||
|
||||
- A flavor file wins over a plain `Bindings.xml`.
|
||||
- A flavor-only addon (no plain `Bindings.xml`) still loads its bindings.
|
||||
|
||||
The `_Vanilla` / `_Classic` names are not used here either, for the same
|
||||
reason as the TOC files.
|
||||
|
||||
**Multi-flavor `## Interface:` version.** A retail TOC can list several
|
||||
interface versions on one line:
|
||||
|
||||
```
|
||||
## Interface: 120100, 50504, 38002, 20506, 11200
|
||||
```
|
||||
|
||||
**Per-line directives.** Inside a TOC, gate individual file lines with a
|
||||
condition, or expand a path variable:
|
||||
|
||||
```
|
||||
# Loads on this client (game type is vanilla):
|
||||
Vanilla.lua [AllowLoadGameType vanilla]
|
||||
|
||||
# Dropped on this client:
|
||||
Mainline.lua [AllowLoadGameType mainline]
|
||||
|
||||
# Loads only under a matching client locale:
|
||||
Localization\deDE.lua [AllowLoadTextLocale deDE]
|
||||
|
||||
# Path variables expand before the file loads:
|
||||
[Family]\Init.lua # -> Classic\Init.lua
|
||||
[Game]\Init.lua # -> Vanilla\Init.lua
|
||||
Localization\[TextLocale].lua # -> Localization\enUS.lua (your locale)
|
||||
```
|
||||
|
||||
How each token resolves on this client:
|
||||
|
||||
| Directive | Result |
|
||||
|---|---|
|
||||
| `[AllowLoadGameType ...]` | Loads the line when the list contains `vanilla`. |
|
||||
| `[AllowLoadTextLocale ...]` | Loads the line when the list contains your client locale (the `GetLocale()` code). |
|
||||
| `[AllowLoad ...]` | Loads on `game`, drops on `glue`. |
|
||||
| `[Family]` | `Classic` |
|
||||
| `[Game]` | `Vanilla` |
|
||||
| `[TextLocale]` | Your client locale code, for example `enUS`. |
|
||||
|
||||
A line loads only when every condition on it passes. An unknown condition
|
||||
drops the line. A file gated by a rule this client does not know stays
|
||||
unloaded.
|
||||
|
||||
**Limits.**
|
||||
|
||||
- Conditions on `## metadata` lines are not supported. Only file-reference
|
||||
lines are gated. Retail added metadata-line conditions in a much later
|
||||
version.
|
||||
- `[AllowLoad glue]` never loads. Addon files load in-game only.
|
||||
|
||||
### SavedVariables loaded first
|
||||
|
||||
Normally 1.12 runs an addon's files first, then loads its SavedVariables,
|
||||
then fires `ADDON_LOADED`. So file-scope code sees its SavedVariables as
|
||||
`nil`. A modern addon avoids this with one TOC line:
|
||||
|
||||
```
|
||||
## SavedVariables: MyAddonDB
|
||||
## LoadSavedVariablesFirst: 1
|
||||
```
|
||||
|
||||
ClassicAPI honors `## LoadSavedVariablesFirst`. For a flagged addon, its
|
||||
SavedVariables load **before** its files run, so file-scope code sees them:
|
||||
|
||||
```lua
|
||||
-- With the directive, in MyAddon's file:
|
||||
local db = MyAddonDB -- the restored table, not nil
|
||||
```
|
||||
|
||||
This covers both `## SavedVariables` and `## SavedVariablesPerCharacter`.
|
||||
File-scope reads and writes both behave as they do on modern clients. A
|
||||
SavedVariables file exists only after the first save, so the first-ever
|
||||
login still sees `nil` — there is nothing on disk to load yet.
|
||||
|
||||
## AuctionHouse
|
||||
|
||||
### `C_AuctionHouse.PostItem(itemLocation, duration, quantity, numStacks, bid, buyout)`
|
||||
@@ -986,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
|
||||
@@ -4232,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
|
||||
@@ -4474,28 +4784,177 @@ addon ports written as `function(self, delta) … end` work unmodified:
|
||||
`OnClick(self, button)`, `OnValueChanged(self, value)`, `OnUpdate(self, elapsed)`.
|
||||
- `OnEvent`: `(self, event, arg1..argN)`.
|
||||
|
||||
It's purely **additive** — the `this` / `arg1` / `event` globals are still set, so
|
||||
vanilla-style handlers keep working; a handler declaring no parameters just ignores
|
||||
the extras. `SetModernScriptArgs(enable)` sets the state and returns it;
|
||||
The `this` / `arg1` / `event` globals stay set, so a handler that reads them still
|
||||
works. A handler that declares no parameters is unaffected — it cannot see the
|
||||
positional arguments. `SetModernScriptArgs(enable)` sets the state and returns it.
|
||||
`GetModernScriptArgs()` returns the current state.
|
||||
|
||||
**Default OFF (opt-in).** It reimplements the tail of the engine's hottest Lua
|
||||
path (the runner that fires for every `OnUpdate`, every frame), so it stays off
|
||||
unless you ask for it; while off the dispatch path is exactly vanilla. Enable it
|
||||
once at load if your addon relies on the modern handler signature.
|
||||
**Caveat — a parameter that was always nil now gets a value.** Vanilla passed
|
||||
every handler zero arguments, so any parameter a handler declared was always nil.
|
||||
When this feature is on, a declared parameter gets its real value. A modern
|
||||
`function(self, delta)` handler needs this behavior. But it also changes a vanilla
|
||||
handler that declared a parameter and expected it to be nil. One example is a
|
||||
function used both as a direct call (with a real argument) and as a script
|
||||
handler. If such a handler misbehaves, disable the feature with
|
||||
`SetModernScriptArgs(false)`.
|
||||
|
||||
**Default ON.** Modern handler signatures are a core Lua 5.1 feature, so ports
|
||||
that use them work with no setup. It reimplements the tail of the engine's
|
||||
hottest Lua path (the runner that fires for every `OnUpdate`, every frame); if
|
||||
you ever need exact-vanilla dispatch, `SetModernScriptArgs(false)` turns it off
|
||||
and both runners become a straight passthrough.
|
||||
|
||||
```lua
|
||||
SetModernScriptArgs(true) -- returns true
|
||||
|
||||
-- On by default; SetModernScriptArgs(false) would turn it off.
|
||||
local f = CreateFrame("Frame")
|
||||
f:EnableMouseWheel(true)
|
||||
f:SetScript("OnMouseWheel", function(self, delta)
|
||||
-- `self` and `delta` are bound; `this` / `arg1` still work too
|
||||
end)
|
||||
|
||||
GetModernScriptArgs() -- true
|
||||
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)`
|
||||
@@ -8346,6 +8805,136 @@ backported addons find them. Most are single-function additions (several are
|
||||
just the 5.0→5.1 renames — `string.gmatch`←`gfind`, `math.fmod`←`math.mod`);
|
||||
`coroutine.*` restores the whole stripped coroutine library.
|
||||
|
||||
### Lua 5.1 syntax
|
||||
|
||||
1.12 runs Lua 5.0. It cannot compile five pieces of Lua 5.1 syntax that
|
||||
modern addons use. These are the length operator `#`, the modulo operator
|
||||
`%`, `...` used as an expression, `0x` hexadecimal number literals, and
|
||||
leveled long brackets (`[=[ ... ]=]`). ClassicAPI rewrites addon source to
|
||||
the 5.0 equivalent before it compiles, so all five work:
|
||||
|
||||
```lua
|
||||
local n = #myTable -- length operator
|
||||
local r = a % b -- modulo operator
|
||||
local args = { ... } -- ... as an expression, not only in a parameter list
|
||||
local mask = 0xFF00 -- hex number literal
|
||||
local doc = [=[ has ]] in it ]=] -- leveled long bracket
|
||||
```
|
||||
|
||||
The rewrite is transparent. You do not call anything. It runs on every
|
||||
chunk the client compiles — addon files, `loadstring`, and XML `<OnLoad>`
|
||||
handlers.
|
||||
|
||||
What each form does:
|
||||
|
||||
- `#x` gives the length of a string, or a border of a table (the value
|
||||
Lua 5.1 `#` returns). It ignores `table.setn`.
|
||||
- `a % b` uses the Lua 5.1 result `a - floor(a/b)*b`, which takes the sign
|
||||
of `b`. This differs from `math.mod` (C `fmod`, truncated toward zero):
|
||||
`-1 % 3` is `2`, while `math.mod(-1, 3)` is `-1`.
|
||||
- `...` as an expression yields all the varargs. The `...` in a function
|
||||
parameter list stays as the vararg declaration.
|
||||
- `0xFF00` becomes its decimal value (`65280`) — the same number Lua 5.1
|
||||
produces. Vanilla's lexer rejects `0x` literals, so without this an addon
|
||||
needs `tonumber("0xFF00", 16)`.
|
||||
- `[=[ ... ]=]` (a leveled long bracket, with any number of `=`) holds text
|
||||
a plain `[[ ... ]]` cannot, such as text that contains `]]`. ClassicAPI
|
||||
rewrites a leveled long string to a plain long string, or to a quoted
|
||||
string when its body needs one. It removes a leveled long comment
|
||||
(`--[=[ ... ]=]`).
|
||||
|
||||
**Addon file arguments.** A modern addon reads its name and its private
|
||||
table from the file arguments:
|
||||
|
||||
```lua
|
||||
local addonName, addonTable = ...
|
||||
```
|
||||
|
||||
Vanilla never passed these to an addon file. ClassicAPI supplies them to
|
||||
any file under `Interface\AddOns\` that reads `...`. `addonName` is the
|
||||
folder name. `addonTable` is one table shared by every file of that addon.
|
||||
To read another addon's table, use
|
||||
[`C_AddOns.GetAddOnLocalTable`](#c_addonsgetaddonlocaltablename), which
|
||||
needs that addon's opt-in.
|
||||
|
||||
**Limits.**
|
||||
|
||||
- The rewrite reads strings and comments correctly. A `%` in `"%d"` or a
|
||||
`#` in `--[[ # ]]` is left alone.
|
||||
- A plain long string that contains `[[` nests and closes by depth, the
|
||||
same as the 5.0 engine. Lua 5.1 does not nest. For text that contains
|
||||
`[[` or `]]`, use a leveled bracket (`[=[ ... ]=]`).
|
||||
- ClassicAPI rewrites a leveled long string to a plain `[[ ... ]]` when it
|
||||
can, which keeps the exact value. A body that contains `[[` or `]]`, or
|
||||
ends with `]`, becomes a quoted string instead. A quoted string keeps a
|
||||
leading newline in the value. Lua 5.1 drops that newline.
|
||||
- Only integer hex is converted. Hex *floats* (`0x1.8p3`) and literals
|
||||
wider than 64 bits are left as-is. Both are almost nonexistent in addon
|
||||
code.
|
||||
- Error line numbers stay correct. The rewrite adds no new lines.
|
||||
- The globals `__len` and `__mod` are the rewrite's helper functions.
|
||||
Treat them as internal. Do not call them directly.
|
||||
- To turn a rewrite off for diagnosis, call
|
||||
`_classicapi_SetTranspileOption(name, false)`, where `name` is
|
||||
`"Length"`, `"Modulo"`, `"VarargExpansion"`, `"HexLiterals"`, or
|
||||
`"LongBrackets"`. This
|
||||
reverts affected chunks to the state that fails to compile, so use it
|
||||
only to answer "is the rewrite breaking this addon?".
|
||||
|
||||
### String methods (`s:upper()`, `s:format(...)`)
|
||||
|
||||
In Lua 5.1, every string value accepts method calls: `s:method(...)`
|
||||
resolves through the `string` table. Lua 5.0 has no string methods — on
|
||||
stock 1.12, any `s:upper()` fails with `attempt to index a string value`.
|
||||
ClassicAPI restores the 5.1 behavior:
|
||||
|
||||
```lua
|
||||
("asd"):upper() -- "ASD"
|
||||
("%d gold"):format(price) -- method call on a literal
|
||||
msg:match("^!(%w+)") -- method call on a variable
|
||||
link:sub(1, 5)
|
||||
```
|
||||
|
||||
- Both call forms work and give the same result: `s:upper()` and
|
||||
`string.upper(s)`.
|
||||
- Every function in the `string` table is reachable as a method. That
|
||||
includes the ClassicAPI additions ([`match` /
|
||||
`gmatch`](#stringmatch--stringgmatch),
|
||||
[`reverse`](#stringreverses--strrevs)) and any function an addon adds to
|
||||
`string`.
|
||||
- Reading an unknown key gives `nil`, the Lua 5.1 result: `("x").nope` is
|
||||
`nil`, and `("x"):nope()` fails with `attempt to call`, not `attempt to
|
||||
index`.
|
||||
- Writing to a string (`("x").y = 1`) still fails, the same as Lua 5.1.
|
||||
- Works everywhere strings do: in the world, on the login screen, and
|
||||
inside coroutines.
|
||||
- To turn method resolution off for diagnosis, call
|
||||
`_classicapi_SetStringMethods(false)`. Strings then error on index, the
|
||||
stock 1.12 behavior. Use it only to answer "is this backport breaking
|
||||
this addon?".
|
||||
|
||||
### `getfenv` / `setfenv` environment protection
|
||||
|
||||
A sandbox can protect the environment table it gives to restricted code.
|
||||
Put a metatable on that table with an `__environment` field:
|
||||
|
||||
```lua
|
||||
local view = setmetatable({}, { __environment = "protected" })
|
||||
setfenv(restrictedFn, view)
|
||||
```
|
||||
|
||||
From then on, code inside the sandbox sees the protection:
|
||||
|
||||
- `getfenv()` returns the `__environment` value, not the real table. The
|
||||
restricted code cannot reach or change the true environment.
|
||||
- `setfenv()` on that environment raises `cannot change a protected
|
||||
environment`.
|
||||
|
||||
This is the Lua 5.1 form. 1.12 already protected environments, but it read
|
||||
a raw `__fenv` field on the table itself. ClassicAPI adds the 5.1
|
||||
metatable form and keeps `__fenv` as a fallback. The change is additive:
|
||||
an environment with neither marker behaves as before.
|
||||
|
||||
### `select(index, ...)`
|
||||
|
||||
The Lua 5.1 vararg helper, backported to 1.12's Lua 5.0. Two forms:
|
||||
@@ -8377,6 +8966,40 @@ Ported from Lua 5.1's `luaB_select` (lbaselib.c). The numeric form pushes
|
||||
nothing — the varargs are already on the stack, so it just returns the
|
||||
count of the slice from `n+1` onward.
|
||||
|
||||
### `unpack(list [, i [, j]])`
|
||||
|
||||
The Lua 5.1 range form. Vanilla's 5.0 `unpack(list)` takes no range
|
||||
arguments and ignored extras **silently** — `unpack(t, 2)` returned the
|
||||
whole table: wrong values, no error. ClassicAPI replaces `unpack` with
|
||||
the 5.1 version (a port of `luaB_unpack`):
|
||||
|
||||
```lua
|
||||
unpack({10, 20, 30}) -- 10, 20, 30
|
||||
unpack({10, 20, 30}, 2) -- 20, 30
|
||||
unpack({10, 20, 30}, 2, 2) -- 20
|
||||
```
|
||||
|
||||
`i` defaults to `1` and `j` to the table length — the `table.getn`
|
||||
length, so a vararg `arg` table's `n` field is honored and embedded nils
|
||||
keep their slots. Errors on a non-table first argument, exactly like 5.1.
|
||||
|
||||
### `collectgarbage(opt [, arg])`
|
||||
|
||||
Accepts Lua 5.1's string options. Vanilla's 5.0 collector takes only an
|
||||
optional numeric threshold and errored on every string option
|
||||
(`bad argument #1 (number expected, got string)`).
|
||||
|
||||
- `"count"` — returns the KB of Lua memory in use (the `gcinfo()` value).
|
||||
- `"collect"` — runs a full garbage-collection cycle. Returns `0`.
|
||||
- `"step"` — accepted no-op returning `true`. The 5.0 collector cannot
|
||||
step incrementally; `true` means "cycle finished", so
|
||||
`repeat until collectgarbage("step")` loops exit immediately.
|
||||
- `"stop"` / `"restart"` / `"setpause"` / `"setstepmul"` — accepted
|
||||
no-ops returning `0`. The 5.0 collector has no toggle or tuning;
|
||||
callers lose the optimization, nothing breaks.
|
||||
- A number (or no argument) keeps vanilla's threshold behavior
|
||||
unchanged.
|
||||
|
||||
### `table.wipe(t)`
|
||||
|
||||
Removes every key from `t`, leaving it empty but preserving its
|
||||
@@ -8421,6 +9044,43 @@ table.count({ [-3] = "x" }) -- 1, 0, 0
|
||||
Pure iteration over the table (`lua_next`), so no dependency on Lua's internal
|
||||
storage layout. Errors on non-table input.
|
||||
|
||||
### `table.maxn(t)`
|
||||
|
||||
Returns the largest positive numeric key of `t`, or `0` when it has none
|
||||
(Lua 5.1, `luaB_maxn`). Unlike `table.getn` it scans every key, so it
|
||||
sees past nil holes in sparse arrays:
|
||||
|
||||
```lua
|
||||
table.maxn({ 10, 20, 30 }) -- 3
|
||||
table.maxn({ [1] = "x", [9] = "y" }) -- 9 (getn would say 1)
|
||||
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.
|
||||
@@ -8468,13 +9128,30 @@ string.match("no digits", "%d+") -- nil
|
||||
for word in string.gmatch("a,bb,ccc", "[^,]+") do print(word) end -- a / bb / ccc
|
||||
```
|
||||
|
||||
> **Only the `string.foo(s, ...)` call form works — not the `("x"):foo(...)`
|
||||
> method sugar.** WoW's Lua VM strips type-metatables for every value except
|
||||
> tables and userdata (both `lua_setmetatable` and the VM's own
|
||||
> `luaT_gettmbyobj` hard-return "no metamethod" for strings), so string values
|
||||
> have no `__index` and there's no client-side way to add one short of hooking
|
||||
> a hot VM-core function. This is the long-standing vanilla 1.12 constraint —
|
||||
> always write `string.match(s, p)`, never `s:match(p)`.
|
||||
Both call forms work: `string.match(s, p)` and `s:match(p)`. See
|
||||
[String methods](#string-methods-supper-sformat).
|
||||
|
||||
### `string.gsub` table replacement
|
||||
|
||||
Lua 5.1 allows a **table** as `gsub`'s replacement: every match looks up
|
||||
the first capture (the whole match when the pattern declares no
|
||||
captures) as a key and substitutes the value. A `nil` or `false` value
|
||||
keeps the original match. Vanilla's 5.0 `gsub` accepts only a string or
|
||||
function replacement and errors on a table
|
||||
(`string or function expected`).
|
||||
|
||||
```lua
|
||||
("$name the $class"):gsub("%$(%w+)", { name = "Bob", class = "Druid" })
|
||||
-- "Bob the Druid", 2
|
||||
|
||||
("$name and $unknown"):gsub("%$(%w+)", { name = "Bob" })
|
||||
-- "Bob and $unknown", 2 (a missing key keeps the match, as in 5.1)
|
||||
```
|
||||
|
||||
Both `string.gsub` and the bare `gsub` global have the upgrade; string
|
||||
and function replacements behave exactly as before. One residual: when
|
||||
the pattern itself contains a `%1`..`%9` back-reference, a missing table
|
||||
key removes the match instead of keeping it.
|
||||
|
||||
### `strsplit(sep, str [, pieces])`
|
||||
|
||||
@@ -8595,9 +9272,9 @@ Restores Lua 5.0's stripped `coroutine.*` library. The C-level
|
||||
coroutine machinery (`lua_resume`, `lua_yield`, `lua_newthread`,
|
||||
and the `thread` type at tag 8) is linked into the engine, but
|
||||
the script-facing library was never registered as a global table.
|
||||
ClassicAPI rewires the standard five entries on `coroutine`,
|
||||
matching Lua 5.0 semantics with two WoW-specific quirks called
|
||||
out below.
|
||||
ClassicAPI rewires the standard five entries on `coroutine`, plus
|
||||
Lua 5.1's `coroutine.running`, matching Lua 5.0 semantics with two
|
||||
WoW-specific quirks called out below.
|
||||
|
||||
#### `coroutine.create(fn)`
|
||||
|
||||
@@ -8695,6 +9372,19 @@ end
|
||||
for n in range(1, 5) do print(n) end
|
||||
```
|
||||
|
||||
#### `coroutine.running()`
|
||||
|
||||
Returns the coroutine that is currently running, or `nil` when called
|
||||
from the main thread (Lua 5.1's contract — the 5.2 second return,
|
||||
`ismain`, is not provided).
|
||||
|
||||
```lua
|
||||
coroutine.running() -- nil (main thread)
|
||||
coroutine.wrap(function()
|
||||
print(coroutine.status(coroutine.running())) -- "running"
|
||||
end)()
|
||||
```
|
||||
|
||||
#### Async pattern (`RunAsync` + `C_Timer.After`)
|
||||
|
||||
Coroutines pair naturally with `C_Timer.After` for chunked
|
||||
@@ -14633,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
|
||||
|
||||
|
||||
+49
-11
@@ -16,12 +16,15 @@
|
||||
#include "MinHook.h"
|
||||
#include "Offsets.h"
|
||||
#include "event/Custom.h"
|
||||
#include "luasyntax/StringMethods.h"
|
||||
#include "model/DisplayInfo.h"
|
||||
#include "nameplate/Walk.h"
|
||||
#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;
|
||||
@@ -61,10 +64,25 @@ 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
|
||||
// LoadScriptFunctions re-captures.
|
||||
LuaSyntax::StringMethods::PrepareForReload();
|
||||
|
||||
// Persist the name cache before the engine starts tearing down.
|
||||
// This hook fires on both `/reload` and `/logout` (the engine
|
||||
// re-initializes Lua state in both cases), giving us a clean
|
||||
@@ -96,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
|
||||
@@ -127,9 +146,20 @@ static void __fastcall FrameRegisterEvent_h(void *frame, void *edx,
|
||||
// the latched EnsureInitialized():
|
||||
// * VanillaFixes' `Load` export, called on the game's main thread after
|
||||
// injection (the sanctioned VF extension point), or
|
||||
// * a fallback worker thread we spawn from DllMain, for any other injector
|
||||
// that never calls `Load`.
|
||||
// Whichever arrives first installs; the other observes the cached result.
|
||||
// * a fallback worker thread we spawn from DllMain, ONLY when VanillaFixes
|
||||
// is not the loader (no VfPatcher.dll in the process), for injectors that
|
||||
// never call `Load`.
|
||||
//
|
||||
// The worker MUST stand down under VanillaFixes. VF creates the process
|
||||
// suspended and injects every dlls.txt DLL sequentially via remote threads;
|
||||
// a worker spawned from our DllMain starts the moment our own LoadLibrary
|
||||
// releases the loader lock — i.e. while VF is still injecting the NEXT
|
||||
// dlls.txt DLL. Its ~90-prologue install (plus the MH_ApplyQueued
|
||||
// thread-freeze) then races that DLL's DllMain patching the same engine
|
||||
// functions, which made SuperWoWhook.dll fail to load whenever ClassicAPI
|
||||
// preceded it in dlls.txt. `Load` has no such race: VfPatcher calls it on the
|
||||
// game's main thread after the process resumes, when every DllMain in the
|
||||
// injection chain has already completed, serialized in dlls.txt order.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static volatile LONG g_initClaimed = 0; // 0 until a thread takes the installer role
|
||||
@@ -218,14 +248,22 @@ BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved) {
|
||||
DisableThreadLibraryCalls(hModule);
|
||||
|
||||
// Install nothing here (loader lock — see the block comment above).
|
||||
// Spawn a worker that installs off the lock; the new thread cannot
|
||||
// run its body until DllMain returns and the loader lock releases,
|
||||
// and we never wait on it. Under VanillaFixes the `Load` export
|
||||
// usually wins the latch first; this worker covers every other
|
||||
// injector. DisableThreadLibraryCalls suppressed its THREAD_ATTACH.
|
||||
HANDLE worker = CreateThread(nullptr, 0, InitWorker, nullptr, 0, nullptr);
|
||||
if (worker != nullptr)
|
||||
CloseHandle(worker);
|
||||
//
|
||||
// Under VanillaFixes, `Load` is the ONLY install trigger. VfPatcher.dll
|
||||
// is injected before any dlls.txt DLL, so its presence identifies VF as
|
||||
// the loader; and the `Load` mechanism shipped in the same VF release
|
||||
// (v1.4) as dlls.txt support itself, so any VF that loaded us will call
|
||||
// it. Spawning the worker here would race the DllMains of the dlls.txt
|
||||
// DLLs VF injects after us (see the block comment above).
|
||||
if (GetModuleHandleW(L"VfPatcher.dll") == nullptr) {
|
||||
// Any other injector: no `Load` caller exists, so install from a
|
||||
// worker off the lock. The new thread cannot run its body until
|
||||
// DllMain returns and the loader lock releases, and we never wait
|
||||
// on it. DisableThreadLibraryCalls suppressed its THREAD_ATTACH.
|
||||
HANDLE worker = CreateThread(nullptr, 0, InitWorker, nullptr, 0, nullptr);
|
||||
if (worker != nullptr)
|
||||
CloseHandle(worker);
|
||||
}
|
||||
} else if (reason == DLL_PROCESS_DETACH) {
|
||||
// Clean /quit path — only tear down if we actually initialized.
|
||||
// Flush before MH_Uninitialize — the cache's file I/O uses Win32
|
||||
|
||||
+332
@@ -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,
|
||||
@@ -4679,6 +4719,17 @@ enum Offsets {
|
||||
|
||||
VAR_LOCALE_INDEX = 0x00C0E080, // 0..8, picks one of the 9 localized strings
|
||||
|
||||
// Locale-name string table — a parallel `const char *[8]` indexed by
|
||||
// VAR_LOCALE_INDEX, holding the exact codes `GetLocale()` returns:
|
||||
// enUS / koKR / frFR / deDE / zhCN / zhTW / esES / xxYY. This is the
|
||||
// source `Script_GetLocale` (FUN_0048d8b0) reads:
|
||||
// lua_pushstring(L, (&PTR_DAT_008558a4)[DAT_00c0e080]);
|
||||
// Verified by resolving all 8 entries from the binary. Index range is
|
||||
// 0..7 (VAR_LOCALE_INDEX is documented 0..8, but slot 8 overreads into
|
||||
// the string pool — clamp to 0..7 before reading). Used by the TOC
|
||||
// `[TextLocale]` / `[AllowLoadTextLocale]` directives (AddOns::TocRewrite).
|
||||
VAR_LOCALE_NAME_TABLE = 0x008558A4,
|
||||
|
||||
LUA_IS_NUMBER = 0x6F34D0,
|
||||
LUA_IS_STRING = 0x6F3510,
|
||||
LUA_TO_NUMBER = 0x6F3620,
|
||||
@@ -4734,6 +4785,15 @@ enum Offsets {
|
||||
// CObjects — which is exactly why the engine pushes handler/frame
|
||||
// through it rather than a plain rawget.
|
||||
LUA_RAWGETI = 0x6F3BC0,
|
||||
// `lua_getmetatable(L, objindex)` — pushes the metatable of the value at
|
||||
// objindex and returns 1, or pushes nothing and returns 0 when it has none.
|
||||
// Verified in docs/LuaCAPI.md and by the getfenv/getmetatable base-lib
|
||||
// functions that call it. Used by the `__environment` env-protection hook.
|
||||
LUA_GET_METATABLE = 0x6F3CF0,
|
||||
// `lua_getfenv(L, idx)` — pushes the environment table of the function
|
||||
// (or userdata/thread) at `idx`. The getfenv/setfenv base-lib code calls
|
||||
// it to read a function's environment before the protection check.
|
||||
LUA_GET_FENV = 0x6F3D50,
|
||||
LUA_SET_TABLE = 0x6F3E20,
|
||||
LUA_RAW_SET = 0x6F3EA0,
|
||||
LUA_INSERT = 0x6F31A0,
|
||||
@@ -4800,6 +4860,85 @@ enum Offsets {
|
||||
// `rawset(nil)` clear leaves it stale and subsequent `table.insert`
|
||||
// appends past the wiped slots.
|
||||
LUAL_SETN = 0x6F4EA0,
|
||||
// `luaL_getn(L, tableIndex)` — Lua 5.0 auxlib table-length getter,
|
||||
// `int __fastcall(L /*ecx*/, idx /*edx*/)`, plain RET (no stack
|
||||
// args), length in eax. Verified by disassembly: a negative idx is
|
||||
// normalized via lua_gettop; tries the `t.n` field (rawget "n"),
|
||||
// then the registry LUA_SIZES path, then falls back to a linear
|
||||
// rawgeti scan for the first nil. Counterpart of LUAL_SETN above
|
||||
// (named there since the table.insert trace). Used by the 5.1
|
||||
// `unpack(list, i, j)` upgrade for the default range end.
|
||||
LUAL_GETN = 0x6F5050,
|
||||
// `luaV_gettable(L, t, key, loop)` — the VM's generic index resolver.
|
||||
// __fastcall(L /*ecx*/, t /*edx*/, key /*stack*/, loop /*stack*/),
|
||||
// RET 8. Returns the result TValue* in eax (Ghidra types it void; the
|
||||
// table-hit path demonstrably reaches the epilogue with eax =
|
||||
// luaH_get's return — `CALL 0x006fa7a0; CMP [EAX],0; JNZ epilogue` at
|
||||
// 0x006F7D1D..25). Table case: luaH_get (0x006FA7A0); nil result →
|
||||
// the metatable continuation FUN_006F7D60, which reads the TABLE'S
|
||||
// OWN metatable (hvalue+0x08, fast-TM flag byte hvalue+0x06 — 5.0
|
||||
// fasttm) and returns &nilobject (0x00811BC0) when there is none
|
||||
// (miss = nil, never an error). Non-table case → the continuation
|
||||
// below, loop+1. Depth-guarded at 100 ("loop in gettable").
|
||||
FUN_LUA_V_GETTABLE = 0x006F7CF0,
|
||||
// The non-table branch of luaV_gettable — stock Lua 5.0's "index a
|
||||
// non-table value" continuation. Same convention/return as
|
||||
// FUN_LUA_V_GETTABLE (result TValue* in eax, RET 8). Body: __index
|
||||
// tag method via luaT_gettmbyobj (0x006F7BD0 — which HARDCODES
|
||||
// &nilobject for anything but table/userdata; stock 5.0, nothing
|
||||
// stripped); nil → luaG_typeerror "attempt to index" (0x006FC620);
|
||||
// function TM → call it; table TM → recurse FUN_LUA_V_GETTABLE with
|
||||
// `loop` passed through UNCHANGED (0x006F7F23). Exactly 3 call
|
||||
// sites: luaV_gettable's else-branch + luaV_execute's OP_GETTABLE /
|
||||
// OP_SELF non-table fallbacks (0x006F8A14 / 0x006F8C17 in
|
||||
// FUN_006F8720) — so it fires ONLY when code indexes a non-table,
|
||||
// which in stock 5.0 is always about to raise "attempt to index"
|
||||
// (table `__index` dispatch goes through FUN_006F7D60's fasttm and
|
||||
// never lands here). A cold, uncontested hook site. Prologue is
|
||||
// plain PUSH/MOV — MinHook-relocatable. Co-hooked by
|
||||
// LuaSyntax::StringMethods to backport Lua 5.1 string methods
|
||||
// (`("x"):upper()`): strings get the `string` library table as their
|
||||
// __index, exactly 5.1's shared-string-metatable mechanism.
|
||||
FUN_LUA_INDEX_NONTABLE = 0x006F7EE0,
|
||||
// `luaL_loadbuffer(L, buff, size, chunkname)` — compiles a source
|
||||
// buffer into a Lua function on the stack (does NOT run it). Returns
|
||||
// an int status (0 = ok) in eax; Ghidra types it void because the
|
||||
// status flows through `lua_load`. __fastcall(L /*ecx*/, buff /*edx*/,
|
||||
// size /*stack*/, chunkname /*stack*/); strips a leading UTF-8 BOM.
|
||||
// This is the UNIVERSAL Lua compile chokepoint — every path that turns
|
||||
// source text into bytecode funnels through it: `luaL_loadstring`
|
||||
// (0x006F57C0, backs the `loadstring` global) and the three FrameScript
|
||||
// execute/load funnels (0x00704AE0 / 0x00704C70 / 0x00703280, backing
|
||||
// `RunScript`, XML `<OnLoad>`/`<OnClick>` handlers, and file-loaded
|
||||
// addon chunks). Verified via xrefs to `lua_load` (0x006F4320) and its
|
||||
// two callers (this = loadbuffer, 0x006F5490 = loadfile). Hooking here
|
||||
// lets a source-level transform see every chunk before the 5.0 parser.
|
||||
FUN_LUAL_LOADBUFFER = 0x006F5690,
|
||||
// Return address of the `call luaL_loadbuffer` inside the compile-AND-run
|
||||
// funnel FUN_00704AE0 (the CALL is at 0x00704B0C; next instruction, and so
|
||||
// the pushed return address, is 0x00704B11). FUN_00704AE0 compiles a buffer
|
||||
// then IMMEDIATELY `lua_pcall`s it (0x00704B42) — it is the funnel every
|
||||
// addon/FrameXML FILE load reaches (FUN_006ede10 -> FUN_00704bc0 -> here)
|
||||
// AND what `RunScript` runs through. LuaSyntax's `luaL_loadbuffer` hook
|
||||
// reads `_ReturnAddress()` and arms the addon-args grant ONLY when the
|
||||
// caller is THIS site — i.e. a chunk that will be run before anything else,
|
||||
// so its preamble consumes the grant immediately. `loadstring` (0x00703280,
|
||||
// returns to 0x007032B5) compiles WITHOUT running and takes a
|
||||
// caller-forgeable chunkname, so it must NOT arm; the call-site check
|
||||
// excludes it even when nested inside a RunScript body.
|
||||
RET_LUA_FILE_COMPILE = 0x00704B11,
|
||||
// Shared environment-protection predicate for `getfenv`/`setfenv`
|
||||
// (`bool __fastcall(L /*ecx*/)`). Pushes the function-at-top's environment
|
||||
// via `lua_getfenv(L, -1)`, then pushes the protection marker
|
||||
// `env["__fenv"]` (a RAW field on the env table) and returns whether it is
|
||||
// non-nil — net stack effect +2 (env, marker). `getfenv` (`0x00702AC0`)
|
||||
// returns the marker in place of the real env when set; `setfenv`
|
||||
// (`0x00702BF8`) raises "cannot change a protected environment" when set.
|
||||
// We co-hook this ONE predicate to read `getmetatable(env).__environment`
|
||||
// (raw) instead — the Lua 5.1 form (verified against 3.3.5's
|
||||
// `FUN_0084F2F0`) — with the raw `__fenv` field kept as a fallback, so both
|
||||
// consumers gain the 5.1 semantics with the 1.12 behavior preserved.
|
||||
FUN_LUA_ENV_PROTECT_PREDICATE = 0x00702BA0,
|
||||
// `lua_checkstack(L, n)` — `int __fastcall(L /*ecx*/, n /*edx*/)`.
|
||||
// Ensures room for `n` more stack values, growing if needed; returns 0
|
||||
// (without growing) when `(top-base)/16 + n` would exceed LUA_MAXCSTACK
|
||||
@@ -4823,6 +4962,19 @@ enum Offsets {
|
||||
// `BaseLib::StringLib` registers `string.gmatch` as a direct alias of
|
||||
// this function pointer — no wrapper.
|
||||
FUN_LUA_STR_GFIND = 0x007FCFA0,
|
||||
// `str_gsub` — the Lua 5.0 `string.gsub` C function (strlib entry
|
||||
// at `0x00822DE0`, name string "gsub" at `0x00882230` — the last
|
||||
// entry before the registration table's NULL terminator, matching
|
||||
// stock 5.0 order). Verified by decode: args (s, pat, repl, maxN),
|
||||
// `^` anchor pre-strip, and the luaL_argerror(3) "string or
|
||||
// function expected" gate. Its add_value helper (`FUN_007fd2a0`)
|
||||
// matches stock 5.0: a FUNCTION replacement's non-string result is
|
||||
// popped and NOTHING is appended (match → empty) — 5.1 instead
|
||||
// keeps the original match for nil/false. That difference is why
|
||||
// `BaseLib::StringLib`'s 5.1 table-replacement shim wraps the
|
||||
// pattern in an outer capture (arg 1 = whole match) so its adapter
|
||||
// closure can hand the match back for missing keys.
|
||||
FUN_LUA_STR_GSUB = 0x007FD0E0,
|
||||
// `str_format` — the Lua 5.0 `string.format` C function (`int
|
||||
// __fastcall(void *L)`), strlib entry at `0x00822dc0`. Verified by decode:
|
||||
// reads the format at arg 1, handles `%%`, `%d/i/c`, `%e/E/f/g/G`,
|
||||
@@ -5139,6 +5291,43 @@ enum Offsets {
|
||||
// call is a no-op when the user has the addon installed on disk.
|
||||
FUN_TOC_PARSER = 0x0051C9B0,
|
||||
|
||||
// Client interface version — `__cdecl() -> uint32` (no args), a bare
|
||||
// `return 0x2BC0` (11200). The addon loadability resolver `FUN_0051e780`
|
||||
// compares an addon's parsed `## Interface:` number (entry+0x1C) against
|
||||
// this; a mismatch is the OUT_OF_DATE reason (code 7) that blocks loading
|
||||
// while the `checkAddonVersion` cvar is on. `AddOns::TocRewrite` calls it
|
||||
// to recognize the client version inside a multi-flavor comma-list.
|
||||
FUN_ADDON_CLIENT_INTERFACE_VERSION = 0x0051D7D0,
|
||||
|
||||
// Per-addon file loader — `__fastcall(char *tocPath, int *bindingsCtx,
|
||||
// int *progress) -> uint32`. `FUN_0051f240` calls it once per addon with
|
||||
// `Interface\AddOns\<Name>\<Name>.toc`; it reads the TOC and runs each
|
||||
// referenced Lua/XML file. Vanilla runs it BEFORE the addon's
|
||||
// SavedVariables are loaded (files execute with SV nil), so
|
||||
// `Addons::SavedVarsFirst` co-hooks it to honor `## LoadSavedVariablesFirst`
|
||||
// by loading the SV first for flagged addons.
|
||||
FUN_ADDON_LOAD_FILES = 0x006EDB90,
|
||||
|
||||
// Read + run a Lua file — `__fastcall(const char *path, void *checksumOut,
|
||||
// void *errCtx) -> uint32`. Reads via `FUN_FILE_READ`, then compiles +
|
||||
// pcalls; returns 0 (and fires errCtx's callback only when errCtx != null)
|
||||
// if the file is missing, so it is safe to call on an absent path. How the
|
||||
// engine loads each SavedVariables `.lua` (and how addon files run).
|
||||
FUN_LUA_LOAD_FILE = 0x00704BC0,
|
||||
|
||||
// File-exists probe — `__stdcall(const char *path, int mode)` (RET 8);
|
||||
// nonzero when the file exists. `mode = 1` on the addon / SavedVariables
|
||||
// paths. Used to mirror the engine's SV path fallback (prefer the
|
||||
// realm-scoped per-character file, else the realm-less one).
|
||||
FUN_FILE_EXISTS = 0x00648A30,
|
||||
|
||||
// `realmName` CVar value reader — no-arg, `-> const char *` (last-connected
|
||||
// realm's display name; lazily registers the cvar). The realm segment of
|
||||
// the per-character SavedVariables path. (The character segment is
|
||||
// FUN_GET_LOGIN_ACCOUNT_NAME, whose label is a misnomer — it returns the
|
||||
// 0x00C27D88 buffer, NULL until a character is logged in.)
|
||||
FUN_GET_REALM_NAME = 0x005AB7D0,
|
||||
|
||||
// AddOn registry init — `__fastcall(accountName)`. Documented in
|
||||
// CLAUDE.md ("AddOn registry & hot-reload"). Hooked post-call so
|
||||
// we can splice the embedded addon entry into the freshly-populated
|
||||
@@ -6711,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.
|
||||
@@ -6732,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
|
||||
|
||||
+55
-48
@@ -47,6 +47,11 @@
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "addons/EngineIO.h"
|
||||
#include "addons/FlavorBindings.h"
|
||||
#include "addons/FlavorToc.h"
|
||||
#include "addons/Toc.h"
|
||||
#include "addons/TocRewrite.h"
|
||||
#include "bindings/Inject.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -111,30 +116,15 @@ const ClassicAPIFiles::File *LookupEmbedded(const char *suffix) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Storm allocator — same one `FUN_FILE_READ` uses internally. Buffer
|
||||
// allocated here is freed cleanly by the caller's standard `SMemFree`
|
||||
// (`FUN_STORM_SMEM_FREE`). `__stdcall` per the function's `RET 0x10`
|
||||
// (4 args × 4 bytes = 16) epilogue.
|
||||
// __stdcall void *SMemAlloc(size_t size, const char *file, int line, int flags)
|
||||
using SMemAlloc_t = void *(__stdcall *)(size_t size, const char *file, int line, int flags);
|
||||
// Engine file I/O + Storm allocator — see addons/EngineIO.h for the shapes and
|
||||
// the __stdcall / ESP-drift hazard. A buffer we SMemAlloc here is freed cleanly
|
||||
// by the caller's standard SMemFree in turn.
|
||||
using AddOns::EngineIO::FileReadFn;
|
||||
using AddOns::EngineIO::SMemAllocFn;
|
||||
using AddOns::EngineIO::SMemFreeFn;
|
||||
|
||||
// `FUN_FILE_READ` — same shape derived from the Octo decompile.
|
||||
// Calling convention is `__stdcall` (callee cleans 28 bytes via
|
||||
// `RET 0x1C`), not `__cdecl` — confirmed via the function epilogue.
|
||||
// Getting this wrong silently corrupts the caller's stack frame.
|
||||
// __stdcall int FileRead(int unused, const char *path, void **outBuf,
|
||||
// size_t *outSize, size_t extraBytes,
|
||||
// int flag1, int flag2)
|
||||
using FileRead_t = int(__stdcall *)(int unused, const char *path, void **outBuf,
|
||||
size_t *outSize, size_t extraBytes,
|
||||
int flag1, int flag2);
|
||||
FileRead_t FileRead_o = nullptr;
|
||||
|
||||
// Storm-allocator free. Used to release the disk-TOC buffer after we
|
||||
// scrape its version line — same path the engine itself uses when
|
||||
// `FileRead_o` succeeded and the caller's done with the result.
|
||||
// __stdcall void SMemFree(void *buf, const char *file, int line, int flags)
|
||||
using SMemFree_t = void(__stdcall *)(void *buf, const char *file, int line, int flags);
|
||||
// The trampoline to the original FUN_FILE_READ (installed by the co-hook below).
|
||||
FileReadFn FileRead_o = nullptr;
|
||||
|
||||
// Extracts the value of the `## Version: X` line from a TOC byte
|
||||
// buffer. Writes into `out` (size `outSize`) and returns true on
|
||||
@@ -142,25 +132,19 @@ using SMemFree_t = void(__stdcall *)(void *buf, const char *file, int line, int
|
||||
// sensitive on the key. Returns false if no Version line is found.
|
||||
bool ExtractTocVersion(const char *content, size_t size,
|
||||
char *out, size_t outSize) {
|
||||
static const char kKey[] = "## Version:";
|
||||
constexpr size_t kKeyLen = sizeof(kKey) - 1;
|
||||
for (size_t i = 0; i + kKeyLen <= size; ++i) {
|
||||
const bool atLineStart = (i == 0) || content[i - 1] == '\n';
|
||||
if (!atLineStart) continue;
|
||||
if (std::memcmp(content + i, kKey, kKeyLen) != 0) continue;
|
||||
const char *p = content + i + kKeyLen;
|
||||
const char *end = content + size;
|
||||
while (p < end && (*p == ' ' || *p == '\t')) ++p;
|
||||
size_t j = 0;
|
||||
while (p < end && *p != '\r' && *p != '\n' && j + 1 < outSize) {
|
||||
out[j++] = *p++;
|
||||
}
|
||||
while (j > 0 && (out[j - 1] == ' ' || out[j - 1] == '\t')) --j;
|
||||
out[j] = '\0';
|
||||
return j > 0;
|
||||
const char *v = nullptr;
|
||||
size_t n = 0;
|
||||
if (outSize == 0)
|
||||
return false;
|
||||
if (!AddOns::Toc::FindValue(content, size, "## Version:", &v, &n) || n == 0) {
|
||||
out[0] = '\0';
|
||||
return false;
|
||||
}
|
||||
if (outSize > 0) out[0] = '\0';
|
||||
return false;
|
||||
if (n >= outSize)
|
||||
n = outSize - 1;
|
||||
std::memcpy(out, v, n);
|
||||
out[n] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
// Returns -1/0/+1 for `a < b` / `a == b` / `a > b`. "DEV" is the
|
||||
@@ -229,7 +213,7 @@ bool DiskHasDevMarker() {
|
||||
const int ok = FileRead_o(0, fullPath, &buf, &size, 1, 1, 0);
|
||||
if (ok == 0 || buf == nullptr)
|
||||
return false;
|
||||
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
|
||||
auto SMemFree = reinterpret_cast<SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE);
|
||||
SMemFree(buf, __FILE__, __LINE__, 0);
|
||||
return true;
|
||||
}
|
||||
@@ -274,7 +258,7 @@ void DecideSource() {
|
||||
// Free the disk buffer we just borrowed — we only needed it for
|
||||
// the version-line scrape. The actual content for the engine's
|
||||
// TOC read comes through the regular hook path below.
|
||||
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
|
||||
auto SMemFree = reinterpret_cast<SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE);
|
||||
SMemFree(diskBuf, __FILE__, __LINE__, 0);
|
||||
|
||||
// Missing or unparseable disk version → assume it's older than
|
||||
@@ -301,10 +285,33 @@ int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
|
||||
|
||||
const char *suffix = StripAddonPrefix(path);
|
||||
if (suffix == nullptr) {
|
||||
// Path isn't under `Interface\AddOns\!!!ClassicAPI\` — straight
|
||||
// pass-through, we don't care.
|
||||
return FileRead_o(unused, path, outBuf, outSize, extraBytes,
|
||||
flag1, flag2);
|
||||
// Not our embedded addon. If this is a base-TOC read for a multi-flavor
|
||||
// addon that ships only `<Name>_Turtle.toc` / `_ClassicAPI.toc`, serve
|
||||
// that in place of the missing `<Name>.toc` so it registers and loads
|
||||
// (see addons/FlavorToc.h).
|
||||
int result;
|
||||
if (AddOns::FlavorToc::TryHandle(unused, path, outBuf, outSize,
|
||||
extraBytes, flag1, flag2, FileRead_o)) {
|
||||
result = 1;
|
||||
} else if (AddOns::FlavorBindings::TryHandle(unused, path, outBuf, outSize,
|
||||
extraBytes, flag1, flag2,
|
||||
FileRead_o)) {
|
||||
// Flavor-specific addon keybindings: serve `Bindings_Turtle.xml` /
|
||||
// `Bindings_ClassicAPI.xml` for a `…\AddOns\<Name>\Bindings.xml`
|
||||
// read (see addons/FlavorBindings.h). The paired exists-check
|
||||
// co-hook lives in that module.
|
||||
result = 1;
|
||||
} else {
|
||||
result = FileRead_o(unused, path, outBuf, outSize, extraBytes,
|
||||
flag1, flag2);
|
||||
}
|
||||
// Per-line TOC directives: evaluate `[Condition]` and expand
|
||||
// `[Variable]` tokens on this addon TOC's file lines so the load
|
||||
// pass sees clean paths (see addons/TocRewrite.h). No-op unless
|
||||
// `path` is an addon `.toc` that actually contains directives.
|
||||
if (result != 0)
|
||||
AddOns::TocRewrite::Transform(path, outBuf, outSize);
|
||||
return result;
|
||||
}
|
||||
|
||||
DecideSource();
|
||||
@@ -331,7 +338,7 @@ int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
|
||||
flag1, flag2);
|
||||
}
|
||||
|
||||
auto SMemAlloc = reinterpret_cast<SMemAlloc_t>(
|
||||
auto SMemAlloc = reinterpret_cast<SMemAllocFn>(
|
||||
Offsets::FUN_STORM_SMEM_ALLOC);
|
||||
const size_t totalSize = entry->size + extraBytes;
|
||||
void *buf = SMemAlloc(totalSize, __FILE__, __LINE__, 0);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
// One home for the engine file-I/O and Storm-allocator function-pointer types
|
||||
// the addon modules call. These are ABI-critical: FUN_FILE_READ /
|
||||
// FUN_FILE_EXISTS / the Storm allocator are all `__stdcall` (callee cleans the
|
||||
// stack), and declaring one `__cdecl` by mistake drifts ESP a few bytes per
|
||||
// call and kills the process with no crash log. The shape lived in ~6 copies
|
||||
// across the addon files; centralize it so the convention cannot drift.
|
||||
namespace AddOns::EngineIO {
|
||||
|
||||
// FUN_FILE_READ (0x00648620) — __stdcall, callee cleans 28 bytes (RET 0x1C).
|
||||
// arg0 is an optional archive handle (0 = merged VFS); outSize may be null.
|
||||
using FileReadFn = int(__stdcall *)(int unused, const char *path, void **outBuf,
|
||||
size_t *outSize, size_t extraBytes,
|
||||
int flag1, int flag2);
|
||||
|
||||
// FUN_FILE_EXISTS (0x00648A30) — __stdcall, RET 8.
|
||||
using FileExistsFn = int(__stdcall *)(const char *path, int mode);
|
||||
|
||||
// FUN_STORM_SMEM_ALLOC / FUN_STORM_SMEM_FREE — __stdcall, RET 0x10. Buffers
|
||||
// FUN_FILE_READ hands out are freed with SMemFreeFn; a buffer we allocate with
|
||||
// SMemAllocFn is freed cleanly by the engine's own SMemFree in turn.
|
||||
using SMemAllocFn = void *(__stdcall *)(size_t size, const char *file, int line, int flags);
|
||||
using SMemFreeFn = void(__stdcall *)(void *buf, const char *file, int line, int flags);
|
||||
|
||||
} // namespace AddOns::EngineIO
|
||||
@@ -0,0 +1,130 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#include "addons/EngineIO.h"
|
||||
#include "addons/FlavorBindings.h"
|
||||
#include "addons/Toc.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "turtle/Detect.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace AddOns::FlavorBindings {
|
||||
|
||||
namespace {
|
||||
|
||||
using AddOns::Toc::EqCI;
|
||||
using AddOns::Toc::Lower;
|
||||
|
||||
constexpr size_t NPOS = static_cast<size_t>(-1);
|
||||
|
||||
// If `path` is an addon Bindings read (`…\AddOns\<Name>\Bindings.xml`, CI),
|
||||
// return the length of the prefix up to the `Bindings.xml` filename (so a
|
||||
// flavor filename can be appended in its place); else NPOS.
|
||||
size_t AddonBindingsPrefix(const char *path) {
|
||||
if (path == nullptr) return NPOS;
|
||||
const size_t len = std::strlen(path);
|
||||
const char *kFile = "bindings.xml"; // 12 chars, compared case-insensitively
|
||||
const size_t flen = 12;
|
||||
if (len < flen + 1) return NPOS;
|
||||
for (size_t i = 0; i < flen; ++i)
|
||||
if (Lower(path[len - flen + i]) != kFile[i]) return NPOS;
|
||||
const size_t prefixLen = len - flen;
|
||||
if (path[prefixLen - 1] != '\\' && path[prefixLen - 1] != '/')
|
||||
return NPOS; // "Bindings.xml" must be a whole path segment
|
||||
for (size_t i = 0; i + 8 <= len; ++i)
|
||||
if (EqCI(path + i, 8, "\\AddOns\\")) return prefixLen;
|
||||
return NPOS;
|
||||
}
|
||||
|
||||
// Write `<prefix><flavorFile>` (e.g. `…\AddOns\Foo\Bindings_Turtle.xml`) into
|
||||
// `out`. Returns false if it would not fit.
|
||||
bool BuildFlavorPath(const char *path, size_t prefixLen, const char *flavorFile,
|
||||
char *out, size_t outSize) {
|
||||
const size_t flen = std::strlen(flavorFile);
|
||||
if (prefixLen + flen + 1 > outSize) return false;
|
||||
std::memcpy(out, path, prefixLen);
|
||||
std::memcpy(out + prefixLen, flavorFile, flen + 1); // incl. NUL
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fills `order` with the flavor filenames to probe, most specific first, and
|
||||
// returns the count. `Bindings_Turtle.xml` only on a Turtle client.
|
||||
int FlavorOrder(const char *(&order)[2]) {
|
||||
int count = 0;
|
||||
if (Turtle::Detected())
|
||||
order[count++] = "Bindings_Turtle.xml";
|
||||
order[count++] = "Bindings_ClassicAPI.xml";
|
||||
return count;
|
||||
}
|
||||
|
||||
// Engine file-exists probe FUN_00648a30 — __stdcall(path, mode) (RET 8).
|
||||
using EngineIO::FileExistsFn;
|
||||
FileExistsFn g_origExists = nullptr;
|
||||
|
||||
// Co-hook the exists-check so `Interface\AddOns\<Name>\Bindings.xml` reports as
|
||||
// existing when the base file is absent but a flavor variant is present — so
|
||||
// the loader (`FUN_004b6f70`, then our TryHandle redirect) runs for a
|
||||
// flavor-only addon. Every non-Bindings path passes straight through.
|
||||
int __stdcall FileExists_h(const char *path, int mode) {
|
||||
const size_t prefixLen = AddonBindingsPrefix(path);
|
||||
if (prefixLen == NPOS)
|
||||
return g_origExists(path, mode);
|
||||
|
||||
if (g_origExists(path, mode) != 0)
|
||||
return 1; // the plain Bindings.xml exists
|
||||
|
||||
const char *order[2];
|
||||
const int count = FlavorOrder(order);
|
||||
char cand[512];
|
||||
for (int i = 0; i < count; ++i) {
|
||||
if (BuildFlavorPath(path, prefixLen, order[i], cand, sizeof cand) &&
|
||||
g_origExists(cand, mode) != 0)
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookExists{
|
||||
Offsets::FUN_FILE_EXISTS,
|
||||
reinterpret_cast<void *>(&FileExists_h),
|
||||
reinterpret_cast<void **>(&g_origExists)};
|
||||
|
||||
} // namespace
|
||||
|
||||
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
|
||||
size_t extraBytes, int flag1, int flag2, FileReadFn orig) {
|
||||
const size_t prefixLen = AddonBindingsPrefix(path);
|
||||
if (prefixLen == NPOS)
|
||||
return false;
|
||||
|
||||
const char *order[2];
|
||||
const int count = FlavorOrder(order);
|
||||
char cand[512];
|
||||
for (int i = 0; i < count; ++i) {
|
||||
if (!BuildFlavorPath(path, prefixLen, order[i], cand, sizeof cand))
|
||||
continue;
|
||||
// Serve the flavor file in place of Bindings.xml (a selected flavor
|
||||
// wins over a plain Bindings.xml). No recursion: `orig` is the original
|
||||
// reader passed by the FUN_FILE_READ hook.
|
||||
if (orig(unused, cand, outBuf, outSize, extraBytes, flag1, flag2) != 0)
|
||||
return true;
|
||||
}
|
||||
return false; // no flavor variant — caller reads the base Bindings.xml
|
||||
}
|
||||
|
||||
} // namespace AddOns::FlavorBindings
|
||||
@@ -0,0 +1,55 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
#include "addons/EngineIO.h"
|
||||
|
||||
namespace AddOns::FlavorBindings {
|
||||
|
||||
using EngineIO::FileReadFn;
|
||||
|
||||
// Flavor-specific addon Bindings selection — the Bindings.xml analog of
|
||||
// AddOns::FlavorToc.
|
||||
//
|
||||
// A modern addon can ship a flavor-specific keybinding file
|
||||
// (`Bindings_Turtle.xml` / `Bindings_ClassicAPI.xml`) instead of / alongside a
|
||||
// plain `Bindings.xml`. The per-addon loader `FUN_0051f240` only ever builds
|
||||
// `Interface\AddOns\<Name>\Bindings.xml`, gates it on an exists-check
|
||||
// (`FUN_00648a30`), and — when that passes — loads it via `FUN_004b6f70`, which
|
||||
// reads the file through `FUN_FILE_READ`. Two pieces make flavor files work:
|
||||
//
|
||||
// 1. This TryHandle, called from the FUN_FILE_READ hook: when `path` is an
|
||||
// addon `…\AddOns\<Name>\Bindings.xml` read, serve a flavor variant in its
|
||||
// place if one exists (a selected flavor wins over a plain `Bindings.xml`,
|
||||
// matching FlavorToc). Probe order `Bindings_Turtle.xml` (only when
|
||||
// Turtle::Detected) then `Bindings_ClassicAPI.xml`.
|
||||
// 2. A co-hook on the exists-check `FUN_00648a30` (inside FlavorBindings.cpp):
|
||||
// report `Bindings.xml` as existing when the base is absent but a flavor
|
||||
// variant is present — otherwise the loader is never called for a
|
||||
// flavor-only addon.
|
||||
//
|
||||
// Only `_Turtle` / `_ClassicAPI` are selected — never `_Vanilla` / `_Classic`
|
||||
// (which target the 1.15 Classic Era build; its bindings reference handlers
|
||||
// that build defines and this client does not load, so they would bind to
|
||||
// missing functions). Both suffixes are ClassicAPI conventions, opt-in by the
|
||||
// addon author, and mirror FlavorToc.
|
||||
//
|
||||
// Returns false when `path` is not an addon Bindings.xml read or no flavor
|
||||
// variant exists — the caller then performs the normal base read.
|
||||
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
|
||||
size_t extraBytes, int flag1, int flag2, FileReadFn orig);
|
||||
|
||||
} // namespace AddOns::FlavorBindings
|
||||
@@ -0,0 +1,108 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#include "addons/FlavorToc.h"
|
||||
|
||||
#include "turtle/Detect.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace AddOns::FlavorToc {
|
||||
|
||||
namespace {
|
||||
|
||||
// If `path` is `…\AddOns\<Name>\<Name>.toc` (case-insensitive), set `*prefixLen`
|
||||
// to the byte length of the path up to and including the base `<Name>` filename
|
||||
// (so a `_Vanilla.toc` suffix can be appended in place) and return true.
|
||||
bool ParseBaseToc(const char *path, size_t *prefixLen) {
|
||||
const size_t len = std::strlen(path);
|
||||
if (len < 5 || _strnicmp(path + len - 4, ".toc", 4) != 0)
|
||||
return false;
|
||||
|
||||
// Last separator -> "<Name>.toc" file part.
|
||||
size_t lastSep = static_cast<size_t>(-1);
|
||||
for (size_t i = len; i-- > 0;)
|
||||
if (path[i] == '\\' || path[i] == '/') { lastSep = i; break; }
|
||||
if (lastSep == static_cast<size_t>(-1))
|
||||
return false;
|
||||
const char *file = path + lastSep + 1; // "<Name>.toc"
|
||||
const size_t nameLen = (len - (lastSep + 1)) - 4; // strip ".toc"
|
||||
if (nameLen == 0)
|
||||
return false;
|
||||
|
||||
// Previous separator -> "<Name>" folder segment; it must equal the base name.
|
||||
size_t prevSep = static_cast<size_t>(-1);
|
||||
for (size_t i = lastSep; i-- > 0;)
|
||||
if (path[i] == '\\' || path[i] == '/') { prevSep = i; break; }
|
||||
if (prevSep == static_cast<size_t>(-1))
|
||||
return false;
|
||||
const char *folder = path + prevSep + 1;
|
||||
const size_t folderLen = lastSep - (prevSep + 1);
|
||||
if (folderLen != nameLen || _strnicmp(folder, file, nameLen) != 0)
|
||||
return false;
|
||||
|
||||
// Grandparent segment must be "AddOns" (so FrameXML/other .toc reads pass through).
|
||||
size_t prev2Sep = static_cast<size_t>(-1);
|
||||
for (size_t i = prevSep; i-- > 0;)
|
||||
if (path[i] == '\\' || path[i] == '/') { prev2Sep = i; break; }
|
||||
if (prev2Sep == static_cast<size_t>(-1))
|
||||
return false;
|
||||
const char *gp = path + prev2Sep + 1;
|
||||
const size_t gpLen = prevSep - (prev2Sep + 1);
|
||||
if (gpLen != 6 || _strnicmp(gp, "AddOns", 6) != 0)
|
||||
return false;
|
||||
|
||||
*prefixLen = lastSep + 1 + nameLen; // "…\AddOns\<Name>\<Name>"
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
|
||||
size_t extraBytes, int flag1, int flag2, FileReadFn orig) {
|
||||
if (path == nullptr)
|
||||
return false;
|
||||
|
||||
size_t prefixLen = 0;
|
||||
if (!ParseBaseToc(path, &prefixLen))
|
||||
return false;
|
||||
|
||||
char cand[512];
|
||||
if (prefixLen + 16 >= sizeof cand) // "_ClassicAPI.toc" + NUL is 16 (longest)
|
||||
return false; // pathological path length — let base read run
|
||||
std::memcpy(cand, path, prefixLen);
|
||||
|
||||
// Only flavors that describe THIS environment, most specific first:
|
||||
// `_Turtle` — Turtle client/content, only when Turtle is detected;
|
||||
// `_ClassicAPI` — 1.12 + ClassicAPI, ALWAYS (ClassicAPI is present by
|
||||
// definition — it is the code doing this redirect).
|
||||
// Deliberately NOT `_Vanilla`/`_Classic` (those target the 1.15 Classic Era
|
||||
// client — a modern engine + full modern API — which this 1.12 +
|
||||
// partial-backport client is not), nor `_Mainline`/`_Cata`/etc.
|
||||
const char *order[2];
|
||||
int count = 0;
|
||||
if (Turtle::Detected())
|
||||
order[count++] = "_Turtle.toc";
|
||||
order[count++] = "_ClassicAPI.toc";
|
||||
|
||||
for (int i = 0; i < count; ++i) {
|
||||
std::memcpy(cand + prefixLen, order[i], std::strlen(order[i]) + 1); // incl. NUL
|
||||
if (orig(unused, cand, outBuf, outSize, extraBytes, flag1, flag2) != 0)
|
||||
return true; // served the flavor TOC in place of the base
|
||||
}
|
||||
// No matching flavor TOC — fall through: the caller reads the base path,
|
||||
// which chains through any other FUN_FILE_READ hooker before the engine.
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace AddOns::FlavorToc
|
||||
@@ -0,0 +1,60 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
#include "addons/EngineIO.h"
|
||||
|
||||
namespace AddOns::FlavorToc {
|
||||
|
||||
using EngineIO::FileReadFn;
|
||||
|
||||
// Modern-client multi-flavor TOC selection, backported.
|
||||
//
|
||||
// A modern addon that targets several game flavors from one folder ships
|
||||
// `<Name>_Vanilla.toc` / `_Mainline.toc` / `_Cata.toc` / … and often NO plain
|
||||
// `<Name>.toc`. Vanilla 1.12 only ever opens `<Name>\<Name>.toc`, so such an
|
||||
// addon never registers — it is invisible, not merely mis-loaded.
|
||||
//
|
||||
// Called from the FUN_FILE_READ hook. If `path` is an addon base-TOC read
|
||||
// (`…\AddOns\<Name>\<Name>.toc`), probe the flavor TOCs that describe THIS
|
||||
// environment and, if one exists, serve it via `orig` (as if it were the base
|
||||
// TOC) and return true. Order, most specific first:
|
||||
// `<Name>_Turtle.toc` — Turtle client/content, ONLY when Turtle is
|
||||
// detected (Turtle::Detected); a ClassicAPI extension;
|
||||
// `<Name>_ClassicAPI.toc` — 1.12 + ClassicAPI, ALWAYS (ClassicAPI is present
|
||||
// by definition — it is the code doing this redirect).
|
||||
// Both are ClassicAPI conventions, not retail flavors. We deliberately do NOT
|
||||
// map to `_Vanilla`/`_Classic` (those target the 1.15 Classic Era client — a
|
||||
// modern engine + full modern API — which a 1.12 + partial-backport client is
|
||||
// not), nor `_Mainline`/`_Cata`/etc. A selected flavor wins over an existing
|
||||
// base `<Name>.toc`. If neither flavor exists, TryHandle returns false and the
|
||||
// caller does the normal base read — which chains through any other DLL that
|
||||
// hooked FUN_FILE_READ before reaching the engine.
|
||||
//
|
||||
// The redirect fires at BOTH the registration scan (glue/char-select) and the
|
||||
// in-world load pass, which must agree on the flavor. `Turtle::Detected()` is
|
||||
// therefore consistent across both: it reads the in-world `TURTLE_WOW_VERSION`
|
||||
// global AND Turtle's glue-time `TURTLE_*` GlueXML globals, so it returns true
|
||||
// at the glue scan too (see turtle/Detect.h).
|
||||
//
|
||||
// Returns false when `path` is not a base-TOC read, or no vanilla flavor TOC
|
||||
// exists — the caller then performs the normal base read. Because the redirect
|
||||
// is at the read layer, the addon scan (FUN_0051C9B0), the load pass
|
||||
// (FUN_006edb90) and GetAddOnMetadata all see the same selected TOC.
|
||||
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
|
||||
size_t extraBytes, int flag1, int flag2, FileReadFn orig);
|
||||
|
||||
} // namespace AddOns::FlavorToc
|
||||
@@ -0,0 +1,132 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_AddOns.GetAddOnLocalTable(name)` — the public, gated view of an addon's
|
||||
// private namespace table (the `local _, addonTable = ...` second vararg the
|
||||
// transpiler injects; see luasyntax/AddonNamespace.h).
|
||||
//
|
||||
// Retail added this in 11.0 alongside the `## AllowAddOnTableAccess` TOC
|
||||
// directive: one addon can read another's local table ONLY when the target
|
||||
// opts in. We mirror that gate:
|
||||
// 1. the named addon is currently LOADED, and
|
||||
// 2. its `.toc` declares `## AllowAddOnTableAccess: 1`.
|
||||
// Either check failing returns nil. The internal preamble path (an addon
|
||||
// receiving its OWN table) is a different, ungated function (`__addonns`) —
|
||||
// an addon never needs permission for its own namespace.
|
||||
//
|
||||
// Directive spelling verified against the 1.15.8 Classic Era binary (both the
|
||||
// `AllowAddOnTableAccess:` TOC key and the `GetAddOnLocalTable` name are
|
||||
// present there). The vanilla 1.12 TOC parser drops unknown `##` directives,
|
||||
// so we scan the `.toc` ourselves via `FUN_FILE_READ`.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "addons/EngineIO.h"
|
||||
#include "addons/Toc.h"
|
||||
#include "luasyntax/AddonNamespace.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
|
||||
namespace AddOns::LocalTable {
|
||||
|
||||
namespace {
|
||||
|
||||
// IS_LOADED is a by-name hash lookup returning the loaded byte (`entry+0x18`);
|
||||
// passing the name string works because the lookup hashes it. See Offsets.h.
|
||||
using IsLoadedFn_t = uint8_t(__fastcall *)(const char *name);
|
||||
|
||||
// FUN_FILE_READ / the Storm free (see addons/EngineIO.h). Calling the raw
|
||||
// FUN_FILE_READ address routes through Embedded's file-read hook, which forwards
|
||||
// every non-`!!!ClassicAPI` path to the original untouched — what we want here.
|
||||
using AddOns::EngineIO::FileReadFn;
|
||||
using AddOns::EngineIO::SMemFreeFn;
|
||||
|
||||
// True iff the addon's `.toc` has a line `## AllowAddOnTableAccess: <nonzero>`.
|
||||
// The value is read as a TOC boolean flag: parse the leading integer, true iff
|
||||
// non-zero (so `1` opts in, `0`/absent do not) — matching WoW's numeric TOC
|
||||
// flag semantics. Not cached: GetAddOnLocalTable is not a hot path.
|
||||
bool TocAllowsTableAccess(const char *name) {
|
||||
// A registered, loaded addon name never carries a path separator; reject
|
||||
// any that does so the read stays bounded to the addon's own folder.
|
||||
for (const char *p = name; *p; ++p)
|
||||
if (*p == '\\' || *p == '/')
|
||||
return false;
|
||||
|
||||
char path[300];
|
||||
std::snprintf(path, sizeof path, "Interface\\AddOns\\%s\\%s.toc", name, name);
|
||||
|
||||
void *buf = nullptr;
|
||||
size_t size = 0;
|
||||
auto fileRead = reinterpret_cast<FileReadFn>(Offsets::FUN_FILE_READ);
|
||||
if (fileRead(0, path, &buf, &size, 1, 1, 0) == 0 || buf == nullptr)
|
||||
return false;
|
||||
|
||||
const char *v = nullptr;
|
||||
size_t n = 0;
|
||||
bool allowed = false;
|
||||
if (AddOns::Toc::FindValue(static_cast<const char *>(buf), size,
|
||||
"## AllowAddOnTableAccess:", &v, &n)) {
|
||||
int value = 0;
|
||||
bool anyDigit = false;
|
||||
for (size_t k = 0; k < n && v[k] >= '0' && v[k] <= '9'; ++k) {
|
||||
value = value * 10 + (v[k] - '0');
|
||||
anyDigit = true;
|
||||
}
|
||||
allowed = anyDigit && value != 0;
|
||||
}
|
||||
|
||||
auto smemFree = reinterpret_cast<SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE);
|
||||
smemFree(buf, __FILE__, __LINE__, 0);
|
||||
return allowed;
|
||||
}
|
||||
|
||||
// `C_AddOns.GetAddOnLocalTable(name)` -> table, or nil.
|
||||
//
|
||||
// Name (string) input only — the local table is a by-name concept (one addon
|
||||
// asking for another by name), and the namespace map is name-keyed. Numeric
|
||||
// index is not accepted (returns nil); no addon references another's table by
|
||||
// load order.
|
||||
int __fastcall Script_GetAddOnLocalTable(void *L) {
|
||||
const char *name = Game::Lua::ToString(L, 1);
|
||||
if (name == nullptr || *name == '\0') {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto isLoaded = reinterpret_cast<IsLoadedFn_t>(Offsets::FUN_ADDON_IS_LOADED);
|
||||
if (isLoaded(name) == 0) { // not loaded (covers "does not exist")
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!TocAllowsTableAccess(name)) { // opted out / no directive
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
LuaSyntax::PushAddonNamespace(L, name);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_AddOns", "GetAddOnLocalTable",
|
||||
&Script_GetAddOnLocalTable);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace AddOns::LocalTable
|
||||
@@ -0,0 +1,270 @@
|
||||
// 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/>.
|
||||
|
||||
// `## LoadSavedVariablesFirst: 1` — modern TOC directive, backported.
|
||||
//
|
||||
// Retail loads a flagged addon's SavedVariables BEFORE its Lua runs, so
|
||||
// file-scope code (`local db = MyAddonDB`) sees restored config immediately.
|
||||
// Vanilla 1.12 always does the opposite: the per-addon loader `FUN_0051f240`
|
||||
// runs the addon's files (`FUN_ADDON_LOAD_FILES`), THEN loads SavedVariables,
|
||||
// THEN fires `ADDON_LOADED`. So file-scope SV is always nil, and vanilla never
|
||||
// parses the directive at all.
|
||||
//
|
||||
// We co-hook `FUN_ADDON_LOAD_FILES` (the per-addon file load, once per addon,
|
||||
// in-world). For an addon whose TOC declares `## LoadSavedVariablesFirst`
|
||||
// (nonzero), load its SavedVariables first — mirroring the engine's OWN path
|
||||
// construction two steps later in `FUN_0051f240` (verified by disassembly):
|
||||
// account : WTF\Account\<account>\SavedVariables\<Name>.lua
|
||||
// per-char: WTF\Account\<account>\<realm>\<character>\SavedVariables\<Name>.lua,
|
||||
// falling back to WTF\Account\<account>\<character>\SavedVariables\<Name>.lua
|
||||
// (account = *VAR_ACCOUNT_NAME_PTR, realm = FUN_GET_REALM_NAME,
|
||||
// character = FUN_GET_LOGIN_ACCOUNT_NAME) — then call the original so the
|
||||
// files run with SV present. `FUN_LUA_LOAD_FILE` no-ops on a missing file, and
|
||||
// `FUN_FILE_EXISTS` picks the same per-char variant the engine would.
|
||||
//
|
||||
// The engine STILL runs its own SavedVariables load at the normal step, right
|
||||
// after the files. Left alone, that re-load would overwrite any value the
|
||||
// addon wrote to its SavedVariable at file scope before ADDON_LOADED. So we
|
||||
// also co-hook FUN_LUA_LOAD_FILE and suppress that one redundant re-load: each
|
||||
// path we pre-load is remembered, and the engine's imminent load of that exact
|
||||
// path is skipped once. This makes the behavior match true
|
||||
// LoadSavedVariablesFirst — file-scope reads AND writes both survive. Our own
|
||||
// early load calls the trampoline directly, so it is never self-suppressed.
|
||||
// FUN_LUA_LOAD_FILE loads FILES only (not RunScript string execution), so the
|
||||
// extra hook is a cold, addon-load-time path.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "addons/EngineIO.h"
|
||||
#include "addons/Toc.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace AddOns::SavedVarsFirst {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr size_t NPOS = static_cast<size_t>(-1);
|
||||
|
||||
// Engine file I/O via the hooked FUN_FILE_READ (so a flavor TOC's flag is read —
|
||||
// FlavorToc applies), the exists-check, and the Storm free. See addons/EngineIO.h.
|
||||
using AddOns::EngineIO::FileExistsFn;
|
||||
using AddOns::EngineIO::FileReadFn;
|
||||
using AddOns::EngineIO::SMemFreeFn;
|
||||
using LoadTocFilesFn = uint32_t(__fastcall *)(char *tocPath, int *a2, int *a3);
|
||||
using LuaLoadFileFn = uint32_t(__fastcall *)(const char *path, void *a2, void *a3);
|
||||
using NameFn = const char *(*)(); // no-arg readers (return in EAX)
|
||||
|
||||
LoadTocFilesFn g_orig = nullptr;
|
||||
LuaLoadFileFn g_origLuaLoad = nullptr;
|
||||
|
||||
// Full SV paths we pre-loaded whose imminent engine re-load must be skipped
|
||||
// once. Populated in LoadSavedVarsEarly, drained by LuaLoad_h. Only ever holds
|
||||
// a handful of entries (one addon's SV files, briefly). Main-thread only.
|
||||
std::vector<std::string> g_pendingSuppress;
|
||||
|
||||
using AddOns::Toc::EqCI;
|
||||
using AddOns::Toc::Lower;
|
||||
|
||||
bool SamePathCI(const char *a, const char *b) {
|
||||
for (; *a != '\0' && *b != '\0'; ++a, ++b)
|
||||
if (Lower(*a) != Lower(*b)) return false;
|
||||
return *a == *b;
|
||||
}
|
||||
|
||||
// Extract "<Name>" from an addon base-TOC path `…\AddOns\<Name>\<Name>.toc`.
|
||||
// Returns false for anything else (FrameXML.toc, nested paths, …) so the
|
||||
// SavedVariables reorder only touches real addon loads.
|
||||
bool AddonNameFromToc(const char *path, char *out, size_t outSize) {
|
||||
const size_t len = std::strlen(path);
|
||||
if (len < 4 || !(path[len - 4] == '.' && Lower(path[len - 3]) == 't' &&
|
||||
Lower(path[len - 2]) == 'o' && Lower(path[len - 1]) == 'c'))
|
||||
return false;
|
||||
size_t lastSep = NPOS;
|
||||
for (size_t i = len; i-- > 0;)
|
||||
if (path[i] == '\\' || path[i] == '/') { lastSep = i; break; }
|
||||
if (lastSep == NPOS) return false;
|
||||
size_t prevSep = NPOS;
|
||||
for (size_t i = lastSep; i-- > 0;)
|
||||
if (path[i] == '\\' || path[i] == '/') { prevSep = i; break; }
|
||||
if (prevSep == NPOS) return false;
|
||||
size_t gpSep = NPOS;
|
||||
for (size_t i = prevSep; i-- > 0;)
|
||||
if (path[i] == '\\' || path[i] == '/') { gpSep = i; break; }
|
||||
if (gpSep == NPOS) return false;
|
||||
if (prevSep - (gpSep + 1) != 6 || !EqCI(path + gpSep + 1, 6, "AddOns"))
|
||||
return false;
|
||||
const size_t nameLen = lastSep - (prevSep + 1);
|
||||
if (nameLen == 0 || nameLen + 1 > outSize) return false;
|
||||
std::memcpy(out, path + prevSep + 1, nameLen);
|
||||
out[nameLen] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
// True iff the leading integer of a TOC value is nonzero (WoW numeric flag).
|
||||
bool NonzeroFlag(const char *v, size_t n) {
|
||||
long val = 0;
|
||||
bool digit = false;
|
||||
for (size_t i = 0; i < n && v[i] >= '0' && v[i] <= '9'; ++i) {
|
||||
val = val * 10 + (v[i] - '0');
|
||||
digit = true;
|
||||
}
|
||||
return digit && val != 0;
|
||||
}
|
||||
|
||||
// The SavedVariables directives that decide what LoadSavedVarsEarly does.
|
||||
struct SvFlags {
|
||||
bool first = false; // `## LoadSavedVariablesFirst:` is nonzero
|
||||
bool account = false; // `## SavedVariables:` declares at least one variable
|
||||
bool perChar = false; // `## SavedVariablesPerCharacter:` declares at least one
|
||||
};
|
||||
|
||||
// Read the addon's TOC once (through the hooked FUN_FILE_READ, so a flavor
|
||||
// TOC's directives are honored) and extract the three flags above.
|
||||
SvFlags ReadSvFlags(const char *tocPath) {
|
||||
SvFlags f;
|
||||
auto FileRead =
|
||||
reinterpret_cast<FileReadFn>(static_cast<uintptr_t>(Offsets::FUN_FILE_READ));
|
||||
void *buf = nullptr;
|
||||
size_t size = 0;
|
||||
if (FileRead(0, tocPath, &buf, &size, 1, 1, 0) == 0 || buf == nullptr)
|
||||
return f;
|
||||
const char *b = static_cast<const char *>(buf);
|
||||
|
||||
const char *v = nullptr;
|
||||
size_t n = 0;
|
||||
if (AddOns::Toc::FindValue(b, size, "## LoadSavedVariablesFirst:", &v, &n))
|
||||
f.first = NonzeroFlag(v, n);
|
||||
// Gate each SV file on the DECLARATION, not mere file existence — mirroring
|
||||
// the engine, which loads the account file iff `## SavedVariables` is
|
||||
// declared and the per-char file iff `## SavedVariablesPerCharacter` is.
|
||||
// (The trailing `:` keeps "## SavedVariables:" from matching the
|
||||
// "## SavedVariablesPerCharacter:" line.) Otherwise a stale SV file from a
|
||||
// scope the addon no longer declares would be executed early — Lua the
|
||||
// engine never runs — and queued for a suppression the engine never drains.
|
||||
f.account = AddOns::Toc::FindValue(b, size, "## SavedVariables:", &v, &n) && n > 0;
|
||||
f.perChar =
|
||||
AddOns::Toc::FindValue(b, size, "## SavedVariablesPerCharacter:", &v, &n) && n > 0;
|
||||
|
||||
reinterpret_cast<SMemFreeFn>(static_cast<uintptr_t>(Offsets::FUN_STORM_SMEM_FREE))(
|
||||
buf, __FILE__, __LINE__, 0);
|
||||
return f;
|
||||
}
|
||||
|
||||
bool FileExists(const char *path) {
|
||||
return reinterpret_cast<FileExistsFn>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_FILE_EXISTS))(path, 1) != 0;
|
||||
}
|
||||
|
||||
// Load a SavedVariables file we found, then mark its path so the engine's own
|
||||
// re-load of it (right after the addon's files) is suppressed — preserving any
|
||||
// file-scope write. Calls the trampoline directly so this load is never itself
|
||||
// suppressed by our LuaLoad_h hook.
|
||||
void RunLuaFile(const char *path) {
|
||||
if (g_origLuaLoad == nullptr)
|
||||
return;
|
||||
g_origLuaLoad(path, nullptr, nullptr);
|
||||
g_pendingSuppress.emplace_back(path);
|
||||
}
|
||||
|
||||
// Load the addon's SavedVariables early, mirroring FUN_0051f240's own paths.
|
||||
// Each scope loads only when its directive is declared (`hasAccount` /
|
||||
// `hasPerChar`) AND the file exists — exactly what the engine's own SV step
|
||||
// would re-load, so every suppress entry we queue is later drained.
|
||||
void LoadSavedVarsEarly(const char *addonName, bool hasAccount, bool hasPerChar) {
|
||||
const char *account = *reinterpret_cast<const char *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_ACCOUNT_NAME_PTR));
|
||||
if (account == nullptr || *account == '\0')
|
||||
return;
|
||||
|
||||
char path[260];
|
||||
|
||||
// Account-wide (`## SavedVariables`).
|
||||
if (hasAccount) {
|
||||
std::snprintf(path, sizeof path, "WTF\\Account\\%s\\SavedVariables\\%s.lua",
|
||||
account, addonName);
|
||||
if (FileExists(path))
|
||||
RunLuaFile(path);
|
||||
}
|
||||
|
||||
// Per-character (`## SavedVariablesPerCharacter`): prefer the realm-scoped
|
||||
// file, else the realm-less one — the engine's own fallback order.
|
||||
if (!hasPerChar)
|
||||
return;
|
||||
const char *character = reinterpret_cast<NameFn>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_GET_LOGIN_ACCOUNT_NAME))();
|
||||
if (character == nullptr || *character == '\0')
|
||||
return;
|
||||
const char *realm = reinterpret_cast<NameFn>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_GET_REALM_NAME))();
|
||||
|
||||
if (realm != nullptr && *realm != '\0') {
|
||||
std::snprintf(path, sizeof path,
|
||||
"WTF\\Account\\%s\\%s\\%s\\SavedVariables\\%s.lua", account,
|
||||
realm, character, addonName);
|
||||
if (FileExists(path)) {
|
||||
RunLuaFile(path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
std::snprintf(path, sizeof path, "WTF\\Account\\%s\\%s\\SavedVariables\\%s.lua",
|
||||
account, character, addonName);
|
||||
if (FileExists(path))
|
||||
RunLuaFile(path);
|
||||
}
|
||||
|
||||
uint32_t __fastcall LoadTocFiles_h(char *tocPath, int *a2, int *a3) {
|
||||
char name[128];
|
||||
if (tocPath != nullptr && AddonNameFromToc(tocPath, name, sizeof name)) {
|
||||
const SvFlags f = ReadSvFlags(tocPath);
|
||||
if (f.first)
|
||||
LoadSavedVarsEarly(name, f.account, f.perChar);
|
||||
}
|
||||
return g_orig(tocPath, a2, a3);
|
||||
}
|
||||
|
||||
// Skip the engine's one redundant SavedVariables re-load per pre-loaded path.
|
||||
// Every other file load passes straight through. Match is by full path
|
||||
// (case-insensitive); a path is suppressed exactly once, so nothing else is
|
||||
// affected. Our own early load bypasses this via the trampoline.
|
||||
uint32_t __fastcall LuaLoad_h(const char *path, void *a2, void *a3) {
|
||||
if (path != nullptr) {
|
||||
for (size_t i = 0; i < g_pendingSuppress.size(); ++i) {
|
||||
if (SamePathCI(g_pendingSuppress[i].c_str(), path)) {
|
||||
g_pendingSuppress.erase(g_pendingSuppress.begin() +
|
||||
static_cast<std::ptrdiff_t>(i));
|
||||
return 0; // suppress — the addon's file-scope SV writes survive
|
||||
}
|
||||
}
|
||||
}
|
||||
return g_origLuaLoad(path, a2, a3);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hook{
|
||||
Offsets::FUN_ADDON_LOAD_FILES,
|
||||
reinterpret_cast<void *>(&LoadTocFiles_h),
|
||||
reinterpret_cast<void **>(&g_orig)};
|
||||
|
||||
const Game::HookAutoRegister _hookLua{
|
||||
Offsets::FUN_LUA_LOAD_FILE,
|
||||
reinterpret_cast<void *>(&LuaLoad_h),
|
||||
reinterpret_cast<void **>(&g_origLuaLoad)};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace AddOns::SavedVarsFirst
|
||||
@@ -0,0 +1,69 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstring>
|
||||
|
||||
namespace AddOns::Toc {
|
||||
|
||||
// Fold an ASCII letter to lower case (leaves every other byte unchanged).
|
||||
inline char Lower(char c) {
|
||||
return (c >= 'A' && c <= 'Z') ? static_cast<char>(c + 32) : c;
|
||||
}
|
||||
|
||||
// Case-insensitive equality of the `n` bytes at `s` against the NUL-terminated
|
||||
// `lit` (ASCII). True only when the lengths match too. Shared by the addon
|
||||
// modules that scan `…\AddOns\<Name>\…` paths and TOC directive lines.
|
||||
inline bool EqCI(const char *s, size_t n, const char *lit) {
|
||||
for (size_t i = 0; i < n; ++i)
|
||||
if (lit[i] == '\0' || Lower(s[i]) != Lower(lit[i]))
|
||||
return false;
|
||||
return lit[n] == '\0';
|
||||
}
|
||||
|
||||
// Scans an in-memory TOC buffer for `directive` (the full line prefix, e.g.
|
||||
// "## Version:") at a line start, case-insensitively, and returns the value
|
||||
// that follows via out-params: [*valStart, *valStart + *valLen), trimmed of
|
||||
// leading and trailing spaces/tabs and excluding the CR/LF/EOF terminator.
|
||||
// Returns false (out-params untouched) when the directive is absent. Pure —
|
||||
// reads no files; the caller owns the buffer (via FUN_FILE_READ, or an embedded
|
||||
// copy). Shared by addons/LocalTable.cpp (AllowAddOnTableAccess) and
|
||||
// addons/Embedded.cpp (Version) so the line-scan lives in one place.
|
||||
inline bool FindValue(const char *buf, size_t size, const char *directive,
|
||||
const char **valStart, size_t *valLen) {
|
||||
const size_t dlen = std::strlen(directive);
|
||||
if (dlen == 0)
|
||||
return false;
|
||||
for (size_t i = 0; i + dlen <= size; ++i) {
|
||||
const bool atLineStart = (i == 0) || buf[i - 1] == '\n';
|
||||
if (!atLineStart || _strnicmp(buf + i, directive, dlen) != 0)
|
||||
continue;
|
||||
const char *p = buf + i + dlen;
|
||||
const char *end = buf + size;
|
||||
while (p < end && (*p == ' ' || *p == '\t'))
|
||||
++p;
|
||||
const char *v = p;
|
||||
while (p < end && *p != '\r' && *p != '\n')
|
||||
++p;
|
||||
while (p > v && (p[-1] == ' ' || p[-1] == '\t'))
|
||||
--p;
|
||||
*valStart = v;
|
||||
*valLen = static_cast<size_t>(p - v);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace AddOns::Toc
|
||||
@@ -0,0 +1,297 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#include "addons/EngineIO.h"
|
||||
#include "addons/TocRewrite.h"
|
||||
#include "addons/Toc.h"
|
||||
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
namespace AddOns::TocRewrite {
|
||||
|
||||
namespace {
|
||||
|
||||
using AddOns::EngineIO::SMemAllocFn;
|
||||
using AddOns::EngineIO::SMemFreeFn;
|
||||
using AddOns::Toc::EqCI;
|
||||
using AddOns::Toc::Lower;
|
||||
|
||||
bool IsSpace(char c) { return c == ' ' || c == '\t'; }
|
||||
|
||||
// The client locale code ("enUS", "frFR", …) — the exact string
|
||||
// GetLocale() returns (Offsets.h VAR_LOCALE_NAME_TABLE derivation).
|
||||
const char *ClientLocale() {
|
||||
uint32_t idx = *reinterpret_cast<const uint32_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
|
||||
if (idx > 7) idx = 0; // slot 8 overreads the string pool — clamp
|
||||
const char *const *table = reinterpret_cast<const char *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_LOCALE_NAME_TABLE));
|
||||
const char *s = table[idx];
|
||||
return (s != nullptr) ? s : "enUS";
|
||||
}
|
||||
|
||||
// True iff the comma-separated `list` (spaces allowed) contains `token` (CI).
|
||||
bool ListContains(const char *list, size_t listLen, const char *token) {
|
||||
size_t i = 0;
|
||||
while (i < listLen) {
|
||||
while (i < listLen && (IsSpace(list[i]) || list[i] == ',')) ++i;
|
||||
const size_t start = i;
|
||||
while (i < listLen && list[i] != ',') ++i;
|
||||
size_t end = i; // [start, end) is one item, maybe with trailing space
|
||||
while (end > start && IsSpace(list[end - 1])) --end;
|
||||
if (end > start && EqCI(list + start, end - start, token)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// A single-word `[Token]` variable expansion, or nullptr if `head` is not
|
||||
// a known variable. This 1.12 + ClassicAPI client is the Classic family,
|
||||
// the Vanilla game type, and whatever the client text locale is.
|
||||
const char *VariableValue(const char *head, size_t headLen) {
|
||||
if (EqCI(head, headLen, "Family")) return "Classic";
|
||||
if (EqCI(head, headLen, "Game")) return "Vanilla";
|
||||
if (EqCI(head, headLen, "TextLocale")) return ClientLocale();
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// The client interface version the loadability check compares against
|
||||
// (FUN_0051d7d0 -> 0x2BC0 = 11200). Read live so we mirror the engine's own
|
||||
// value rather than hardcoding it.
|
||||
using ClientIfaceVer_t = uint32_t(__cdecl *)();
|
||||
uint32_t ClientInterfaceVersion() {
|
||||
return reinterpret_cast<ClientIfaceVer_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_ADDON_CLIENT_INTERFACE_VERSION))();
|
||||
}
|
||||
|
||||
// If `line` is a `## Interface:` directive whose value is a comma-list that
|
||||
// contains the client version but does NOT lead with it, append a normalized
|
||||
// `<prefix>: <clientVersion>` line to `out` and return true. Vanilla's parser
|
||||
// reads only the first number, so without this a later 11200 is never seen and
|
||||
// the addon is marked out of date. Returns false for any other line, and for
|
||||
// an interface line that needs no change (single value, no client version, or
|
||||
// client version already first) — the caller then copies the line verbatim.
|
||||
bool TryRewriteInterfaceLine(const char *line, size_t len, std::string &out) {
|
||||
size_t s = 0;
|
||||
while (s < len && IsSpace(line[s])) ++s;
|
||||
if (s + 1 >= len || line[s] != '#' || line[s + 1] != '#') return false;
|
||||
size_t p = s + 2;
|
||||
while (p < len && IsSpace(line[p])) ++p;
|
||||
static const char kKey[] = "interface"; // matched case-insensitively
|
||||
const size_t klen = 9;
|
||||
if (p + klen > len) return false;
|
||||
for (size_t i = 0; i < klen; ++i)
|
||||
if (Lower(line[p + i]) != kKey[i]) return false;
|
||||
p += klen;
|
||||
while (p < len && IsSpace(line[p])) ++p;
|
||||
if (p >= len || line[p] != ':') return false;
|
||||
++p; // past ':'
|
||||
const size_t prefixEnd = p; // "## Interface:" (author spacing preserved)
|
||||
|
||||
const uint32_t client = ClientInterfaceVersion();
|
||||
long first = -1;
|
||||
bool hasComma = false, hasClient = false;
|
||||
size_t q = p;
|
||||
while (q < len) {
|
||||
while (q < len && (IsSpace(line[q]) || line[q] == ',')) {
|
||||
if (line[q] == ',') hasComma = true;
|
||||
++q;
|
||||
}
|
||||
long v = 0;
|
||||
bool digit = false;
|
||||
while (q < len && line[q] >= '0' && line[q] <= '9') {
|
||||
v = v * 10 + (line[q] - '0');
|
||||
++q;
|
||||
digit = true;
|
||||
}
|
||||
if (digit) {
|
||||
if (first < 0) first = v;
|
||||
if (static_cast<uint32_t>(v) == client) hasClient = true;
|
||||
} else if (q < len) {
|
||||
++q; // skip a stray non-digit so the scan always advances
|
||||
}
|
||||
}
|
||||
if (!hasComma || !hasClient || first == static_cast<long>(client))
|
||||
return false; // single value / no client version / already first
|
||||
|
||||
out.append(line, prefixEnd);
|
||||
out.push_back(' ');
|
||||
out += std::to_string(client);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Evaluate a `[keyword args]` condition — the return is whether the line should
|
||||
// load. An unrecognized keyword returns false so the caller drops the line (a
|
||||
// condition we cannot confirm must not load — fail safe).
|
||||
bool EvalCondition(const char *head, size_t headLen, const char *args,
|
||||
size_t argsLen) {
|
||||
if (EqCI(head, headLen, "AllowLoadGameType"))
|
||||
return ListContains(args, argsLen, "vanilla");
|
||||
if (EqCI(head, headLen, "AllowLoadTextLocale"))
|
||||
return ListContains(args, argsLen, ClientLocale());
|
||||
if (EqCI(head, headLen, "AllowLoad"))
|
||||
return ListContains(args, argsLen, "game"); // addon files: in-game only
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rewrite one file-reference line into `out`: expand `[Variable]` tokens,
|
||||
// evaluate and strip `[Condition]` directives. Returns true to KEEP the
|
||||
// line (all conditions passed); on true `out` holds the cleaned,
|
||||
// right-trimmed path.
|
||||
bool ProcessFileLine(const char *line, size_t len, std::string &out) {
|
||||
out.clear();
|
||||
bool keep = true;
|
||||
size_t i = 0;
|
||||
while (i < len) {
|
||||
const char c = line[i];
|
||||
if (c != '[') {
|
||||
out.push_back(c);
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
size_t close = i + 1;
|
||||
while (close < len && line[close] != ']') ++close;
|
||||
if (close >= len) { // unterminated '[' — copy the rest verbatim
|
||||
out.append(line + i, len - i);
|
||||
break;
|
||||
}
|
||||
const char *inner = line + i + 1;
|
||||
const size_t innerLen = close - (i + 1);
|
||||
size_t h = 0;
|
||||
while (h < innerLen && !IsSpace(inner[h])) ++h; // [0,h) = head word
|
||||
size_t a = h;
|
||||
while (a < innerLen && IsSpace(inner[a])) ++a; // skip separating ws
|
||||
const char *head = inner;
|
||||
const size_t headLen = h;
|
||||
const char *args = inner + a;
|
||||
const size_t argsLen = innerLen - a;
|
||||
|
||||
if (argsLen == 0) {
|
||||
// Single word: a variable to expand, else leave verbatim (an
|
||||
// unknown token becomes a path the engine cannot open — the
|
||||
// file just does not load).
|
||||
const char *val = VariableValue(head, headLen);
|
||||
if (val != nullptr) out.append(val);
|
||||
else out.append(line + i, (close + 1) - i);
|
||||
} else {
|
||||
// Keyword + args: a condition. Strip it from the path (drop a
|
||||
// space we already emitted before it so nothing dangles).
|
||||
keep = keep && EvalCondition(head, headLen, args, argsLen);
|
||||
if (!out.empty() && out.back() == ' ') out.pop_back();
|
||||
}
|
||||
i = close + 1;
|
||||
}
|
||||
while (!out.empty() && IsSpace(out.back())) out.pop_back();
|
||||
return keep;
|
||||
}
|
||||
|
||||
// Walk the whole TOC, preserving line terminators and any UTF-8 BOM.
|
||||
// `#` comment and `##` metadata lines pass through verbatim; only file
|
||||
// reference lines are rewritten. A gated-out line becomes a `#` comment
|
||||
// so the load loop skips it (and a dumped TOC shows why it was dropped).
|
||||
void RebuildToc(const char *src, size_t srcLen, std::string &out) {
|
||||
out.clear();
|
||||
out.reserve(srcLen + 32);
|
||||
size_t i = 0;
|
||||
if (srcLen >= 3 && static_cast<uint8_t>(src[0]) == 0xEF &&
|
||||
static_cast<uint8_t>(src[1]) == 0xBB &&
|
||||
static_cast<uint8_t>(src[2]) == 0xBF) {
|
||||
out.append(src, 3);
|
||||
i = 3;
|
||||
}
|
||||
std::string lineOut;
|
||||
while (i < srcLen) {
|
||||
const size_t lineStart = i;
|
||||
while (i < srcLen && src[i] != '\r' && src[i] != '\n') ++i;
|
||||
const char *line = src + lineStart;
|
||||
const size_t len = i - lineStart;
|
||||
|
||||
const size_t termStart = i;
|
||||
if (i < srcLen && src[i] == '\r') ++i;
|
||||
if (i < srcLen && src[i] == '\n') ++i;
|
||||
|
||||
size_t s = 0;
|
||||
while (s < len && IsSpace(line[s])) ++s;
|
||||
if (s >= len) {
|
||||
out.append(line, len); // blank
|
||||
} else if (line[s] == '#') {
|
||||
// Comment / `##` metadata — verbatim, except a multi-flavor
|
||||
// `## Interface:` list that hides the client version.
|
||||
if (!TryRewriteInterfaceLine(line, len, out))
|
||||
out.append(line, len);
|
||||
} else if (ProcessFileLine(line, len, lineOut)) {
|
||||
out.append(lineOut);
|
||||
} else {
|
||||
out.push_back('#'); // gated out — comment it so the loader skips it
|
||||
out.append(line, len);
|
||||
}
|
||||
out.append(src + termStart, i - termStart);
|
||||
}
|
||||
}
|
||||
|
||||
// True iff `path` is an addon `.toc` read (…\AddOns\…\*.toc). Gates the
|
||||
// rewrite off every non-TOC read (a `.lua` with a `t[1]` subscript would
|
||||
// otherwise be mangled) and off non-addon TOCs (FrameXML.toc, etc.).
|
||||
bool IsAddonToc(const char *path) {
|
||||
const size_t len = std::strlen(path);
|
||||
if (len < 4) return false;
|
||||
if (!(path[len - 4] == '.' && Lower(path[len - 3]) == 't' &&
|
||||
Lower(path[len - 2]) == 'o' && Lower(path[len - 1]) == 'c'))
|
||||
return false;
|
||||
for (size_t i = 0; i + 8 <= len; ++i)
|
||||
if (EqCI(path + i, 8, "\\addons\\")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void Transform(const char *path, void **outBuf, size_t *outSize) {
|
||||
if (path == nullptr || outBuf == nullptr || *outBuf == nullptr) return;
|
||||
if (!IsAddonToc(path)) return;
|
||||
|
||||
const char *content = static_cast<const char *>(*outBuf);
|
||||
const size_t size = (outSize != nullptr && *outSize != 0)
|
||||
? *outSize
|
||||
: std::strlen(content);
|
||||
if (size == 0) return;
|
||||
|
||||
// Fast path: no '[' (directives) and no ',' (a possible `## Interface:`
|
||||
// list) means nothing to do — leave the engine buffer alone.
|
||||
if (std::memchr(content, '[', size) == nullptr &&
|
||||
std::memchr(content, ',', size) == nullptr)
|
||||
return;
|
||||
|
||||
std::string rebuilt;
|
||||
RebuildToc(content, size, rebuilt);
|
||||
if (rebuilt.size() == size &&
|
||||
std::memcmp(rebuilt.data(), content, size) == 0)
|
||||
return; // brackets/commas were only in unaffected lines — nothing changed
|
||||
|
||||
auto SMemAlloc = reinterpret_cast<SMemAllocFn>(Offsets::FUN_STORM_SMEM_ALLOC);
|
||||
auto SMemFree = reinterpret_cast<SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE);
|
||||
const size_t newLen = rebuilt.size();
|
||||
void *buf = SMemAlloc(newLen + 1, __FILE__, __LINE__, 0); // +1 NUL (extraBytes=1)
|
||||
if (buf == nullptr) return; // OOM — keep the original
|
||||
std::memcpy(buf, rebuilt.data(), newLen);
|
||||
static_cast<char *>(buf)[newLen] = '\0';
|
||||
|
||||
SMemFree(*outBuf, __FILE__, __LINE__, 0);
|
||||
*outBuf = buf;
|
||||
if (outSize != nullptr) *outSize = newLen;
|
||||
}
|
||||
|
||||
} // namespace AddOns::TocRewrite
|
||||
@@ -0,0 +1,79 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace AddOns::TocRewrite {
|
||||
|
||||
// Modern-client per-line TOC directives, backported.
|
||||
//
|
||||
// A single-TOC multi-flavor addon gates individual file lines with
|
||||
// conditions and expands path variables:
|
||||
//
|
||||
// [Family]\Init.lua # -> Classic\Init.lua
|
||||
// Vanilla.lua [AllowLoadGameType vanilla]
|
||||
// Mainline.lua [AllowLoadGameType mainline] # dropped on this client
|
||||
// Localization\[TextLocale].lua # -> Localization\enUS.lua
|
||||
// deDE.lua [AllowLoadTextLocale deDE]
|
||||
//
|
||||
// Vanilla 1.12's TOC parsers never understood any of this. The scan
|
||||
// parser (FUN_0051C9B0) reads only `##` metadata; the load pass
|
||||
// (FUN_006edb90) hands each non-`#` line to the per-file loader verbatim,
|
||||
// where the trailing ` [...]` breaks the extension check and the file
|
||||
// silently never loads. `[Variable]` tokens likewise become part of a
|
||||
// path that does not exist.
|
||||
//
|
||||
// This closes the gap at the FUN_FILE_READ layer (alongside FlavorToc /
|
||||
// Embedded): after an addon TOC is read, rewrite its file-reference lines
|
||||
// so the engine's own load loop sees clean paths. `##` metadata and `#`
|
||||
// comment lines are copied verbatim, with ONE exception (`## Interface:`,
|
||||
// below); metadata-line conditions, added in retail 12.0.7, are out of
|
||||
// scope. What the tokens resolve to on this 1.12 client:
|
||||
//
|
||||
// [AllowLoadGameType ...] keep the line iff the list contains `vanilla`
|
||||
// [AllowLoadTextLocale ...] keep the line iff the list contains the
|
||||
// client locale (GetLocale() code, e.g. enUS)
|
||||
// [AllowLoad ...] addon files only load in-game, so `game`
|
||||
// keeps and `glue` drops
|
||||
// [Family] -> Classic
|
||||
// [Game] -> Vanilla
|
||||
// [TextLocale] -> the client locale code
|
||||
//
|
||||
// All conditions on a line must pass for it to load (AND). An unknown
|
||||
// `[keyword args]` condition fails safe (drops the line); an unknown
|
||||
// single-word `[Token]` is left verbatim (the engine then fails to find
|
||||
// that path, so the file does not load).
|
||||
//
|
||||
// Multi-flavor `## Interface:` list. Retail lets a TOC list several
|
||||
// interface versions (`## Interface: 120100, 50504, 11200`). Vanilla's
|
||||
// parser is a plain atoi that reads only the FIRST number, so it never
|
||||
// sees a later 11200 and marks the addon out of date. When the client
|
||||
// version (FUN_ADDON_CLIENT_INTERFACE_VERSION, 11200) appears anywhere in
|
||||
// the list but is not first, the `## Interface:` line is rewritten to that
|
||||
// single value so the engine marks the addon compatible. A list WITHOUT
|
||||
// the client version is left unchanged — it still parses to its first
|
||||
// value and stays out of date, exactly as before.
|
||||
//
|
||||
// Called from the FUN_FILE_READ hook after the read succeeds. Does
|
||||
// nothing unless `path` is an addon `.toc` read AND its content contains a
|
||||
// `[` (directives) or a `,` (a possible `## Interface:` list) — the
|
||||
// fast-path bail keeps every unaffected TOC zero-copy, and a rebuild that
|
||||
// changes nothing is discarded by a content compare. When it does rewrite,
|
||||
// it allocates a fresh Storm buffer, frees the original, and updates
|
||||
// `*outBuf` / `*outSize` — the caller frees the replacement exactly as it
|
||||
// would the engine's own read buffer.
|
||||
void Transform(const char *path, void **outBuf, size_t *outSize);
|
||||
|
||||
} // namespace AddOns::TocRewrite
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// 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/>.
|
||||
|
||||
// `collectgarbage(opt [, arg])` — accept Lua 5.1's string options on the
|
||||
// 5.0 collector. 5.0's collectgarbage takes only an optional NUMBER (a KB
|
||||
// threshold for the next cycle) and errors on every 5.1 option (verified
|
||||
// in-game: `collectgarbage("count")` → "bad argument #1 (number expected,
|
||||
// got string)"). Shim dispatch:
|
||||
//
|
||||
// "count" → KB of Lua memory in use — the engine's own gcinfo()
|
||||
// value, the same number 5.1's "count" reports.
|
||||
// "collect" → original collectgarbage(0): threshold 0 forces a full
|
||||
// cycle, the canonical 5.0 "collect now". Returns 0.
|
||||
// "step" → no-op returning true ("cycle finished"): the 5.0 GC
|
||||
// cannot step incrementally, and true is the loop-safe
|
||||
// sentinel (`repeat until collectgarbage("step")` exits
|
||||
// immediately instead of spinning forever).
|
||||
// "stop" / "restart" / "setpause" / "setstepmul"
|
||||
// → accepted no-ops returning 0 — the 5.0 GC has no toggle
|
||||
// or tuning; callers just lose the optimization.
|
||||
// number / no argument
|
||||
// → forwarded to the original untouched (5.0 threshold
|
||||
// semantics preserved for vanilla-era callers).
|
||||
//
|
||||
// The originals (`collectgarbage`, `gcinfo`) are function objects stashed
|
||||
// in the state's registry at registration, BEFORE the global is
|
||||
// overwritten — no raw engine address needed, and each /reload re-stashes
|
||||
// the freshly re-registered engine original (module callbacks run after
|
||||
// the engine's own registration pass). Registered on BOTH states.
|
||||
|
||||
#include "Game.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace BaseLib::CollectGarbage {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kOrigCollect = "__classicapi_orig_collectgarbage";
|
||||
constexpr const char *kOrigGcInfo = "__classicapi_orig_gcinfo";
|
||||
|
||||
// Pushes registry[key]; true when it holds a function (left on the
|
||||
// stack), false otherwise (stack unchanged).
|
||||
bool PushStashed(void *L, const char *key) {
|
||||
Game::Lua::PushString(L, key);
|
||||
Game::Lua::RawGet(L, Game::Lua::REGISTRY_INDEX);
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_FUNCTION)
|
||||
return true;
|
||||
Game::Lua::SetTop(L, -2);
|
||||
return false;
|
||||
}
|
||||
|
||||
int __fastcall Script_collectgarbage(void *L) {
|
||||
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_STRING) {
|
||||
const char *opt = Game::Lua::ToString(L, 1);
|
||||
if (std::strcmp(opt, "count") == 0) {
|
||||
if (PushStashed(L, kOrigGcInfo)) {
|
||||
Game::Lua::Call(L, 0, 1); // gcinfo's first return = KB in use
|
||||
return 1;
|
||||
}
|
||||
Game::Lua::PushNumber(L, 0.0); // no gcinfo on this state
|
||||
return 1;
|
||||
}
|
||||
if (std::strcmp(opt, "collect") == 0) {
|
||||
if (PushStashed(L, kOrigCollect)) {
|
||||
Game::Lua::PushNumber(L, 0.0); // threshold 0 = collect now
|
||||
Game::Lua::Call(L, 1, 0);
|
||||
}
|
||||
Game::Lua::PushNumber(L, 0.0); // 5.1 returns 0 for "collect"
|
||||
return 1;
|
||||
}
|
||||
if (std::strcmp(opt, "step") == 0) {
|
||||
Game::Lua::PushBool(L, true); // "cycle finished" — see header
|
||||
return 1;
|
||||
}
|
||||
if (std::strcmp(opt, "stop") == 0 || std::strcmp(opt, "restart") == 0 ||
|
||||
std::strcmp(opt, "setpause") == 0 ||
|
||||
std::strcmp(opt, "setstepmul") == 0) {
|
||||
Game::Lua::PushNumber(L, 0.0);
|
||||
return 1;
|
||||
}
|
||||
Game::Lua::Error(L, "bad argument #1 to 'collectgarbage' (invalid option '%s')",
|
||||
opt);
|
||||
return 0; // unreachable
|
||||
}
|
||||
|
||||
// Number or no argument: original 5.0 threshold behavior, forwarded.
|
||||
const int n = Game::Lua::GetTop(L);
|
||||
if (!PushStashed(L, kOrigCollect))
|
||||
return 0; // no original captured — inert
|
||||
for (int idx = 1; idx <= n; ++idx)
|
||||
Game::Lua::PushValue(L, idx);
|
||||
Game::Lua::Call(L, n, Game::Lua::MULTRET);
|
||||
return Game::Lua::GetTop(L) - n;
|
||||
}
|
||||
|
||||
// Stashes the function at `_G[name]` under registry[key]; true when a
|
||||
// function was actually stashed.
|
||||
bool Stash(void *L, const char *name, const char *key) {
|
||||
Game::Lua::PushString(L, name);
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // [fn?]
|
||||
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_FUNCTION) {
|
||||
Game::Lua::SetTop(L, -2);
|
||||
return false;
|
||||
}
|
||||
Game::Lua::PushString(L, key); // [fn, key]
|
||||
Game::Lua::Insert(L, -2); // [key, fn]
|
||||
Game::Lua::RawSet(L, Game::Lua::REGISTRY_INDEX);
|
||||
return true;
|
||||
}
|
||||
|
||||
void DoRegister(bool glue) {
|
||||
void *L = Game::Lua::State();
|
||||
if (L == nullptr)
|
||||
return;
|
||||
// Stash BEFORE overwriting; if the engine original is somehow absent,
|
||||
// leave the global untouched rather than installing a dead shim.
|
||||
if (!Stash(L, "collectgarbage", kOrigCollect))
|
||||
return;
|
||||
Stash(L, "gcinfo", kOrigGcInfo); // optional; "count" degrades to 0
|
||||
if (glue)
|
||||
Game::Lua::RegisterGlueFunction("collectgarbage", &Script_collectgarbage);
|
||||
else
|
||||
Game::Lua::RegisterGlobalFunction("collectgarbage", &Script_collectgarbage);
|
||||
}
|
||||
|
||||
void RegisterInGame() { DoRegister(false); }
|
||||
void RegisterGlue() { DoRegister(true); }
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
|
||||
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace BaseLib::CollectGarbage
|
||||
+133
-6
@@ -15,6 +15,7 @@
|
||||
//
|
||||
// - `string.match(s, pattern [, init])` — first-match extraction (5.1).
|
||||
// - `string.gmatch(s, pattern)` — match iterator (5.1).
|
||||
// - `string.gsub` TABLE replacement — 5.1 form (see the shim below).
|
||||
// - `strsplit(sep, str [, pieces])` — WoW global; split on any char.
|
||||
// - `strjoin(delimiter, ...)` — WoW global; join varargs.
|
||||
// - `strtrim(str [, chars])` — WoW global; trim a char set.
|
||||
@@ -28,12 +29,11 @@
|
||||
// it as a direct alias of the engine's `gfind` C function.
|
||||
// `strsplit` is a hand-rolled port of 3.3.5's `strsplit` (see below).
|
||||
//
|
||||
// NOTE: only the `string.foo(s, ...)` call form works — NOT the
|
||||
// `("x"):foo(...)` method sugar. WoW's Lua VM strips type-metatables for
|
||||
// every value except tables/userdata (both `lua_setmetatable` and the VM's
|
||||
// `luaT_gettmbyobj` hard-return "no metamethod" for strings), so strings have
|
||||
// no `__index`, and there's no client-side way to add one short of hooking a
|
||||
// hot VM-core function. This is the long-standing vanilla constraint.
|
||||
// NOTE: both call forms work — `string.foo(s, ...)` AND the `("x"):foo(...)`
|
||||
// method sugar. Method calls resolve through this same `string` table via
|
||||
// LuaSyntax::StringMethods (the Lua 5.1 string-metatable backport), so
|
||||
// everything registered here is automatically reachable as a method, on
|
||||
// both states.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
@@ -261,11 +261,138 @@ int __fastcall Script_string_reverse(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// --- string.gsub table-replacement upgrade (Lua 5.1) ------------------------
|
||||
//
|
||||
// 5.1 allows a TABLE as gsub's replacement: each match looks up the first
|
||||
// capture (the whole match when the pattern declares no captures) in the
|
||||
// table; a nil/false value keeps the original match; string/number values
|
||||
// substitute. This engine's gsub (FUN_LUA_STR_GSUB) is stock 5.0 —
|
||||
// string/function only ("string or function expected" otherwise), and its
|
||||
// add_value appends NOTHING when a replacement function returns a
|
||||
// non-string (match → empty; verified by decode, see Offsets.h). So a
|
||||
// plain table→closure adapter would EAT unmatched tokens where 5.1 keeps
|
||||
// them, and the closure can't reconstruct the whole match from capture
|
||||
// args alone.
|
||||
//
|
||||
// The shim therefore rewrites the pattern with one OUTER capture —
|
||||
// `pat` → `(pat)`, keeping a leading `^` and an unescaped trailing `$`
|
||||
// outside (both are anchors only at their positional ends; inside the
|
||||
// parens they'd turn literal) — so the adapter closure always receives
|
||||
// the whole match as arg 1, the pattern's own first capture as arg 2, and
|
||||
// can hand arg 1 back to reproduce 5.1's keep-match for missing keys.
|
||||
// Patterns containing a `%1`..`%9` back-reference can't be wrapped (the
|
||||
// outer capture would renumber what `%N` refers to); those fall back to
|
||||
// an unwrapped adapter whose missing-key behavior is the engine's own
|
||||
// (match → empty) — the one documented divergence, and the combination
|
||||
// (table replacement + pattern back-references) is essentially absent
|
||||
// from real code. String/function replacements tail-call the engine gsub
|
||||
// untouched.
|
||||
|
||||
// True when the pattern contains a `%1`..`%9` back-reference. Escape-aware:
|
||||
// the char after any `%` is consumed, so `%%1` (literal percent, then '1')
|
||||
// doesn't count.
|
||||
bool PatternHasBackref(const char *p, unsigned len) {
|
||||
for (unsigned i = 0; i + 1 < len; ++i) {
|
||||
if (p[i] != '%')
|
||||
continue;
|
||||
const char c = p[i + 1];
|
||||
if (c >= '1' && c <= '9')
|
||||
return true;
|
||||
++i; // skip the escaped char
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Adapter for the wrapped-pattern path: arg 1 = whole match, arg 2 = the
|
||||
// pattern's own first capture (when it declared any). Upvalue 1 = the
|
||||
// replacement table. Missing/false value → return the whole match (5.1's
|
||||
// keep-match).
|
||||
int __fastcall GsubTableLookupWrapped(void *L) {
|
||||
const int nargs = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushValue(L, nargs >= 2 ? 2 : 1); // the 5.1 lookup key
|
||||
Game::Lua::GetTable(L, Game::Lua::UpvalueIndex(1)); // tbl[key]
|
||||
const int t = Game::Lua::Type(L, -1);
|
||||
if (t == Game::Lua::TYPE_NIL ||
|
||||
(t == Game::Lua::TYPE_BOOLEAN && Game::Lua::ToBoolean(L, -1) == 0))
|
||||
Game::Lua::PushValue(L, 1); // keep the original match
|
||||
return 1; // top value is the result
|
||||
}
|
||||
|
||||
// Adapter for back-reference patterns (unwrapped): arg 1 is already the
|
||||
// 5.1 key. A missing key returns nil → the engine appends nothing.
|
||||
int __fastcall GsubTableLookupPlain(void *L) {
|
||||
Game::Lua::PushValue(L, 1);
|
||||
Game::Lua::GetTable(L, Game::Lua::UpvalueIndex(1));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_string_gsub(void *L) {
|
||||
const auto engineGsub =
|
||||
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_STR_GSUB);
|
||||
if (Game::Lua::Type(L, 3) != Game::Lua::TYPE_TABLE)
|
||||
return engineGsub(L); // string/function/error paths untouched
|
||||
|
||||
const char *pat = Game::Lua::ToString(L, 2);
|
||||
if (pat == nullptr)
|
||||
return engineGsub(L); // let the engine raise its own arg error
|
||||
const unsigned patLen = Game::Lua::StrLen(L, 2);
|
||||
|
||||
Game::Lua::SetTop(L, 4); // normalize to (s, pat, tbl, n) — gsub reads ≤4
|
||||
|
||||
if (!PatternHasBackref(pat, patLen)) {
|
||||
// Build `(body)` with the anchors outside the capture.
|
||||
unsigned b = 0, e = patLen;
|
||||
std::string wrapped;
|
||||
wrapped.reserve(patLen + 3);
|
||||
if (e > b && pat[0] == '^') {
|
||||
wrapped += '^';
|
||||
b = 1;
|
||||
}
|
||||
bool anchoredBack = false;
|
||||
if (e > b && pat[e - 1] == '$') {
|
||||
// `$` is an anchor only when not %-escaped: an even number of
|
||||
// `%` immediately before it means the `$` itself is unescaped.
|
||||
unsigned pc = 0;
|
||||
for (int idx = static_cast<int>(e) - 2;
|
||||
idx >= static_cast<int>(b) && pat[idx] == '%'; --idx)
|
||||
++pc;
|
||||
anchoredBack = (pc % 2) == 0;
|
||||
if (anchoredBack)
|
||||
--e;
|
||||
}
|
||||
wrapped += '(';
|
||||
wrapped.append(pat + b, e - b);
|
||||
wrapped += ')';
|
||||
if (anchoredBack)
|
||||
wrapped += '$';
|
||||
|
||||
Game::Lua::PushLString(L, wrapped.data(),
|
||||
static_cast<unsigned int>(wrapped.size())); // 5
|
||||
Game::Lua::PushValue(L, 3); // 6: tbl
|
||||
Game::Lua::PushCClosure(L, &GsubTableLookupWrapped, 1); // 6: closure
|
||||
Game::Lua::Insert(L, 2); // closure→2: s, closure, pat, tbl, n, wrapped
|
||||
Game::Lua::Insert(L, 2); // wrapped→2: s, wrapped, closure, pat, tbl, n
|
||||
Game::Lua::Remove(L, 4); // drop the original pattern
|
||||
Game::Lua::Remove(L, 4); // drop the table → (s, wrapped, closure, n)
|
||||
} else {
|
||||
Game::Lua::PushValue(L, 3); // 5: tbl
|
||||
Game::Lua::PushCClosure(L, &GsubTableLookupPlain, 1); // 5: closure
|
||||
Game::Lua::Insert(L, 3); // → s, pat, closure, tbl, n
|
||||
Game::Lua::Remove(L, 4); // → s, pat, closure, n
|
||||
}
|
||||
return engineGsub(L);
|
||||
}
|
||||
|
||||
void RegisterFns() {
|
||||
Game::Lua::RegisterTableFunction("string", "match", &Script_string_match);
|
||||
Game::Lua::RegisterTableFunction("string", "gmatch", Script_string_gmatch);
|
||||
Game::Lua::RegisterTableFunction("string", "gsub", &Script_string_gsub);
|
||||
Game::Lua::RegisterTableFunction("string", "reverse", &Script_string_reverse);
|
||||
Game::Lua::RegisterTableFunction("string", "split", &Script_strsplit);
|
||||
// The bare `gsub` global (1.12 exposes the string functions as globals
|
||||
// too) must match `string.gsub`, so addons calling either form get the
|
||||
// table-replacement upgrade.
|
||||
Game::Lua::RegisterGlobalFunction("gsub", &Script_string_gsub);
|
||||
Game::Lua::RegisterGlobalFunction("strsplit", &Script_strsplit);
|
||||
Game::Lua::RegisterGlobalFunction("strjoin", &Script_strjoin);
|
||||
Game::Lua::RegisterGlobalFunction("strtrim", &Script_strtrim);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `unpack(list [, i [, j]])` — upgrade 1.12's Lua 5.0 `unpack` to the 5.1
|
||||
// signature. 5.0's unpack takes NO range arguments and silently ignores
|
||||
// extras, so 5.1 code calling `unpack(t, 2)` got the WHOLE table back —
|
||||
// wrong values with no error, the worst failure mode (verified in-game:
|
||||
// `unpack({10,20,30}, 2)` returned 10, 20, 30). This replacement is a
|
||||
// port of 5.1's `luaB_unpack` (lbaselib.c): `i` defaults to 1 and `j` to
|
||||
// the table length via luaL_getn — which reads a `t.n` field first, so
|
||||
// the transpiler's `unpack(arg)` vararg expansion keeps its
|
||||
// embedded-nil-aware count exactly as before.
|
||||
//
|
||||
// Registered on BOTH states (language-level helper, both are 5.0).
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
namespace BaseLib::Unpack {
|
||||
|
||||
namespace {
|
||||
|
||||
// Engine auxlib/API entries (see Offsets.h for derivations).
|
||||
using LuaLGetN_t = int(__fastcall *)(void *L, int idx);
|
||||
using RawGetI_t = void(__fastcall *)(void *L, int idx, int n);
|
||||
|
||||
// Range argument at `idx`: absent/nil → `fallback`; a number (or numeric
|
||||
// string, matching luaL_optint's coercion) → its value; anything else
|
||||
// errors like 5.1's luaL_optint.
|
||||
int OptRange(void *L, int idx, int fallback) {
|
||||
if (Game::Lua::Type(L, idx) <= Game::Lua::TYPE_NIL) // none or nil
|
||||
return fallback;
|
||||
if (!Game::Lua::IsNumber(L, idx)) {
|
||||
Game::Lua::Error(L, "bad argument #%d to 'unpack' (number expected)", idx);
|
||||
return 0; // unreachable
|
||||
}
|
||||
return static_cast<int>(Game::Lua::ToNumber(L, idx));
|
||||
}
|
||||
|
||||
int __fastcall Script_unpack(void *L) {
|
||||
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
|
||||
Game::Lua::Error(L, "bad argument #1 to 'unpack' (table expected)");
|
||||
return 0; // unreachable
|
||||
}
|
||||
const int i = OptRange(L, 2, 1);
|
||||
const int e = OptRange(
|
||||
L, 3, reinterpret_cast<LuaLGetN_t>(Offsets::LUAL_GETN)(L, 1));
|
||||
if (i > e)
|
||||
return 0;
|
||||
// `n <= 0` guards the int overflow of `e - i + 1` (i <= e holds here),
|
||||
// mirroring 5.1's luaB_unpack.
|
||||
const int n = e - i + 1;
|
||||
if (n <= 0 || Game::Lua::CheckStack(L, n) == 0) {
|
||||
Game::Lua::Error(L, "too many results to unpack");
|
||||
return 0; // unreachable
|
||||
}
|
||||
const auto rawGetI = reinterpret_cast<RawGetI_t>(Offsets::LUA_RAWGETI);
|
||||
for (int k = i; k <= e; ++k)
|
||||
rawGetI(L, 1, k);
|
||||
return n;
|
||||
}
|
||||
|
||||
void RegisterInGame() {
|
||||
Game::Lua::RegisterGlobalFunction("unpack", &Script_unpack);
|
||||
}
|
||||
|
||||
void RegisterGlue() {
|
||||
Game::Lua::RegisterGlueFunction("unpack", &Script_unpack);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
|
||||
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace BaseLib::Unpack
|
||||
@@ -40,11 +40,6 @@ namespace Chat::RaidMarkers {
|
||||
|
||||
namespace {
|
||||
|
||||
// On-screen pixel size of an inline marker. The default chat font is ~14px, so 14
|
||||
// keeps a marker about one line tall. Fixed (the substitution is a static string
|
||||
// rewrite and can't see the target font); tunable here if it reads too small/large.
|
||||
constexpr int kMarkerSizePx = 14;
|
||||
|
||||
struct Token {
|
||||
const char *name; // lowercase alias, no braces
|
||||
int marker; // raid-target index 1..8
|
||||
@@ -126,11 +121,16 @@ const char *Substitute(const char *msg, char *buf, size_t bufSize) {
|
||||
// and renders). Anti-spoof Sanitize runs before this, so these
|
||||
// are the only `||T` openers a player can produce.
|
||||
char markup[128];
|
||||
// `:0:0` = auto-size. The inline emitter resolves height/width
|
||||
// to the target line's font pixel height at draw time, so a
|
||||
// marker tracks the chat font size (and grows in speech bubbles)
|
||||
// exactly like retail's ICON_LIST `…Icon_%d:0`. The rest of the
|
||||
// payload crops the 256x256 sheet to the cell (l,r,t,b).
|
||||
const int m = std::snprintf(
|
||||
markup, sizeof markup,
|
||||
"||TInterface\\TargetingFrame\\UI-RaidTargetingIcons:%d:%d:0:0:256:256:"
|
||||
"||TInterface\\TargetingFrame\\UI-RaidTargetingIcons:0:0:0:0:256:256:"
|
||||
"%d:%d:%d:%d||t",
|
||||
kMarkerSizePx, kMarkerSizePx, l, r, t, b);
|
||||
l, r, t, b);
|
||||
// Reserve room for the literal tail after this token
|
||||
// (msg[k+1..]) so the rest of the message always fits.
|
||||
const size_t tail = msgLen - (k + 1);
|
||||
|
||||
@@ -211,12 +211,42 @@ static int __fastcall Script_Coroutine_Wrap(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `coroutine.running()` — Lua 5.1 addition: the running coroutine, or
|
||||
// nil when called from the main thread. The 5.0 C API has no
|
||||
// `lua_pushthread` (also a 5.1 addition), so the thread TValue is
|
||||
// written to the stack raw: this build's TValue is 16 bytes with
|
||||
// {tt @ +0, value @ +8} (see LuaSyntax::StringMethods for the layout
|
||||
// derivation), tag 8 = thread, and the value is the running `lua_State`
|
||||
// itself — exactly what `lua_newthread` stores and what LUA_TO_POINTER
|
||||
// reads back at tvalue[2]. No stack-overflow check needed: every C
|
||||
// function is guaranteed LUA_MINSTACK free slots and this pushes one.
|
||||
// The running thread is trivially GC-alive, so no anchoring either.
|
||||
// Main-thread detection: module callbacks run on the state's main
|
||||
// thread, so the L captured at registration IS the main thread;
|
||||
// coroutine bodies run on their own lua_State.
|
||||
static void *g_mainL = nullptr;
|
||||
static constexpr uintptr_t OFF_L_TOP = 0x8; // L->top (push helpers' [ecx+8])
|
||||
|
||||
static int __fastcall Script_Coroutine_Running(void *L) {
|
||||
if (L == g_mainL) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
uint8_t *top = Game::Read<uint8_t *>(L, OFF_L_TOP);
|
||||
*reinterpret_cast<int *>(top) = Game::Lua::TYPE_THREAD;
|
||||
*reinterpret_cast<void **>(top + 8) = L;
|
||||
Game::Ref<uint8_t *>(L, OFF_L_TOP) = top + 16;
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
g_mainL = Game::Lua::State(); // the state's main thread (see Running)
|
||||
Game::Lua::RegisterTableFunction("coroutine", "create", &Script_Coroutine_Create);
|
||||
Game::Lua::RegisterTableFunction("coroutine", "resume", &Script_Coroutine_Resume);
|
||||
Game::Lua::RegisterTableFunction("coroutine", "yield", &Script_Coroutine_Yield);
|
||||
Game::Lua::RegisterTableFunction("coroutine", "status", &Script_Coroutine_Status);
|
||||
Game::Lua::RegisterTableFunction("coroutine", "wrap", &Script_Coroutine_Wrap);
|
||||
Game::Lua::RegisterTableFunction("coroutine", "running", &Script_Coroutine_Running);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
+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
|
||||
+93
-20
@@ -25,13 +25,28 @@
|
||||
// So `function(self, delta)` gets nothing today.
|
||||
//
|
||||
// We co-hook both runners and REPLACE their pcall tail: keep setting the same
|
||||
// globals (so vanilla handlers reading `this`/`arg1` keep working — this is
|
||||
// purely additive; Lua silently drops the extra positional args a zero-param
|
||||
// handler doesn't declare), but push `self` + `arg1..argN` as real arguments
|
||||
// and `pcall` with `nargs = 1 + N`. The reimplementation mirrors the engine's
|
||||
// message-handler errfunc, but saves the clobbered globals on the Lua stack
|
||||
// (not registry refs like the engine) so an out-of-memory longjmp mid-push
|
||||
// unwinds them for free — no leaked refs, no per-call ref churn. See RunModern.
|
||||
// globals (so handlers reading `this`/`arg1` keep working), but push `self` +
|
||||
// `arg1..argN` as real arguments and `pcall` with `nargs = 1 + N`. The
|
||||
// reimplementation mirrors the engine's message-handler errfunc, but saves the
|
||||
// clobbered globals on the Lua stack (not registry refs like the engine) so an
|
||||
// out-of-memory longjmp mid-push unwinds them for free — no leaked refs, no
|
||||
// per-call ref churn. See RunModern.
|
||||
//
|
||||
// The reimplemented path is taken ONLY for a handler that actually declares a
|
||||
// parameter or is vararg (checked via its Proto — see HandlerWantsArgs). A
|
||||
// handler that declares NO parameters can't observe positional args, so pushing
|
||||
// them is a no-op — those route through the original engine runner unchanged:
|
||||
// provably identical to vanilla, and OFF the engine's hottest Lua path (the vast
|
||||
// majority of vanilla handlers, which read `this`/`arg1`, are param-less). So a
|
||||
// param-less vanilla handler is untouched; only a param-declaring handler sees
|
||||
// the `(self, [event,] arg1..argN)` shape.
|
||||
//
|
||||
// Irreducible caveat: a param-declaring handler and a modern handler are
|
||||
// indistinguishable to the dispatcher — both are one-param Lua functions. So a
|
||||
// vanilla handler that declares a parameter expecting it to be nil (e.g. a
|
||||
// function reused as both a direct call and a handler) WILL now receive a real
|
||||
// value. There is no signal to tell the two apart; such a handler must opt out
|
||||
// per-session via `SetModernScriptArgs(false)`.
|
||||
//
|
||||
// Convention: `(self, arg1..argN)` for every script, plus `event` inserted as
|
||||
// the first positional for OnEvent — `(self, event, arg1..argN)` — matching
|
||||
@@ -41,10 +56,12 @@
|
||||
// SetScript creates a distinct ref, so a function bound to two scripts still
|
||||
// differs per slot — so no fragile "are we mid event-dispatch" flag is needed.
|
||||
//
|
||||
// Runtime switch, default OFF (opt-in): `SetModernScriptArgs(true)` enables it,
|
||||
// `GetModernScriptArgs()` reads the state. While disabled both detours are a
|
||||
// straight passthrough to the original runner (zero reimplementation risk).
|
||||
// Opt-in because this lands on the hottest Lua path in the engine
|
||||
// Runtime switch, default ON — modern handler signatures are a core 5.1
|
||||
// feature, so ports that write `function(self, delta)` work out of the box.
|
||||
// `SetModernScriptArgs(false)` disables it, `GetModernScriptArgs()` reads the
|
||||
// state. While disabled both detours are a straight passthrough to the original
|
||||
// runner (exact vanilla behavior, zero reimplementation risk) — the OFF escape
|
||||
// valve, since this lands on the hottest Lua path in the engine
|
||||
// (FUN_FRAME_RUN_SCRIPT_ARGS fires for every OnUpdate frame).
|
||||
|
||||
#include "Game.h"
|
||||
@@ -56,9 +73,10 @@ namespace Frame::ScriptArgs {
|
||||
|
||||
namespace {
|
||||
|
||||
// Runtime switch. Default OFF (opt-in). When false, both detours tail-call the
|
||||
// original runner unchanged (exact vanilla behavior, no reimplementation risk).
|
||||
bool g_enabled = false;
|
||||
// Runtime switch. Default ON (core 5.1 behavior). When false, both detours
|
||||
// tail-call the original runner unchanged (exact vanilla behavior, no
|
||||
// reimplementation risk) — the OFF escape valve for this hot path.
|
||||
bool g_enabled = true;
|
||||
|
||||
// Engine cap: the runner stops setting arg globals at index 19 (arg1..arg19).
|
||||
constexpr int kMaxArgs = 19;
|
||||
@@ -263,6 +281,55 @@ bool RunModern(int handlerRef, void *frame, const char *fmt, const void *vaPtr)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Lua 5.0 internal offsets for reading a handler's arity — verified against the
|
||||
// engine's own luaD_precall (0x006F6050) / lua_dump (0x006F4370) /
|
||||
// lua_iscfunction (0x006F34A0):
|
||||
// lua_State: top @ +0x08 (TValue *, next free slot)
|
||||
// TValue: tag @ +0x00 (int); GC/closure pointer @ +0x08 (16-byte TValue)
|
||||
// Closure: isC byte @ +0x06; union member (C func / Proto) @ +0x0C
|
||||
// Proto: numparams (u8) @ +0x45, is_vararg (u8) @ +0x46
|
||||
// Single-use here, so kept local (like the lua_State field offsets elsewhere).
|
||||
constexpr uintptr_t OFF_LUASTATE_TOP = 0x08;
|
||||
constexpr uintptr_t OFF_TVALUE_GC = 0x08;
|
||||
constexpr uintptr_t OFF_CLOSURE_ISC = 0x06;
|
||||
constexpr uintptr_t OFF_LCLOSURE_PROTO = 0x0C;
|
||||
constexpr uintptr_t OFF_PROTO_NUMPARAMS = 0x45;
|
||||
constexpr uintptr_t OFF_PROTO_IS_VARARG = 0x46;
|
||||
constexpr int kLuaTFunction = 6;
|
||||
constexpr uintptr_t kTValueSize = 0x10;
|
||||
|
||||
// Does this handler actually observe positional args? A vanilla handler reads
|
||||
// this/arg1 globals and declares NO parameters, so pushing positional args to
|
||||
// it is observably identical to vanilla — those route through the original
|
||||
// engine runner instead (exact vanilla, and off the hottest Lua path). Only a
|
||||
// Lua closure that declares a parameter or is vararg — a modern
|
||||
// `function(self, delta)` / `function(self, ...)` — takes the reimplemented
|
||||
// positional path. A C-closure handler (HookScript's thunk, say) reads
|
||||
// globals/upvalues, never positional args, so it counts as "no" too.
|
||||
//
|
||||
// Reads the handler's Proto directly (the engine exposes no arity via the C
|
||||
// API); every read is bounded — a non-function ref, a C closure, or a null
|
||||
// Proto all fall through to "no".
|
||||
bool HandlerWantsArgs(void *L, int handlerRef) {
|
||||
RawGetI(L, Game::Lua::REGISTRY_INDEX, handlerRef); // push registry[handlerRef]
|
||||
bool wants = false;
|
||||
const void *tv = reinterpret_cast<const void *>(
|
||||
Game::Read<uintptr_t>(L, OFF_LUASTATE_TOP) - kTValueSize);
|
||||
if (Game::Read<int>(tv, 0) == kLuaTFunction) {
|
||||
const void *cl =
|
||||
reinterpret_cast<const void *>(Game::Read<uintptr_t>(tv, OFF_TVALUE_GC));
|
||||
if (cl != nullptr && Game::Read<uint8_t>(cl, OFF_CLOSURE_ISC) == 0) { // Lua closure
|
||||
const void *proto = reinterpret_cast<const void *>(
|
||||
Game::Read<uintptr_t>(cl, OFF_LCLOSURE_PROTO));
|
||||
if (proto != nullptr)
|
||||
wants = Game::Read<uint8_t>(proto, OFF_PROTO_NUMPARAMS) > 0 ||
|
||||
Game::Read<uint8_t>(proto, OFF_PROTO_IS_VARARG) != 0;
|
||||
}
|
||||
}
|
||||
Game::Lua::SetTop(L, -2); // pop the handler
|
||||
return wants;
|
||||
}
|
||||
|
||||
// --- FUN_FRAME_RUN_SCRIPT_ARGS co-hook (scripts with values) ----------------
|
||||
using RunArgs_t = void(__cdecl *)(int handlerRef, void *frame, const char *fmt,
|
||||
const void *vaPtr);
|
||||
@@ -270,9 +337,12 @@ RunArgs_t g_origRunArgs = nullptr;
|
||||
|
||||
void __cdecl RunArgs_h(int handlerRef, void *frame, const char *fmt,
|
||||
const void *vaPtr) {
|
||||
if (g_enabled && handlerRef != 0 && fmt != nullptr &&
|
||||
RunModern(handlerRef, frame, fmt, vaPtr))
|
||||
return;
|
||||
if (g_enabled && handlerRef != 0 && fmt != nullptr) {
|
||||
void *L = Game::Lua::State();
|
||||
if (L != nullptr && HandlerWantsArgs(L, handlerRef) &&
|
||||
RunModern(handlerRef, frame, fmt, vaPtr))
|
||||
return;
|
||||
}
|
||||
g_origRunArgs(handlerRef, frame, fmt, vaPtr);
|
||||
}
|
||||
|
||||
@@ -283,9 +353,12 @@ using Invoke_t = void(__fastcall *)(int handlerRef, void *frame);
|
||||
Invoke_t g_origInvoke = nullptr;
|
||||
|
||||
void __fastcall Invoke_h(int handlerRef, void *frame) {
|
||||
if (g_enabled && handlerRef != 0 && frame != nullptr &&
|
||||
RunModern(handlerRef, frame, /*fmt*/ nullptr, /*vaPtr*/ nullptr))
|
||||
return;
|
||||
if (g_enabled && handlerRef != 0 && frame != nullptr) {
|
||||
void *L = Game::Lua::State();
|
||||
if (L != nullptr && HandlerWantsArgs(L, handlerRef) &&
|
||||
RunModern(handlerRef, frame, /*fmt*/ nullptr, /*vaPtr*/ nullptr))
|
||||
return;
|
||||
}
|
||||
g_origInvoke(handlerRef, frame);
|
||||
}
|
||||
|
||||
|
||||
+69
-12
@@ -176,12 +176,20 @@ struct Pending {
|
||||
|
||||
constexpr int kMaxPending = 512;
|
||||
constexpr int kMaxWaitTicks = 1200;
|
||||
// World ticks after which we assume the engine's startup
|
||||
// Wall-clock delay past world-live before we assume the engine's startup
|
||||
// network/opcode-dispatch state has settled and eager queries are safe.
|
||||
// Also latched the moment the engine's own response handler fires (a
|
||||
// response proves dispatch is live) — see `ItemStatsCacheResponse_h`.
|
||||
// Anchored to VAR_IN_WORLD (set to 1 by the enter-world initializer
|
||||
// FUN_004908C0, cleared on every transition — the same flag
|
||||
// bag/UpdateDelayed trusts), NOT a raw frame count: a fixed tick count is
|
||||
// FPS-dependent, so it could fire before dispatch was ready on a fast
|
||||
// machine (orphan risk — see the block comment above) yet leave bag icons
|
||||
// as "?" far longer than intended on a slow login. World-live alone is
|
||||
// still PLAYER_ENTERING_WORLD's first frame — inside the orphan window —
|
||||
// so we wait this long past it. Also latched the instant the engine's own
|
||||
// item-response handler fires (a response proves dispatch is live, so a
|
||||
// warm WDB settles with no delay) — see `ItemStatsCacheResponse_h`.
|
||||
// One-way latch; see the relog note in `WarmCache`.
|
||||
constexpr int kSettleTicks = 60;
|
||||
constexpr uint32_t kSettleMs = 1000;
|
||||
|
||||
// Minimum spacing between outgoing *background* SMSG_ITEM_QUERY_SINGLE
|
||||
// requests. A single /reload can make addons (pfQuest / Questie / Bagnon)
|
||||
@@ -198,10 +206,10 @@ constexpr uint32_t kMinRequestSpacingMs = 50;
|
||||
// login; behind the background floor they showed as "unknown" for a
|
||||
// second or two while a bulk addon scan drained ahead of them. These
|
||||
// are issued on a separate, more generous budget so they resolve fast.
|
||||
// The set is bounded (~120 items) and the engine prefetches inventory in
|
||||
// parallel, so a small per-tick cap keeps them near-instant without
|
||||
// re-introducing a burst: worst case is kOwnedPerTick + 1 packets per
|
||||
// frame (~7), far below the flood that broke connectivity.
|
||||
// The set is bounded (~120 items), so a small per-tick cap keeps them
|
||||
// near-instant without re-introducing a burst: worst case is
|
||||
// kOwnedPerTick + 1 packets per frame (~7), far below the flood that
|
||||
// broke connectivity.
|
||||
constexpr int kOwnedPerTick = 6;
|
||||
constexpr int kMaxOwned = 256;
|
||||
constexpr uint32_t kOwnedRefreshMs = 1000; // carried set changes slowly
|
||||
@@ -209,7 +217,10 @@ constexpr uint32_t kOwnedRefreshMs = 1000; // carried set changes slowly
|
||||
static Pending g_pending[kMaxPending];
|
||||
static int g_pendingCount = 0;
|
||||
static bool g_settled = false;
|
||||
static int g_worldTicks = 0;
|
||||
// Tick timestamp when we first saw the world go live (VAR_IN_WORLD == 1);
|
||||
// 0 = not yet. Anchors the kSettleMs delay. Only meaningful until the
|
||||
// one-way g_settled latch flips, so it is never reset.
|
||||
static uint32_t g_inWorldSinceMs = 0;
|
||||
static uint32_t g_lastRequestMs = 0;
|
||||
|
||||
// Snapshot of the itemIDs the player currently carries. Rebuilt by a
|
||||
@@ -219,6 +230,10 @@ static uint32_t g_owned[kMaxOwned];
|
||||
static int g_ownedCount = 0;
|
||||
static uint32_t g_ownedBuiltMs = 0;
|
||||
static bool g_ownedEverBuilt = false;
|
||||
// One-shot latch: have we proactively seeded the queue with the player's
|
||||
// carried items this enter-world session? Re-armed per in-game init (login
|
||||
// or /reload) in RegisterLuaFunctions.
|
||||
static bool g_ownedPrefetched = false;
|
||||
|
||||
static int FindPending(uint32_t itemID) {
|
||||
for (int i = 0; i < g_pendingCount; ++i) {
|
||||
@@ -441,11 +456,47 @@ static bool IsOwned(uint32_t itemID) {
|
||||
// giving up after `kMaxWaitTicks` for items whose query was already
|
||||
// sent but never resolved (lost query, server silent).
|
||||
static void OnWorldTick() {
|
||||
if (!g_settled && ++g_worldTicks >= kSettleTicks)
|
||||
g_settled = true;
|
||||
|
||||
const uint32_t now = Time::Clock::NowMs();
|
||||
|
||||
// Settle the startup gate a fixed wall-clock delay past world-live —
|
||||
// or leave it to ItemStatsCacheResponse_h to latch the instant a real
|
||||
// item response arrives, whichever comes first. See kSettleMs.
|
||||
if (!g_settled) {
|
||||
const bool inWorld = *reinterpret_cast<const volatile uint8_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_IN_WORLD)) != 0;
|
||||
if (inWorld && g_inWorldSinceMs == 0)
|
||||
g_inWorldSinceMs = now;
|
||||
if (g_inWorldSinceMs != 0 &&
|
||||
Time::Clock::Elapsed(g_inWorldSinceMs, now) >= kSettleMs)
|
||||
g_settled = true;
|
||||
}
|
||||
|
||||
// Proactive local-inventory prefetch (once per enter-world session).
|
||||
// The engine does NOT query bag item stats on enter-world or bag draw:
|
||||
// GetContainerItemInfo and the icon getter both do a null-callback
|
||||
// (pure lookup) _GetRecord, and an uncached bag item resolves
|
||||
// displayInfoID = 0, which the icon path draws as INV_Misc_QuestionMark
|
||||
// ("?"). (Equipped items are exempt — their icon comes from the
|
||||
// server-pushed visible-items table.) So on a cold WDB the player's
|
||||
// carried items stay "?" until something else queries them — a hover, a
|
||||
// loot, or an addon's GetItemInfo. Seed the queue with every carried
|
||||
// item so they issue on the owned priority lane ahead of any addon's
|
||||
// background flood, giving complete carried-item coverage regardless of
|
||||
// addon behavior. Bounded (<= kMaxOwned) and issued under the same owned
|
||||
// budget as everything else, so it adds no burst (issue #25). Deferred
|
||||
// to post-settle like every other query, so it can't orphan an entry
|
||||
// before the engine's network/opcode dispatch is ready. Gate on a live
|
||||
// inventory manager so a too-early settle retries next tick instead of
|
||||
// latching on an empty walk.
|
||||
if (g_settled && !g_ownedPrefetched &&
|
||||
Unit::Identity::PlayerInventoryManager() != nullptr) {
|
||||
RebuildOwned();
|
||||
g_ownedBuiltMs = now;
|
||||
for (int i = 0; i < g_ownedCount; ++i)
|
||||
Track(g_owned[i], /*implicit=*/true);
|
||||
g_ownedPrefetched = true;
|
||||
}
|
||||
|
||||
// Refresh the carried-item set while there is queued work, at most
|
||||
// once per kOwnedRefreshMs — the set changes slowly and the walk is
|
||||
// pointer-chasing, not free.
|
||||
@@ -567,6 +618,12 @@ static int __fastcall Script_RequestLoadItemData(void *L) {
|
||||
}
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
// Re-arm the proactive carried-item prefetch for this enter-world
|
||||
// session. Fires on login and /reload (this callback runs per in-game
|
||||
// Lua init). g_settled is deliberately NOT reset — it is a one-way
|
||||
// process latch (see WarmCache); on a /reload the cache is warm, so the
|
||||
// re-run resolves as cache hits and does nothing.
|
||||
g_ownedPrefetched = false;
|
||||
Game::Lua::RegisterTableFunction("C_Item", "IsItemDataCachedByID",
|
||||
&Script_IsItemDataCachedByID);
|
||||
Game::Lua::RegisterTableFunction("C_Item", "IsItemDataCached", &Script_IsItemDataCached);
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// getfenv / setfenv `__environment` protection — Lua 5.1 backport.
|
||||
//
|
||||
// A sandbox hands restricted code an environment table E and puts a metatable
|
||||
// on it carrying `__environment = <safe value>`. From then on, code inside the
|
||||
// sandbox that calls `getfenv()` gets the safe value instead of E (so it can't
|
||||
// reach or mutate the real environment), and `setfenv` refuses to change E.
|
||||
//
|
||||
// 1.12's engine ALREADY implements this protection — its `getfenv`
|
||||
// (0x00702AC0) and `setfenv` (0x00702BF8) share one predicate (0x00702BA0)
|
||||
// that reads a marker off the environment and, when present, substitutes it
|
||||
// (getfenv) or errors "cannot change a protected environment" (setfenv). But
|
||||
// 1.12 reads a RAW `__fenv` field on the env TABLE, whereas Lua 5.1 (verified
|
||||
// against 3.3.5's `FUN_0084F2F0`) reads a RAW `__environment` field off the
|
||||
// env's METATABLE. Modern addons written for 5.1 use the metatable form.
|
||||
//
|
||||
// We co-hook the ONE shared predicate and REPLACE it (the original is never
|
||||
// called) so both getfenv and setfenv gain the 5.1 semantics at once. The
|
||||
// reimplementation preserves the predicate's exact stack contract — push the
|
||||
// function's environment, then push the marker value (or nil), net +2, and
|
||||
// return whether the marker is non-nil — which getfenv (`settop -2`) and
|
||||
// setfenv (`settop -3`, then error) both depend on.
|
||||
//
|
||||
// Order: metatable `__environment` (5.1) is checked first; the raw `__fenv`
|
||||
// field (1.12's original) is kept as a fallback, so anything still relying on
|
||||
// `__fenv` — e.g. compiled FrameXML we can't see on disk — keeps working. The
|
||||
// change is additive: nothing on disk sets either marker, so for every
|
||||
// existing addon the predicate returns nil and getfenv/setfenv behave exactly
|
||||
// as before. It only activates when code deliberately marks an environment.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
namespace LuaEnv {
|
||||
|
||||
namespace {
|
||||
|
||||
// Raw engine primitives not exposed via Game::Lua.
|
||||
using GetFenv_t = void(__fastcall *)(void *L, int idx);
|
||||
using GetMetatable_t = int(__fastcall *)(void *L, int idx);
|
||||
const auto GetFenv = reinterpret_cast<GetFenv_t>(Offsets::LUA_GET_FENV);
|
||||
const auto GetMetatable = reinterpret_cast<GetMetatable_t>(Offsets::LUA_GET_METATABLE);
|
||||
|
||||
// Replacement for the shared getfenv/setfenv protection predicate
|
||||
// (FUN_LUA_ENV_PROTECT_PREDICATE). Contract mirrored exactly from the engine
|
||||
// original: the function whose environment to test is at stack top (-1); on
|
||||
// return the stack has grown by 2 (env, marker) and eax is non-zero iff the
|
||||
// environment is protected.
|
||||
int __fastcall EnvProtectPredicate_h(void *L) {
|
||||
GetFenv(L, -1); // push the function's environment -> [.., env]
|
||||
|
||||
bool found = false;
|
||||
if (GetMetatable(L, -1) != 0) { // -> [.., env, metatable]
|
||||
Game::Lua::PushLString(L, "__environment", 13);
|
||||
Game::Lua::RawGet(L, -2); // raw metatable["__environment"] -> [.., env, mt, marker]
|
||||
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_NIL) {
|
||||
Game::Lua::Remove(L, -2); // drop the metatable -> [.., env, marker]
|
||||
found = true;
|
||||
} else {
|
||||
Game::Lua::SetTop(L, -3); // drop the nil + metatable -> [.., env]
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
// Fallback: 1.12's original raw `__fenv` field on the env table.
|
||||
Game::Lua::PushLString(L, "__fenv", 6);
|
||||
Game::Lua::RawGet(L, -2); // raw env["__fenv"] -> [.., env, marker|nil]
|
||||
}
|
||||
return Game::Lua::Type(L, -1) != Game::Lua::TYPE_NIL;
|
||||
}
|
||||
|
||||
// Full replacement — the original predicate is never invoked (nullptr trampoline).
|
||||
const Game::HookAutoRegister _hook{Offsets::FUN_LUA_ENV_PROTECT_PREDICATE,
|
||||
reinterpret_cast<void *>(&EnvProtectPredicate_h),
|
||||
nullptr};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace LuaEnv
|
||||
@@ -0,0 +1,31 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace LuaSyntax {
|
||||
|
||||
// Pushes the per-addon shared namespace table for `name` onto the Lua stack,
|
||||
// creating it on first use. The same table object is returned on every call
|
||||
// for a given name, so the internal addon-args preamble (which hands a file
|
||||
// its `(addonName, addonTable)` varargs) and the public gated accessor
|
||||
// `C_AddOns.GetAddOnLocalTable` share one table per addon. Backed by a map in
|
||||
// the Lua registry, hidden from addons. Net stack effect: +1. `name` must be
|
||||
// non-null.
|
||||
//
|
||||
// This is the ONE place the namespace map is read/created — do not duplicate
|
||||
// the registry-key or map access elsewhere, or the two paths would hand out
|
||||
// different tables for the same addon.
|
||||
void PushAddonNamespace(void *L, const char *name);
|
||||
|
||||
} // namespace LuaSyntax
|
||||
@@ -0,0 +1,205 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Lua 5.1 string methods (`("asd"):upper()`, `s:format(...)`) on the 5.0 VM.
|
||||
//
|
||||
// Lua 5.0 has no string methods AT ALL — the shared string metatable with
|
||||
// `__index = string` is a 5.1 addition (`createmetatable` in 5.1's
|
||||
// lstrlib.c). Nothing was stripped from this build: both halves of the
|
||||
// would-be mechanism match stock 5.0 source exactly —
|
||||
// * `lua_setmetatable` (0x006F4020) accepts only tables/userdata, so no
|
||||
// API call can attach a metatable to the string TYPE; and
|
||||
// * `luaT_gettmbyobj` (0x006F7BD0) hardcodes `&nilobject` for anything
|
||||
// that isn't a table or userdata, so the VM would never read one.
|
||||
// So this is the runtime half of the 5.1 backport (the syntax half is
|
||||
// Transpile.cpp): we transplant 5.1's mechanism at the read side.
|
||||
//
|
||||
// Mechanism: co-hook FUN_LUA_INDEX_NONTABLE (0x006F7EE0) — luaV_gettable's
|
||||
// non-table continuation, the ONE choke every string index reaches (3 call
|
||||
// sites: luaV_gettable's else-branch + luaV_execute's OP_GETTABLE/OP_SELF
|
||||
// fallbacks; see Offsets.h). When the indexed object is a string, answer
|
||||
// with `luaV_gettable(L, <string library table>, key, loop)` — byte-for-byte
|
||||
// what the original's own TM-table branch would do had gettmbyobj returned
|
||||
// that table (it passes `loop` through unchanged too, 0x006F7F23). The
|
||||
// engine's unmodified lookup then does everything, including exact 5.1 miss
|
||||
// semantics: `("x").nonexistent` → plain table miss → nil, not an error
|
||||
// (verified: FUN_006F7D60 returns &nilobject when the table has no
|
||||
// metatable). Non-strings fall through to the original — userdata __index,
|
||||
// and the stock "attempt to index" error for everything else. `settable`
|
||||
// on a string is a different continuation (FUN_006F7F40), untouched: still
|
||||
// an error, which is also 5.1's behavior.
|
||||
//
|
||||
// Hotness: cold. Table `__index` dispatch (all frame/OO method traffic)
|
||||
// reads the table's OWN metatable via FUN_006F7D60/fasttm and never lands
|
||||
// here; today's only callers are about-to-error non-table indexes. No other
|
||||
// Octo DLL hooks the Lua VM internals.
|
||||
//
|
||||
// State/GC lifecycle: one slot per Lua state (in-game + glue), keyed by the
|
||||
// state's global_State (`*(L+0x10)`) so coroutine threads — which share G
|
||||
// with their parent — resolve too. Each capture anchors the string table in
|
||||
// that state's registry, so the hook can never serve a GC-collected table:
|
||||
// even if an addon nils/replaces `_G.string`, resolution keeps the snapshot
|
||||
// (5.1 parity — its metatable pins the open-time table the same way).
|
||||
// Captures re-run per /reload (ModuleAutoRegister) and per glue boot
|
||||
// (GlueModuleAutoRegister), overwriting the anchor so the previous table is
|
||||
// released. Slots are cleared BEFORE their state can die, so a capture can
|
||||
// never outlive the table it points at: PrepareForReload (called from
|
||||
// FrameScript_Initialize_h, which fires on /reload AND /logout before the
|
||||
// world Lua state is reset) clears the game slot, and the in-game
|
||||
// registration clears the glue slot (once world scripts are loading, the
|
||||
// glue state is gone; the next glue boot re-captures before any glue Lua
|
||||
// runs). A cleared slot fails closed — strings error on index exactly as
|
||||
// stock 1.12 until the re-capture.
|
||||
//
|
||||
// Kill switch: `_classicapi_SetStringMethods(false)` (registered on both
|
||||
// states) makes the hook forward everything to the original resolver —
|
||||
// exact stock behavior — for "is this backport breaking this addon?"
|
||||
// diagnosis, mirroring `_classicapi_SetTranspileOption`.
|
||||
|
||||
#include "StringMethods.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace LuaSyntax::StringMethods {
|
||||
|
||||
namespace {
|
||||
|
||||
// `global_State *` slot in lua_State — the per-universe object shared by
|
||||
// every coroutine thread of a state. Verified: luaT_gettmbyobj reads the
|
||||
// tmname array at `*(L+0x10) + 0x80`, lua_setmetatable resolves the
|
||||
// REGISTRY pseudo-index via `*(L+0x10) + 0x30`. Single-consumer offset,
|
||||
// local per the Offsets.h rule.
|
||||
constexpr uintptr_t OFF_L_GLOBALSTATE = 0x10;
|
||||
|
||||
// This build's TValue is 16 bytes with `tt` FIRST: {tt @ +0, value @ +8} —
|
||||
// NOT stock 5.0's {value, tt} order. Verified from lua_setmetatable
|
||||
// (tag = `*tv`, GC pointer = `tv[2]`, Table->metatable at hvalue+0x08 /
|
||||
// fast-TM flags byte at hvalue+0x06) and the push helpers' 0x10 stride.
|
||||
struct TValueMirror {
|
||||
int tt = 0;
|
||||
int pad0 = 0;
|
||||
const void *value = nullptr; // Table* when tt == TYPE_TABLE
|
||||
int pad1 = 0;
|
||||
};
|
||||
static_assert(sizeof(TValueMirror) == 16, "TValue is 16 bytes in this build");
|
||||
|
||||
// Captured `string` library table for one Lua state. `G` doubles as the
|
||||
// valid flag — nullptr means "no capture, pass everything through".
|
||||
struct StateSlot {
|
||||
const void *G = nullptr;
|
||||
TValueMirror tv{};
|
||||
};
|
||||
StateSlot g_gameSlot;
|
||||
StateSlot g_glueSlot;
|
||||
|
||||
// Runtime kill switch — `_classicapi_SetStringMethods(false)` reverts to
|
||||
// exact stock behavior (the hook forwards everything). Process-wide; both
|
||||
// states share it.
|
||||
bool g_enabled = true;
|
||||
|
||||
// Registry key anchoring the captured table against GC (rawset — the
|
||||
// registry has no metatable). One key per state's registry; re-capture
|
||||
// overwrites it, releasing the previously anchored table.
|
||||
constexpr const char *kAnchorKey = "__classicapi_string_lib";
|
||||
|
||||
// FUN_LUA_INDEX_NONTABLE / FUN_LUA_V_GETTABLE shared convention:
|
||||
// __fastcall(L /*ecx*/, tv /*edx*/, key /*stack*/, loop /*stack*/), RET 8,
|
||||
// result TValue* in eax (see Offsets.h for the disassembly evidence).
|
||||
using IndexResolve_t = void *(__fastcall *)(void *L, const void *tv, void *key, int loop);
|
||||
IndexResolve_t g_indexNonTableOriginal = nullptr;
|
||||
|
||||
void *__fastcall IndexNonTable_h(void *L, const void *obj, void *key, int loop) {
|
||||
// 5.1 backport: a string's `__index` is the `string` library table.
|
||||
if (g_enabled && obj != nullptr &&
|
||||
*static_cast<const int *>(obj) == Game::Lua::TYPE_STRING) {
|
||||
const void *G = Game::Read<const void *>(L, OFF_L_GLOBALSTATE);
|
||||
const StateSlot *slot = nullptr;
|
||||
if (g_gameSlot.G == G)
|
||||
slot = &g_gameSlot;
|
||||
else if (g_glueSlot.G == G)
|
||||
slot = &g_glueSlot;
|
||||
if (slot != nullptr && slot->tv.value != nullptr)
|
||||
return reinterpret_cast<IndexResolve_t>(Offsets::FUN_LUA_V_GETTABLE)(
|
||||
L, &slot->tv, key, loop);
|
||||
}
|
||||
return g_indexNonTableOriginal(L, obj, key, loop);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hook{Offsets::FUN_LUA_INDEX_NONTABLE,
|
||||
reinterpret_cast<void *>(&IndexNonTable_h),
|
||||
reinterpret_cast<void **>(&g_indexNonTableOriginal)};
|
||||
|
||||
// Captures `_G.string` for the CURRENT state (whatever VAR_LUA_STATE points
|
||||
// at — the registration callbacks below run inside the right window for
|
||||
// each) into `slot`, anchoring it in that state's registry.
|
||||
void CaptureFor(StateSlot &slot) {
|
||||
slot.G = nullptr;
|
||||
slot.tv.value = nullptr;
|
||||
void *L = Game::Lua::State();
|
||||
if (L == nullptr)
|
||||
return;
|
||||
Game::Lua::PushString(L, "string");
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // [stringlib?]
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE) {
|
||||
Game::Lua::PushString(L, kAnchorKey); // [tbl, key]
|
||||
Game::Lua::PushValue(L, -2); // [tbl, key, tbl]
|
||||
Game::Lua::RawSet(L, Game::Lua::REGISTRY_INDEX); // registry[key]=tbl. [tbl]
|
||||
slot.tv.tt = Game::Lua::TYPE_TABLE;
|
||||
slot.tv.value = Game::Lua::ToPointer(L, -1); // hvalue (Table*)
|
||||
slot.G = Game::Read<const void *>(L, OFF_L_GLOBALSTATE);
|
||||
}
|
||||
Game::Lua::SetTop(L, -2); // pop
|
||||
}
|
||||
|
||||
// `_classicapi_SetStringMethods(enabled)` — diagnostic kill switch. While
|
||||
// false, string index falls through to the original resolver and errors as
|
||||
// stock 1.12. Use only to answer "is this backport breaking this addon?".
|
||||
int __fastcall Script_SetStringMethods(void *L) {
|
||||
g_enabled = Game::Lua::ToBoolean(L, 1) != 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
// World scripts are loading ⇒ the glue state is gone (destroyed on the
|
||||
// glue→world handoff). Clear its slot so a future glue global_State
|
||||
// that lands at the same address can never match this stale capture.
|
||||
g_glueSlot.G = nullptr;
|
||||
g_glueSlot.tv.value = nullptr;
|
||||
CaptureFor(g_gameSlot);
|
||||
Game::Lua::RegisterGlobalFunction("_classicapi_SetStringMethods",
|
||||
&Script_SetStringMethods);
|
||||
}
|
||||
void RegisterGlueFunctions() {
|
||||
CaptureFor(g_glueSlot);
|
||||
Game::Lua::RegisterGlueFunction("_classicapi_SetStringMethods",
|
||||
&Script_SetStringMethods);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
const Game::GlueModuleAutoRegister _glueAutoreg{&RegisterGlueFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
void PrepareForReload() {
|
||||
// The world Lua state is about to be reset (/reload) or destroyed
|
||||
// (/logout); its string table may not survive. Fail closed until the
|
||||
// next LoadScriptFunctions re-capture — a cleared slot means strings
|
||||
// error on index exactly as stock, and nothing can read a freed table.
|
||||
g_gameSlot.G = nullptr;
|
||||
g_gameSlot.tv.value = nullptr;
|
||||
}
|
||||
|
||||
} // namespace LuaSyntax::StringMethods
|
||||
@@ -0,0 +1,25 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace LuaSyntax::StringMethods {
|
||||
|
||||
// Forget the in-game state's captured `string` table. Called from
|
||||
// FrameScript_Initialize_h BEFORE the engine resets (/reload) or destroys
|
||||
// (/logout) the world Lua state, so the capture can never outlive the table
|
||||
// it points at. Fails closed: until the ModuleAutoRegister re-capture at the
|
||||
// next LoadScriptFunctions, strings error on index exactly as stock 1.12.
|
||||
void PrepareForReload();
|
||||
|
||||
} // namespace LuaSyntax::StringMethods
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -0,0 +1,64 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `table.maxn(t)` — Lua 5.1 addition (luaB_maxn, ltablib.c): the largest
|
||||
// positive numeric key of `t`, or 0 when it has none. Unlike `#`/getn it
|
||||
// scans EVERY key, so it sees past nil holes — common in Ace3-era code
|
||||
// for sparse arrays. Port of the 5.1 reference: full `lua_next` walk,
|
||||
// tracking the maximum numeric key.
|
||||
//
|
||||
// Registered on BOTH states (language-level helper, both are 5.0).
|
||||
|
||||
#include "Game.h"
|
||||
|
||||
namespace Table::Maxn {
|
||||
|
||||
namespace {
|
||||
|
||||
int __fastcall Script_table_maxn(void *L) {
|
||||
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
|
||||
Game::Lua::Error(L, "bad argument #1 to 'maxn' (table expected)");
|
||||
return 0; // unreachable
|
||||
}
|
||||
double max = 0.0;
|
||||
Game::Lua::PushNil(L);
|
||||
while (Game::Lua::Next(L, 1) != 0) {
|
||||
Game::Lua::SetTop(L, -2); // pop value, keep key for the next iter
|
||||
// Type-gate before ToNumber: lua_tonumber would convert a numeric
|
||||
// STRING key in place, corrupting the key `lua_next` resumes from.
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_NUMBER) {
|
||||
const double k = Game::Lua::ToNumber(L, -1);
|
||||
if (k > max)
|
||||
max = k;
|
||||
}
|
||||
}
|
||||
Game::Lua::PushNumber(L, max);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterInGame() {
|
||||
Game::Lua::RegisterTableFunction("table", "maxn", &Script_table_maxn);
|
||||
}
|
||||
|
||||
void RegisterGlue() {
|
||||
// RegisterTableFunction writes to whichever state VAR_LUA_STATE
|
||||
// references — the glue state inside this callback.
|
||||
Game::Lua::RegisterTableFunction("table", "maxn", &Script_table_maxn);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
|
||||
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Table::Maxn
|
||||
+211
-110
@@ -1225,24 +1225,6 @@ void __fastcall WrapStepper_h(void *font, uint8_t *text, float fontH, float wrap
|
||||
float slack = probeW - pWidth;
|
||||
if (slack < 0.0f)
|
||||
slack = 0.0f;
|
||||
// AUTO-WIDTH bail: the whole text fits its budget with at
|
||||
// most ~a glyph of slack — the signature of a
|
||||
// single-anchor, no-width fontstring whose wrap budget is
|
||||
// its own text-only measure plus a ~1-glyph engine margin
|
||||
// (measured 1.07×fontH on pfUI's addon-list labels; the
|
||||
// half-glyph first cut missed it). There is no real frame
|
||||
// edge to protect there: the box is synthetic, the icons
|
||||
// draw past it harmlessly, and any shrink FABRICATES a
|
||||
// wrap on a line the engine would never have wrapped.
|
||||
// Real-width content stays covered: an overflowing line
|
||||
// breaks (lineLen < len), and a fitting line in a real
|
||||
// frame carries frame-sized slack (the Marks harness line
|
||||
// measures ~3.5×fontH) — only a text that lands within
|
||||
// 1.5 glyphs of exactly filling its frame gives up its
|
||||
// shrink, and its overflow is bounded by its icon sum
|
||||
// (the pre-hook status quo).
|
||||
if (pass == 0 && lineLen == len && slack <= glyphU * 1.5f)
|
||||
break; // s == 0, best unset → final shrink = 0
|
||||
// Half-glyph feasibility tolerance. An AUTO-WIDTH
|
||||
// fontstring's wrap width IS the icon-inclusive string
|
||||
// width (the GetStringWidth hook feeds the effective-width
|
||||
@@ -1647,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
|
||||
@@ -1786,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, {});
|
||||
@@ -1908,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
|
||||
@@ -1921,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
|
||||
|
||||
+73
-18
@@ -27,33 +27,85 @@ namespace {
|
||||
bool g_detected = false;
|
||||
char g_version[32] = {0};
|
||||
|
||||
// Which Lua state is live right now: the glue screen (true) or the in-world
|
||||
// FrameScript state (false). The `TURTLE_*` glue markers are set by Turtle's
|
||||
// GlueXML and are only trustworthy on the glue state — no user addon runs
|
||||
// there. In-world, ANY addon can define a global by one of those names (a
|
||||
// Turtle-UI port shipping the strings, say), which must NOT latch Turtle
|
||||
// detection. So the marker probe is gated on this. Driven by the engine's own
|
||||
// per-state script-registration hooks below; starts true (the client boots to
|
||||
// the glue screen).
|
||||
bool g_onGlue = true;
|
||||
|
||||
void Probe() {
|
||||
if (g_detected)
|
||||
return;
|
||||
if (g_detected && g_version[0] != '\0')
|
||||
return; // fully resolved (Turtle + version known)
|
||||
void *L = Game::Lua::State();
|
||||
if (L == nullptr)
|
||||
return;
|
||||
|
||||
// Read `_G["TURTLE_WOW_VERSION"]` without disturbing the caller's
|
||||
// stack. RawGet avoids any (nonexistent, but free-to-skip) metatable
|
||||
// side effects on the globals table.
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushString(L, "TURTLE_WOW_VERSION");
|
||||
Game::Lua::RawGet(L, Game::Lua::GLOBALS_INDEX);
|
||||
if (Game::Lua::IsString(L, -1)) {
|
||||
const char *v = Game::Lua::ToString(L, -1);
|
||||
if (v != nullptr) {
|
||||
const size_t n = std::strlen(v);
|
||||
const size_t copy =
|
||||
(n < sizeof(g_version) - 1) ? n : sizeof(g_version) - 1;
|
||||
std::memcpy(g_version, v, copy);
|
||||
g_version[copy] = '\0';
|
||||
|
||||
// In-world: `TURTLE_WOW_VERSION` (an engine-set global — NOT defined in
|
||||
// Turtle's FrameXML Lua) both proves Turtle and gives the version string.
|
||||
// RawGet avoids any (nonexistent, but free-to-skip) globals metatable.
|
||||
if (g_version[0] == '\0') {
|
||||
Game::Lua::PushString(L, "TURTLE_WOW_VERSION");
|
||||
Game::Lua::RawGet(L, Game::Lua::GLOBALS_INDEX);
|
||||
if (Game::Lua::IsString(L, -1)) {
|
||||
const char *v = Game::Lua::ToString(L, -1);
|
||||
if (v != nullptr) {
|
||||
const size_t n = std::strlen(v);
|
||||
const size_t copy =
|
||||
(n < sizeof(g_version) - 1) ? n : sizeof(g_version) - 1;
|
||||
std::memcpy(g_version, v, copy);
|
||||
g_version[copy] = '\0';
|
||||
}
|
||||
g_detected = true;
|
||||
}
|
||||
Game::Lua::SetTop(L, top);
|
||||
}
|
||||
|
||||
// Glue screen: `TURTLE_WOW_VERSION` is not on the glue Lua state, but
|
||||
// Turtle's GlueXML sets a family of `TURTLE_*` string globals
|
||||
// (GlueStrings.lua) at glue boot — before the addon-registration scan
|
||||
// runs — so their presence marks a Turtle-lineage client. Clones keep the
|
||||
// NAMES (Octo rebrands only the URL values), so checking name presence is
|
||||
// clone-robust. Latch detected; the version stays empty until in-world.
|
||||
// This is what lets `Addons::FlavorToc` select `_Turtle.toc` consistently
|
||||
// at BOTH the glue registration scan and the in-world load pass.
|
||||
//
|
||||
// Only consult the markers while actually ON the glue state (`g_onGlue`).
|
||||
// In-world these names are forgeable by any loaded addon, so trusting them
|
||||
// there would let a stock client be mis-detected as Turtle for the session.
|
||||
if (!g_detected && g_onGlue) {
|
||||
static const char *const kGlueMarkers[] = {
|
||||
"AUTH_TURTLE_WEBSITE", "TURTLE_ARMORY", "TURTLE_KNOWLEDGE_DATABASE"};
|
||||
for (const char *marker : kGlueMarkers) {
|
||||
Game::Lua::PushString(L, marker);
|
||||
Game::Lua::RawGet(L, Game::Lua::GLOBALS_INDEX);
|
||||
const bool present = Game::Lua::IsString(L, -1);
|
||||
Game::Lua::SetTop(L, top);
|
||||
if (present) {
|
||||
g_detected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
g_detected = true;
|
||||
}
|
||||
Game::Lua::SetTop(L, top);
|
||||
}
|
||||
|
||||
// Track the live Lua state via the engine's per-state script-registration
|
||||
// hooks. The in-world registration (`ModuleAutoRegister`) fires on each world
|
||||
// load BEFORE the addon load pass, so `g_onGlue` is already false by the time
|
||||
// any addon's `FlavorToc` calls `Detected()`; the glue registration
|
||||
// (`GlueModuleAutoRegister`) fires on every glue boot (initial launch + each
|
||||
// world->glue logout). Neither reads a forgeable Lua global.
|
||||
void OnInWorldRegister() { g_onGlue = false; }
|
||||
void OnGlueRegister() { g_onGlue = true; }
|
||||
|
||||
const Game::ModuleAutoRegister _inWorldReg{&OnInWorldRegister};
|
||||
const Game::GlueModuleAutoRegister _glueReg{&OnGlueRegister};
|
||||
|
||||
} // namespace
|
||||
|
||||
bool Detected() {
|
||||
@@ -63,7 +115,10 @@ bool Detected() {
|
||||
|
||||
const char *Version() {
|
||||
Probe();
|
||||
return g_detected ? g_version : nullptr;
|
||||
// nullptr until the in-world `TURTLE_WOW_VERSION` fills g_version — a
|
||||
// glue-only detection latches Detected() with the version still empty, and
|
||||
// the header promises nullptr (not "") in that window.
|
||||
return (g_detected && g_version[0] != '\0') ? g_version : nullptr;
|
||||
}
|
||||
|
||||
} // namespace Turtle
|
||||
|
||||
+18
-13
@@ -14,24 +14,29 @@
|
||||
#pragma once
|
||||
|
||||
// Turtle WoW client detection — the gate for every module under
|
||||
// `src/turtle/`. Turtle's FrameXML sets a `TURTLE_WOW_VERSION` global (see
|
||||
// `FrameXML/Globals.lua`, e.g. `"1.18.1"`); its presence is the
|
||||
// authoritative "this is the Turtle client" signal — no realm-list
|
||||
// sniffing (which conflates "connected to Turtle" with "running the
|
||||
// Turtle client" and was rejected for the Rip combo-duration work), no
|
||||
// binary fingerprinting.
|
||||
// `src/turtle/`. Two signals, so it works in-world AND on the glue screen:
|
||||
// * in-world: the engine-set `TURTLE_WOW_VERSION` global (e.g. `"1.18.1"`;
|
||||
// not defined in Turtle's FrameXML Lua — it's set by the client binary);
|
||||
// * glue screen: Turtle's GlueXML `TURTLE_*` string globals (GlueStrings.lua,
|
||||
// e.g. `AUTH_TURTLE_WEBSITE` / `TURTLE_ARMORY`), set at glue boot.
|
||||
// Clones keep those NAMES (they rebrand only the URL/value), so name presence
|
||||
// is clone-robust. No realm-list sniffing (which conflates "connected to
|
||||
// Turtle" with "running the Turtle client", and varies per clone — rejected
|
||||
// for the Rip combo-duration work), no binary fingerprinting.
|
||||
namespace Turtle {
|
||||
|
||||
// True iff running on a Turtle WoW client. Reads the `TURTLE_WOW_VERSION`
|
||||
// global and LATCHES true once seen. Safe to call any time: it returns
|
||||
// false until the in-game Lua state exists AND FrameXML's Globals.lua has
|
||||
// run (so it can read false very early in boot / on the glue screen), then
|
||||
// flips true and stays true. Never caches a false result, so an early
|
||||
// probe can't wrongly pin a Turtle client as non-Turtle.
|
||||
// True iff running on a Turtle WoW (or clone) client. LATCHES true once either
|
||||
// signal is seen. Safe to call from any state / any time — including during the
|
||||
// glue-time addon-registration scan, which is what lets `Addons::FlavorToc`
|
||||
// pick `_Turtle.toc` consistently at both scan and in-world load. Never caches
|
||||
// a false result, so an early probe (before either signal exists) can't wrongly
|
||||
// pin a Turtle client as non-Turtle.
|
||||
bool Detected();
|
||||
|
||||
// The Turtle client version string (e.g. `"1.18.1"`), or nullptr when not
|
||||
// Turtle / not yet detected. Points at an internally-cached copy.
|
||||
// Turtle / not yet in-world (glue detection latches Detected() without a
|
||||
// version — it fills once `TURTLE_WOW_VERSION` is readable in-world). Points at
|
||||
// an internally-cached copy.
|
||||
const char *Version();
|
||||
|
||||
} // namespace Turtle
|
||||
|
||||
+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