mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-12 04:47:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
695077b6da | ||
|
|
4d744b6638 | ||
|
|
c2a4cbdf27 | ||
|
|
d30ae53fbf | ||
|
|
8f32955823 | ||
|
|
2778643f4f | ||
|
|
3b60ca4094 | ||
|
|
c20be0cef1 | ||
|
|
1ad6b57202 | ||
|
|
993c82cca7 | ||
|
|
94e77ef5af | ||
|
|
1458d60b51 | ||
|
|
8b106ae0d6 | ||
|
|
3c4d4eda5f | ||
|
|
6aea4a1752 | ||
|
|
6304a253dd | ||
|
|
fbe40743c0 | ||
|
|
772cd5ec15 | ||
|
|
a962c76f09 | ||
|
|
292e206dee | ||
|
|
eef7140722 | ||
|
|
5279c463ed | ||
|
|
855b338736 | ||
|
|
6ca6af1497 | ||
|
|
1170a4e63a | ||
|
|
0c8468cf2f | ||
|
|
5196aab4b8 | ||
|
|
536c5e3c51 | ||
|
|
c06bed9158 | ||
|
|
e793f80f6b | ||
|
|
ded767bf19 | ||
|
|
70a69b64fd | ||
|
|
0f5b50ea14 | ||
|
|
be6dc200fe | ||
|
|
7f52afeef4 | ||
|
|
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 | ||
|
|
85c5c924a9 | ||
|
|
f2af2734f0 | ||
|
|
dc13d7bbfc | ||
|
|
99c0ab3ba3 | ||
|
|
87acb70c5b | ||
|
|
92a959c381 | ||
|
|
efc6b29b6b | ||
|
|
b09db4d5a5 | ||
|
|
0ce9d159e9 | ||
|
|
23b622acff | ||
|
|
f806f17f40 | ||
|
|
e9407f6564 | ||
|
|
f42f7459e2 | ||
|
|
968335debe | ||
|
|
04d4bf3326 | ||
|
|
c54556f6a1 | ||
|
|
bd14a93a11 | ||
|
|
5978d7dd62 | ||
|
|
67172c95f7 | ||
|
|
59c3bf9e0f | ||
|
|
54782df95e | ||
|
|
3ab690ac53 | ||
|
|
6b13368e65 | ||
|
|
5456295e2e | ||
|
|
9b34ceff9e | ||
|
|
a373a775cf | ||
|
|
e9014801b6 | ||
|
|
dc8a510611 | ||
|
|
d514dac465 | ||
|
|
621d1a9db9 | ||
|
|
7d566f3787 | ||
|
|
acc9a85bc6 | ||
|
|
207d47c8b8 | ||
|
|
76f48de67f | ||
|
|
72c4185026 | ||
|
|
d366fec43e | ||
|
|
c6d99cf843 | ||
|
|
39d22ac124 |
@@ -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,4 +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,32 @@ 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
|
||||
|
||||
-- ModernSpellBook (Spellbook\MSB_Spellbook.lua) re-hooks its "show all ranks"
|
||||
-- checkbox by calling HookScript as a GLOBAL -- HookScript(frame, "OnClick",
|
||||
-- handler). That global never existed on any client: HookScript has always
|
||||
-- been the widget method frame:HookScript, which our DLL provides. MSB even
|
||||
-- guards the call with `if checkbox.HookScript` (the method IS present), then
|
||||
-- calls the global (nil) -> "attempt to call global 'HookScript'". Define the
|
||||
-- global so the call resolves, forwarding to the method. Gated on the addon
|
||||
-- being present so the global namespace stays clean otherwise.
|
||||
if C_AddOns.DoesAddOnExist("ModernSpellBook") and not HookScript then
|
||||
function HookScript(frame, script, handler)
|
||||
if frame and frame.HookScript then
|
||||
frame:HookScript(script, handler)
|
||||
end
|
||||
end
|
||||
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
|
||||
@@ -132,4 +132,9 @@ INSCRIPTION_AVAILABLE = false;
|
||||
EQUIPPED_FIRST = 1;
|
||||
EQUIPPED_LAST = 19;
|
||||
|
||||
BASE_MOVEMENT_SPEED = 7;
|
||||
BASE_MOVEMENT_SPEED = 7;
|
||||
|
||||
UPPER_LEFT_VERTEX = 1;
|
||||
LOWER_LEFT_VERTEX = 2;
|
||||
UPPER_RIGHT_VERTEX = 3;
|
||||
LOWER_RIGHT_VERTEX = 4;
|
||||
@@ -2,12 +2,15 @@
|
||||
-- 1.12 / Lua 5.0.
|
||||
--
|
||||
-- 5.0 notes: no `#`/`%`/string-method syntax — uses string.*, math.mod.
|
||||
-- The currency/cost helpers (C_CurrencyInfo etc.) are omitted. GetMoneyString
|
||||
-- uses text symbols, not coin texture markup (markup doesn't render in 1.12).
|
||||
-- GetMoneyString gives the text-symbol form ("12g 34s 56c"). The coin-icon form
|
||||
-- (GetCoinTextureString / C_CurrencyInfo.GetCoinTextureString) is provided by the
|
||||
-- ClassicAPI DLL in C++ (src/currency/CoinText.cpp) — like retail, where it's a
|
||||
-- C function — now that the inline-texture backport renders |T markup in 1.12.
|
||||
--
|
||||
-- The FrameXML format-string globals these functions consume
|
||||
-- (LARGE_NUMBER_SEPERATOR, PERCENTAGE_STRING, GOLD_AMOUNT_SYMBOL, …) are
|
||||
-- defined in the locale layer (locales/enUS.lua), which loads first.
|
||||
-- (LARGE_NUMBER_SEPERATOR, PERCENTAGE_STRING, GOLD_AMOUNT_SYMBOL,
|
||||
-- GOLD_AMOUNT_TEXTURE, …) are defined in the locale layer (locales/enUS.lua),
|
||||
-- which loads first.
|
||||
|
||||
COPPER_PER_SILVER = 100;
|
||||
SILVER_PER_GOLD = 100;
|
||||
@@ -73,10 +76,9 @@ function SplitTextIntoHeaderAndNonHeader(text)
|
||||
end
|
||||
|
||||
-- (money, separateThousands [, checkGoldThreshold, showZeroAsGold])
|
||||
-- Text-symbol form ("12g 34s 56c"); the coin-texture form from the
|
||||
-- reference is dropped since texture markup doesn't render in 1.12. The
|
||||
-- checkGoldThreshold arg is accepted for signature compatibility but not
|
||||
-- applied (it relied on modern Constants tables).
|
||||
-- Text-symbol form ("12g 34s 56c"); for the coin-icon form use
|
||||
-- GetCoinTextureString below. The checkGoldThreshold arg is accepted for
|
||||
-- signature compatibility but not applied (it relied on modern Constants tables).
|
||||
function GetMoneyString(money, separateThousands, checkGoldThreshold, showZeroAsGold)
|
||||
local gold = math.floor(money / COPPER_PER_GOLD);
|
||||
local silver = math.floor(math.mod(money, COPPER_PER_GOLD) / COPPER_PER_SILVER);
|
||||
|
||||
@@ -1,101 +1,100 @@
|
||||
-- Backport of 3.3.5 FrameXML/GlobalCallbackRegistry.lua to vanilla 1.12 / Lua 5.0.
|
||||
--
|
||||
-- Implementation differences from the 3.3.5 source:
|
||||
-- - 3.3.5 ref-counts WoW frame events via a SetAttribute/OnAttributeChanged
|
||||
-- dance because attribute mutations are taint-safe in the secure-template
|
||||
-- model. 1.12 has neither secure templates nor SetAttribute on frames,
|
||||
-- so we keep an ordinary {[event]=count} table and Register/Unregister
|
||||
-- on 0<->1 transitions.
|
||||
-- Implementation notes:
|
||||
-- - 3.3.5 ref-counts frame events through a SetAttribute/OnAttributeChanged
|
||||
-- dance (attribute mutations are taint-safe under secure templates).
|
||||
-- ClassicAPI backports SetAttribute, but routing counters through it is
|
||||
-- heavier than a plain table: every set lowercases the name, runs the
|
||||
-- secure-attribute parsing, and fires OnAttributeChanged. So we keep an
|
||||
-- ordinary {[event]=count} table and Register/Unregister on 0<->1.
|
||||
-- - OnEvent takes its (self, event, ...) positionally (modern script args,
|
||||
-- on by default) and forwards straight to TriggerEvent, falling back to
|
||||
-- the event/arg globals if that switch is turned off.
|
||||
|
||||
EventRegistry = CreateFromMixins(CallbackRegistryMixin)
|
||||
EventRegistry = CreateFromMixins(CallbackRegistryMixin);
|
||||
|
||||
function EventRegistry:OnLoad()
|
||||
CallbackRegistryMixin.OnLoad(self)
|
||||
self:SetUndefinedEventsAllowed(true)
|
||||
CallbackRegistryMixin.OnLoad(self);
|
||||
self:SetUndefinedEventsAllowed(true);
|
||||
|
||||
self.eventCounts = {}
|
||||
self.frameEventFrame = CreateFrame("Frame")
|
||||
self.frameEventFrame.registry = self
|
||||
self.frameEventFrame:SetScript("OnEvent", function()
|
||||
-- 1.12 globals: `event` is the event name; `arg1..argN` are payload.
|
||||
-- We don't repack them here; CallbackRegistryMixin:TriggerEvent uses
|
||||
-- Lua-5.0 varargs, so we forward through a small wrapper.
|
||||
self:DispatchGameEvent(event, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9)
|
||||
end)
|
||||
self.eventCounts = {};
|
||||
self.frameEventFrame = CreateFrame("Frame");
|
||||
self.frameEventFrame:SetScript("OnEvent", function(frameEventFrame, evt, ...)
|
||||
if evt ~= nil then
|
||||
-- Modern positional script args (default): (self, event, payload...),
|
||||
-- forwarded with the exact arg count.
|
||||
self:TriggerEvent(evt, ...);
|
||||
else
|
||||
-- SetModernScriptArgs is off: read the event/arg globals 1.12 always
|
||||
-- sets. This path keeps the historical 9-arg cap.
|
||||
self:TriggerEvent(event, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9);
|
||||
end
|
||||
end);
|
||||
|
||||
-- Stop delivering events once logout begins: a callback firing during the
|
||||
-- engine's teardown can touch state that's already gone. A dedicated frame
|
||||
-- owns PLAYER_LOGOUT so this fires regardless of what consumers registered
|
||||
-- — folding the check into frameEventFrame's OnEvent would depend on the
|
||||
-- ref-counted event set still holding PLAYER_LOGOUT at logout time.
|
||||
self.logoutFrame = CreateFrame("Frame")
|
||||
self.logoutFrame:RegisterEvent("PLAYER_LOGOUT")
|
||||
self.logoutFrame:SetScript("OnEvent", function()
|
||||
self.frameEventFrame:UnregisterAllEvents()
|
||||
self.frameEventFrame:SetScript("OnEvent", nil)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Helper so we can forward up to nine event args (the most any vanilla
|
||||
-- event currently emits) into TriggerEvent's vararg machinery.
|
||||
function EventRegistry:DispatchGameEvent(event, ...)
|
||||
self:TriggerEvent(event, unpack(arg))
|
||||
self.logoutFrame = CreateFrame("Frame");
|
||||
self.logoutFrame:RegisterEvent("PLAYER_LOGOUT");
|
||||
self.logoutFrame:SetScript("OnEvent", function()
|
||||
self.frameEventFrame:UnregisterAllEvents();
|
||||
self.frameEventFrame:SetScript("OnEvent", nil);
|
||||
end);
|
||||
end
|
||||
|
||||
function EventRegistry:RegisterFrameEvent(frameEvent)
|
||||
local n = (self.eventCounts[frameEvent] or 0) + 1
|
||||
self.eventCounts[frameEvent] = n
|
||||
if n == 1 then
|
||||
self.frameEventFrame:RegisterEvent(frameEvent)
|
||||
end
|
||||
local n = (self.eventCounts[frameEvent] or 0) + 1;
|
||||
self.eventCounts[frameEvent] = n;
|
||||
if n == 1 then
|
||||
self.frameEventFrame:RegisterEvent(frameEvent);
|
||||
end
|
||||
end
|
||||
|
||||
function EventRegistry:UnregisterFrameEvent(frameEvent)
|
||||
local n = self.eventCounts[frameEvent] or 0
|
||||
if n > 0 then
|
||||
n = n - 1
|
||||
self.eventCounts[frameEvent] = n
|
||||
if n == 0 then
|
||||
self.frameEventFrame:UnregisterEvent(frameEvent)
|
||||
end
|
||||
end
|
||||
local n = self.eventCounts[frameEvent] or 0;
|
||||
if n > 0 then
|
||||
n = n - 1;
|
||||
self.eventCounts[frameEvent] = n;
|
||||
if n == 0 then
|
||||
self.frameEventFrame:UnregisterEvent(frameEvent);
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function EventRegistry:RegisterFrameEventAndCallback(frameEvent, ...)
|
||||
self:RegisterFrameEvent(frameEvent)
|
||||
return self:RegisterCallback(frameEvent, unpack(arg))
|
||||
self:RegisterFrameEvent(frameEvent);
|
||||
return self:RegisterCallback(frameEvent, ...);
|
||||
end
|
||||
|
||||
local function CreateCallbackHandle(cbr, cbrHandle, frameEvent)
|
||||
local handle = {
|
||||
Unregister = function()
|
||||
cbr:UnregisterFrameEvent(frameEvent)
|
||||
cbrHandle:Unregister()
|
||||
end,
|
||||
}
|
||||
return handle
|
||||
-- Wrapped in a table for future flexibility.
|
||||
local handle = {
|
||||
Unregister = function()
|
||||
cbr:UnregisterFrameEvent(frameEvent);
|
||||
cbrHandle:Unregister();
|
||||
end,
|
||||
};
|
||||
return handle;
|
||||
end
|
||||
|
||||
|
||||
function EventRegistry:RegisterFrameEventAndCallbackWithHandle(frameEvent, ...)
|
||||
self:RegisterFrameEvent(frameEvent)
|
||||
local cbrHandle = self:RegisterCallbackWithHandle(frameEvent, unpack(arg))
|
||||
return CreateCallbackHandle(self, cbrHandle, frameEvent)
|
||||
self:RegisterFrameEvent(frameEvent);
|
||||
local cbrHandle = self:RegisterCallbackWithHandle(frameEvent, ...);
|
||||
return CreateCallbackHandle(self, cbrHandle, frameEvent);
|
||||
end
|
||||
|
||||
function EventRegistry:UnregisterFrameEventAndCallback(frameEvent, ...)
|
||||
self:UnregisterFrameEvent(frameEvent)
|
||||
self:UnregisterCallback(frameEvent, unpack(arg))
|
||||
self:UnregisterFrameEvent(frameEvent);
|
||||
self:UnregisterCallback(frameEvent, ...);
|
||||
end
|
||||
|
||||
function EventRegistry:GetEventCounts(...)
|
||||
local counts = {}
|
||||
for i = 1, arg.n do
|
||||
local frameEvent = arg[i]
|
||||
local count = self.eventCounts[frameEvent] or "?"
|
||||
table.insert(counts, frameEvent .. " : " .. tostring(count))
|
||||
end
|
||||
local counts = {};
|
||||
for i = 1, select("#", ...) do
|
||||
local frameEvent = select(i, ...);
|
||||
local count = self.eventCounts[frameEvent] or "?";
|
||||
table.insert(counts, ("%s : %s"):format(frameEvent, tostring(count)));
|
||||
end
|
||||
|
||||
return table.concat(counts, "\n")
|
||||
return table.concat(counts, "\n");
|
||||
end
|
||||
|
||||
EventRegistry:OnLoad()
|
||||
EventRegistry:OnLoad();
|
||||
|
||||
@@ -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
|
||||
@@ -7,19 +7,42 @@ function RegisterNewSlashCommand(callback, command, commandAlias)
|
||||
end
|
||||
|
||||
SlashCmdList["FOCUS"] = function(msg)
|
||||
if msg == "" then
|
||||
FocusUnit();
|
||||
else
|
||||
FocusUnit(msg);
|
||||
end
|
||||
if ( msg == "" ) then
|
||||
FocusUnit();
|
||||
else
|
||||
local action, target = SecureCmdOptionParse(msg);
|
||||
if ( action ) then
|
||||
if ( not target or target == "focus" ) then
|
||||
target = action;
|
||||
end
|
||||
FocusUnit(target);
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
SlashCmdList["CLEARFOCUS"] = function(msg)
|
||||
ClearFocus()
|
||||
if ( SecureCmdOptionParse(msg) ) then
|
||||
ClearFocus();
|
||||
end
|
||||
end
|
||||
|
||||
SlashCmdList["EQUIP_SET"] = function(msg)
|
||||
if msg ~= "" then
|
||||
C_EquipmentSet.UseEquipmentSet(C_EquipmentSet.GetEquipmentSetID(msg))
|
||||
end
|
||||
local set = SecureCmdOptionParse(msg);
|
||||
if ( set and set ~= "" ) then
|
||||
C_EquipmentSet.UseEquipmentSet(C_EquipmentSet.GetEquipmentSetID(set))
|
||||
end
|
||||
end
|
||||
|
||||
SlashCmdList["CLICK"] = function(msg)
|
||||
local action = SecureCmdOptionParse(msg);
|
||||
if ( action and action ~= "" ) then
|
||||
local name, mouseButton = string.match(action, "([^%s]+)%s+([^%s]+)");
|
||||
if ( not name ) then
|
||||
name = action;
|
||||
end
|
||||
local button = GetClickFrame(name);
|
||||
if ( button and button:IsObjectType("Button") ) then
|
||||
button:Click(mouseButton);
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
TextureUtil = {};
|
||||
|
||||
function SetClampedTextureRotation(texture, rotationDegrees)
|
||||
if (rotationDegrees ~= 0 and rotationDegrees ~= 90 and rotationDegrees ~= 180 and rotationDegrees ~= 270) then
|
||||
error("SetRotation: rotationDegrees must be 0, 90, 180, or 270");
|
||||
return;
|
||||
end
|
||||
|
||||
if not (texture.rotationDegrees) then
|
||||
texture.origTexCoords = {texture:GetTexCoord()};
|
||||
texture.origWidth = texture:GetWidth();
|
||||
texture.origHeight = texture:GetHeight();
|
||||
end
|
||||
|
||||
if (texture.rotationDegrees == rotationDegrees) then
|
||||
return;
|
||||
end
|
||||
|
||||
texture.rotationDegrees = rotationDegrees;
|
||||
|
||||
if (rotationDegrees == 0 or rotationDegrees == 180) then
|
||||
texture:SetWidth(texture.origWidth);
|
||||
texture:SetHeight(texture.origHeight);
|
||||
else
|
||||
texture:SetWidth(texture.origHeight);
|
||||
texture:SetHeight(texture.origWidth);
|
||||
end
|
||||
|
||||
if (rotationDegrees == 0) then
|
||||
texture:SetTexCoord( texture.origTexCoords[1], texture.origTexCoords[2],
|
||||
texture.origTexCoords[3], texture.origTexCoords[4],
|
||||
texture.origTexCoords[5], texture.origTexCoords[6],
|
||||
texture.origTexCoords[7], texture.origTexCoords[8] );
|
||||
elseif (rotationDegrees == 90) then
|
||||
texture:SetTexCoord( texture.origTexCoords[3], texture.origTexCoords[4],
|
||||
texture.origTexCoords[7], texture.origTexCoords[8],
|
||||
texture.origTexCoords[1], texture.origTexCoords[2],
|
||||
texture.origTexCoords[5], texture.origTexCoords[6] );
|
||||
elseif (rotationDegrees == 180) then
|
||||
texture:SetTexCoord( texture.origTexCoords[7], texture.origTexCoords[8],
|
||||
texture.origTexCoords[5], texture.origTexCoords[6],
|
||||
texture.origTexCoords[3], texture.origTexCoords[4],
|
||||
texture.origTexCoords[1], texture.origTexCoords[2] );
|
||||
elseif (rotationDegrees == 270) then
|
||||
texture:SetTexCoord( texture.origTexCoords[5], texture.origTexCoords[6],
|
||||
texture.origTexCoords[1], texture.origTexCoords[2],
|
||||
texture.origTexCoords[7], texture.origTexCoords[8],
|
||||
texture.origTexCoords[3], texture.origTexCoords[4] );
|
||||
end
|
||||
end
|
||||
|
||||
function ClearClampedTextureRotation(texture)
|
||||
if (texture.rotationDegrees) then
|
||||
SetClampedTextureRotation(texture, 0);
|
||||
texture.origTexCoords = nil;
|
||||
texture.origWidth = nil;
|
||||
texture.origHeight = nil;
|
||||
texture.rotationDegrees = nil;
|
||||
end
|
||||
end
|
||||
|
||||
function GetTexCoordsByGrid(xOffset, yOffset, textureWidth, textureHeight, gridWidth, gridHeight)
|
||||
local widthPerGrid = gridWidth / textureWidth;
|
||||
local heightPerGrid = gridHeight / textureHeight;
|
||||
return (xOffset - 1) * widthPerGrid, (xOffset) * widthPerGrid, (yOffset - 1) * heightPerGrid, (yOffset) * heightPerGrid;
|
||||
end
|
||||
|
||||
-- left/right/top/bottom are normalized [0..1] coords within the file (the
|
||||
-- markup's texel fields are produced by multiplying back up by the file size).
|
||||
function CreateTextureMarkup(file, fileWidth, fileHeight, width, height, left, right, top, bottom, xOffset, yOffset)
|
||||
return string.format("|T%s:%d:%d:%d:%d:%d:%d:%d:%d:%d:%d|t",
|
||||
file
|
||||
, height
|
||||
, width
|
||||
, xOffset or 0
|
||||
, yOffset or 0
|
||||
, fileWidth
|
||||
, fileHeight
|
||||
, left * fileWidth
|
||||
, right * fileWidth
|
||||
, top * fileHeight
|
||||
, bottom * fileHeight
|
||||
);
|
||||
end
|
||||
|
||||
function CreateSimpleTextureMarkup(file, width, height, xOffset, yOffset)
|
||||
return string.format("|T%s:%d:%d:%d:%d|t",
|
||||
file
|
||||
, height or width
|
||||
, width
|
||||
, xOffset or 0
|
||||
, yOffset or 0
|
||||
);
|
||||
end
|
||||
@@ -754,8 +754,8 @@ local function ShowTradeSkillLink(link, text)
|
||||
frame.bar:SetMinMaxValues(0, (max and max > 0) and max or 1);
|
||||
frame.bar:SetValue(cur or 0);
|
||||
frame.barName:SetText(prof);
|
||||
frame.barRank:SetText(string.format("%d/%d", cur or 0, max or 0));
|
||||
frame.summary:SetText(string.format("%d of %d recipes known", known, total));
|
||||
frame.barRank:SetFormattedText("%d/%d", cur or 0, max or 0);
|
||||
frame.summary:SetFormattedText("%d of %d recipes known", known, total);
|
||||
frame.recipes = knownList;
|
||||
frame.subclassFilter = nil;
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
function MouseIsOver(region, topOffset, bottomOffset, leftOffset, rightOffset)
|
||||
return region:IsMouseOver(topOffset, bottomOffset, leftOffset, rightOffset);
|
||||
if region:IsMouseOver(topOffset, bottomOffset, leftOffset, rightOffset) then
|
||||
return 1;
|
||||
end
|
||||
end
|
||||
|
||||
@@ -80,6 +80,8 @@ SET_NOTE = "Notiz eintragen";
|
||||
SILVER_AMOUNT_SYMBOL = "S";
|
||||
SLASH_CLEARFOCUS1 = "/fokusaufheben";
|
||||
SLASH_CLEARFOCUS2 = "/clearfocus";
|
||||
SLASH_CLICK1 = "/klick";
|
||||
SLASH_CLICK2 = "/click";
|
||||
SLASH_EQUIP_SET1 = "/Ausrüstungsset";
|
||||
SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_FOCUS1 = "/fokus";
|
||||
|
||||
@@ -4,6 +4,7 @@ BINDING_NAME_TARGETFOCUS = "Target Focus";
|
||||
CONFIRM_DELETE_EQUIPMENT_SET = "Are you sure you want to delete the equipment set %s?";
|
||||
CONFIRM_SAVE_EQUIPMENT_SET = "Would you like to save the equipment set \"%s\"?";
|
||||
COPPER_AMOUNT_SYMBOL = "c";
|
||||
COPPER_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:2:3:0:1|t";
|
||||
DEBUFF_SYMBOL_CURSE = "Cu";
|
||||
DEBUFF_SYMBOL_DISEASE = "Di";
|
||||
DEBUFF_SYMBOL_MAGIC = "Ma";
|
||||
@@ -31,6 +32,8 @@ FOCUSTARGET = "Focus Target";
|
||||
GEARSETS_POPUP_TEXT = "Enter Set Name (Max 16 Characters):";
|
||||
GENERIC_FRACTION_STRING = "%d/%d";
|
||||
GOLD_AMOUNT_SYMBOL = "g";
|
||||
GOLD_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:0:1:0:1|t";
|
||||
GOLD_AMOUNT_TEXTURE_STRING = "%s|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:0:1:0:1|t";
|
||||
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
|
||||
ICON_FILTER_ALL = "All Icons";
|
||||
ICON_FILTER_ITEM = "Items";
|
||||
@@ -75,8 +78,11 @@ PERCENTAGE_STRING = "%d%%";
|
||||
SET_FRIENDNOTE_LABEL = "Set Notes for %s:";
|
||||
SET_NOTE = "Set Note";
|
||||
SILVER_AMOUNT_SYMBOL = "s";
|
||||
SILVER_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:1:2:0:1|t";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/clearfocus";
|
||||
SLASH_CLICK1 = "/click";
|
||||
SLASH_CLICK2 = "/click";
|
||||
SLASH_EQUIP_SET1 = "/equipset";
|
||||
SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
|
||||
@@ -80,6 +80,8 @@ SET_NOTE = "Establecer nota";
|
||||
SILVER_AMOUNT_SYMBOL = "p";
|
||||
SLASH_CLEARFOCUS1 = "/quitarenfoque";
|
||||
SLASH_CLEARFOCUS2 = "/clearfocus";
|
||||
SLASH_CLICK1 = "/clic";
|
||||
SLASH_CLICK2 = "/click";
|
||||
SLASH_EQUIP_SET1 = "/conjequip";
|
||||
SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_FOCUS1 = "/enfoque";
|
||||
|
||||
@@ -80,6 +80,8 @@ SET_NOTE = "Écrire une note";
|
||||
SILVER_AMOUNT_SYMBOL = "a";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/annulerfocal";
|
||||
SLASH_CLICK1 = "/click";
|
||||
SLASH_CLICK2 = "/cliquer";
|
||||
SLASH_EQUIP_SET1 = "/equipset";
|
||||
SLASH_EQUIP_SET2 = "/équipens";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
|
||||
@@ -80,6 +80,8 @@ SET_NOTE = "Добавить заметку";
|
||||
SILVER_AMOUNT_SYMBOL = "с";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/сбросфокуса";
|
||||
SLASH_CLICK1 = "/click";
|
||||
SLASH_CLICK2 = "/щелчок";
|
||||
SLASH_EQUIP_SET1 = "/комплект";
|
||||
SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
|
||||
@@ -80,6 +80,8 @@ SET_NOTE = "设置备注";
|
||||
SILVER_AMOUNT_SYMBOL = "银";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/clearfocus";
|
||||
SLASH_CLICK1 = "/click";
|
||||
SLASH_CLICK2 = "/click";
|
||||
SLASH_EQUIP_SET1 = "/equipset";
|
||||
SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
|
||||
@@ -80,6 +80,8 @@ SET_NOTE = "設定註記";
|
||||
SILVER_AMOUNT_SYMBOL = "銀";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/清除專注目標";
|
||||
SLASH_CLICK1 = "/click";
|
||||
SLASH_CLICK2 = "/點擊";
|
||||
SLASH_EQUIP_SET1 = "/equipset";
|
||||
SLASH_EQUIP_SET2 = "/裝備套裝";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
|
||||
+24
-7
@@ -142,12 +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.)
|
||||
"/OPT:REF /OPT:ICF /MERGE:.rdata=.text /MERGE:.pdata=.text /SECTION:.text,EWR /NODEFAULTLIB:MSVCRT /MAP"
|
||||
# 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 /DEBUG /DEF:${CMAKE_CURRENT_SOURCE_DIR}/src/ClassicAPI.def"
|
||||
)
|
||||
|
||||
@@ -1,41 +1,73 @@
|
||||
# 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](docs/API.md#lua-51-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 = ...`). |
|
||||
| [String methods](docs/API.md#string-methods-supper-sformat) | 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. Works on literals and variables, in-world and on the login screen, and inside coroutines. |
|
||||
| [Script-handler arguments](docs/API.md#setmodernscriptargsenable--getmodernscriptargs) | Frame-script handlers receive their values as positional arguments — `OnMouseWheel(self, delta)`, `OnClick(self, button)`, `OnEvent(self, event, ...)`, etc. — 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. |
|
||||
|
||||
### Modern client behaviors
|
||||
|
||||
| Feature | Effect |
|
||||
|---------|--------|
|
||||
| Inline textures | Draws inline texture markup (`\|T…\|t`) as icons in FontStrings, chat, and tooltips. 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](docs/API.md#nameplate) | The modern `C_NamePlate` API, driven by real events. `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. |
|
||||
| [Focus target](docs/API.md#focus) | A sticky focus unit. `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. |
|
||||
| [Retail-like `/reload`](docs/API.md#reload-picks-up-new-addons-and-new-files) | `/reload` picks up addon changes made while the game runs. A new folder under `Interface\AddOns\` registers and loads as a normal addon, new files added to an existing addon's TOC load, a newly installed addon's first SavedVariables save survives `/reload`, `##` metadata edits (`## SavedVariables:`, `## Dependencies:`, `## Title:`, …) take effect, and a deleted addon folder drops from the addon list. |
|
||||
| [Multi-flavor & conditional TOC](docs/API.md#conditional-and-multi-flavor-toc-loading) | Loads modern multi-flavor addons that ship one folder. Selects a version-specific TOC (`<Name>_ClassicAPI.toc` or `<Name>_Turtle.toc`) and the matching keybinding file (`Bindings_ClassicAPI.xml` / `Bindings_Turtle.xml`), accepts a comma-separated `## Interface:` version list (compatible when it includes the client version `11200`), and honors per-line `[AllowLoadGameType]` / `[AllowLoadTextLocale]` conditions and `[Family]` / `[Game]` / `[TextLocale]` path variables inside a TOC. |
|
||||
|
||||
## 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` |
|
||||
| [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` |
|
||||
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
|
||||
| [ColorUtil](docs/API.md#colorutil) | `C_ColorUtil.ConvertRGBToHSV`, `C_ColorUtil.ConvertHSVToRGB`, `C_ColorUtil.ConvertHSVToHSL`, `C_ColorUtil.ConvertHSLToHSV`, `C_ColorUtil.ConvertHSLToRGB`, `C_ColorUtil.GenerateTextColorCode`, `C_ColorUtil.WrapTextInColor`, `C_ColorUtil.WrapTextInColorCode` |
|
||||
| [Combat](docs/API.md#combat) | `InCombatLockdown` |
|
||||
| [Combat](docs/API.md#combat) | `InCombatLockdown`, `StartAttack`, `StopAttack` |
|
||||
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
|
||||
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
|
||||
| [Container](docs/API.md#container) | `C_Container.CalculateTotalNumberOfFreeBagSlots`, `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemInfo`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetItemCooldown`, `C_Container.HasContainerItem`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
|
||||
| [Creature](docs/API.md#creature) | `C_CreatureInfo.GetCreatureID`, `C_CreatureInfo.GetCreatureInfoByID`, `C_CreatureInfo.RequestLoadCreatureByID`, `C_CreatureInfo.GetRaceInfo`, `C_CreatureInfo.GetClassInfo`, `C_CreatureInfo.GetCreatureFamilyInfo`, `C_CreatureInfo.GetCreatureFamilyIDs`, `C_CreatureInfo.GetFactionInfo`, `C_CreatureInfo.GetCreatureTypeInfo`, `C_CreatureInfo.GetCreatureTypeIDs` |
|
||||
| [Currency](docs/API.md#currency) | `GetCoinTextureString`, `C_CurrencyInfo.GetCoinTextureString` |
|
||||
| EncodingUtil | `C_EncodingUtil.CompressString`, `C_EncodingUtil.DecompressString`, `C_EncodingUtil.EncodeBase64`, `C_EncodingUtil.DecodeBase64`, `C_EncodingUtil.EncodeHex`, `C_EncodingUtil.DecodeHex`, `C_EncodingUtil.SerializeJSON`, `C_EncodingUtil.DeserializeJSON`, `C_EncodingUtil.SerializeCBOR`, `C_EncodingUtil.DeserializeCBOR` |
|
||||
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` |
|
||||
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid`, `GetFramesRegisteredForEvent` |
|
||||
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
|
||||
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
|
||||
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
|
||||
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `GetMouseFoci`, `frame:SetShown`, `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`, `editBox:SetCursorPosition`, `editBox:GetCursorPosition`, `editBox:GetUTF8CursorPosition`, `editBox:ClearHighlightText`, `editBox:HasFocus`, `editBox:HasText`, `editBox:SetHighlightColor`, `editBox:GetHighlightColor`, `editBox:ClearHistory`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs`, `PreClick` (script), `PostClick` (script), `GetClickFrame` |
|
||||
| [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` |
|
||||
@@ -48,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` |
|
||||
@@ -61,28 +93,30 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetHeaderIndexForQuest`, `C_QuestLog.GetLogIndexForQuestID`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
|
||||
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellDispelType`, `C_Spell.GetSpellEffectMechanics`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsNextMeleeSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.ResetsMeleeSwing`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CanDualWield`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
|
||||
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellBookItemInfo`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
|
||||
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsOutdoors`, `IsStealthed`, `IsSwimming` |
|
||||
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `GetSheathState`, `IsAssistingRitual`, `IsFalling`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsOutdoors`, `IsStealthed`, `IsSwimming` |
|
||||
| [System](docs/API.md#system) | `CopyToClipboard`, `GetPhysicalScreenSize` |
|
||||
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
|
||||
| [Targeting](docs/API.md#targeting) | `GetPlayerFacing`, `TargetDirectionEnemy`, `TargetDirectionFriend`, `TargetNearest`, `TargetNearestEnemyPlayer`, `TargetNearestFriendPlayer` |
|
||||
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes`, `C_TaxiMap.GetTaxiPaths`, `C_TaxiMap.GetTaxiPathWaypoints`, `C_TaxiMap.GetTaxiRoute` |
|
||||
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime`, `GetTimeCached` |
|
||||
| [Totem](docs/API.md#totem) | `GetTotemInfo`, `GetTotemTimeLeft`, `GetTotemDuration`, `TargetTotem` |
|
||||
| [Tracking](docs/API.md#tracking) | `GetNumTrackingTypes`, `GetTrackingInfo`, `SetTracking` |
|
||||
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
|
||||
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
|
||||
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
|
||||
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration` |
|
||||
| [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`, `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
|
||||
in the first row are *glue-only* — they exist to support GlueXML
|
||||
patches that need a small persistence surface across sessions, since
|
||||
vanilla 1.12 glue ships no general-purpose persistence API beyond
|
||||
`GetSavedAccountName`/`SetSavedAccountName` (saturated by autologin).
|
||||
patches that need a small persistence surface across sessions.
|
||||
The rest of the table is in-game calls that we also mirror onto
|
||||
the glue state because GlueXML had no way to reach them otherwise.
|
||||
|
||||
@@ -95,10 +129,13 @@ 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
|
||||
functions. See the
|
||||
[Macros section in the Lua reference](docs/API.md#macros) for details.
|
||||
|
||||
| Form | What it does |
|
||||
@@ -107,7 +144,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
|
||||
@@ -119,7 +159,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 |
|
||||
|-------|---------|
|
||||
@@ -175,11 +218,14 @@ 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 |
|
||||
|-------|-----------|
|
||||
| Version | `CLASSIC_API_VERSION` |
|
||||
| Version | `CLASSIC_API_VERSION`, `INTERFACE_VERSION` |
|
||||
| Expansion | `LE_EXPANSION_LEVEL_CURRENT`, `LE_EXPANSION_CLASSIC` … `LE_EXPANSION_MIDNIGHT` |
|
||||
| Item quality | `LE_ITEM_QUALITY_POOR` … `LE_ITEM_QUALITY_WOWTOKEN` |
|
||||
| Unit stat | `LE_UNIT_STAT_STRENGTH` … `LE_UNIT_STAT_SPIRIT` |
|
||||
@@ -191,7 +237,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 |
|
||||
|-------|-------------|
|
||||
@@ -199,26 +248,34 @@ 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>
|
||||
|
||||
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.
|
||||
<details>
|
||||
<summary><b>Bindings</b> — direct-action and override binding families</summary>
|
||||
|
||||
ClassicAPI backports the direct-action and temporary override
|
||||
binding families. Permanent bindings use the standard `SPELL`, `ITEM`,
|
||||
`MACRO`, and `CLICK` command strings and can be saved normally. Overrides are
|
||||
session-only, frame-owned, and support the usual priority flag.
|
||||
|
||||
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.
|
||||
|
||||
| Binding | Action |
|
||||
|---------|--------|
|
||||
| `FOCUSTARGET` | `FocusUnit("target")` — pin current target as focus |
|
||||
| `TARGETFOCUS` | `TargetUnit("focus")` — switch target to the focus |
|
||||
|
||||
See [Focus / Bindings](docs/API.md#bindings-focustarget--targetfocus) for the implementation note.
|
||||
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. |
|
||||
</details>
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -277,7 +334,7 @@ identically in both cases.
|
||||
## Bundled addon: DebugTools
|
||||
|
||||
A 1.12.1 / Lua 5.0 backport of Blizzard's `Blizzard_DebugTools` addon
|
||||
(originally shipped with the 3.0 client) lives in [`AddOns/DebugTools/`](AddOns/DebugTools/).
|
||||
lives in [`AddOns/DebugTools/`](AddOns/DebugTools/).
|
||||
It's an independent addon — it doesn't use anything ClassicAPI adds, and
|
||||
ClassicAPI works fine without it. It's bundled here because it's the
|
||||
natural companion for testing and debugging anything written against the
|
||||
@@ -292,12 +349,11 @@ Slash commands provided:
|
||||
| `/framestack` (or `/fstack`) | Tooltip showing the frame hierarchy under the mouse cursor. |
|
||||
| `/luaerrors` (or `/scripterrors`) | Lua error display window. |
|
||||
|
||||
Lua globals provided (backports of 3.3.5 helpers that don't exist in
|
||||
1.12):
|
||||
Lua globals provided (backports of Blizzard helpers):
|
||||
|
||||
| Global | Purpose |
|
||||
|--------|---------|
|
||||
| `print(...)` | Backport of the 3.3.5 `print` — concats varargs with `" "` and pushes to `DEFAULT_CHAT_FRAME`. Routes through `setprinthandler`'s handler; falls back via `geterrorhandler` if the handler errors. |
|
||||
| `print(...)` | Backport of Blizzard's `print` — concats varargs with `" "` and pushes to `DEFAULT_CHAT_FRAME`. Routes through `setprinthandler`'s handler; falls back via `geterrorhandler` if the handler errors. |
|
||||
| `setprinthandler(func)` / `getprinthandler()` | Install / query a custom print handler. Useful for redirecting print output in tests. |
|
||||
| `tostringall(...)` | Apply `tostring()` to every vararg, preserving the count. Lua 5.0-compatible (uses `arg.n` since `select` doesn't exist in 5.0). |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2705
-1334
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,778 @@
|
||||
# Backporting inline `|T…|t` texture escape sequences to 1.12
|
||||
|
||||
The 1.12 text engine shows `|Tpath:height:width:…|t` markup as literal text.
|
||||
This project makes it draw the icon inline in FontStrings, chat, and tooltips —
|
||||
the way 4.3.4 and later clients do. The feature is complete and shipped
|
||||
([src/text/InlineTexture.cpp](../src/text/InlineTexture.cpp),
|
||||
[src/text/InlineTexturePool.cpp](../src/text/InlineTexturePool.cpp)).
|
||||
|
||||
This file is the reverse-engineering map. **Current design** below states what
|
||||
ships today. Everything from **Goal & spec** down is the historical RE trail —
|
||||
the 4.3.4 study, the 1.12 injection map, and the bring-up plan. It is kept for
|
||||
the addresses and the reasoning, not as a description of the current code.
|
||||
|
||||
## Current design (shipped)
|
||||
|
||||
Each icon renders as an **engine-managed region** (a pooled `CSimpleTexture`)
|
||||
anchored to its owning FontString. The engine draws the region every frame and
|
||||
moves it with its line, so texture residency and scroll tracking are correct by
|
||||
construction. (A raw-GxU-quad renderer existed during bring-up and was removed
|
||||
in `56c2670`; its RE is the "REMOVED" section below.)
|
||||
|
||||
The DLL hooks the engine's own text pipeline — no companion addon:
|
||||
|
||||
- **Positioning** — a co-hook on the glyph emitter `FUN_005ccbe0` records each
|
||||
icon at its pen position and delegates the plain text runs to the engine
|
||||
(segmented delegation, below). The pen read-back needed a fix: the engine's
|
||||
pen is lazy and omits the last glyph's own advance, so a coin sat on its
|
||||
digits; the emitter now adds the true terminal advance from the engine's own
|
||||
glyph-advance helpers (`550cf5e`).
|
||||
- **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
|
||||
`FUN_005cdf70` multiplies by the integer raster dimensions
|
||||
`[0xC2B9A4]`/`[0xC2B9A0]` (rounded, with the justify shift and vertical
|
||||
align folded in). So the flush converts pen → fs-relative anchor with
|
||||
`K = raster ÷ anchorExtent`, **per axis**, from those four live globals — no
|
||||
calibration, no cache, correct at every resolution and UI scale (`968335d`).
|
||||
Two earlier forms both failed: an empirical K (`origin ÷ rect edge`) that
|
||||
chat could never self-derive (its rect edge sits near screen x = 0 and
|
||||
failed the divide guard) and so rode a global cache seeded by whichever
|
||||
fontstring derived first — right only at the layout it was calibrated on;
|
||||
then a closed form built from the SetPoint px factor, which used the wrong
|
||||
global pair. Full derivation chain at `VAR_TEXT_RASTER_X/Y` in `Offsets.h`.
|
||||
- **Fade / parent alpha** — icons fade with their line (the pool mirrors the
|
||||
fs's folded colour alpha per frame, `3ca3b7e`), and segmented GLYPHS do too:
|
||||
a bit-3 (standalone FontString) node's baked per-glyph colours never re-bake
|
||||
on an alpha change (the engine's colour-change invalidate `FUN_005ccb40`
|
||||
skips bit-3 nodes), so the flush mirrors the node's live alpha byte
|
||||
(node+0x2c — SetTextColor alpha × frame-chain alpha, kept folded by the
|
||||
engine) into every baked colour, change-detected per page (`bd14a93`). An
|
||||
earlier `0xFF` alpha force at emit was a misdiagnosis of a bubble that built
|
||||
while faded out; it froze icon-line text fully opaque while everything
|
||||
around it faded.
|
||||
- **Outlined fonts** — outline ink extends past the glyph advances; the
|
||||
engine itself grows line height by exactly 4.0 / 2.0 pen px for thick / thin
|
||||
outline (`face+0x180` bits 3 / 0, `.rdata` constants). `OutlineInkPen` reads
|
||||
the same flags and constants, and half the ink leads each icon, half trails
|
||||
— threaded through the shared advance helper so the emitter advance, the
|
||||
justify pre-shift, and the wrap-stepper probes all agree. Without it, coins
|
||||
clipped into outlined digits the moment the exact K removed the old
|
||||
miscalibration's accidental slack (`968335d`).
|
||||
- **Measure** — three cold FontString-level co-hooks make measure match the
|
||||
drawn result: width (`FUN_00772890`), height (`FUN_007729B0`), and wrap
|
||||
(`FUN_005C7260`). `GetStringWidth`, `fontstring:GetStringHeight`, wrap
|
||||
breaks, and line height all count the icon.
|
||||
- **Tall icons** — an icon taller than the font grows its line height, so it
|
||||
does not overlap its neighbors, and it centers in the grown line. A hyperlink
|
||||
that contains a tall icon is hoverable across the whole icon
|
||||
(`49934b0`, `b9d0705`).
|
||||
- **Chat fade** — icons fade in lockstep with their chat line (`3ca3b7e`).
|
||||
- **Editboxes** — an editbox keeps the raw markup, so the caret stays aligned
|
||||
(two complementary signals, below).
|
||||
- **Payload** — the full
|
||||
`|Tpath:height:width:offsetX:offsetY:texW:texH:left:right:top:bottom:r:g:b|t`:
|
||||
size, pen offset, sprite-sheet crop, and vertex tint. BLP and uncompressed
|
||||
TGA.
|
||||
- **Anti-spoof** — chat strips player-injected `|T` icons.
|
||||
|
||||
Every fs-level measure path now counts icons: width (`FUN_00772890`), height
|
||||
(`FUN_007729B0`), wrap (`FUN_005C7260`), and the substring measure
|
||||
(`FUN_00772AE0` — an earlier note claimed nothing consumed it; its xrefs
|
||||
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
|
||||
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
|
||||
|
||||
This was the FIRST renderer: each icon drawn as a raw textured quad through the
|
||||
text VB path. It shipped, then was removed in `56c2670` for the engine-region
|
||||
model (regions are resident by construction; the quad path needed the texture-
|
||||
residency machinery below and still flickered on VRAM pressure — see
|
||||
`docs/InlineTextureResidency.md`). This section is the address record for the
|
||||
removed code, not a description of the current renderer. `LoadTextureByPath`,
|
||||
`DrawTexturedQuad`, and the GxU device offsets it names are gone from
|
||||
`Offsets.h`; the values live here and in git history.
|
||||
|
||||
An arbitrary texture draws through the text VB path as a coloured, alpha-blended
|
||||
quad. The end-to-end recipe:
|
||||
|
||||
1. **Load by path** — `LoadTextureByPath` replicates `FUN_00770200`'s load: build
|
||||
flags via `FUN_0058a980(&flags, DAT_00878cf0, 0,0, 0,0,0, 1, 0)`, then
|
||||
`FUN_00449d90(path, &desc, flags, 0, 1)` with a byte-identical 5-dword desc
|
||||
(`{PTR_FUN_007ffa10, 8, &self8, (&self8)|1, 0}`). Returns the **HTEXTURE**
|
||||
(0x15c-byte struct; path@+0xc, flags@+0x110, refcount@+4).
|
||||
2. **THE KEY — residency + correct bind via `FUN_0044acf0(HTEXTURE, 1, 0)`.**
|
||||
This is the engine's own "get renderable texture" call (what the widget
|
||||
render uses every frame). It returns the bindable **CGxTexture** at
|
||||
`[HTEXTURE+0x140]` and, with force=1, drives the streaming load
|
||||
(`FUN_0044ad50` submits/prioritises it via `[HTEXTURE+0x138]`). **Calling it
|
||||
every frame is the live reference that makes the texture resident** — no
|
||||
hidden widget, no manual BLP decode. This was the entire multi-session
|
||||
blocker: WoW streams textures and only loads ones a live frame references; a
|
||||
standalone `FUN_00449d90` texture never loads on its own. Binding the raw
|
||||
HTEXTURE is `SetTexture([HTEXTURE+0x48]=0)` = a **white** quad; the D3D
|
||||
handle lives on the CGxTexture (`[CGxTexture+0x48]`), not the HTEXTURE.
|
||||
Dead ends that wasted time: manually invoking the decode callback
|
||||
`FUN_0044a260` (fills `[+0x120]` but not the bindable `+0x140`, and faults);
|
||||
binding `[HTEXTURE+0x48]` or `[HTEXTURE+0x120]` (both wrong offsets); forcing
|
||||
the streamable flag `tex[0x3c]|=0x80` (crashes in d3d9 — the bind's stream-in
|
||||
vfunc corrupts the resource).
|
||||
3. **Bind** — `FUN_00589e80(0x17, cgxTex)` (selector 0x17 = text texture stage).
|
||||
4. **Colour** — the font stage's default stage-0 COLOROP is already MODULATE, so
|
||||
`texture.rgb × diffuse(white)` = the icon's true colours; texture alpha works
|
||||
too. No colorop override is needed for a plain white vertex. (If a tint is
|
||||
wanted later: COLOROP is D3DTSS internal state `0x42` via `FUN_005a2570`; the
|
||||
engine leaves COLORARG1 at the D3D default = TEXTURE. Do NOT poke it directly
|
||||
mid-draw — it leaks `MODULATE` to the whole UI when a fault skips the restore.
|
||||
Use the engine's selector `0x1f` (value→COLOROP/ALPHAOP preset table at
|
||||
`DAT_0080a25c`/`DAT_0080a274`; value 1 = MODULATE/MODULATE).)
|
||||
5. **Quad** — lock the shared dynamic VB `FUN_0058a140(0, 0x18, 0x800)`, get the
|
||||
write ptr `FUN_0058a080`, write **4 verts** of the format
|
||||
`{float x,y,z; u32 colorBGRA; float u,v}` (stride 0x18) in corner order
|
||||
**TL, TR, BL, BR** (matches the shared quad index buffer `{0,1,2, 2,1,3}` and
|
||||
the device cull winding), submit `FUN_005c8f40(&handle, 4)`, unlock
|
||||
`FUN_0058a0a0(handle, 0)`.
|
||||
6. **Where** — co-hook the text paint pass `FUN_005c8fe0` and draw after the
|
||||
original glyph flush (device is in the correct textured-UI state there).
|
||||
Coordinate space is **per-layout pixels, y-down**; glyph verts live at small
|
||||
local coords (a probed glyph was at ~(9,33)–(17,50)). Draw coords must be
|
||||
within the layout's rendered area (a fixed (100,100) test lands in chat).
|
||||
|
||||
The build-side gotcha: the D3D device (default backend, `FUN_0058dd70`) is
|
||||
actually **OpenGL** (`glMaterialfv`/`glFogi`) — the D3D backend is `FUN_0058ba70`
|
||||
(`FUN_00598ce0`, vtable `PTR_FUN_00809ef8`); its state applier is `vtable[1]` =
|
||||
`FUN_005a2f00`, which maps engine selectors to D3D via `FUN_005a2570`
|
||||
(SetRenderState `+0xe4`, SetTextureStageState `+0x10c`, SetSamplerState `+0x114`,
|
||||
SetTexture `+0x104`). Selector map (shared across GL/D3D backends): 7=blend,
|
||||
0x10=depthtest, 0x14=cullface, 0x17-0x1e=texture bind (8 units),
|
||||
0x1f-0x26=colorop/alphaop preset (unit 0-7).
|
||||
|
||||
## SOLVED — positioning, measure/wrap, editbox exclusion, texcoords
|
||||
|
||||
The primitive draws at arbitrary coords; the rest is knowing WHERE and WHEN.
|
||||
Everything is in [src/text/InlineTexture.cpp](../src/text/InlineTexture.cpp);
|
||||
offsets under the "Inline texture escape" + positioning blocks in
|
||||
[Offsets.h](../src/Offsets.h).
|
||||
|
||||
### The text render hierarchy (verified)
|
||||
|
||||
```
|
||||
frame → renderNode [frame+0x118] → group [renderNode+0x18]
|
||||
→ node [group+0x18] → line [node+0x24] ← the emitter's `this`
|
||||
```
|
||||
|
||||
- Main UI render `FUN_007657d0(DAT_00cf0bd8)` walks strata → frames, calls
|
||||
`FUN_0076fb00(renderNode)` per frame's render node.
|
||||
- `FUN_0076fb00` draws the frame's Texture regions then `FUN_005c1ef0(group)` →
|
||||
`FUN_005c8b70(group)` → `FUN_005c8fe0(node)` per node.
|
||||
- `FUN_005c8fe0(node)` = the **paint pass** we co-hook: ensures each line built
|
||||
(`FUN_005cd6a0` → `FUN_005cdc20` → the emitter), then binds each font page and
|
||||
copies the line's per-page glyph batches into the dynamic VB, translated by the
|
||||
line origin `[line+0x70]/[+0x74]`.
|
||||
- The **emitter** `FUN_005ccbe0(line, text, len, colorState, penXYZ, pageMask,
|
||||
linkState)` builds one wrapped line's glyph quads. `this` (the "line"/node the
|
||||
icon list keys on) owns batches `[+0xa0+page*4]` and origin `[+0x70]`. **Note
|
||||
the render node the emitter sees has NO back-pointer up to the frame** — the
|
||||
editbox discriminator had to be found another way (see below).
|
||||
|
||||
### Positioning — segmented delegation (co-hook the emitter)
|
||||
|
||||
We do NOT reimplement the emitter's intricate glyph vertex math. Instead, in the
|
||||
`FUN_005ccbe0` co-hook, for a line containing an inline texture:
|
||||
|
||||
1. Split the line at icon boundaries. Render each **plain run** by calling the
|
||||
ORIGINAL emitter on just that substring — the engine still draws every glyph.
|
||||
2. Thread the pen across runs: on return the emitter leaves the final node-local
|
||||
pen x in **`linkState[4]`**, stored with `FSTP dword` — a **FLOAT bit
|
||||
pattern**, not an int. Read it as a float (`*(float*)&linkState[4]`); an
|
||||
int-cast yields garbage (this bug hid the icon off-screen and dropped the
|
||||
trailing text).
|
||||
3. Record an `IconRecord` at the pen (node-local `x`,`y`) into `g_nodeIcons[line]`
|
||||
and advance the pen by the icon width. No draw runs during the build — icons
|
||||
are recorded and **flushed** in the `FUN_005c8fe0` paint co-hook, which
|
||||
computes each icon's pen-space position (line origin `[line+0x70]/[+0x74]`
|
||||
maps node-local → pen, the same translate the glyph copy `FUN_005c8710`
|
||||
applies), converts pen → anchor with the per-axis K (see **Placement
|
||||
coordinates** in Current design), and queues it to `Text::InlineTexturePool`
|
||||
as a placement RELATIVE TO THE OWNING FONTSTRING. The pool applies
|
||||
placements on the next frame tick.
|
||||
4. Restore `penXYZ[0]` on exit (the original never writes it; the draw builder
|
||||
does NOT reset it between left-justified lines, so leaving it mutated
|
||||
cascade-shifts every following line).
|
||||
|
||||
### FontString pipe-doubling — the escape arrives as `||T…||t`
|
||||
|
||||
The FontString text sanitizer **doubles any pipe that doesn't begin a recognized
|
||||
escape** so it renders as a literal `|`. 1.12 doesn't know `|T`, so a caller's
|
||||
`|Tpath:h|t` reaches the emitter as `||Tpath:h||t` (verified: emitter text length
|
||||
+2, pipes doubled). The detector therefore matches BOTH the doubled form (the
|
||||
real-world case) and a clean `|T` (should one ever arrive undoubled). Normal
|
||||
doubled pipes (`|| `) never match because the next char must be `T`.
|
||||
|
||||
### bit-3 batch-clear mode
|
||||
|
||||
The emitter clears the line's page batches at its top only when node flag
|
||||
`[line+0x5c] & 0x08` is set (the FontString/static case). Calling the original
|
||||
per segment would then wipe all but the last run. Handled by doing the clear
|
||||
ONCE per build (a `len==0` original call with bit 3 still set), then clearing
|
||||
bit 3 so the per-segment calls APPEND, restoring flags on exit. Bit 5 (shadow)
|
||||
isn't set for these nodes, so the only added work is a harmless colour append.
|
||||
|
||||
Clearing bit 3 has one more consequence: it switches the engine from the
|
||||
UNIFORM colour path (node+0x2c, consulted live at every paint) to baked
|
||||
PER-GLYPH colours the paint uses verbatim. The build-time alpha in colorState
|
||||
is already correct (the engine folds SetTextColor alpha × frame-chain alpha
|
||||
into node+0x2c on every alpha change), but the baked copies never refresh —
|
||||
the flush's alpha mirror keeps them tracking later fades. See **Fade / parent
|
||||
alpha** in Current design.
|
||||
|
||||
### Multi-line / multi-icon accumulation
|
||||
|
||||
The draw builder walks a node's wrapped lines by calling the emitter repeatedly
|
||||
on the SAME node, advancing the text pointer. So the icon list is cleared ONCE
|
||||
per build — on the first wrapped line, detected by `text == [node+0x48]` (the
|
||||
node's text start) — and icons ACCUMULATE across lines. Multiple icons per line
|
||||
fall out of the segmentation loop naturally.
|
||||
|
||||
### Vertical centring — font-relative
|
||||
|
||||
`penXYZ[1]` sits near the text TOP, so the icon centre = `penY + fontHeight *
|
||||
g_centerFrac` (0.6, calibrated in-game across chat + pfUI's bubble). Font height
|
||||
comes from the engine's own `FUN_005c6fa0(flag, [node+0x1c])` (the emitter's
|
||||
glyph-sizing call), so centring holds across font sizes without a fixed pixel
|
||||
nudge. A TALL icon (height > font) centres in its GROWN line (see the tall-icon
|
||||
sections in Current design) — the string-height co-hook adds the overflow, so
|
||||
the extra room sits half above and half below the text.
|
||||
|
||||
### Measure / wrap — co-hook the tokenizer
|
||||
|
||||
Line wrap is decided by the measure/fit functions (`FUN_005c6940`, `FUN_005c7470`
|
||||
via `FUN_005c7260`), which all share the `|`-tokenizer `FUN_005c2810`. Left
|
||||
alone, they count the hidden path text and wrap early. Fix: co-hook the
|
||||
tokenizer — when it's at an inline-texture span, consume the whole span as ONE
|
||||
near-zero-width token so every measure/fit caller ignores the path characters.
|
||||
The emitter detects icons independently (it scans the raw bytes), so the draw
|
||||
path is unaffected.
|
||||
|
||||
### Measure width: FIXED at the fs-level choke (`GetStringWidth` counts icons)
|
||||
|
||||
The tokenizer reports an icon span as a **near-zero-width** token, so at the
|
||||
gxu loop level every measure caller counts the icon as ~0 pixels wide. (The
|
||||
emitter still reserves the render width, so text after an icon positions
|
||||
correctly.) An earlier version of this doc concluded that the gap could not
|
||||
be closed cleanly, because the gxu engine has **no single cold choke**:
|
||||
|
||||
- `FUN_005c6940` (width), `FUN_005c7470`/`FUN_005c7260` (wrap), and the
|
||||
hit-test loop each tokenize and accrue width independently.
|
||||
- The token contract has no width field.
|
||||
- The per-glyph helpers (`FUN_005cabd0`/`FUN_005c6b70`) are the hottest text
|
||||
functions in the engine.
|
||||
|
||||
That analysis was right about the gxu level. The choke sits ONE LEVEL UP:
|
||||
**`FUN_00772890` = `CSimpleFontString::GetStringWidthInternal`**
|
||||
(`Offsets::FUN_FONTSTRING_STRING_WIDTH`) is cold, MinHook-safe, and the single
|
||||
funnel for exactly 4 callers:
|
||||
|
||||
- `Script_GetStringWidth` (0x0079E510)
|
||||
- the GameTooltip auto-size (0x00530640)
|
||||
- the editbox caret positioner (0x0077DE70)
|
||||
- the layout effective-width vmethod (`FUN_00772930` = fs+0x24 vtbl +0x1C,
|
||||
which feeds auto-width fontstring layout)
|
||||
|
||||
It lazily computes into the `fs+0xFC` cache (anchor units, 0.0 sentinel) via
|
||||
the measure core `FUN_0044D670`, then divides the pen-space result by
|
||||
`fs+0x7C`.
|
||||
|
||||
The co-hook lives in `src/text/InlineTexture.cpp`. It calls the original,
|
||||
then adds `SumIconAdvances(text) / K` when two gates pass:
|
||||
|
||||
- `InlineInterceptActive(text)` is true — the **same predicate** the tokenizer
|
||||
hook stands down on, factored into one function so measure and render can
|
||||
never disagree.
|
||||
- The fs is not an editbox: `fs+0x120 & 0x1000` is clear (the fs-level bit
|
||||
`FUN_0044D670` translates into the gxu editbox flag 0x40).
|
||||
|
||||
The icon sum is computed in TRUE PEN PX — the exact numbers the emitter
|
||||
reserves — and bridged into the getter's return space by the font height
|
||||
known in both spaces: `sumPen × (fontHInt / fontHPen)`. `ResolveFsPenFont`
|
||||
replicates the render chain: `fontHPen = round((fontHInt × fs+0x7C, floored
|
||||
at [0x801628]/rasterY) × rasterY)` (the rebuild's node fontSize + the node
|
||||
ctor's min clamp + the emitter's own height helper), and the outline ink
|
||||
resolves through `face = [[fs+0xE0]+0x20]` — the same object the node stores
|
||||
at +0x44. fs-built nodes are always in snap mode (no fs-flag maps to node
|
||||
bit 7), so the snap-truncated advances are exact. The per-icon advance comes
|
||||
from `IconAdvancePen`, the **same helper** the emitter reserves with — so the
|
||||
measured width matches the rendered width by construction, outline ink and
|
||||
pixel snapping included. (An earlier bridge used the SetPoint push factor
|
||||
`div×1024/mul ≈ 1468` as the intermediate — the units cancelled for `:0`
|
||||
icons, but it couldn't express the truncation or the ink.)
|
||||
|
||||
Unit trap (hit on first flight): escape sizes are UI PIXELS. Do NOT divide
|
||||
the icon sum by `fs+0x7C` — that field is the layout UI *scale* (~0.68–1.0).
|
||||
The original's own `out / fs+0x7C` applies to a value the measure core
|
||||
already ran through the `FUN_0041AD80` gxu→internal converter, not to raw
|
||||
pixels. The `/0x7C` version measured a 16px icon as +44k px.
|
||||
|
||||
The hook NEVER writes the `fs+0xFC` cache: the original can serve the cached
|
||||
value, so the icon sum is re-derived and re-added per call (idempotent).
|
||||
|
||||
The same hook also fixes two layout consumers for free: tooltips auto-size
|
||||
wide enough for icon-bearing lines, and auto-width fontstrings lay out at
|
||||
their true rendered width.
|
||||
|
||||
### Wrap: FIXED at the shared stepper (`FUN_TEXT_WRAP_STEPPER` = 0x005C7260)
|
||||
|
||||
Wrap decisions also measured icons as ~0, so a chat line with prefix icons
|
||||
broke as if the icons were not there and the emitter's real advances ran past
|
||||
the right edge. The choke is the wrap-stepper dispatcher `FUN_005C7260`: it
|
||||
lays out one wrapped line per call, and its exactly 4 callers are EVERY wrap
|
||||
consumer — the draw builder `FUN_005CDC20` (render breaks), the height
|
||||
measure `FUN_005C2070`, the chars-that-fit counter `FUN_005C21C0` (ellipsis),
|
||||
and the break-array computer `FUN_005C2430` (behind `FUN_00772B60`).
|
||||
|
||||
The co-hook shrinks the `wrapWidth` argument by a **minimal feasible
|
||||
shrink**, gated on the same `InlineInterceptActive` predicate plus the flags
|
||||
editbox bit 0x40 the dispatcher already carries. The stepper reports each
|
||||
probe's measured (icons-at-zero) line width in `outWidth`, which gives a
|
||||
direct feasibility check: the line renders inside the frame iff its icons
|
||||
fit in the shrink plus the spare the break left (`iconSum ≤ s + (probeW −
|
||||
outWidth)`). The hook probes sandboxed (local out-params plus a COPY of the
|
||||
`p10` in/out state byte, so the caller's first-line state is not consumed):
|
||||
s = 0 first (feasible → no shrink at all — the common short-message case),
|
||||
escalate by the measured deficit when infeasible, bisect down after the
|
||||
first feasible hit; ≤8 probes, and with no feasible hit the last escalation
|
||||
target wins (wraps early rather than overflowing). The feasibility check
|
||||
carries a HALF-GLYPH tolerance: an auto-width fontstring (single anchor, no
|
||||
width — e.g. pfUI's addon-list labels) wraps at the icon-inclusive string
|
||||
width our GetStringWidth hook feeds the effective-width vmethod, so its line
|
||||
fits with exactly zero slack, and cross-computation drift between the two
|
||||
hooks' unit chains (plus OUTLINE extras and the trailing-glyph quirk) read
|
||||
"fits exactly" as "1px over" — shrinking a zero-slack line forces a wrap
|
||||
that had no business existing. Genuine icon overflow is ≥ a full icon, so
|
||||
half a glyph separates drift from real overflow. `outBreak` and
|
||||
`outNext − text` are byte counts; `outBreak == len` means the whole text
|
||||
fit. Two rejected earlier schemes, for the record: a naive whole-remaining-
|
||||
text sum shredded an 8-icon line to one word per line, and a fixed-point
|
||||
iteration on the icon count oscillated (a ~55-byte zero-width escape makes
|
||||
the break↔icon-count coupling discontinuous — no fixed point exists).
|
||||
`text` always points at the REMAINING text, so a continuation line whose
|
||||
icons are behind it gets sum = 0. Because all four consumers share the hook,
|
||||
render breaks, `GetStringHeight`, truncation, and break arrays shift
|
||||
together. (A `_classicapi_InlineTexWrap(n)` ring dumped the last 8
|
||||
icon-bearing calls during bring-up; removed with the other diagnostics in
|
||||
`5e5677b`.)
|
||||
|
||||
Unit trap #2 (hit TWICE): the stepper's inputs are gxu-normalized, PER AXIS —
|
||||
fontH is normalized-Y (× rasterY = pen px) and wrapWidth/outWidth are
|
||||
normalized-X (× rasterX = pen px). Both caller flavors land there: the draw
|
||||
builder passes node+0x1C/+0x3C (normalized at block creation), and the
|
||||
fs-level callers (the path chat wraps through) x-normalize their widths
|
||||
inside `FUN_0044d670` before gxu. First flight subtracted raw pixels and
|
||||
annihilated the small normalized widths (2-glyph chat fragments). The second
|
||||
converted with `fontH / fontHPx` — a Y-AXIS factor applied to an X-axis
|
||||
width — overshrinking the budget by the render aspect (×1.33 at 4:3, ×2.37
|
||||
at 2560×1080): icon-bearing chat lines wrapped a word or two early, worse per
|
||||
icon and per aspect. The correct pen-x → width-units factor is `1/rasterX`,
|
||||
and the glyph-relative tolerances use `fontHPx/rasterX` so "half a glyph"
|
||||
means an actual glyph width.
|
||||
|
||||
**Chat hyperlink hover over inline icons works (verified).** An emote wrapped in a
|
||||
hyperlink — `|Htel:name|h|T…|t|h`, the TwitchEmotes pattern — pops its tooltip when
|
||||
you hover the *icon*, not just adjacent text. The hover region is NOT re-measured
|
||||
on hover: the engine records each hyperlink's screen rect at layout time into a
|
||||
`GXUFONTHYPERLINKINFO` array (32-byte entries: rect + link data) via `FUN_005cd310`,
|
||||
called from the draw builder `FUN_005cdc20` — the same layout pass that runs the
|
||||
line through our co-hooked tokenizer (`FUN_005c2810`) and wrap-stepper
|
||||
(`FUN_005c7260`). So the recorded rect rides the same icon-aware position
|
||||
accounting the wrap fix installs; the icon lands inside the hover region for free,
|
||||
and hovering it fires the frame's `OnHyperlinkEnter`. This is a consequence of the
|
||||
wrap fix, not a separate feature. (Ghidra caveat: the decompile of the
|
||||
`FUN_005cd310` call args is mangled — a pointer-as-float + an uninitialized temp —
|
||||
so this is confirmed by the call path plus in-game test, not the byte-level
|
||||
formula.)
|
||||
|
||||
**Still icon-blind (accepted residuals):**
|
||||
|
||||
- Sub-pixel only for a string-final icon: the width hooks report its INK edge
|
||||
(advance minus the trail half-pad/ink — `TrailingIconTrimPen`), matching the
|
||||
engine's own last-glyph convention (the gxu width loop ends on ink, not
|
||||
advance — the source of the old −0.8px measurement, whose previous-glyph
|
||||
advance treatment is semantically CORRECT once an icon renders after it).
|
||||
What remains is the icon region's ±0.5px placement rounding and the base
|
||||
loop's ~0 contribution for the payload-0 icon token.
|
||||
- An fs whose `fs+0xFC` cache was filled while the feature was toggled
|
||||
differently (`_classicapi_InlineTexEnable`) keeps its old base width until
|
||||
the next `SetText`/font change re-dirties it. Debug-toggle-only.
|
||||
|
||||
**Editbox measure stays raw (by design).** The caret/measure path reads the
|
||||
focused editbox's INPUT buffer in place, and the tokenizer stands down on it
|
||||
per-editbox (`TextInFocusedEditbox` pointer-range test — not a global focus
|
||||
check), so caret math measures the raw `|T…|t` literal the editbox renders.
|
||||
The width hook mirrors that with the `fs+0x120 & 0x1000` editable gate. One
|
||||
marginal wobble: the focused single-line chat editbox's display fontstring
|
||||
carries editable=0 (only multi-line editors set the bit), so the caret
|
||||
positioner's line-boundary branch (multi-line-only in practice) can see an
|
||||
icon-adjusted `FUN_00772890` on it. Closing that costs a per-token content
|
||||
compare, which is not worth it.
|
||||
|
||||
### Editbox exclusion — two COMPLEMENTARY signals (bit 6 + focus global)
|
||||
|
||||
Editable text must show raw, editable `|T…|t` markup, not rendered icons — you
|
||||
edit macros, tweak icon fields, copy/paste texture strings. **This is
|
||||
retail-correct, verified in-game on a modern client:** typing
|
||||
`|TInterface\Icons\INV_Misc_Coin_01:16|t` into the chat entry box OR a macro body
|
||||
shows it **raw** (no icon) — Blizzard neutralizes player-*typed* escape sequences
|
||||
as anti-abuse — while addon/system text (`AddMessage`, `GetCoinTextureString`)
|
||||
renders. Retail's discriminator is the text's SOURCE (trusted vs player-typed),
|
||||
not editbox-vs-display; a shift-clicked item link renders in the chat box because
|
||||
it's inserted through the trusted path, but a typed `|T`/`|c` does not. Our
|
||||
editbox-raw + display-renders split reproduces the same observable outcome. (One
|
||||
mechanism-level divergence we accept: our editbox-raw uses the GLOBAL focus signal
|
||||
below, so while an editbox is focused OTHER display text that rebuilds also
|
||||
briefly shows raw; retail suppresses per-source. Cosmetic, and a per-editbox fix
|
||||
would need hot-path text-buffer matching — not worth it.) 1.12's tokenizer,
|
||||
unlike 4.3.4's, has no per-render texture-disable bit (4.3.4 uses tokenizer flag
|
||||
**0x1000** to kill only `|T`, **0x800** to kill all escapes), and editbox text
|
||||
nodes are NOT distinguishable by class from the render node — the emitter can't
|
||||
reach the owning frame. It takes **two signals together**, not one (an earlier
|
||||
version of this note wrongly called the focus global a redundant "belt-and-
|
||||
suspenders fallback" — removing it renders the chat/copy boxes' `|T` as icons):
|
||||
|
||||
- **Node flag `[node+0x5c]` bit 6 (0x40) = EDITABLE** — catches the **macro
|
||||
editor** (`MacroFrameText`, flags `0x4D`), which builds its layout once,
|
||||
**un-focused**, so the focus global never sees it. But single-line inputs
|
||||
(chat entry box, `pfChatCopyBox`) have bit 6 **CLEAR** — their nodes are
|
||||
`0x0D`/`0x20D`, identical to display FontStrings — so bit 6 can't catch them.
|
||||
- **Focused-editbox global `DAT_00cf4dc8`** (`InputFocused()`; holds the editbox
|
||||
with keyboard focus, cleared by `ClearFocus` = `FUN_0077e410`) — catches those
|
||||
single-line inputs. It's GLOBAL (true UI-wide, not per-node), which is why,
|
||||
while an editbox is focused, other display text that *rebuilds* also briefly
|
||||
renders raw. That's an accepted tradeoff: there is no per-node signal for
|
||||
single-line inputs, and when you're in an editbox seeing raw markup is what you
|
||||
want anyway.
|
||||
|
||||
**Why the tokenizer's bit-6 gate is load-bearing for RENDER, not just measure:**
|
||||
when the emitter suppresses an editbox it delegates to the ORIGINAL emitter,
|
||||
which **re-enters the tokenizer** to lay out glyphs (`FUN_005ccbe0` →
|
||||
`FUN_005c2810`). On that render path `flags` is the node's own flags, so bit
|
||||
0x40 makes the tokenizer stand down and the `|T` draws as literal text. Drop the
|
||||
bit-6 gate and the (un-focused) macro editor draws **BLANK** — the tokenizer eats
|
||||
the span as a zero-width token and the original emitter renders nothing (verified
|
||||
in-game). Focused single-line inputs get the same "don't intercept" via
|
||||
`Suppressed()` → `InputFocused()`. So both the emitter and the tokenizer consult
|
||||
`Suppressed() || bit-6`; the two must agree or you get blank-vs-raw mismatches.
|
||||
|
||||
(Dead end: **monochrome render flag `DAT_00c2b9dc`** (`FUN_005c8b70` sets it per
|
||||
group) — un-set at MacroFrameText's build time and flickered as a flush gate.
|
||||
Dropped. The editbox class vtable is `0x0081c8c0`, its script-slot resolver
|
||||
`FUN_0077a310` with focus slots at `+0x438`/`+0x440` — useful if a frame-level
|
||||
hook is ever needed.)
|
||||
|
||||
### Descriptor fields + the vertical flip
|
||||
|
||||
`ParseIcon` handles the full positional payload
|
||||
`path:height:width:offsetX:offsetY:texW:texH:left:right:top:bottom:r:g:b`.
|
||||
Texcoords crop a sprite sheet to one cell: `u = left/texW, right/texW`,
|
||||
`v = top/texH, bottom/texH` — e.g. `UI-RaidTargetingIcons` is a 256×256 sheet,
|
||||
4×2 grid of 64×64, so marker N's cell is `256:256:<col*64>:<+64>:<row*64>:<+64>`.
|
||||
|
||||
**Vertex colour (`r:g:b`, 0-255):** the last three positional fields tint the
|
||||
icon. They pack to `0xFFrrggbb` — the same `0xAARRGGBB` order the tokenizer's
|
||||
`|cAARRGGBB` colour path builds (verified from `FUN_005c2810`'s colour branch),
|
||||
so the value flows straight into the quad's vertex colour and the font stage's
|
||||
default MODULATE COLOROP multiplies `texture.rgb × tint`. White (`255:255:255`,
|
||||
the default) = untinted. Because parsing is positional, a tint needs all the
|
||||
preceding fields present; the `TintIcon` helper passes `0`s for width..bottom
|
||||
(texW/texH `0` disables the texcoord crop → full texture) so the colour lands in
|
||||
the trailing slots.
|
||||
|
||||
**Texcoord order:** the region's 4-float texcoord setter
|
||||
(`FUN_SIMPLETEXTURE_SET_TEXCOORD`) takes the rect **Y-FIRST INTERLEAVED**
|
||||
(`{v0, u0, v1, u1}`), matching the engine's `{yA, left, yB, right}` rect
|
||||
convention — diagnosed in-game when `{u0,v0,u1,v1}` rendered a raid-mark crop as
|
||||
the wrong cell. v is top-down, same as the `|T` payload's top/bottom fields.
|
||||
(The removed quad path had the inverse concern: the OpenGL backend put v=0 at
|
||||
the bottom, so the quad mapped the TOP screen corners to v1 — kept here as the
|
||||
backend note.)
|
||||
|
||||
### Build / deploy / test cadence
|
||||
|
||||
- `set -o pipefail; cmake --build build --config Release 2>&1 | tail -5 && cp build/Release/ClassicAPI.dll "C:/WoW/Octo/dll/ClassicAPI.dll"`
|
||||
— `pipefail` stops a failed build from deploying a stale DLL.
|
||||
- **The client must be fully EXITED to relink** (it loads a symlink to the
|
||||
linker's output). DLL changes need a full restart, not `/reload`.
|
||||
- **The embedded `!!!ClassicAPI` addon is symlinked**, so Lua-only changes (the
|
||||
`TextureTest()` harness in `Util/AddOnCompat.lua`) need only `/reload`.
|
||||
|
||||
### Remaining / optional
|
||||
|
||||
- **Measure width, height, wrap** — all DONE (the three fs-level co-hooks in
|
||||
Current design). Vertex-colour tint (`:r:g:b`) — DONE. The bring-up
|
||||
diagnostics (`_classicapi_InlineTexSuppress/Tune/Stats/ProbeFS/RegionCal/Wrap`
|
||||
and the capture globals) were REMOVED (`5e5677b`); only
|
||||
`_classicapi_InlineTexEnable` remains.
|
||||
- **Animated emotes** — no DLL work is needed: a TwitchEmotes-style animator
|
||||
re-`SetText`s each line ~30fps and rewrites the `|T` texcoords to crop the
|
||||
current film-strip frame. That is addon Lua. RLE-compressed TGAs do not
|
||||
decode in 1.12 (uncompressed only) — convert with
|
||||
`magick in.tga -compress none in.tga` (no `-orient`/`-flip`: the region
|
||||
renderer's texcoord order shows the frame right-side-up).
|
||||
- **Still icon-blind** (accepted): the hyperlink hit-test past a tall icon
|
||||
OUTSIDE the link on the same line. The substring measure `FUN_00772AE0` was
|
||||
hooked once its real consumer surfaced (the tooltip auto-size measures
|
||||
wrapped SEGMENTS with it), and the trailing-icon width residual is trimmed
|
||||
to sub-pixel (`TrailingIconTrimPen` — a string-final icon reports its ink
|
||||
edge, not its advance, per the engine's own last-glyph convention).
|
||||
|
||||
## Goal & spec
|
||||
|
||||
Render the modern inline-texture escape (warcraft.wiki.gg/wiki/UI_escape_sequences#Textures):
|
||||
|
||||
```
|
||||
|Tpath:height[:width[:offsetX:offsetY[:texWidth:texHeight:left:right:top:bottom[:rV:gV:bV]]]]|t
|
||||
```
|
||||
|
||||
Atlas markup (`|A:atlas:…|a`) is Legion+ (needs an atlas DB 1.12 lacks) — out
|
||||
of scope. FileDataID paths are Legion+ — 1.12 resolves texture PATH strings
|
||||
only.
|
||||
|
||||
## Confirmed: 1.12 has ZERO inline-texture support
|
||||
|
||||
In-game test on the target client — all of these render as literal text, no
|
||||
icon:
|
||||
|
||||
```
|
||||
|TInterface\Icons\INV_Misc_Coin_01:16|t
|
||||
|TInterface\Icons\INV_Misc_Coin_01:16:16|t
|
||||
|TInterface\Icons\INV_Misc_Coin_01:16:16:0:0|t
|
||||
```
|
||||
|
||||
Root cause (see 1.12 map below): the shared escape tokenizer has no `T`/`t`
|
||||
case, so `|T` falls through to "literal `|` + ordinary glyphs".
|
||||
|
||||
## Reference: 4.3.4 (`C:\WoW\Proudmoore\apwow.exe`, build 15595)
|
||||
|
||||
4.3.4's text pipeline is a shared `|`-tokenizer feeding per-purpose loops that
|
||||
`switch` on the returned token type. It DOES support `|T`.
|
||||
|
||||
- **Tokenizer `FUN_00613980`** — reads one token, returns a type code, bytes
|
||||
consumed (`*param2`), payload char (`*param5`). `|`-switch token types:
|
||||
`1`=color(`|c`+8 hex), `2`=reset(`|r`), `3`=break(`|n`/newline), `4`=`||`,
|
||||
`5`=`|H`, `6`=`|h`, **`7`=texture(`|T`…`|t`)**, `8`=`|t`, `9`/`10`=`|K`/`|k`
|
||||
callback, `0xb`/`0xc`=`|W`/`|w`, `0`=ordinary glyph. The `T` case scans
|
||||
forward to the closing `|t` and returns 7 with the whole span's byte length.
|
||||
- **Field parser `FUN_00617ba0(tokenStart, &desc, size)`** — splits the `|T`
|
||||
payload on `:` (helpers: `FUN_0040fa50`=strchr, `FUN_004b3d00`=atof,
|
||||
`FUN_004b3480`=atoi) into a **0x40-byte descriptor**:
|
||||
|
||||
| off | field |
|
||||
|----|-------|
|
||||
| `+0x10`/`+0x14` | path ptr / length (up to first `:`) |
|
||||
| `+0x18` | height (font-scaled) — required |
|
||||
| `+0x1C` | width (defaults to height) |
|
||||
| `+0x20`/`+0x24` | offsetX / offsetY |
|
||||
| `+0x28`–`+0x34` | 4 normalized texcoords (texel ÷ texW/texH) |
|
||||
| `+0x3C` | hasTexCoords flag |
|
||||
| `+0x38`–`+0x3B` | vertex B/G/R/A |
|
||||
| `+0x3D` | hasColor flag |
|
||||
|
||||
- **Measure loop `FUN_00618430`** — walks tokens; `case 7` calls
|
||||
`FUN_00617ba0` and adds the texture width to the pen.
|
||||
- **Draw emitter `FUN_0061ea10`** (analog of 1.12 `FUN_005ccbe0`) — its
|
||||
`case 7` is the texture draw:
|
||||
1. `FUN_00617ba0(tokenStart, &desc)` — parse fields into the descriptor
|
||||
(stored inline on the line-state struct at `param7+0xb`, 0x10 dwords).
|
||||
2. Compute pen x, width (`desc.width[+0x18 dword = param7[0x12]] * scale`),
|
||||
and y from the justify mode `[obj+0x58]` (0=baseline, 1=center, 2=top).
|
||||
3. Copy the descriptor to a stack struct and call **`FUN_0061e820`** — the
|
||||
inline-texture quad emit. (`FUN_0061dd50` handles a region-state 1→2
|
||||
transition when a texture interrupts a hyperlink capture.)
|
||||
- **`FUN_0061e820`** — normalizes the 4 quad corners (÷ screen w/h from
|
||||
`DAT_00dd9ea0`/`DAT_00dd9ea4`) and calls `FUN_0061e410(1, &verts)`.
|
||||
- **`FUN_0061e410(count=1, &verts)`** (`__thiscall`, ECX = a vertex-buffer
|
||||
object) — appends `count` quads of **0x40 bytes** (one quad = pos + texcoords)
|
||||
to a **dedicated inline-texture vertex buffer**, growing it as needed
|
||||
(`FUN_0061db40`). So inline textures accumulate in their OWN batch, separate
|
||||
from the per-font-page glyph batches, each quad carrying its own texture
|
||||
(bound at flush). This is the concrete confirmation of the "separate quad
|
||||
path" — the backport must add such a batch + flush on the 1.12 side.
|
||||
|
||||
## Target: 1.12 (`C:\WoW\Octo\WoW.exe`, build 5875) injection map
|
||||
|
||||
Same architecture as 4.3.4, different addresses and token numbering. Reached
|
||||
from `Script_GetStringWidth` (`FUN_0079e510`) → `FUN_00772890` →
|
||||
`FUN_0044d670` → `FUN_005c2050` → the loops.
|
||||
|
||||
- **Shared tokenizer `FUN_005c2810`** — `~11 callers`. `|`-switch token types:
|
||||
`0`=color(`|c`), `1`=reset(`|r`), `2`=break(`|n`/newline), `3`=`||`,
|
||||
`4`=`|H`, `5`=`|h`, `6`=ordinary glyph. **No `T`/`t` case** → `|T` hits the
|
||||
fall-through (`*param5=0x7c; return 6`) = literal `|` then glyphs. Adding a
|
||||
texture token means picking an unused code (e.g. `7`).
|
||||
- **Measure loop `FUN_005c6940`** — `switch`: `0/1/4/5` zero-width, `2` break,
|
||||
default glyph (advance via `FUN_005c6b70`; kerning `FUN_005ca2d0`/
|
||||
`FUN_005ca4b0`). No texture case.
|
||||
- **Fit/width loops** `FUN_005c6c50` (chars-into-width + per-char x, for
|
||||
cursor/wrap) and `FUN_005c7470` (truncation/ellipsis). Same switch shape.
|
||||
- **Draw builder `FUN_005cdc20`** — reads the layout object's text at `[+0x48]`,
|
||||
handles line-wrap / justify (`[+0x54]`) / color state, per line calls the
|
||||
glyph emitter and `FUN_005cd310` (hyperlink rect registration).
|
||||
- **Glyph emitter `FUN_005ccbe0`** — the real quad emitter. Per glyph:
|
||||
`FUN_005cabd0(fontFace,char)` → glyph record (atlas **page** at `rec[10]`,
|
||||
advance `rec[0x12]`, texcoords `rec[0x18..0x1b]`), then appends 4 verts to a
|
||||
**per-atlas-page** batch at `[obj + page*4 + 0xa0]`. `case 0/1` set/reset
|
||||
color, `4/5` open/close hyperlink region, default = glyph.
|
||||
|
||||
### The architectural wrinkle
|
||||
|
||||
Glyph quads batch **per font-atlas page**. An inline `|T` texture is an
|
||||
arbitrary texture, not a font page, so it cannot join that batch — it needs a
|
||||
**separate textured-quad draw** (its own draw call, correctly ordered against
|
||||
the glyph batches so it appears inline). This is the substantive work, not the
|
||||
tokenizing. 4.3.4 solves it exactly this way — captured below: its draw `case 7`
|
||||
appends the quad to a dedicated inline-texture vertex buffer at
|
||||
`renderObject+0x9c`, separate from the per-font-page glyph batches (see the
|
||||
`FUN_0061ea10` / `FUN_0061e820` items under Open items).
|
||||
|
||||
## Strategy
|
||||
|
||||
The tokenizer is shared by ~11 callers, so DON'T just make it return a new
|
||||
type — the other callers' `switch`es would hit `default` and mis-handle it.
|
||||
Instead:
|
||||
|
||||
1. **Reimplement (co-hook) the measure loop `FUN_005c6940` and the draw
|
||||
builder `FUN_005cdc20`/emitter `FUN_005ccbe0`** — copy existing logic
|
||||
(fully decompiled), delegate every non-texture token to the original
|
||||
handling, and add an inline `|T…|t` case: measure → advance pen by texture
|
||||
width; draw → emit the arbitrary-texture quad.
|
||||
2. **Port `FUN_00617ba0`** (field parse → 0x40 descriptor). Standalone, safe to
|
||||
write now from the 4.3.4 decompilation; needs 1.12 equivalents of strchr/
|
||||
atof/atoi (trivial or already in `Offsets.h`).
|
||||
3. **Leave the shared tokenizer `FUN_005c2810` and the other ~9 callers
|
||||
untouched** — they keep rendering `|T` literally (wrap/cursor math slightly
|
||||
off inside a texture in v1 — cosmetic, extend later).
|
||||
4. **Texture load by path** — reuse the engine's `Texture:SetTexture` loader;
|
||||
**quad emit** — the primitive 4.3.4's draw case uses (to pin).
|
||||
|
||||
Risk: the hot text path (every string every frame). But these are
|
||||
deterministic C functions, NOT the contended nampower/SuperWoW targets, so the
|
||||
MinHook-collision concern is low. Reimplementing two complex loops faithfully
|
||||
is the real cost.
|
||||
|
||||
## Incremental slices
|
||||
|
||||
1. Fixed-size `|Tpath:height|t` — render + width only (ignore width/offset/
|
||||
texcoord/color). Proves the end-to-end quad path.
|
||||
2. width, offsetX/Y.
|
||||
3. texcoords (`texW:texH:l:r:t:b`).
|
||||
4. vertex color.
|
||||
5. correct wrap/cursor (extend `FUN_005c6c50`/`FUN_005c7470`).
|
||||
|
||||
## Open items / next steps
|
||||
|
||||
- [x] **4.3.4 draw-side `case 7`** — captured: `FUN_0061ea10` case 7 →
|
||||
`FUN_00617ba0` (parse) + `FUN_0061e820`→`FUN_0061e410` (append quad to a
|
||||
dedicated inline-texture vertex buffer). Separate batch, per-quad texture.
|
||||
- [x] **4.3.4 inline-texture buffer located** — `FUN_0061e820` (`RET 0x40`,
|
||||
i.e. a 0x40-byte quad struct by value) does `ADD ECX,0x9c` before
|
||||
`FUN_0061e410`, so the dedicated inline-texture vertex buffer is at
|
||||
**renderObject+0x9c** (glyph batches are the `+0xa0 + page*4` array — the
|
||||
two are adjacent but distinct). Quad struct: positions at `+0x8..+0x14`,
|
||||
texcoords at `+0x20..+0x2c` (rest = z / texture handle / color). Appended
|
||||
one quad at a time via `FUN_0061e410(this=buf@+0x9c, count, &quad)`.
|
||||
- [x] **Flush + path→texture load** — NOT traced on the 4.3.4 side by design;
|
||||
its purpose (the flush + per-quad texture bind + path→load mechanism) is
|
||||
fully answered on the **1.12 side** below: paint/flush `FUN_005c8fe0`,
|
||||
bind `FUN_00589e80(0x17,tex)` + draw `FUN_005c8f40`, load `FUN_00449d90`.
|
||||
4.3.4's own `+0x9c`-buffer flush is not needed — we implement against 1.12.
|
||||
- [x] **1.12 path→texture loader** — `FUN_00770200(textureObj, path, flag,
|
||||
blend=DAT_00878cf0, flag)`. It's the string-path branch of
|
||||
`Script_Texture_SetTexture` (`FUN_0079bb40`, method-table entry at
|
||||
`0x0087c1a0`). This is how a `|T` path becomes a bound texture.
|
||||
- [x] **1.12 batch render + textured-quad GPU primitive — FOUND.** The paint
|
||||
pass is `FUN_005c8fe0(layoutObj)`: it ensures each line built
|
||||
(`FUN_005cd6a0`), then per font page (stride 0x18, indices 0..7 over
|
||||
`[obj+0x18c+page*0x18]` = the page's texture object) does:
|
||||
- **bind texture:** `FUN_00589e80(0x17, texturePage)` (0x17 = the texture
|
||||
render-state selector),
|
||||
- **lock dynamic VB:** `FUN_0058a140(0, stride=0x18, 0x800)` → handle;
|
||||
`FUN_0058a080(handle)` → vertex data ptr,
|
||||
- copy the page's built verts from the per-page batch at
|
||||
`[lineObj + 0xa0 + page*4]` (`FUN_005ce090`=count, `FUN_005ce0c0`=copy),
|
||||
- **draw:** `FUN_005c8f40(&handle, vertCount)` (flush/submit),
|
||||
- **unlock:** `FUN_0058a0a0(handle, 0)`.
|
||||
|
||||
**Vertex stride = 0x18** (24 bytes = pos + uv + packed color). So an inline
|
||||
`|T` quad = 4 (or 6) such verts, bound to its own texture. Reuse path for
|
||||
the `|T` draw: `FUN_00589e80(0x17, inlineTex)` → write quad verts →
|
||||
`FUN_005c8f40`. **`FUN_005c8fe0` is the co-hook site for strategy B** —
|
||||
after the glyph batches are drawn, draw the frame's collected `|T` quads.
|
||||
- [x] **Path → bindable texture handle — RESOLVED.** `FUN_00770200`
|
||||
(SetTexture-by-path) internally calls **`FUN_00449d90(path, &desc, flags,
|
||||
0, 1)`** — the general path→CGxTexture loader (normalizes path, by-name
|
||||
cache, creates on miss via `FUN_0044a140`/`FUN_0044a310`, returns a
|
||||
**fallback texture on failure so never null**) — and stores the handle at
|
||||
`textureObj+0xcc`. That handle is the SAME CGxTexture type
|
||||
`FUN_00589e80(0x17, tex)` binds (the `tex[0x3c]` streamable flag +
|
||||
non-null `*tex` that `FUN_00593840` checks are CGxTexture fields; font
|
||||
pages and Texture widgets bind identically). So a `|T` draws as:
|
||||
`tex = FUN_00449d90(path,&desc,flags,0,1)` → `FUN_00589e80(0x17,tex)` →
|
||||
write 4 verts (stride 0x18) → `FUN_005c8f40(&vb,n)`. No scratch widget
|
||||
needed if we replicate `FUN_00770200`'s descriptor setup
|
||||
(`FUN_0058a980(&desc, blend, …)` then `FUN_00449d90`); or, simplest, keep
|
||||
one scratch Texture object, `FUN_00770200(scratch,path,…)`, read
|
||||
`[scratch+0xcc]`. The internal by-name cache makes repeats cheap.
|
||||
|
||||
Two implementation strategies once the primitive is pinned:
|
||||
- **A (mirror 4.3.4):** own a separate inline-texture vertex buffer, flush it
|
||||
in the paint pass alongside the glyph batches. Faithful, more integration.
|
||||
- **B (immediate):** at paint time, draw each collected `|T` quad immediately
|
||||
via the GxU primitive (bind `FUN_00770200`'d texture, draw one quad). Simpler;
|
||||
inline textures are few, so per-quad draw calls are fine. Likely the better
|
||||
first cut.
|
||||
- [ ] Confirm 1.12's tokenizer flag bits (`param4 & 0x100/0x200/0x400/0x800`)
|
||||
that gate color/escape handling, so the reimplemented loops pass them
|
||||
through unchanged.
|
||||
|
||||
## Reality check on scope — how it actually landed
|
||||
|
||||
The plan above feared reimplementing 1.12's glyph emitter `FUN_005ccbe0` (a
|
||||
long, intricate function) and the measure loop. It did not come to that. The
|
||||
shipped design avoids both:
|
||||
|
||||
- **Positioning** does NOT reimplement the emitter — it SEGMENTS the line at
|
||||
icon boundaries and delegates each plain run to the ORIGINAL emitter, so the
|
||||
engine still lays out every glyph. We only track where the icons go.
|
||||
- **Rendering** does NOT stand up a separate quad batch — icons are
|
||||
engine-managed regions anchored to their FontString (the removed quad batch
|
||||
was strategy B; regions replaced it).
|
||||
- **Measure** does NOT reimplement the loops — three cold co-hooks on the
|
||||
fs-level width / height / wrap functions add the icons' contribution and call
|
||||
the originals.
|
||||
|
||||
The result is the largest feature in the project, but built from co-hooks that
|
||||
delegate to the engine rather than from reimplemented layout code. It was
|
||||
verified by in-game iteration over many build/test rounds, as expected.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Why inline icons render as engine regions
|
||||
|
||||
Inline `|T…|t` icons render as pooled `CSimpleTexture` regions
|
||||
([src/text/InlineTexturePool.cpp](../src/text/InlineTexturePool.cpp)) — one per
|
||||
visible icon, anchored to the icon's owning FontString. The engine draws each
|
||||
region every frame like any UI texture, so the icon is resident by construction
|
||||
and moves with its line for free. This is 4.3.4's `CSimpleEmbeddedTexture`
|
||||
model, built from the parts 1.12 ships.
|
||||
|
||||
This file records why that design was chosen and the RE behind it. A raw-GxU-
|
||||
quad renderer came first and was removed (`56c2670`).
|
||||
|
||||
## History — a residency misdiagnosis, corrected
|
||||
|
||||
The quad renderer flickered: a random subset of chat icons blanked on scroll,
|
||||
and a second client showed no icons at all. The first diagnosis was **texture
|
||||
residency** — the theory that a raw quad bound a texture the VRAM manager had
|
||||
evicted, because a raw quad is owned by no frame object.
|
||||
|
||||
That diagnosis was wrong. The real cause was the Large-Address-Aware pointer
|
||||
bound (`dc61f77`): a 2GB cap in the flush's `LooksReadable` check rejected valid
|
||||
heap pointers above `0x80000000`, and the flush's node walk BREAKS on a rejected
|
||||
node. So one high node truncated the walk, and every node after it lost its
|
||||
icon. That is the "random subset blanks" pattern, and a second client shows none
|
||||
because it grows the heap past 2GB sooner. With the bound fixed, the raw quads
|
||||
did not flicker either.
|
||||
|
||||
So engine regions are kept for **4.3.4 parity**, not necessity. They are
|
||||
resident by construction, which closes the residency question for good — but the
|
||||
question was never the real bug. Do not re-open the residency investigation; the
|
||||
D3D notes at the end are the record of what was ruled out.
|
||||
|
||||
## The region the pool builds
|
||||
|
||||
1.12 ships `CSimpleTexture` — the ordinary `Texture` widget
|
||||
(`frame:CreateTexture()`). It is a managed, reference-counted, `CGxTexCache`-
|
||||
backed, engine-drawn texture. The only thing 3.3.5's `CSimpleEmbeddedTexture`
|
||||
adds is automatic inline layout inside the FontString, and the emitter already
|
||||
computes the icon positions — so the pool supplies them. (`CSimpleEmbeddedTexture`
|
||||
is compiled into 3.3.5 only; RTTI confirms it is absent from 1.12.)
|
||||
|
||||
Per icon, the pool takes a pooled region and sets the texture, texcoords,
|
||||
colour, and two anchor points relative to the owning FontString's bottom-left.
|
||||
The engine then owns load, residency, managed-pool reload, clipping, and draw.
|
||||
|
||||
### 1.12 entry points (used by the pool)
|
||||
|
||||
RTTI name strings: `.?AVCSimpleTexture@@` @ `0x00846588`,
|
||||
`.?AVCSimpleFontString@@` @ `0x00846544`.
|
||||
|
||||
Create a `CSimpleTexture` from C (mirrors `Tooltip::LinePool`'s FontString path):
|
||||
|
||||
```
|
||||
void* mem = FUN_00760450(&DAT_00cf4ce0, 0, /*rtti*/0x00846588, 0xFFFFFFFE); // pool alloc
|
||||
void* tex = FUN_0076fc40(mem, parentFrame, /*drawLayer*/2, /*sublayer*/1); // ctor
|
||||
```
|
||||
|
||||
Set the texture path (loads through the engine's own loader, owns the handle):
|
||||
|
||||
```
|
||||
FUN_00770200(tex, path, 0, DAT_00878cf0, 0); // CSimpleTexture::SetTexture(path)
|
||||
```
|
||||
|
||||
`FUN_00770200` builds GxTexFlags (`FUN_0058a980`), loads via `FUN_00449d90`,
|
||||
stores the HTEXTURE at `tex+0xCC` (owned ref), releases the old one
|
||||
(`FUN_0041aed0`), and marks the region dirty. This ownership is the whole
|
||||
residency story — the engine holds the texture and reloads it on eviction.
|
||||
|
||||
Region methods the pool calls directly:
|
||||
|
||||
| method | C++ target |
|
||||
|---|---|
|
||||
| SetTexCoord | `FUN_00770410(tex, &{v0,u0,v1,u1})` — Y-FIRST interleaved |
|
||||
| SetColor | `FUN_0077F750(tex, &BGRA)` — also the chat-fade alpha mirror |
|
||||
| SetPoint | `FUN_00767C70` — internal units (see LinePool `PixelToInternal`) |
|
||||
| Show / Hide | `FUN_0077FCB0` / `FUN_0077FC60` (desired-shown `+0xC4`, realized `+0xC8`) |
|
||||
|
||||
`CSimpleTexture` layout (from ctor `FUN_0076fc40`): vtable `0x0081c718`;
|
||||
region-render vtable at `+0x24`; HTEXTURE `+0xCC`; blend `+0xD0`; per-corner
|
||||
quad position/texcoord arrays `+0xD4..+0x100` (`OFF_SIMPLETEXTURE_CORNERS`);
|
||||
`+0x128` a flag. The region-render vtable slot 0 (`0x00770670`) computes the
|
||||
screen rect and calls `FUN_007705b0` to STORE the quad corners; the GPU draw is
|
||||
deferred to the engine's batched region paint.
|
||||
|
||||
## The pen↔anchor bridge
|
||||
|
||||
The emitter computes icon positions in PEN units; the pool stores region
|
||||
`SetPoint` offsets in ANCHOR units, relative to the FontString's rect. The
|
||||
bridge is one scale `K` (pen units per anchor unit) plus the FontString's own
|
||||
rect: the flush reads the FontString rect and derives `K` per node, converts
|
||||
each icon's screen rect to FontString-relative anchor offsets, and queues that.
|
||||
The pool applies the queued placement on the next frame tick — never mid-render.
|
||||
`K` derivation and the FontString-relative store are in `FlushLayout`
|
||||
([src/text/InlineTexture.cpp](../src/text/InlineTexture.cpp)); the
|
||||
convergence pass in `Maintain`
|
||||
([src/text/InlineTexturePool.cpp](../src/text/InlineTexturePool.cpp)) self-heals
|
||||
any residual scale from the region's own frame-scale chain.
|
||||
|
||||
## What was ruled out during the residency chase (do not re-investigate)
|
||||
|
||||
The blank icons had zero CPU-side signature — records present, node chain
|
||||
contiguous, geometry sane, index buffer ready at submit, batched draw, z-buffer
|
||||
excluded twice. The full D3D9 render state at the true draw point was benign:
|
||||
`zFunc=8` always, `aBlend=1`, `colorW=0xF`, scissor and stencil off, texture
|
||||
stage MODULATE with a valid bound texture every draw. All of this was consistent
|
||||
with the LAA-bound truncation (the node never reached the draw), not with a lost
|
||||
texture surface.
|
||||
|
||||
D3D9 device access, if it is ever needed again:
|
||||
|
||||
- `IDirect3DDevice9* = *(void**)(*(void**)0x00C0ED38 + 0x38a8)` (CGxDeviceD3d).
|
||||
- `GetRenderState` = device vtable `+0xE8`; `SetRenderState` `+0xE4`.
|
||||
- The GxU re-applies its cached render states before every draw (`FUN_005a1b20`),
|
||||
so a raw `SetRenderState` for a GxU-managed state is overwritten; `ZENABLE` is
|
||||
not in that path, so a direct set survives.
|
||||
@@ -0,0 +1,9 @@
|
||||
; Exports for ClassicAPI.dll.
|
||||
;
|
||||
; `Load` is VanillaFixes' post-injection entry point: after the launcher
|
||||
; injects each dlls.txt DLL, the VanillaFixes runtime calls the DLL's `Load`
|
||||
; export on the game's MAIN thread (GetProcAddress(module, "Load")), outside
|
||||
; the loader lock and with no timeout. Listing it here exports it undecorated
|
||||
; as "Load" so GetProcAddress finds it regardless of calling convention.
|
||||
EXPORTS
|
||||
Load
|
||||
@@ -13,26 +13,6 @@
|
||||
|
||||
namespace Common {
|
||||
|
||||
#define HOOK_FUNCTION(offset, hook, original) \
|
||||
{ \
|
||||
auto *target = reinterpret_cast<LPVOID>(offset); \
|
||||
if (MH_CreateHook(target, reinterpret_cast<LPVOID>(hook), \
|
||||
reinterpret_cast<LPVOID *>(&original)) != MH_OK) \
|
||||
return FALSE; \
|
||||
if (MH_EnableHook(target) != MH_OK) \
|
||||
return FALSE; \
|
||||
}
|
||||
|
||||
#define UNHOOK_FUNCTION(offset, original) \
|
||||
{ \
|
||||
auto *target = reinterpret_cast<LPVOID>(offset); \
|
||||
if (MH_DisableHook(target) != MH_OK) \
|
||||
return FALSE; \
|
||||
if (MH_RemoveHook(target) != MH_OK) \
|
||||
return FALSE; \
|
||||
(original) = nullptr; \
|
||||
}
|
||||
|
||||
bool PatchBytes(void *dst, const void *src, size_t len);
|
||||
|
||||
} // namespace Common
|
||||
|
||||
+164
-54
@@ -16,8 +16,11 @@
|
||||
#include "MinHook.h"
|
||||
#include "Offsets.h"
|
||||
#include "event/Custom.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"
|
||||
|
||||
static Game::FrameScript_Initialize_t FrameScript_Initialize_o = nullptr;
|
||||
static Game::LoadScriptFunctions_t LoadScriptFunctions_o = nullptr;
|
||||
@@ -33,24 +36,17 @@ static FrameRegisterEvent_t FrameRegisterEvent_o = nullptr;
|
||||
static void __fastcall InvalidFunctionPtrCheck_h() {}
|
||||
|
||||
static bool __fastcall FrameScript_Initialize_h() {
|
||||
// BEFORE the engine tears down the old event table (at the start of
|
||||
// FrameScript_Initialize), invalidate our cached slot indices. The
|
||||
// table is rebuilt at a fresh allocation; the old slots are stale.
|
||||
Event::Custom::PrepareForReload();
|
||||
// Fire every module's PrepareForReload BEFORE the engine tears down the old
|
||||
// event table / Lua state. Each module that keeps reload-fragile state (Lua
|
||||
// refs, frame-pointer-keyed maps, cached event-slot indices) self-registers
|
||||
// via `static const Game::ReloadAutoRegister _reload{&PrepareForReload};`,
|
||||
// so this one call clears them all — a new module can't forget to wire in
|
||||
// (see the ReloadAutoRegister doc in Game.h). This hook fires on both
|
||||
// `/reload` and `/logout` (the engine re-inits the Lua state in both).
|
||||
Game::RunReloadCleanups();
|
||||
|
||||
// Clear the nameplate diff state so currently-visible plates
|
||||
// refire CREATED and UNIT_ADDED on the next tick — re-presents
|
||||
// them to the freshly reloaded UI, matching modern WoW. The
|
||||
// wrappers themselves now live in the engine's own registry
|
||||
// (via `FrameScript_Object::ScriptRegister`), which the engine
|
||||
// tears down and rebuilds across the Lua reset — no per-module
|
||||
// cache to clear here.
|
||||
NamePlate::Events::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
|
||||
// deterministic save point for the common lifecycle events.
|
||||
// Persist the name cache before the engine tears down — a SAVE, not a clear,
|
||||
// so it stays an explicit call rather than a ReloadAutoRegister.
|
||||
Player::NameCache::Flush();
|
||||
|
||||
FrameScript_Initialize_o();
|
||||
@@ -71,6 +67,14 @@ static void __fastcall LoadScriptFunctions_h() {
|
||||
static void __stdcall LoadGlueScriptFunctions_h() {
|
||||
LoadGlueScriptFunctions_o();
|
||||
Game::RunGlueModuleRegistrations();
|
||||
// World→glue teardown destroyed every world fontstring and text node the
|
||||
// inline-icon maps reference; forget them now that the glue UI is booting.
|
||||
// (Glue→world is covered by FrameScript_Initialize_h; the node-free hook
|
||||
// handles individual deaths, but bulk teardown paths may bypass it.)
|
||||
Text::InlineTexture::PrepareForReload();
|
||||
Text::InlineTexturePool::PrepareForReload();
|
||||
Texture::Transform::PrepareForReload();
|
||||
Texture::Mask::PrepareForReload();
|
||||
}
|
||||
|
||||
// Every Lua-side `frame:RegisterEvent(...)` is a chance to claim a slot
|
||||
@@ -86,47 +90,153 @@ static void __fastcall FrameRegisterEvent_h(void *frame, void *edx,
|
||||
FrameRegisterEvent_o(frame, edx, eventName);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook install — kept OFF the Windows loader lock.
|
||||
//
|
||||
// Every MH_EnableHook freezes all process threads (CreateToolhelp32Snapshot +
|
||||
// SuspendThread/GetThreadContext each). Doing ~90 of those from DllMain, under
|
||||
// the loader lock, is the pattern MinHook documents as unsafe: on machines
|
||||
// whose security stack intercepts thread suspension it stalled the remote
|
||||
// LoadLibrary thread past VanillaFixes' 10-second injection deadline, so VF
|
||||
// read the still-running thread as STILL_ACTIVE (259) and showed its generic
|
||||
// "compatible client" error — a false diagnosis of a slow load.
|
||||
//
|
||||
// So DllMain installs nothing. The install runs later, on a thread that does
|
||||
// NOT hold the loader lock, via one of two triggers that both funnel through
|
||||
// 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, 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
|
||||
static volatile LONG g_initDone = 0; // 0 until the install has finished
|
||||
static volatile LONG g_initResult = 1; // 0 = success, 1 = failure (until proven)
|
||||
static volatile LONG g_mhInitialized = 0; // MH_Initialize succeeded (gates detach teardown)
|
||||
|
||||
static bool CreateAndQueue(uintptr_t offset, void *hook, void **original) {
|
||||
auto *target = reinterpret_cast<LPVOID>(offset);
|
||||
if (MH_CreateHook(target, hook, original) != MH_OK)
|
||||
return false;
|
||||
// Queue only — a single MH_ApplyQueued below applies the whole batch in
|
||||
// one thread-freeze.
|
||||
if (MH_QueueEnableHook(target) != MH_OK)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool InstallHooks() {
|
||||
if (MH_Initialize() != MH_OK)
|
||||
return false;
|
||||
InterlockedExchange(&g_mhInitialized, 1);
|
||||
|
||||
if (!CreateAndQueue(Offsets::FUN_INVALID_FUNCTION_PTR_CHECK,
|
||||
reinterpret_cast<void *>(InvalidFunctionPtrCheck_h), nullptr))
|
||||
return false;
|
||||
|
||||
// Four core init hooks — each runs glue logic (PrepareForReload,
|
||||
// RunModuleRegistrations, RunGlueModuleRegistrations, EnableWrites,
|
||||
// RetryClaims) tightly coupled to DllMain state, so a declarative
|
||||
// HookAutoRegister wouldn't simplify them.
|
||||
if (!CreateAndQueue(Offsets::FUN_FRAME_SCRIPT_INITIALIZE,
|
||||
reinterpret_cast<void *>(FrameScript_Initialize_h),
|
||||
reinterpret_cast<void **>(&FrameScript_Initialize_o)))
|
||||
return false;
|
||||
if (!CreateAndQueue(Offsets::FUN_LOAD_SCRIPT_FUNCTIONS,
|
||||
reinterpret_cast<void *>(LoadScriptFunctions_h),
|
||||
reinterpret_cast<void **>(&LoadScriptFunctions_o)))
|
||||
return false;
|
||||
if (!CreateAndQueue(Offsets::FUN_LOAD_GLUE_SCRIPT_FUNCTIONS,
|
||||
reinterpret_cast<void *>(LoadGlueScriptFunctions_h),
|
||||
reinterpret_cast<void **>(&LoadGlueScriptFunctions_o)))
|
||||
return false;
|
||||
if (!CreateAndQueue(Offsets::FUN_FRAME_REGISTER_EVENT,
|
||||
reinterpret_cast<void *>(FrameRegisterEvent_h),
|
||||
reinterpret_cast<void **>(&FrameRegisterEvent_o)))
|
||||
return false;
|
||||
|
||||
// All feature hooks declared via `Game::HookAutoRegister` at file scope
|
||||
// in their respective modules (create + queue-enable, no apply yet).
|
||||
if (!Game::RunHookRegistrations())
|
||||
return false;
|
||||
|
||||
// One thread-freeze that activates every queued hook at once.
|
||||
return MH_ApplyQueued() == MH_OK;
|
||||
}
|
||||
|
||||
// Runs InstallHooks exactly once. Returns 0 on success, 1 on failure. If a
|
||||
// second caller arrives while the install is in flight, it blocks briefly so
|
||||
// both callers observe the real result.
|
||||
static DWORD EnsureInitialized() {
|
||||
if (InterlockedCompareExchange(&g_initClaimed, 1, 0) == 0) {
|
||||
g_initResult = InstallHooks() ? 0 : 1;
|
||||
InterlockedExchange(&g_initDone, 1);
|
||||
} else {
|
||||
while (InterlockedCompareExchange(&g_initDone, 0, 0) == 0)
|
||||
Sleep(1);
|
||||
}
|
||||
return static_cast<DWORD>(g_initResult);
|
||||
}
|
||||
|
||||
static DWORD WINAPI InitWorker(LPVOID) {
|
||||
EnsureInitialized();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// VanillaFixes calls this on the game's MAIN thread after injection
|
||||
// (InitAdditionalDLLs -> GetProcAddress(module, "Load")), outside the loader
|
||||
// lock and with no timeout — the sanctioned VF extension point. Returns 0 on
|
||||
// success; VF reports any non-zero result to the user. Exported undecorated
|
||||
// as "Load" via src/ClassicAPI.def.
|
||||
extern "C" DWORD Load() { return EnsureInitialized(); }
|
||||
|
||||
BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved) {
|
||||
if (reason == DLL_PROCESS_ATTACH) {
|
||||
DisableThreadLibraryCalls(hModule);
|
||||
|
||||
if (MH_Initialize() != MH_OK)
|
||||
return FALSE;
|
||||
|
||||
auto *target = reinterpret_cast<LPVOID>(Offsets::FUN_INVALID_FUNCTION_PTR_CHECK);
|
||||
if (MH_CreateHook(target, reinterpret_cast<LPVOID>(InvalidFunctionPtrCheck_h), nullptr) != MH_OK)
|
||||
return FALSE;
|
||||
if (MH_EnableHook(target) != MH_OK)
|
||||
return FALSE;
|
||||
|
||||
// Four core init hooks stay here — each runs glue logic
|
||||
// (PrepareForReload, RunModuleRegistrations,
|
||||
// RunGlueModuleRegistrations, EnableWrites, RetryClaims)
|
||||
// that's tightly coupled to DllMain state, so a declarative
|
||||
// HookAutoRegister wouldn't simplify them.
|
||||
HOOK_FUNCTION(Offsets::FUN_FRAME_SCRIPT_INITIALIZE, FrameScript_Initialize_h,
|
||||
FrameScript_Initialize_o);
|
||||
HOOK_FUNCTION(Offsets::FUN_LOAD_SCRIPT_FUNCTIONS, LoadScriptFunctions_h,
|
||||
LoadScriptFunctions_o);
|
||||
HOOK_FUNCTION(Offsets::FUN_LOAD_GLUE_SCRIPT_FUNCTIONS,
|
||||
LoadGlueScriptFunctions_h, LoadGlueScriptFunctions_o);
|
||||
HOOK_FUNCTION(Offsets::FUN_FRAME_REGISTER_EVENT, FrameRegisterEvent_h,
|
||||
FrameRegisterEvent_o);
|
||||
|
||||
// All feature hooks declared via `Game::HookAutoRegister` at
|
||||
// file scope in their respective modules.
|
||||
if (!Game::RunHookRegistrations())
|
||||
return FALSE;
|
||||
// Install nothing here (loader lock — see the block comment above).
|
||||
//
|
||||
// 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. Flush before MH_Uninitialize — the cache's
|
||||
// file I/O uses Win32 directly (no MinHook involvement), so
|
||||
// either order works in practice, but flushing first lets us
|
||||
// bail early if the hook teardown ever grows side effects.
|
||||
// Hard process termination (task manager kill) bypasses this
|
||||
// path; that's an inherent OS limitation, and the 5-minute
|
||||
// backstop flush in Remember() covers the worst case there.
|
||||
Player::NameCache::Flush();
|
||||
MH_Uninitialize();
|
||||
// Clean /quit path — only tear down if we actually initialized.
|
||||
// Flush before MH_Uninitialize — the cache's file I/O uses Win32
|
||||
// directly (no MinHook involvement), so either order works in
|
||||
// practice, but flushing first lets us bail early if the hook
|
||||
// teardown ever grows side effects. Hard process termination (task
|
||||
// manager kill) bypasses this path; that's an inherent OS
|
||||
// limitation, and the 5-minute backstop flush in Remember() covers
|
||||
// the worst case there.
|
||||
if (g_mhInitialized) {
|
||||
Player::NameCache::Flush();
|
||||
MH_Uninitialize();
|
||||
}
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
+24
-2
@@ -17,6 +17,12 @@
|
||||
|
||||
namespace Game {
|
||||
|
||||
void *ResolveUnitToken(const char *token) {
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
return fn(token);
|
||||
}
|
||||
|
||||
namespace Lua {
|
||||
// Each entry binds a typed function pointer in `Game::Lua::` to the
|
||||
// corresponding raw VA in `Offsets`. The X-macro keeps the column-aligned
|
||||
@@ -40,6 +46,7 @@ namespace Lua {
|
||||
F(RawGet, lua_rawget, LUA_RAW_GET) \
|
||||
F(SetTable, lua_settable, LUA_SET_TABLE) \
|
||||
F(RawSet, lua_rawset, LUA_RAW_SET) \
|
||||
F(SetMetatable,lua_setmetatable,LUA_SET_METATABLE)\
|
||||
F(Insert, lua_insert, LUA_INSERT) \
|
||||
F(Remove, lua_remove, LUA_REMOVE) \
|
||||
F(GetTop, lua_gettop, LUA_GET_TOP) \
|
||||
@@ -73,7 +80,7 @@ using RegisterFrameMethods_t = void(__fastcall *)(const FrameMethodEntry *table,
|
||||
} // namespace
|
||||
|
||||
void *State() {
|
||||
return *reinterpret_cast<void **>(static_cast<uintptr_t>(Offsets::VAR_LUA_STATE));
|
||||
return Game::Read<void *>(Offsets::VAR_LUA_STATE);
|
||||
}
|
||||
|
||||
namespace {
|
||||
@@ -288,6 +295,7 @@ namespace {
|
||||
ModuleAutoRegister *g_moduleHead = nullptr;
|
||||
GlueModuleAutoRegister *g_glueModuleHead = nullptr;
|
||||
HookAutoRegister *g_hookHead = nullptr;
|
||||
ReloadAutoRegister *g_reloadHead = nullptr;
|
||||
} // namespace
|
||||
|
||||
ModuleAutoRegister::ModuleAutoRegister(Fn f) : fn(f), next(g_moduleHead) {
|
||||
@@ -309,6 +317,15 @@ void RunGlueModuleRegistrations() {
|
||||
node->fn();
|
||||
}
|
||||
|
||||
ReloadAutoRegister::ReloadAutoRegister(Fn f) : fn(f), next(g_reloadHead) {
|
||||
g_reloadHead = this;
|
||||
}
|
||||
|
||||
void RunReloadCleanups() {
|
||||
for (auto *node = g_reloadHead; node != nullptr; node = node->next)
|
||||
node->fn();
|
||||
}
|
||||
|
||||
HookAutoRegister::HookAutoRegister(uintptr_t target, void *hook, void **original)
|
||||
: target(target), hook(hook), original(original), next(g_hookHead) {
|
||||
g_hookHead = this;
|
||||
@@ -320,7 +337,12 @@ bool RunHookRegistrations() {
|
||||
if (MH_CreateHook(targetPtr, node->hook,
|
||||
reinterpret_cast<LPVOID *>(node->original)) != MH_OK)
|
||||
return false;
|
||||
if (MH_EnableHook(targetPtr) != MH_OK)
|
||||
// Queue, don't enable. The caller (InstallHooks) applies every
|
||||
// queued hook with a single MH_ApplyQueued, so all ~90 hooks share
|
||||
// ONE thread-freeze instead of one freeze per hook. This is the
|
||||
// difference that keeps install cheap on machines whose security
|
||||
// stack intercepts SuspendThread / VirtualProtect per call.
|
||||
if (MH_QueueEnableHook(targetPtr) != MH_OK)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
+102
-8
@@ -17,6 +17,59 @@
|
||||
|
||||
namespace Game {
|
||||
|
||||
// --- Typed engine-memory access ---------------------------------------------
|
||||
//
|
||||
// The canonical way to read/write an engine object field or a fixed-VA global,
|
||||
// replacing the `*reinterpret_cast<const T *>(base + Offsets::OFF_X)` idiom
|
||||
// (and its double-cast variant when `base` isn't a byte pointer). Offsets stay
|
||||
// in Offsets.h per the single-source-of-truth rule — these only centralize the
|
||||
// cast noise, they add no checking.
|
||||
//
|
||||
// Game::Read<int>(obj, Offsets::OFF_FIELD) field read
|
||||
// Game::Ref<uint32_t>(obj, Offsets::OFF_FIELD) writable field lvalue
|
||||
// Game::Ptr<float>(obj, Offsets::OFF_FIELD) pointer TO a field (arrays,
|
||||
// out-params for engine calls)
|
||||
// Game::Read<float>(Offsets::VAR_GLOBAL) fixed-VA global read
|
||||
// Game::Ref<int>(Offsets::VAR_GLOBAL) fixed-VA global lvalue
|
||||
|
||||
template <typename T>
|
||||
inline const T *Ptr(const void *base, uintptr_t offset) {
|
||||
return reinterpret_cast<const T *>(reinterpret_cast<const uint8_t *>(base) + offset);
|
||||
}
|
||||
template <typename T>
|
||||
inline T *Ptr(void *base, uintptr_t offset) {
|
||||
return reinterpret_cast<T *>(reinterpret_cast<uint8_t *>(base) + offset);
|
||||
}
|
||||
template <typename T>
|
||||
inline T Read(const void *base, uintptr_t offset) {
|
||||
return *Ptr<T>(base, offset);
|
||||
}
|
||||
template <typename T>
|
||||
inline T &Ref(void *base, uintptr_t offset) {
|
||||
return *Ptr<T>(base, offset);
|
||||
}
|
||||
template <typename T>
|
||||
inline T Read(uintptr_t address) {
|
||||
return *reinterpret_cast<const T *>(address);
|
||||
}
|
||||
template <typename T>
|
||||
inline T &Ref(uintptr_t address) {
|
||||
return *reinterpret_cast<T *>(address);
|
||||
}
|
||||
|
||||
// --- Engine primitives ------------------------------------------------------
|
||||
|
||||
// Resolve a unit token (`"player"`, `"target"`, `"party3"`, `"nameplate1"`,
|
||||
// …) to its live `CGUnit_C *`, through the engine's own resolver
|
||||
// (`FUN_RESOLVE_UNIT_TOKEN`). Returns null for a valid token that currently
|
||||
// has no unit (e.g. `"target"` with nothing targeted). NOTE: the engine
|
||||
// RAISES a Lua error for a string that isn't a valid token form (an arbitrary
|
||||
// name like `"Bob"`), so only pass real unit tokens — the same contract every
|
||||
// stock `Unit*` global carries. Centralizes the
|
||||
// `reinterpret_cast<…>(FUN_RESOLVE_UNIT_TOKEN)` idiom that was copy-pasted
|
||||
// across a dozen modules.
|
||||
void *ResolveUnitToken(const char *token);
|
||||
|
||||
using FrameScript_Initialize_t = bool(__fastcall *)();
|
||||
using LoadScriptFunctions_t = void(__fastcall *)();
|
||||
|
||||
@@ -70,6 +123,9 @@ using lua_gettable_t = void(__fastcall *)(void *L, int idx);
|
||||
using lua_rawget_t = void(__fastcall *)(void *L, int idx);
|
||||
using lua_settable_t = void(__fastcall *)(void *L, int idx);
|
||||
using lua_rawset_t = void(__fastcall *)(void *L, int idx);
|
||||
// Pops the table at the top and installs it as the metatable of the value
|
||||
// at idx; returns 1.
|
||||
using lua_setmetatable_t = int(__fastcall *)(void *L, int idx);
|
||||
using lua_insert_t = void(__fastcall *)(void *L, int idx);
|
||||
using lua_remove_t = void(__fastcall *)(void *L, int idx);
|
||||
using lua_gettop_t = int(__fastcall *)(void *L);
|
||||
@@ -125,6 +181,7 @@ extern const lua_gettable_t GetTable;
|
||||
extern const lua_rawget_t RawGet;
|
||||
extern const lua_settable_t SetTable;
|
||||
extern const lua_rawset_t RawSet;
|
||||
extern const lua_setmetatable_t SetMetatable;
|
||||
extern const lua_insert_t Insert;
|
||||
extern const lua_remove_t Remove;
|
||||
extern const lua_gettop_t GetTop;
|
||||
@@ -341,19 +398,24 @@ void RunGlueModuleRegistrations();
|
||||
|
||||
// Declarative MinHook registration. Each feature module declares a
|
||||
// file-scope `static const Game::HookAutoRegister _hookreg{target,
|
||||
// &hook_fn, reinterpret_cast<void**>(&original_fn)};` and DllMain's
|
||||
// `RunHookRegistrations` walks the list once after `MH_Initialize`,
|
||||
// installing each hook with `MH_CreateHook` + `MH_EnableHook`.
|
||||
// &hook_fn, reinterpret_cast<void**>(&original_fn)};` and the installer
|
||||
// (`InstallHooks` in DllMain.cpp) walks the list once after
|
||||
// `MH_Initialize`, creating each hook and QUEUE-enabling it; a single
|
||||
// `MH_ApplyQueued` then activates them all in one thread-freeze.
|
||||
//
|
||||
// The install runs OFF the loader lock (via the `Load` export or the
|
||||
// fallback worker thread — see DllMain.cpp), never inside DllMain, so
|
||||
// the thread-freeze can't deadlock or stall the loader.
|
||||
//
|
||||
// Same lifetime rules as ModuleAutoRegister: constructors chain onto
|
||||
// a static-init list before DllMain runs, the linker keeps the OBJ
|
||||
// because the constructor has side effects, and order across TUs is
|
||||
// undefined but doesn't matter here (hooks are independent).
|
||||
//
|
||||
// Use only for feature hooks. The three core engine-init hooks in
|
||||
// Use only for feature hooks. The four core engine-init hooks in
|
||||
// DllMain (FrameScript_Initialize / LoadScriptFunctions /
|
||||
// Frame::RegisterEvent) have inline logic that interleaves with the
|
||||
// hook chain and stays in DllMain.
|
||||
// LoadGlueScriptFunctions / Frame::RegisterEvent) have inline logic that
|
||||
// interleaves with the hook chain and stays with the installer.
|
||||
struct HookAutoRegister {
|
||||
HookAutoRegister(uintptr_t target, void *hook, void **original);
|
||||
uintptr_t target;
|
||||
@@ -362,8 +424,40 @@ struct HookAutoRegister {
|
||||
HookAutoRegister *next;
|
||||
};
|
||||
|
||||
// Installs every registered hook. Returns `false` and stops on first
|
||||
// failure — caller (DllMain) should propagate by returning FALSE.
|
||||
// Creates and QUEUE-enables every registered hook (no `MH_ApplyQueued`
|
||||
// here — the caller applies the whole batch at once). Returns `false`
|
||||
// and stops on first failure.
|
||||
bool RunHookRegistrations();
|
||||
|
||||
// Self-registration for per-/reload state cleanup. A module that keeps
|
||||
// file-static state which goes STALE across a game /reload declares a
|
||||
// file-scope `static const Game::ReloadAutoRegister _reload{&PrepareForReload};`
|
||||
// right after its `void PrepareForReload()`. `RunReloadCleanups()` — called
|
||||
// from the FrameScript_Initialize hook, BEFORE the engine tears down the Lua
|
||||
// state — fires them all, so DllMain no longer hand-maintains the list and a
|
||||
// new module can't forget to wire itself in.
|
||||
//
|
||||
// State is "reload-fragile" when it survives in C++ but its meaning does not:
|
||||
// - Lua registry refs / handler cells (the Lua reset invalidates them);
|
||||
// - maps/sets keyed by a frame/object POINTER (the allocator recycles
|
||||
// addresses, so an old entry aliases an unrelated new object);
|
||||
// - cached event-slot indices (the engine rebuilds the event table).
|
||||
// Firing a stale entry is the recurring bug this prevents (Tooltip::SetEvents
|
||||
// issue #33, Frame::Attributes). A pure-C++ cache keyed by spellID/itemID is
|
||||
// NOT fragile and must not register.
|
||||
//
|
||||
// Same lifetime + ordering rules as ModuleAutoRegister: static-init chaining,
|
||||
// undefined cross-TU order — fine, because each callback only clears its OWN
|
||||
// state. This is the "clear stale state" category only; a save-before-teardown
|
||||
// like Player::NameCache::Flush stays an explicit DllMain call (it persists
|
||||
// rather than clears, and also runs on the glue-return path).
|
||||
struct ReloadAutoRegister {
|
||||
using Fn = void (*)();
|
||||
explicit ReloadAutoRegister(Fn fn);
|
||||
Fn fn;
|
||||
ReloadAutoRegister *next;
|
||||
};
|
||||
|
||||
void RunReloadCleanups();
|
||||
|
||||
} // namespace Game
|
||||
|
||||
+1535
-8
File diff suppressed because it is too large
Load Diff
+90
-89
@@ -47,6 +47,12 @@
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "addons/EngineIO.h"
|
||||
#include "addons/FlavorBindings.h"
|
||||
#include "addons/FlavorToc.h"
|
||||
#include "addons/Registry.h"
|
||||
#include "addons/Toc.h"
|
||||
#include "addons/TocRewrite.h"
|
||||
#include "bindings/Inject.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -111,30 +117,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 +133,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 +214,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 +259,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 +286,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 +339,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);
|
||||
@@ -370,26 +378,22 @@ using ListInsert_t = void(__thiscall *)(void *listCtrl, void *entry,
|
||||
// engine's own insert helper handles remove-then-insert atomically,
|
||||
// so it's safe to call on an entry that's already in the list.
|
||||
//
|
||||
// Entries are pointed to by `[VAR_ADDON_LIST_HEAD]` (`0x00BE1B6C`),
|
||||
// with the next-pointer field at `entry + 0x10` (computed as
|
||||
// `*(value-at-VAR_ADDON_LIST_CTRL) + entry + 4` = `0xC + entry + 4`
|
||||
// = `entry + 0x10`). Low-bit-1 means "sentinel" (end of list).
|
||||
// Name pointer lives at `entry + 0x14`.
|
||||
constexpr uintptr_t VAR_ADDON_LIST_HEAD = 0x00BE1B6C;
|
||||
constexpr int OFF_ADDON_ENTRY_NAME_PTR = 0x14;
|
||||
|
||||
// `entry+0x29` — the "filter-out" byte. The flat display-array builder
|
||||
// `FUN_0051da70` walks the linked list and copies an entry into
|
||||
// `GetNumAddOns`/`GetAddOnInfo`'s array ONLY when this byte is 0, so
|
||||
// setting it hides `!!!ClassicAPI` from the character-select AddOns
|
||||
// list — there's no checkbox, so it can't be toggled off. The builder
|
||||
// only ever *writes* this byte on the secure/SMSG path (`entry+0x28 !=
|
||||
// 0`); our entry is non-secure, so a manual `1` here is stable across
|
||||
// rebuilds. Hiding does NOT stop loading (the load pass `FUN_0051f600`
|
||||
// never reads `+0x29`) and does NOT break dependency resolution or
|
||||
// by-name queries (those use the addon hash table, which keeps the
|
||||
// entry regardless).
|
||||
constexpr int OFF_ADDON_ENTRY_FILTER_OUT = 0x29;
|
||||
// The list is walked via the shared `Addons::ForEachEntry`
|
||||
// (`addons/Registry.h`); the callback stops the walk once it finds and
|
||||
// re-links our entry, since the re-link invalidates the current
|
||||
// next-pointer.
|
||||
//
|
||||
// `entry + OFF_ADDON_ENTRY_FILTER_OUT` is the "filter-out" byte. The
|
||||
// flat display-array builder `FUN_0051da70` walks the linked list and
|
||||
// copies an entry into `GetNumAddOns`/`GetAddOnInfo`'s array ONLY when
|
||||
// this byte is 0, so setting it hides `!!!ClassicAPI` from the
|
||||
// character-select AddOns list — there's no checkbox, so it can't be
|
||||
// toggled off. The builder only ever *writes* this byte on the
|
||||
// secure/SMSG path (`entry+0x28 != 0`); our entry is non-secure, so a
|
||||
// manual `1` here is stable across rebuilds. Hiding does NOT stop
|
||||
// loading (the load pass `FUN_0051f600` never reads `+0x29`) and does
|
||||
// NOT break dependency resolution or by-name queries (those use the
|
||||
// addon hash table, which keeps the entry regardless).
|
||||
|
||||
// `entry+0x2b` — DefaultState (`## DefaultState: enabled/disabled`).
|
||||
// The enable resolver `FUN_ADDON_ENABLE_RESOLVE` falls back to this
|
||||
@@ -404,29 +408,26 @@ constexpr int OFF_ADDON_ENTRY_DEFAULT_STATE = 0x2b;
|
||||
// with the `FUN_ADDON_ENABLE_RESOLVE` co-hook, `!!!ClassicAPI` always
|
||||
// loads and never appears as a toggleable entry.
|
||||
void FinalizeEmbeddedEntry() {
|
||||
uintptr_t entry = *reinterpret_cast<uintptr_t *>(VAR_ADDON_LIST_HEAD);
|
||||
const int linkOffset = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL));
|
||||
while ((entry & 1) == 0 && entry != 0) {
|
||||
Addons::ForEachEntry([](uintptr_t entry) -> bool {
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
entry + OFF_ADDON_ENTRY_NAME_PTR);
|
||||
if (name != nullptr && std::strcmp(name, kAddonName) == 0) {
|
||||
// Hide from the character-select AddOns list and default it
|
||||
// to enabled.
|
||||
*reinterpret_cast<uint8_t *>(entry + OFF_ADDON_ENTRY_FILTER_OUT) = 1;
|
||||
*reinterpret_cast<uint8_t *>(entry + OFF_ADDON_ENTRY_DEFAULT_STATE) = 1;
|
||||
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR);
|
||||
if (name == nullptr || std::strcmp(name, kAddonName) != 0)
|
||||
return true; // keep walking
|
||||
|
||||
auto fn = reinterpret_cast<ListInsert_t>(
|
||||
Offsets::FUN_INTRUSIVE_LIST_INSERT);
|
||||
fn(reinterpret_cast<void *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL)),
|
||||
reinterpret_cast<void *>(entry),
|
||||
/*position=*/ 1, /*anchor=*/ 0);
|
||||
return;
|
||||
}
|
||||
entry = *reinterpret_cast<const uintptr_t *>(
|
||||
entry + linkOffset + 4);
|
||||
}
|
||||
// Hide from the character-select AddOns list and default it to
|
||||
// enabled, then move it to the head of the load order.
|
||||
*reinterpret_cast<uint8_t *>(
|
||||
entry + Offsets::OFF_ADDON_ENTRY_FILTER_OUT) = 1;
|
||||
*reinterpret_cast<uint8_t *>(entry + OFF_ADDON_ENTRY_DEFAULT_STATE) = 1;
|
||||
|
||||
auto fn = reinterpret_cast<ListInsert_t>(
|
||||
Offsets::FUN_INTRUSIVE_LIST_INSERT);
|
||||
fn(reinterpret_cast<void *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL)),
|
||||
reinterpret_cast<void *>(entry),
|
||||
/*position=*/ 1, /*anchor=*/ 0);
|
||||
return false; // stop — the re-link invalidated our next-pointer
|
||||
});
|
||||
}
|
||||
|
||||
void __fastcall AddonInit_h(const char *accountName) {
|
||||
|
||||
@@ -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,50 @@
|
||||
// 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 "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <type_traits>
|
||||
|
||||
namespace Addons {
|
||||
|
||||
// Walk the engine's intrusive AddOn-registry linked list, calling `fn(entry)`
|
||||
// for each `AddOnEntry *`. Head at `[VAR_ADDON_LIST_HEAD]`; the next-pointer
|
||||
// field is at `entry + [VAR_ADDON_LIST_CTRL] + 4` (= `entry + 0x10`); a
|
||||
// low-bit-1 sentinel or NULL terminates. This is the same traversal the
|
||||
// engine's own load pass (`FUN_0051F600`) and scan tail loop use.
|
||||
//
|
||||
// `fn` may return `void` (always continue) or `bool` (return `false` to STOP
|
||||
// early). Early stop is load-bearing for a visitor that mutates the list —
|
||||
// e.g. re-linking an entry to the head invalidates the current next-pointer,
|
||||
// so the walk must not follow it. Shared by `Addons::Embedded` (find + re-link
|
||||
// the embedded entry) and `Addons::Rescan` (snapshot / diff / fix-up passes).
|
||||
template <typename Fn> void ForEachEntry(Fn fn) {
|
||||
const int linkOffset = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL));
|
||||
uintptr_t entry = *reinterpret_cast<const uintptr_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_HEAD));
|
||||
while ((entry & 1) == 0 && entry != 0) {
|
||||
if constexpr (std::is_void_v<decltype(fn(entry))>) {
|
||||
fn(entry);
|
||||
} else {
|
||||
if (!fn(entry))
|
||||
return;
|
||||
}
|
||||
entry = *reinterpret_cast<const uintptr_t *>(entry + linkOffset + 4);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace Addons
|
||||
@@ -0,0 +1,523 @@
|
||||
// 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/>.
|
||||
|
||||
// Retail-like `/reload`: pick up new addon folders and new files without
|
||||
// restarting the client. Two independent engine limitations block that on
|
||||
// a stock 1.12 client, each fixed here with the engine's own machinery:
|
||||
//
|
||||
// 1. FILE VISIBILITY. Every relative-path read resolves through a
|
||||
// loose-file hash index built ONCE at boot (see Offsets.h,
|
||||
// `FUN_VFS_INDEX_SUBTREE`); files created after boot — a new
|
||||
// addon's TOC, a new .lua in an existing addon, a freshly written
|
||||
// SavedVariables file — are invisible until restart. The boot
|
||||
// indexer is dedup-safe and works on subtrees, so we re-run it per
|
||||
// /reload on the only two subtrees whose contents can change what
|
||||
// a /reload loads: `Interface\AddOns` and `WTF\Account`. New files
|
||||
// in EXISTING addons need nothing more — the per-addon loader
|
||||
// `FUN_0051F240` re-reads the TOC from disk on every load pass, so
|
||||
// once the files are visible they load.
|
||||
//
|
||||
// 2. REGISTRY MEMBERSHIP. The addon registry is built once at login;
|
||||
// new folders are never walked. We replay the login scan's disk
|
||||
// walk verbatim (`FUN_ADDON_SCAN_DISK_DIRS` with the engine's own
|
||||
// per-directory callback, which feeds the dedup-safe TOC parser),
|
||||
// so new folders register as completely normal entries — then
|
||||
// mirror the two registry structures the scan's OTHER passes would
|
||||
// have filled: the reverse-LoadWith lists (scan tail loop) and the
|
||||
// flat `GetNumAddOns` display array (`FUN_0051DA70` phase 2).
|
||||
//
|
||||
// 3. METADATA IMMUTABILITY. The parser's dedup guard makes a
|
||||
// registered entry's `##` metadata read-once: an edited
|
||||
// `## SavedVariables:` / `## Dependencies:` / any `##` line never
|
||||
// re-parses. The engine's own complete per-entry destructor
|
||||
// (`FUN_ADDON_ENTRY_DESTROY` — frees every owned allocation and
|
||||
// self-unlinks from both the list and the hash; see Offsets.h)
|
||||
// makes evict + re-register safe: destroy the entry so the dedup
|
||||
// guard MISSES, re-feed the name to `FUN_TOC_PARSER`, and the
|
||||
// entry rebuilds from the edited TOC exactly like a fresh
|
||||
// registration. Gated on an actual `##` change (hash of the TOC's
|
||||
// `##` lines only — file-reference edits load natively and never
|
||||
// trigger the evict path). A TOC that no longer reads at all
|
||||
// (folder deleted) evicts without re-registering — the deletion
|
||||
// analog; a folder the scan still finds simply re-registers.
|
||||
//
|
||||
// Runs from the `ModuleAutoRegister` callback — LoadScriptFunctions
|
||||
// post-hook, which fires inside FrameXML init (`FUN_0048FBF0`) BEFORE
|
||||
// the AddOns.txt enable-state re-read, the load-progress count, and the
|
||||
// addon load pass, on both login and every /reload. The engine then
|
||||
// loads new entries natively: dep ordering, Bindings.xml, flavor TOCs,
|
||||
// SavedVariables, ADDON_LOADED, the load-screen "Loading add-on %s".
|
||||
// The window is also what makes eviction safe: the unload pass already
|
||||
// ran (SavedVariables written from the OLD entry's SV lists, loaded
|
||||
// bytes cleared), and the load pass hasn't — nothing holds entry state.
|
||||
//
|
||||
// Excluded from the evict path (everything else is fair game):
|
||||
// - `!!!ClassicAPI` — embedded; `Addons::Embedded` owns its entry
|
||||
// (head re-link + filter byte);
|
||||
// - `## Secure:` / SMSG-managed entries — the parser cannot restore
|
||||
// packet-delivered state (see OFF_ADDON_ENTRY_SECURE).
|
||||
//
|
||||
// NOTE the surgical discipline. A previous attempt re-invoked the
|
||||
// login-only teardown+rescan (`FUN_ADDON_INIT`) mid-/reload and
|
||||
// corrupted the registry (duplicate loads + Lua memory explosion on the
|
||||
// NEXT login). This module never runs bulk teardown; the only entry
|
||||
// mutation beyond what a login scan produces is the per-entry evict
|
||||
// above, which is (a) the engine's own complete destructor, (b) always
|
||||
// preceded by the reverse-LoadWith scrub (the one reference the dtor
|
||||
// can't clean), and (c) always followed by the same fix-ups a new
|
||||
// registration gets. New entries come from the same parser call
|
||||
// `Addons::Embedded` has exercised every login.
|
||||
//
|
||||
// Thread note: the loose-file index has no lock, but the engine itself
|
||||
// builds it lazily on first access and we mutate it only during the
|
||||
// reload loading screen on the main thread — the same context in which
|
||||
// the engine does all its own file work.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "addons/EngineIO.h"
|
||||
#include "addons/Registry.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace Addons::Rescan {
|
||||
|
||||
namespace {
|
||||
|
||||
// ── Engine entry points (all verified by disassembly; see Offsets.h) ──
|
||||
|
||||
using FindOpen_t = void *(__stdcall *)(const char *dirPath);
|
||||
using FindClose_t = void(__stdcall *)(void *findBlock);
|
||||
using IndexSubtree_t = void(__fastcall *)(const char *basePath,
|
||||
const char *relSubdir,
|
||||
void *findHandle);
|
||||
using ScanDiskDirs_t = int(__fastcall *)(const char *basePath,
|
||||
const char *pattern, void *callback,
|
||||
void *userParam, int includeHidden);
|
||||
using ResolveByName_t = void *(__fastcall *)(const char *name);
|
||||
using DescGrow_t = void(__thiscall *)(void *desc, uint32_t newCap);
|
||||
using QuantumCalc_t = uint32_t(__thiscall *)(void *desc, uint32_t needed);
|
||||
using Qsort_t = void(__cdecl *)(void *base, uint32_t num, uint32_t width,
|
||||
void *compare);
|
||||
using TocParser_t = void(__fastcall *)(const char *name);
|
||||
using EntryDestroy_t = void(__stdcall *)(void *entry);
|
||||
|
||||
// The engine's ubiquitous growable-array descriptor.
|
||||
struct Desc {
|
||||
uint32_t cap;
|
||||
uint32_t count;
|
||||
uint32_t *data;
|
||||
uint32_t quantum;
|
||||
};
|
||||
|
||||
// Append one dword to a descriptor, mirroring the engine's inline
|
||||
// grow-and-append (the scan tail loop and `FUN_0051DA70` phase 2 share
|
||||
// it): round the needed cap up to the quantum — computed by the
|
||||
// engine's own `FUN_DESC_QUANTUM_CALC` when the desc has none — grow
|
||||
// via the site's grow instantiation (which reallocs `data` and writes
|
||||
// `cap`), then `data[count++] = value`.
|
||||
void DescAppend(Desc *desc, uint32_t value, DescGrow_t grow) {
|
||||
uint32_t needed = desc->count + 1;
|
||||
if (desc->cap < needed) {
|
||||
uint32_t quantum = desc->quantum;
|
||||
if (quantum == 0) {
|
||||
auto calc =
|
||||
reinterpret_cast<QuantumCalc_t>(Offsets::FUN_DESC_QUANTUM_CALC);
|
||||
quantum = calc(desc, needed);
|
||||
}
|
||||
if (needed % quantum != 0)
|
||||
needed += quantum - needed % quantum;
|
||||
grow(desc, needed);
|
||||
}
|
||||
desc->data[desc->count++] = value;
|
||||
}
|
||||
|
||||
// The registry linked-list walk lives in `addons/Registry.h`
|
||||
// (`Addons::ForEachEntry`), shared with `Addons::Embedded`.
|
||||
|
||||
// By-name registry lookup via the engine's own hash resolver (returns
|
||||
// the entry's RequiredDeps desc, so subtract its offset — same recovery
|
||||
// `Addons::Info` uses).
|
||||
uintptr_t ResolveEntryByName(const char *name) {
|
||||
auto resolve =
|
||||
reinterpret_cast<ResolveByName_t>(Offsets::FUN_ADDON_RESOLVE_REQ_DEPS);
|
||||
const uintptr_t desc = reinterpret_cast<uintptr_t>(resolve(name));
|
||||
return desc != 0 ? desc - Offsets::OFF_ADDON_REQDEPS_DESC : 0;
|
||||
}
|
||||
|
||||
// ── Step 1: loose-file index refresh ──────────────────────────────────
|
||||
|
||||
// Register new on-disk files under `relSubdir` with the frozen index,
|
||||
// exactly as the boot walk would have (the indexer skips every file
|
||||
// already present). No-op when the directory doesn't exist —
|
||||
// `FUN_VFS_FIND_OPEN` requires an existing directory and returns NULL.
|
||||
void ReindexSubtree(const char *base, const char *relSubdir) {
|
||||
char dir[260];
|
||||
std::snprintf(dir, sizeof(dir), "%s\\%s", base, relSubdir);
|
||||
auto findOpen = reinterpret_cast<FindOpen_t>(Offsets::FUN_VFS_FIND_OPEN);
|
||||
auto findClose = reinterpret_cast<FindClose_t>(Offsets::FUN_VFS_FIND_CLOSE);
|
||||
void *handle = findOpen(dir);
|
||||
if (handle == nullptr)
|
||||
return;
|
||||
auto index =
|
||||
reinterpret_cast<IndexSubtree_t>(Offsets::FUN_VFS_INDEX_SUBTREE);
|
||||
index(base, relSubdir, handle);
|
||||
findClose(handle);
|
||||
}
|
||||
|
||||
void RefreshLooseFileIndex() {
|
||||
// The boot walk's base choice (`FUN_00646EA0`): the recorded game
|
||||
// dir if it opens as a directory, else ".". Index keys are relative
|
||||
// to this, so it must match or the new keys would never be hit.
|
||||
auto findOpen = reinterpret_cast<FindOpen_t>(Offsets::FUN_VFS_FIND_OPEN);
|
||||
auto findClose = reinterpret_cast<FindClose_t>(Offsets::FUN_VFS_FIND_CLOSE);
|
||||
const char *base =
|
||||
reinterpret_cast<const char *>(Offsets::VAR_VFS_BASE_PATH);
|
||||
void *probe = findOpen(base);
|
||||
if (probe != nullptr)
|
||||
findClose(probe);
|
||||
else
|
||||
base = ".";
|
||||
|
||||
// The only subtrees whose post-boot changes affect what a /reload
|
||||
// loads: addon content, and the SavedVariables the unload pass
|
||||
// wrote moments ago (a first-ever SV file is otherwise invisible —
|
||||
// the stock client loses a freshly installed addon's settings on
|
||||
// /reload for exactly this reason).
|
||||
ReindexSubtree(base, "Interface\\AddOns");
|
||||
ReindexSubtree(base, "WTF\\Account");
|
||||
}
|
||||
|
||||
// ── Step 1.5: `##` metadata refresh (evict + re-register on change) ──
|
||||
|
||||
// The embedded addon's name — its entry is owned by `Addons::Embedded`
|
||||
// (head re-link + filter byte) and never refreshed here.
|
||||
constexpr const char *kEmbeddedAddon = "!!!ClassicAPI";
|
||||
|
||||
// name → FNV-1a hash of the addon's `##` lines, as last parsed.
|
||||
// Per-process on purpose: the registry persists across /reload, and a
|
||||
// stale hash after a full re-login scan only causes one harmless
|
||||
// re-parse of already-current metadata.
|
||||
std::unordered_map<std::string, uint32_t> g_metaHash;
|
||||
|
||||
// Read `<name>`'s base TOC exactly as the parser does — through the
|
||||
// (hooked) `FUN_FILE_READ`, so flavor selection and TOC rewriting are
|
||||
// reflected in what gets hashed. Caller frees `*buf` via Storm.
|
||||
bool ReadToc(const char *name, void **buf, size_t *size) {
|
||||
char path[260];
|
||||
std::snprintf(path, sizeof(path), "Interface\\AddOns\\%s\\%s.toc", name,
|
||||
name);
|
||||
auto read = reinterpret_cast<AddOns::EngineIO::FileReadFn>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_FILE_READ));
|
||||
*buf = nullptr;
|
||||
*size = 0;
|
||||
return read(0, path, buf, size, 1, 1, 0) != 0 && *buf != nullptr;
|
||||
}
|
||||
|
||||
// FNV-1a over the TOC's `##` lines only. File-reference lines are
|
||||
// excluded on purpose — the load pass re-reads those from disk every
|
||||
// /reload anyway, so an edit there must not trigger the evict path.
|
||||
// Line terminators are excluded (a CRLF↔LF conversion is not a
|
||||
// metadata change); a per-line separator keeps adjacent lines from
|
||||
// concatenating into the same hash. The BOM skip mirrors the parser.
|
||||
uint32_t HashTocMetadata(const char *buf, size_t size) {
|
||||
uint32_t h = 2166136261u;
|
||||
size_t i = 0;
|
||||
if (size >= 3 && static_cast<uint8_t>(buf[0]) == 0xEF &&
|
||||
static_cast<uint8_t>(buf[1]) == 0xBB &&
|
||||
static_cast<uint8_t>(buf[2]) == 0xBF)
|
||||
i = 3;
|
||||
while (i < size) {
|
||||
size_t end = i;
|
||||
while (end < size && buf[end] != '\n')
|
||||
++end;
|
||||
size_t stop = end;
|
||||
while (stop > i && buf[stop - 1] == '\r')
|
||||
--stop;
|
||||
if (stop - i >= 2 && buf[i] == '#' && buf[i + 1] == '#') {
|
||||
for (size_t k = i; k < stop; ++k) {
|
||||
h ^= static_cast<uint8_t>(buf[k]);
|
||||
h *= 16777619u;
|
||||
}
|
||||
h ^= '\n';
|
||||
h *= 16777619u;
|
||||
}
|
||||
i = end + 1;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
// True when the entry never takes the evict path: the embedded addon
|
||||
// (owned by `Addons::Embedded`) and SMSG-managed secure entries (the
|
||||
// parser cannot restore packet-delivered state).
|
||||
bool IsRefreshExempt(uintptr_t entry, const char *name) {
|
||||
return name == nullptr || std::strcmp(name, kEmbeddedAddon) == 0 ||
|
||||
*reinterpret_cast<const uint8_t *>(
|
||||
entry + Offsets::OFF_ADDON_ENTRY_SECURE) != 0;
|
||||
}
|
||||
|
||||
// Read + hash `<name>`'s `##` lines. False when the TOC doesn't read.
|
||||
bool TryHashToc(const char *name, uint32_t *outHash) {
|
||||
void *buf = nullptr;
|
||||
size_t size = 0;
|
||||
if (!ReadToc(name, &buf, &size))
|
||||
return false;
|
||||
*outHash = HashTocMetadata(static_cast<const char *>(buf), size);
|
||||
reinterpret_cast<AddOns::EngineIO::SMemFreeFn>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_STORM_SMEM_FREE))(
|
||||
buf, __FILE__, __LINE__, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Remove every pointer to `victim` from the OTHER entries'
|
||||
// reverse-LoadWith lists — the one reference `FUN_ADDON_ENTRY_DESTROY`
|
||||
// cannot clean (it frees the victim's own list, not the victim's
|
||||
// presence in others'). Without this, the loader's post-ADDON_LOADED
|
||||
// loop dereferences freed memory.
|
||||
void ScrubReverseLoadWith(uintptr_t victim) {
|
||||
ForEachEntry([victim](uintptr_t entry) {
|
||||
if (entry == victim)
|
||||
return;
|
||||
auto *desc = reinterpret_cast<Desc *>(
|
||||
entry + Offsets::OFF_ADDON_REVLOADWITH_DESC);
|
||||
uint32_t w = 0;
|
||||
for (uint32_t r = 0; r < desc->count; ++r)
|
||||
if (desc->data[r] != static_cast<uint32_t>(victim))
|
||||
desc->data[w++] = desc->data[r];
|
||||
desc->count = w;
|
||||
});
|
||||
}
|
||||
|
||||
// The refresh pass. Walks the registry comparing each entry's current
|
||||
// `##` hash against the last-parsed one; on change (or an unreadable
|
||||
// TOC — deleted folder), evicts via the engine's own destructor and,
|
||||
// for changes, re-feeds the name to the parser. Re-registered entries
|
||||
// are appended to `refreshed` so they join the same reverse-LoadWith /
|
||||
// display-array fix-ups a new folder gets. Returns true when anything
|
||||
// was actually evicted (the display array holds a freed name pointer
|
||||
// until it's rebuilt).
|
||||
//
|
||||
// Victims are collected during the walk and processed after it — the
|
||||
// destructor unlinks the entry the iterator stands on (its next-pointer
|
||||
// is zeroed), so evicting mid-walk would silently truncate the walk.
|
||||
// Names are copied for the same reason: the dtor frees the entry's
|
||||
// name string. `g_metaHash` is committed only once the re-registration
|
||||
// actually happened — updating it during the walk would absorb the edit
|
||||
// forever if any later step failed, with no retry on the next /reload.
|
||||
bool RefreshChangedMetadata(std::vector<uintptr_t> &refreshed) {
|
||||
struct Victim {
|
||||
std::string name;
|
||||
uint32_t newHash;
|
||||
bool reRegister; // false: TOC unreadable — evict only
|
||||
};
|
||||
std::vector<Victim> victims;
|
||||
ForEachEntry([&victims](uintptr_t entry) {
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR);
|
||||
if (IsRefreshExempt(entry, name))
|
||||
return;
|
||||
uint32_t h = 0;
|
||||
if (!TryHashToc(name, &h)) {
|
||||
victims.push_back({name, 0, false});
|
||||
return;
|
||||
}
|
||||
auto it = g_metaHash.find(name);
|
||||
if (it == g_metaHash.end())
|
||||
g_metaHash.emplace(name, h); // first sighting — seed only
|
||||
else if (it->second != h)
|
||||
victims.push_back({name, h, true});
|
||||
});
|
||||
|
||||
bool anyEvicted = false;
|
||||
auto destroy =
|
||||
reinterpret_cast<EntryDestroy_t>(Offsets::FUN_ADDON_ENTRY_DESTROY);
|
||||
auto parse = reinterpret_cast<TocParser_t>(Offsets::FUN_TOC_PARSER);
|
||||
for (const Victim &v : victims) {
|
||||
const uintptr_t entry = ResolveEntryByName(v.name.c_str());
|
||||
if (entry == 0)
|
||||
continue;
|
||||
ScrubReverseLoadWith(entry);
|
||||
destroy(reinterpret_cast<void *>(entry));
|
||||
anyEvicted = true;
|
||||
if (!v.reRegister) {
|
||||
g_metaHash.erase(v.name);
|
||||
continue;
|
||||
}
|
||||
// The parser appends the fresh entry at the list TAIL, so a
|
||||
// refreshed addon's position in the load pass changes for this
|
||||
// reload. Declared dependencies still force-load first; only
|
||||
// undeclared load-order assumptions between unrelated addons
|
||||
// could observe the difference.
|
||||
parse(v.name.c_str());
|
||||
const uintptr_t fresh = ResolveEntryByName(v.name.c_str());
|
||||
if (fresh != 0) {
|
||||
refreshed.push_back(fresh);
|
||||
g_metaHash[v.name] = v.newHash;
|
||||
} else {
|
||||
// Evicted but the parse didn't restore it (TOC became
|
||||
// unreadable between hash and parse) — treat as deleted so
|
||||
// a reappearing folder re-seeds instead of diffing against
|
||||
// a hash the registry never absorbed.
|
||||
g_metaHash.erase(v.name);
|
||||
}
|
||||
}
|
||||
return anyEvicted;
|
||||
}
|
||||
|
||||
// ── Step 2: registry rescan + mirrored fix-ups ────────────────────────
|
||||
|
||||
// The scan tail loop's reverse-LoadWith build, restricted to pairs
|
||||
// involving a newly registered entry. Login already linked old→old
|
||||
// pairs; a pair is missing exactly when one side is new (the login
|
||||
// walk skipped `## LoadWith:` names that didn't resolve, and new
|
||||
// entries were never walked at all) — so appending just those pairs
|
||||
// reproduces what a login scan would have built, with no duplicates in
|
||||
// the engine's no-dedup lists.
|
||||
void FixupReverseLoadWith(const std::vector<uintptr_t> &added) {
|
||||
auto isNew = [&added](uintptr_t e) {
|
||||
return std::find(added.begin(), added.end(), e) != added.end();
|
||||
};
|
||||
auto grow =
|
||||
reinterpret_cast<DescGrow_t>(Offsets::FUN_ADDON_REVLOADWITH_GROW);
|
||||
ForEachEntry([&](uintptr_t entry) {
|
||||
const bool entryIsNew = isNew(entry);
|
||||
const uint32_t count = *reinterpret_cast<const uint32_t *>(
|
||||
entry + Offsets::OFF_ADDON_LOADWITH_COUNT);
|
||||
auto names = *reinterpret_cast<const char *const *const *>(
|
||||
entry + Offsets::OFF_ADDON_LOADWITH_ARRAY);
|
||||
for (uint32_t i = 0; i < count; ++i) {
|
||||
const uintptr_t target = ResolveEntryByName(names[i]);
|
||||
if (target == 0 || (!entryIsNew && !isNew(target)))
|
||||
continue;
|
||||
DescAppend(reinterpret_cast<Desc *>(
|
||||
target + Offsets::OFF_ADDON_REVLOADWITH_DESC),
|
||||
static_cast<uint32_t>(entry), grow);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// `FUN_0051DA70` phase 2, mirrored (never call that function itself —
|
||||
// its phase 1 consumes an SMSG_ADDON_INFO packet): rebuild the
|
||||
// `GetNumAddOns`/`GetAddOnInfo(i)` name-pointer array from the linked
|
||||
// list, skipping filtered entries (keeps `!!!ClassicAPI` hidden), then
|
||||
// sort with the engine's own comparator to restore alphabetical order.
|
||||
void RebuildDisplayArray() {
|
||||
auto desc = reinterpret_cast<Desc *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_ADDON_ARRAY_CAP));
|
||||
auto grow = reinterpret_cast<DescGrow_t>(Offsets::FUN_ADDON_ARRAY_GROW);
|
||||
desc->count = 0;
|
||||
ForEachEntry([&](uintptr_t entry) {
|
||||
if (*reinterpret_cast<const uint8_t *>(
|
||||
entry + Offsets::OFF_ADDON_ENTRY_FILTER_OUT) != 0)
|
||||
return;
|
||||
DescAppend(desc,
|
||||
*reinterpret_cast<const uint32_t *>(
|
||||
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR),
|
||||
grow);
|
||||
});
|
||||
auto qsort = reinterpret_cast<Qsort_t>(Offsets::FUN_CRT_QSORT);
|
||||
qsort(desc->data, desc->count, 4,
|
||||
reinterpret_cast<void *>(Offsets::FUN_ADDON_NAME_COMPARE));
|
||||
}
|
||||
|
||||
void Run() {
|
||||
// Registry populated (login scan ran) and loose index built — both
|
||||
// always true by the first in-world LoadScriptFunctions, but these
|
||||
// are the states the steps below mutate, so gate explicitly.
|
||||
if (*reinterpret_cast<const uint8_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_ADDON_INITIALIZED)) == 0 ||
|
||||
*reinterpret_cast<const uint8_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_VFS_INDEX_READY)) == 0)
|
||||
return;
|
||||
|
||||
RefreshLooseFileIndex();
|
||||
|
||||
// Metadata refresh runs BEFORE the snapshot: a re-registered entry
|
||||
// is alive by snapshot time, so it lands in `before` and can never
|
||||
// double-count in `added` — even if the allocator reuses the old
|
||||
// entry's address, `refreshed` tracks it independently of the diff.
|
||||
std::vector<uintptr_t> refreshed;
|
||||
const bool evicted = RefreshChangedMetadata(refreshed);
|
||||
|
||||
std::vector<uintptr_t> before;
|
||||
ForEachEntry([&before](uintptr_t entry) { before.push_back(entry); });
|
||||
|
||||
// Replay login scan walk #2 verbatim: engine walker + engine
|
||||
// callback + engine parser. The parser's dedup guard makes this a
|
||||
// hash lookup per already-registered addon; new folders register
|
||||
// as complete, normal entries (their TOC is readable now — and it
|
||||
// goes through the FlavorToc/TocRewrite read hooks like any other).
|
||||
//
|
||||
// The dedup guard is why this replay can NEVER refresh or corrupt
|
||||
// an existing entry's `##` metadata (verified in the parser's
|
||||
// decompile): the guard is the function's FIRST block — hash
|
||||
// lookup + name compare, early `return` on match BEFORE the TOC
|
||||
// path is even built, so no file read and no entry write happen
|
||||
// for a registered name. Both the Storm hash (`FUN_0064B3F0`,
|
||||
// uppercases a–z and folds '/'→'\\' before hashing) and the
|
||||
// compare (`SStrCmpI`) are case-insensitive, so a case-renamed
|
||||
// folder still matches its existing entry rather than registering
|
||||
// a duplicate.
|
||||
auto scan =
|
||||
reinterpret_cast<ScanDiskDirs_t>(Offsets::FUN_ADDON_SCAN_DISK_DIRS);
|
||||
scan(reinterpret_cast<const char *>(Offsets::VAR_ADDON_PATH_PREFIX),
|
||||
reinterpret_cast<const char *>(Offsets::VAR_ADDON_SCAN_PATTERN),
|
||||
reinterpret_cast<void *>(Offsets::FUN_ADDON_DISK_DIR_CB),
|
||||
/*userParam=*/nullptr, /*includeHidden=*/0);
|
||||
|
||||
std::vector<uintptr_t> added;
|
||||
ForEachEntry([&](uintptr_t entry) {
|
||||
if (std::find(before.begin(), before.end(), entry) == before.end())
|
||||
added.push_back(entry);
|
||||
});
|
||||
|
||||
// Seed the metadata map for entries this reload registered, so the
|
||||
// next reload diffs a `##` edit against what was actually parsed
|
||||
// (waiting for the next pass to seed would silently absorb an edit
|
||||
// made between now and then).
|
||||
for (uintptr_t entry : added) {
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR);
|
||||
uint32_t h = 0;
|
||||
if (!IsRefreshExempt(entry, name) && TryHashToc(name, &h))
|
||||
g_metaHash[name] = h;
|
||||
}
|
||||
|
||||
if (added.empty() && refreshed.empty() && !evicted)
|
||||
return;
|
||||
|
||||
// Refreshed entries need the same link fix-ups a new folder gets:
|
||||
// their old reverse-LoadWith pairs died with the old entry (scrub +
|
||||
// dtor), and the parser doesn't build reverse links.
|
||||
std::vector<uintptr_t> linked = added;
|
||||
linked.insert(linked.end(), refreshed.begin(), refreshed.end());
|
||||
if (!linked.empty())
|
||||
FixupReverseLoadWith(linked);
|
||||
|
||||
// Rebuild whenever membership OR an entry identity changed — after
|
||||
// any eviction (including a pure deletion) the display array still
|
||||
// holds the dead entry's freed name pointer.
|
||||
RebuildDisplayArray();
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&Run};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Addons::Rescan
|
||||
@@ -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
|
||||
@@ -68,12 +68,9 @@ namespace AuctionHouse::PostItem {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kEvtStart = "AUCTION_MULTISELL_START";
|
||||
constexpr const char *kEvtUpdate = "AUCTION_MULTISELL_UPDATE";
|
||||
constexpr const char *kEvtFailure = "AUCTION_MULTISELL_FAILURE";
|
||||
const Event::Custom::AutoReserve _r1{kEvtStart};
|
||||
const Event::Custom::AutoReserve _r2{kEvtUpdate};
|
||||
const Event::Custom::AutoReserve _r3{kEvtFailure};
|
||||
const Event::Custom::AutoReserve _evtStart{"AUCTION_MULTISELL_START"};
|
||||
const Event::Custom::AutoReserve _evtUpdate{"AUCTION_MULTISELL_UPDATE"};
|
||||
const Event::Custom::AutoReserve _evtFailure{"AUCTION_MULTISELL_FAILURE"};
|
||||
|
||||
// Generous per-step abort bound. A split or a post is one server round-trip
|
||||
// (~tens of frames); 600 frames (~10s at 60fps) means "something's wrong"
|
||||
@@ -103,14 +100,14 @@ int CGItemCount(const uint8_t *item) {
|
||||
auto *desc = Item::ObjectFields(item);
|
||||
if (desc == nullptr)
|
||||
return 0;
|
||||
return *reinterpret_cast<const int *>(desc + Offsets::OFF_DESCRIPTOR_STACK_COUNT);
|
||||
return Game::Read<int>(desc, Offsets::OFF_DESCRIPTOR_STACK_COUNT);
|
||||
}
|
||||
|
||||
uint64_t CGItemGuid(const uint8_t *item) {
|
||||
auto *inst = Item::InstanceBlock(item);
|
||||
if (inst == nullptr)
|
||||
return 0;
|
||||
return *reinterpret_cast<const uint64_t *>(inst + Offsets::OFF_INSTANCE_BLOCK_GUID);
|
||||
return Game::Read<uint64_t>(inst, Offsets::OFF_INSTANCE_BLOCK_GUID);
|
||||
}
|
||||
|
||||
struct SlotInfo {
|
||||
@@ -135,18 +132,18 @@ SlotInfo ReadSlot(void *L, int bag, int slot) {
|
||||
// --- auction-house globals ----------------------------------------------
|
||||
|
||||
uint64_t AuctioneerGuid() {
|
||||
const uint32_t lo = *reinterpret_cast<const uint32_t *>(
|
||||
const uint32_t lo = Game::Read<uint32_t>(
|
||||
Offsets::VAR_AUCTION_AUCTIONEER_GUID_LO);
|
||||
const uint32_t hi = *reinterpret_cast<const uint32_t *>(
|
||||
const uint32_t hi = Game::Read<uint32_t>(
|
||||
Offsets::VAR_AUCTION_AUCTIONEER_GUID_HI);
|
||||
return (static_cast<uint64_t>(hi) << 32) | lo;
|
||||
}
|
||||
bool AHOpen() { return AuctioneerGuid() != 0; }
|
||||
|
||||
void SetSellGuid(uint64_t guid) {
|
||||
*reinterpret_cast<uint32_t *>(Offsets::VAR_AUCTION_SELL_GUID_LO) =
|
||||
Game::Ref<uint32_t>(Offsets::VAR_AUCTION_SELL_GUID_LO) =
|
||||
static_cast<uint32_t>(guid);
|
||||
*reinterpret_cast<uint32_t *>(Offsets::VAR_AUCTION_SELL_GUID_HI) =
|
||||
Game::Ref<uint32_t>(Offsets::VAR_AUCTION_SELL_GUID_HI) =
|
||||
static_cast<uint32_t>(guid >> 32);
|
||||
}
|
||||
|
||||
@@ -181,8 +178,8 @@ bool BagIsGeneral(int bag) {
|
||||
const uint8_t *rec = Item::PeekRecord(static_cast<uint32_t>(id));
|
||||
if (rec == nullptr)
|
||||
return false;
|
||||
return *reinterpret_cast<const uint32_t *>(
|
||||
rec + Offsets::OFF_ITEMSTATS_BAG_FAMILY) == 0;
|
||||
return Game::Read<uint32_t>(
|
||||
rec, Offsets::OFF_ITEMSTATS_BAG_FAMILY) == 0;
|
||||
}
|
||||
|
||||
bool FindFreeGeneralSlot(void *L, int *outBag, int *outSlot) {
|
||||
@@ -206,7 +203,7 @@ bool FindFreeGeneralSlot(void *L, int *outBag, int *outSlot) {
|
||||
void Finish(bool success) {
|
||||
g_job.active = false;
|
||||
if (!success)
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kEvtFailure), "");
|
||||
Event::Custom::Fire(_evtFailure.Slot(), "");
|
||||
// success needs no event: the last PostWait already fired
|
||||
// AUCTION_MULTISELL_UPDATE(total, total).
|
||||
}
|
||||
@@ -279,7 +276,7 @@ void Tick() {
|
||||
// Post confirmed once the server pulls the item out of the slot.
|
||||
if (w.item == nullptr || w.guid != g_job.postGuid) {
|
||||
++g_job.postedStacks;
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kEvtUpdate), "%d%d",
|
||||
Event::Custom::Fire(_evtUpdate.Slot(), "%d%d",
|
||||
g_job.postedStacks, g_job.totalStacks);
|
||||
g_job.phase = Phase::Idle;
|
||||
g_job.waitTicks = 0;
|
||||
@@ -400,7 +397,7 @@ int __fastcall Script_C_AuctionHouse_PostItem(void *L) {
|
||||
g_job.runTime = runTime;
|
||||
g_job.phase = Phase::Idle;
|
||||
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kEvtStart), "");
|
||||
Event::Custom::Fire(_evtStart.Slot(), "");
|
||||
Game::Lua::PushBool(L, true);
|
||||
return 1;
|
||||
}
|
||||
|
||||
+76
-5
@@ -46,14 +46,10 @@ namespace Aura::Api {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
|
||||
const uint8_t *ResolveUnit(const char *token) {
|
||||
if (token == nullptr)
|
||||
return nullptr;
|
||||
auto fn = reinterpret_cast<ResolveUnitToken_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN));
|
||||
return static_cast<const uint8_t *>(fn(token));
|
||||
return static_cast<const uint8_t *>(Game::ResolveUnitToken(token));
|
||||
}
|
||||
|
||||
// Resolves a token to its GUID for the out-of-range (no live CGUnit) path.
|
||||
@@ -183,6 +179,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 +236,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 +491,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",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#include "net/PacketReader.h"
|
||||
#include "net/SendObserver.h"
|
||||
#include "spell/Mod.h"
|
||||
#include "time/Clock.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -32,12 +33,7 @@ namespace Aura::ComboDuration {
|
||||
namespace {
|
||||
|
||||
using Net::CDataStore;
|
||||
|
||||
uint32_t NowMs() {
|
||||
using TickCount_t = uint32_t(__fastcall *)();
|
||||
return reinterpret_cast<TickCount_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
|
||||
}
|
||||
using Time::Clock::NowMs;
|
||||
|
||||
// ---- Combo-point snapshot (NetClient send co-hook) ------------------------
|
||||
|
||||
|
||||
+196
-102
@@ -21,6 +21,7 @@
|
||||
#include "guid/Guid.h"
|
||||
#include "spell/CrowdControl.h"
|
||||
#include "spell/IsSelfBuff.h"
|
||||
#include "time/Clock.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -76,13 +77,9 @@ const char *SpellIconPath(const uint8_t *spellRecord) {
|
||||
Offsets::OFF_SPELLICON_PATH);
|
||||
}
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *);
|
||||
|
||||
// Returns the local player's CGUnit pointer, or nullptr pre-login.
|
||||
const uint8_t *LocalPlayer() {
|
||||
auto fn = reinterpret_cast<ResolveUnitToken_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN));
|
||||
return static_cast<const uint8_t *>(fn("player"));
|
||||
return static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
|
||||
}
|
||||
|
||||
// Looks up the player-buff-table entry for a given spellID. The table
|
||||
@@ -144,12 +141,10 @@ double PlayerBuffExpirationSeconds(const uint8_t *entry) {
|
||||
// Permanent auras (paladin auras, passives, racials) carry an infinite
|
||||
// Spell.dbc duration (SpellDuration base -1); the server stores the fixed
|
||||
// cast-time in this slot for them, so it's always "already elapsed" and the
|
||||
// engine shows no countdown. The signed `(int)` compare also absorbs the
|
||||
// ~24.86-day GetTickCount wrap (both operands wrap together).
|
||||
using TickCount_t = uint32_t(__fastcall *)();
|
||||
const uint32_t nowMs = reinterpret_cast<TickCount_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
|
||||
if (static_cast<int32_t>(nowMs - expirationMs) >= 0)
|
||||
// engine shows no countdown. `Time::Clock::Reached` takes the signed diff, so
|
||||
// it also absorbs the ~24.86-day GetTickCount wrap (both operands wrap
|
||||
// together).
|
||||
if (Time::Clock::Reached(expirationMs))
|
||||
return 0.0; // expiration reached/passed → permanent or no timer
|
||||
return static_cast<double>(expirationMs) * 0.001;
|
||||
}
|
||||
@@ -209,12 +204,7 @@ double SpellBaseDurationSeconds(uint32_t spellID, int unitLevel) {
|
||||
// the ~24.86-day GetTickCount wrap). An already-elapsed cached expiration is
|
||||
// not meaningful — the caster is kept regardless, but the time is reported as
|
||||
// unknown rather than as a negative remaining.
|
||||
bool ExpirationElapsed(uint32_t expMs) {
|
||||
using TickCount_t = uint32_t(__fastcall *)();
|
||||
const uint32_t nowMs = reinterpret_cast<TickCount_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
|
||||
return static_cast<int32_t>(nowMs - expMs) >= 0;
|
||||
}
|
||||
bool ExpirationElapsed(uint32_t expMs) { return Time::Clock::Reached(expMs); }
|
||||
|
||||
int PlayerLevel(const uint8_t *player) {
|
||||
auto *desc = Descriptor(player);
|
||||
@@ -224,23 +214,33 @@ int PlayerLevel(const uint8_t *player) {
|
||||
desc + Offsets::OFF_UNIT_FIELD_LEVEL);
|
||||
}
|
||||
|
||||
// The caster GUID attributed to `(guid, spellID)`: the observed caster from
|
||||
// the Aura::Source cache, else — when we never saw the cast — the unit itself
|
||||
// if the spell is a self-only-target buff (self-buffs can't be cross-cast).
|
||||
// 0 when genuinely unknown. Single source of truth for both display
|
||||
// (sourceGUID/sourceUnit) and the PLAYER caster filter, so a self-buff shows a
|
||||
// source AND matches HELPFUL|PLAYER instead of the two paths disagreeing.
|
||||
uint64_t EffectiveCaster(uint64_t guid, uint32_t spellID) {
|
||||
// Caster + timing attributed to ONE aura instance — the aura `spellID` sitting
|
||||
// in absolute descriptor `slot` on `guid` (`Aura::Source::SLOT_UNBOUND` when
|
||||
// the caller has no slot: the out-of-range group array, the cache fallback).
|
||||
// The slot is what tells two casters' copies of one spell apart; without it
|
||||
// they are indistinguishable from a spell ID alone.
|
||||
//
|
||||
// The caster is the observed one from the Aura::Source cache, else — when we
|
||||
// never saw the cast — the unit itself if the spell is a self-only-target buff
|
||||
// (self-buffs can't be cross-cast), else 0. Single source of truth for both
|
||||
// display (sourceGUID/sourceUnit) and the PLAYER caster filter, so a self-buff
|
||||
// shows a source AND matches HELPFUL|PLAYER instead of the two paths
|
||||
// disagreeing.
|
||||
struct Attribution {
|
||||
uint64_t caster;
|
||||
uint32_t expirationMs;
|
||||
uint32_t durationMs;
|
||||
};
|
||||
|
||||
Attribution Attribute(uint64_t guid, uint32_t spellID, int slot) {
|
||||
Attribution a = {0, 0, 0};
|
||||
if (guid == 0 || spellID == 0)
|
||||
return 0;
|
||||
uint64_t c = 0;
|
||||
uint32_t expMs = 0;
|
||||
uint32_t durMs = 0;
|
||||
if (Aura::Source::Get(guid, spellID, &c, &expMs, &durMs) && c != 0)
|
||||
return c; // observed caster
|
||||
if (Spell::IsSelfBuff::IsSelfBuff(spellID))
|
||||
return guid; // self-only-target aura → cast by the unit itself
|
||||
return 0;
|
||||
return a;
|
||||
Aura::Source::Get(guid, spellID, slot, &a.caster, &a.expirationMs,
|
||||
&a.durationMs);
|
||||
if (a.caster == 0 && Spell::IsSelfBuff::IsSelfBuff(spellID))
|
||||
a.caster = guid; // self-only-target aura → cast by the unit itself
|
||||
return a;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -277,7 +277,7 @@ bool IsPlayerCast(const uint8_t *unit, int slot) {
|
||||
if (spellID == 0)
|
||||
return false;
|
||||
const uint64_t player = Unit::Identity::PlayerGuid();
|
||||
return player != 0 && EffectiveCaster(UnitGuid(unit), spellID) == player;
|
||||
return player != 0 && Attribute(UnitGuid(unit), spellID, slot).caster == player;
|
||||
}
|
||||
|
||||
// Applies the PLAYER / !PLAYER caster restriction. `isPlayerCast` is the
|
||||
@@ -327,12 +327,13 @@ bool MatchesAura(const Match &match, bool isPlayerCast, uint32_t spellID) {
|
||||
CcMatches(match.cc, spellID);
|
||||
}
|
||||
|
||||
// Group-array analog of IsPlayerCast: the group aura carries no caster, so we
|
||||
// consult the Aura::Source cache by (guid, spellID). A miss counts as "not the
|
||||
// player" (same as IsPlayerCast).
|
||||
// Group-array analog of IsPlayerCast: the member has no descriptor, so there is
|
||||
// no slot to attribute by and the cache is consulted by (guid, spellID) alone.
|
||||
// A miss counts as "not the player" (same as IsPlayerCast).
|
||||
bool GroupIsPlayerCast(uint64_t guid, uint32_t spellID) {
|
||||
const uint64_t player = Unit::Identity::PlayerGuid();
|
||||
return player != 0 && EffectiveCaster(guid, spellID) == player;
|
||||
return player != 0 &&
|
||||
Attribute(guid, spellID, Aura::Source::SLOT_UNBOUND).caster == player;
|
||||
}
|
||||
|
||||
int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
|
||||
@@ -444,23 +445,49 @@ 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 and the table's
|
||||
// `isFromPlayerOrPlayerPet` — same value, retail's two names for the two aura
|
||||
// shapes. Both mean "applied by ANY player or a player's pet" (Era
|
||||
// semantics), not the local player — that's the `PLAYER` filter's job. The
|
||||
// answer is the caster GUID's HIGHGUID prefix: player 0x0000 / pet 0xF140
|
||||
// (server-confirmed in tortoise-wow ObjectGuid.h). Vanilla totems carry the
|
||||
// creature prefix, so totem buffs read false. False on a cache miss (cast not
|
||||
// observed), since a GUID of 0 classifies as neither.
|
||||
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 = Guid::IsPlayer(casterGuid) || Guid::IsPet(casterGuid);
|
||||
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);
|
||||
|
||||
@@ -497,10 +524,11 @@ static void BuildTable(void *L, uint32_t spellID, int applications,
|
||||
Game::Lua::SetFieldNumber(L, "maxCharges", 0);
|
||||
Game::Lua::SetFieldNumber(L, "timeMod", 1);
|
||||
|
||||
Game::Lua::SetFieldBool(L, "isFromPlayerOrPlayerPet", disp.castByPlayer);
|
||||
|
||||
// Boolean fields whose modern semantics don't apply in vanilla.
|
||||
Game::Lua::SetFieldBool(L, "isStealable", false);
|
||||
Game::Lua::SetFieldBool(L, "isBossAura", false);
|
||||
Game::Lua::SetFieldBool(L, "isFromPlayerOrPlayerPet", false);
|
||||
Game::Lua::SetFieldBool(L, "isNameplateOnly", false);
|
||||
Game::Lua::SetFieldBool(L, "nameplateShowAll", false);
|
||||
Game::Lua::SetFieldBool(L, "nameplateShowPersonal", false);
|
||||
@@ -513,6 +541,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
|
||||
@@ -527,10 +594,22 @@ static void BuildTable(void *L, uint32_t spellID, int applications,
|
||||
// buff table (gated on `isPlayer`); for everyone else it, the caster, and the
|
||||
// applied (caster-modified) duration come from the `Aura::Source` SMSG_SPELL_GO
|
||||
// cache when it observed the cast — a miss leaves the modern-truthful defaults
|
||||
// (expiration 0, no sourceUnit/GUID).
|
||||
// (expiration 0, no sourceUnit/GUID). `slot` is the absolute descriptor slot
|
||||
// the aura occupies, which is what attributes it to the right caster when two
|
||||
// of them hold the same spell on the unit; `Aura::Source::SLOT_UNBOUND` for the
|
||||
// paths with no descriptor to read one from.
|
||||
//
|
||||
// `known` short-circuits the by-(guid,spell,slot) resolve: the cache-fallback
|
||||
// paths already hold the exact entry's attribution (Enumerate copied it out),
|
||||
// so they pass it straight through. Re-resolving there would call `Get` a second
|
||||
// time and, for an unbound entry sharing its spell with another caster's entry,
|
||||
// return a miss — dropping a caster Enumerate already knew. Null for the
|
||||
// descriptor / group-array paths, which have no per-entry attribution in hand.
|
||||
static void PushEnriched(void *L, uint64_t guid, uint32_t spellID,
|
||||
bool isHelpful, int applications, int unitLevel,
|
||||
bool isPlayer) {
|
||||
bool isPlayer, int slot,
|
||||
const Attribution *known = nullptr,
|
||||
Emit emit = Emit::Table) {
|
||||
double duration = 0.0;
|
||||
double expirationTime = 0.0;
|
||||
uint64_t casterGuid = 0;
|
||||
@@ -542,36 +621,47 @@ static void PushEnriched(void *L, uint64_t guid, uint32_t spellID,
|
||||
expirationTime = PlayerBuffExpirationSeconds(entry);
|
||||
}
|
||||
if (spellID != 0 && guid != 0) {
|
||||
uint64_t c = 0;
|
||||
uint32_t expMs = 0;
|
||||
uint32_t durMs = 0;
|
||||
if (Aura::Source::Get(guid, spellID, &c, &expMs, &durMs)) {
|
||||
// A cached expiration that already elapsed while the aura is still
|
||||
// present (non-player casters get an underestimated base duration)
|
||||
// is not meaningful — report unknown (0) rather than a negative
|
||||
// remaining time.
|
||||
if (expirationTime == 0.0 && expMs != 0 && !ExpirationElapsed(expMs))
|
||||
expirationTime = static_cast<double>(expMs) * 0.001;
|
||||
if (durMs != 0)
|
||||
duration = static_cast<double>(durMs) * 0.001;
|
||||
}
|
||||
// Caster (sourceGUID/sourceUnit): observed caster, else the self-buff
|
||||
// inference — same resolution the PLAYER filter uses, so a self-buff
|
||||
// both shows a source and matches HELPFUL|PLAYER. Timing stays unknown
|
||||
// when we never saw the cast; only the caster is inferred, and it's
|
||||
// exact.
|
||||
casterGuid = EffectiveCaster(guid, spellID);
|
||||
// Caster (sourceGUID/sourceUnit) and timing resolved together, so a
|
||||
// self-buff both shows a source and matches HELPFUL|PLAYER instead of
|
||||
// the two paths disagreeing.
|
||||
Attribution a = (known != nullptr) ? *known : Attribute(guid, spellID, slot);
|
||||
// Self-buff inference for a pre-resolved entry whose caster the cache
|
||||
// never learned (idempotent on the Attribute() path, which already
|
||||
// applied it — the caster is nonzero there when it matched).
|
||||
if (a.caster == 0 && Spell::IsSelfBuff::IsSelfBuff(spellID))
|
||||
a.caster = guid;
|
||||
// A cached expiration that already elapsed while the aura is still
|
||||
// present (non-player casters get an underestimated base duration)
|
||||
// is not meaningful — report unknown (0) rather than a negative
|
||||
// remaining time.
|
||||
if (expirationTime == 0.0 && a.expirationMs != 0 &&
|
||||
!ExpirationElapsed(a.expirationMs))
|
||||
expirationTime = static_cast<double>(a.expirationMs) * 0.001;
|
||||
if (a.durationMs != 0)
|
||||
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);
|
||||
}
|
||||
|
||||
void Push(void *L, const uint8_t *unit, int slot) {
|
||||
// Attribution held directly by an `Aura::Source` cache entry (from `Enumerate`),
|
||||
// for the cache-fallback push paths to hand to `PushEnriched` as `known`.
|
||||
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, 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());
|
||||
unitLevel, unit != nullptr && unit == LocalPlayer(), slot,
|
||||
/*known*/ nullptr, emit);
|
||||
}
|
||||
|
||||
namespace {
|
||||
@@ -581,13 +671,17 @@ 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) {
|
||||
// `PushEnriched` re-reads the same cache entry `c` came from (by
|
||||
// guid+spellID) to fill caster / expiration / applied duration, so the
|
||||
// result is identical to reading `c`'s fields directly. Stacks aren't in
|
||||
// SMSG_SPELL_GO, so `applications` is 1.
|
||||
const 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
|
||||
// with another caster's would otherwise resolve to a miss and drop the
|
||||
// caster. Stacks aren't in SMSG_SPELL_GO, so `applications` is 1.
|
||||
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
|
||||
PushEnriched(L, UnitGuid(unit), c.spellId, isHelpful, 1, unitLevel, false);
|
||||
const Attribution a = CachedAttribution(c);
|
||||
PushEnriched(L, UnitGuid(unit), c.spellId, isHelpful, 1, unitLevel, false,
|
||||
c.slot, &a, emit);
|
||||
}
|
||||
|
||||
// Reconciles the `Aura::Source` cache against `unit`'s descriptor: when the
|
||||
@@ -604,15 +698,9 @@ void ReconcileCache(const uint8_t *unit) {
|
||||
if (unit == nullptr)
|
||||
return;
|
||||
uint32_t present[Offsets::UNIT_AURA_TOTAL];
|
||||
int n = 0;
|
||||
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
|
||||
const uint32_t id = ReadSpellID(unit, slot);
|
||||
if (id != 0)
|
||||
present[n++] = id;
|
||||
}
|
||||
if (n == 0)
|
||||
return;
|
||||
Aura::Source::EvictAbsent(UnitGuid(unit), present, n);
|
||||
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot)
|
||||
present[slot] = ReadSpellID(unit, slot);
|
||||
Aura::Source::EvictAbsent(UnitGuid(unit), present);
|
||||
}
|
||||
|
||||
// True if `unit`'s descriptor currently exposes any visible aura in either
|
||||
@@ -679,7 +767,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
|
||||
@@ -702,7 +790,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;
|
||||
}
|
||||
}
|
||||
@@ -809,7 +897,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);
|
||||
@@ -818,8 +907,9 @@ bool PushNthGroupCacheFallback(void *L, uint64_t guid, const uint16_t *arr,
|
||||
if (!GroupFallbackEligible(arr, buf[i], match))
|
||||
continue;
|
||||
if (++seen == oneBasedIndex) {
|
||||
const Attribution a = CachedAttribution(buf[i]);
|
||||
PushEnriched(L, guid, buf[i].spellId, filter == Filter::Helpful, 1,
|
||||
GroupMemberLevel(guid), false);
|
||||
GroupMemberLevel(guid), false, buf[i].slot, &a, emit);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -838,8 +928,9 @@ void AppendGroupCacheFallbacks(void *L, uint64_t guid, const uint16_t *arr,
|
||||
if (!GroupFallbackEligible(arr, buf[i], match))
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
|
||||
const Attribution a = CachedAttribution(buf[i]);
|
||||
PushEnriched(L, guid, buf[i].spellId, filter == Filter::Helpful, 1, level,
|
||||
false);
|
||||
false, buf[i].slot, &a);
|
||||
Game::Lua::SetTable(L, outerIdx);
|
||||
}
|
||||
}
|
||||
@@ -861,8 +952,9 @@ bool PushGroupCacheFallbackMatch(void *L, uint64_t guid, const uint16_t *arr,
|
||||
}
|
||||
if (!GroupFallbackEligible(arr, buf[i], match))
|
||||
continue;
|
||||
const Attribution a = CachedAttribution(buf[i]);
|
||||
PushEnriched(L, guid, buf[i].spellId, !harmful, 1, GroupMemberLevel(guid),
|
||||
false);
|
||||
false, buf[i].slot, &a);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -871,7 +963,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);
|
||||
@@ -887,14 +979,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);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -918,7 +1011,7 @@ bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
|
||||
if (!GroupSlotEligible(guid, arr, slot, match))
|
||||
continue;
|
||||
PushEnriched(L, guid, spellID, slot < Offsets::UNIT_AURA_BUFF_COUNT, 1,
|
||||
GroupMemberLevel(guid), false);
|
||||
GroupMemberLevel(guid), false, Aura::Source::SLOT_UNBOUND);
|
||||
return true;
|
||||
}
|
||||
if (!GroupArrayHasVisibleAura(arr)) {
|
||||
@@ -959,7 +1052,7 @@ bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
|
||||
if (!GroupSlotEligible(guid, arr, slot, match))
|
||||
continue;
|
||||
PushEnriched(L, guid, id, slot < Offsets::UNIT_AURA_BUFF_COUNT, 1,
|
||||
GroupMemberLevel(guid), false);
|
||||
GroupMemberLevel(guid), false, Aura::Source::SLOT_UNBOUND);
|
||||
return true;
|
||||
}
|
||||
if (!GroupArrayHasVisibleAura(arr)) {
|
||||
@@ -992,7 +1085,8 @@ void AppendGroupAuras(void *L, uint64_t guid, Filter filter, Match match,
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
|
||||
PushEnriched(L, guid, GroupSpellID(arr, slot),
|
||||
filter == Filter::Helpful, 1, level, false);
|
||||
filter == Filter::Helpful, 1, level, false,
|
||||
Aura::Source::SLOT_UNBOUND);
|
||||
Game::Lua::SetTable(L, outerIdx);
|
||||
}
|
||||
// Empty array (server delta-resend gap): supplement from the cache.
|
||||
|
||||
+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,78 @@
|
||||
// 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/>.
|
||||
|
||||
// Paladin judgement melee refresh — client mirror of the server's white-swing
|
||||
// rule (pfUI#45: the judgement debuff's timer ran to zero while the debuff
|
||||
// stayed up through the paladin's auto-attacks).
|
||||
//
|
||||
// The server rule (tortoise-wow Unit::DealMeleeDamage, vmangos lineage): a
|
||||
// melee swing that dealt ANY damage (`totalDamage > 0` — misses/dodges/parries
|
||||
// deal none) refreshes every aura on the victim that the attacker cast whose
|
||||
// spell is SPELLFAMILY_PALADIN with SPELL_ATTR_EX3_ALWAYS_HIT — the judgement
|
||||
// marker (all 14 Judgement of Light/Wisdom/Justice/Crusader debuff ranks carry
|
||||
// it, verified in the client Spell.dbc). RefreshHolder resets the holder to
|
||||
// its own full duration.
|
||||
//
|
||||
// The edit itself is packet-silent (UpdateAuraDuration is self-scoped — the
|
||||
// same blindness every Turtle::DurationMods rule works around), but its
|
||||
// TRIGGER is not: DealMeleeDamage's only caller is Unit::AttackerStateUpdate
|
||||
// (white swings), which then broadcasts SMSG_ATTACKERSTATEUPDATE to everyone
|
||||
// in range — attacker, victim, and totalDamage are its first fields (wire
|
||||
// format verified against the server's own SendAttackStateUpdate writer; see
|
||||
// Offsets.h at SMSG_ATTACKERSTATEUPDATE). So the swing packet is an exact
|
||||
// client-side stand-in for the refresh, for ANY paladin's judgements — ours
|
||||
// or a raid member's.
|
||||
//
|
||||
// Ability damage (Crusader/Holy Strike) does NOT reach that server block —
|
||||
// melee-class spell damage never routes through DealMeleeDamage on this core
|
||||
// — so no rule is registered for it. Re-judging is covered separately: the
|
||||
// judgement debuff is cast as a real triggered spell, its SMSG_SPELL_GO
|
||||
// refreshes the cache entry via StoreFromCast.
|
||||
//
|
||||
// Not gated on Turtle::Detected(): the block is vmangos-lineage core (the
|
||||
// classic "judgements are refreshed by the paladin's melee attacks"
|
||||
// mechanic), and the refresh only ever touches existing judgement entries
|
||||
// with a matching known caster — on a server without the mechanic the debuff
|
||||
// would genuinely expire, removing the entry via OnAuraRemoved before drift
|
||||
// could show.
|
||||
|
||||
#include "Offsets.h"
|
||||
#include "aura/Source.h"
|
||||
#include "net/PacketDispatch.h"
|
||||
#include "net/PacketReader.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Aura::JudgementRefresh {
|
||||
|
||||
namespace {
|
||||
|
||||
void OnPacket(uint32_t opcode, Net::CDataStore *packet) {
|
||||
if (opcode != Offsets::SMSG_ATTACKERSTATEUPDATE || packet == nullptr)
|
||||
return;
|
||||
Net::Read<uint32_t>(packet); // hitInfo — the damage gate below subsumes it
|
||||
const uint64_t attacker = Net::ReadPackedGuid(packet);
|
||||
const uint64_t victim = Net::ReadPackedGuid(packet);
|
||||
const uint32_t totalDamage = Net::Read<uint32_t>(packet);
|
||||
// The server's exact gate: any nonzero melee damage refreshes; a fully
|
||||
// avoided/absorbed swing (totalDamage 0) does not.
|
||||
if (totalDamage == 0 || attacker == 0 || victim == 0)
|
||||
return;
|
||||
Aura::Source::RefreshJudgements(victim, attacker);
|
||||
}
|
||||
|
||||
const Net::PacketDispatch::AutoSubscribe _sub{&OnPacket};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Aura::JudgementRefresh
|
||||
+910
-114
File diff suppressed because it is too large
Load Diff
+129
-21
@@ -19,7 +19,15 @@
|
||||
// the client ever sees the caster + a server-authoritative duration is the
|
||||
// `SMSG_SPELL_GO` packet at cast time. We parse it (the same packet
|
||||
// nampower parses for its `AURA_CAST_ON_*` events) and remember, per
|
||||
// `(targetGuid, spellId)`, who cast it and when it should expire.
|
||||
// `(targetGuid, spellId, casterGuid)`, who cast it and when it should expire.
|
||||
//
|
||||
// The caster belongs in that key because it belongs in the server's: two
|
||||
// same-class raiders' Corruption on one boss are two auras in two descriptor
|
||||
// slots, and a `(target, spell)` cache holds one entry for both — the second
|
||||
// cast overwrites the first caster's timing, and either copy falling off
|
||||
// evicts the survivor's entry too. The descriptor stores only spell IDs, so
|
||||
// each entry additionally records the slot its aura was seated in
|
||||
// (`OnAuraAdded`), which is what lets a per-slot query name the right instance.
|
||||
//
|
||||
// `Aura::Data::Push` consults this to fill `sourceUnit` (caster, resolved
|
||||
// to a unit token) and `expirationTime` for non-player units. The cache is
|
||||
@@ -31,15 +39,25 @@
|
||||
|
||||
namespace Aura::Source {
|
||||
|
||||
// Looks up the cached caster + timing for the aura `spellId` currently on
|
||||
// the unit identified by `unitGuid`. Returns true and fills the out params
|
||||
// on a hit. `*outExpirationMs` is an absolute `GetTickCount`-epoch timestamp
|
||||
// (0 = unknown / infinite-duration aura); `*outDurationMs` is the applied
|
||||
// duration including the caster's modifiers (talents etc.; 0 = none) — use
|
||||
// it for the `duration` field so it stays consistent with `expirationTime`;
|
||||
// `*outCaster` is the caster's 64-bit GUID (never 0 on a hit). Returns false
|
||||
// on a miss or for zero inputs.
|
||||
bool Get(uint64_t unitGuid, uint32_t spellId, uint64_t *outCaster,
|
||||
// `slot` value for a query with no descriptor slot to go on (out-of-range
|
||||
// group array, cache fallback) and for an entry not yet seated in one.
|
||||
constexpr int SLOT_UNBOUND = -1;
|
||||
|
||||
// Looks up the cached caster + timing for the aura `spellId` occupying
|
||||
// absolute descriptor `slot` on the unit identified by `unitGuid`. Returns
|
||||
// true and fills the out params on a hit. `*outExpirationMs` is an absolute
|
||||
// `GetTickCount`-epoch timestamp (0 = unknown / infinite-duration aura);
|
||||
// `*outDurationMs` is the applied duration including the caster's modifiers
|
||||
// (talents etc.; 0 = none) — use it for the `duration` field so it stays
|
||||
// consistent with `expirationTime`; `*outCaster` is the caster's 64-bit GUID
|
||||
// (0 when the aura was seen applied but its cast never observed). Returns
|
||||
// false on a miss or for zero inputs.
|
||||
//
|
||||
// Pass `SLOT_UNBOUND` when the caller has no slot. Resolution is by slot
|
||||
// first, then by spell ID when the unit carries only one entry for it; two
|
||||
// casters' entries with no slot binding resolve to a miss rather than to a
|
||||
// coin flip between them.
|
||||
bool Get(uint64_t unitGuid, uint32_t spellId, int slot, uint64_t *outCaster,
|
||||
uint32_t *outExpirationMs, uint32_t *outDurationMs);
|
||||
|
||||
// Refreshes `casterGuid`'s aura on `unitGuid` matching the same selector the
|
||||
@@ -51,6 +69,36 @@ uint32_t RefreshDurationByFamily(uint64_t unitGuid, uint32_t family,
|
||||
uint64_t mask, uint32_t icon,
|
||||
uint64_t casterGuid);
|
||||
|
||||
// Mirrors the server's melee-swing judgement refresh (tortoise-wow
|
||||
// Unit::DealMeleeDamage): a white swing from `attackerGuid` that dealt any
|
||||
// damage refreshes every aura it cast on `unitGuid` whose spell is
|
||||
// SPELLFAMILY_PALADIN with SPELL_ATTR_EX3_ALWAYS_HIT (the judgement-debuff
|
||||
// marker — all Judgement of Light/Wisdom/Justice/Crusader ranks) back to its
|
||||
// own applied duration. Caster-strict: entries with an unknown caster are
|
||||
// left alone (a swing gives no proof of ownership). The caster comes from
|
||||
// the Judgement-cast attribution inside the SpellGo path — the debuff itself
|
||||
// is cast server-side with no client-visible SPELL_GO, so the Judgement cast
|
||||
// (20271) is what names the owner: it refresh-with-adoption's existing
|
||||
// entries (re-judge = in-place server refresh, no aura event) and arms the
|
||||
// fresh application's attribution. Returns the number refreshed. Fed by
|
||||
// Aura::JudgementRefresh's SMSG_ATTACKERSTATEUPDATE subscriber.
|
||||
int RefreshJudgements(uint64_t unitGuid, uint64_t attackerGuid);
|
||||
|
||||
// Re-anchors the expiration of every cached aura the local player cast with
|
||||
// `spellId` to `now + remainingMs`. Mirrors the server's channel pushback
|
||||
// (Spell::DelayedChannel → Unit::DelaySpellAuraHolder): a hit shortens the
|
||||
// channel AND every hit target's holder of the channel spell by the same
|
||||
// amount, then tells the caster the new remaining time via MSG_CHANNEL_UPDATE
|
||||
// — the packet Spell::Cast feeds this from. Without it a pushed-back channel's
|
||||
// target-side aura (Dark Harvest, the drains) keeps its full-length SpellGo
|
||||
// expiration and reads late by the accumulated pushback. `durationMs` (the
|
||||
// applied total, the bar's full width) is deliberately untouched. Self-target
|
||||
// holders are also corrected natively by the server (it re-sends
|
||||
// SMSG_UPDATE_AURA_DURATION), so restamping those entries just keeps the two
|
||||
// sources agreeing. Caster-only packet ⇒ only the player's channels are
|
||||
// correctable; remote pushback stays invisible (see spell/Cast.cpp).
|
||||
void RestampPlayerChannel(uint32_t spellId, uint32_t remainingMs);
|
||||
|
||||
// Op codes for AddDurationMod (mirror the Lua op strings).
|
||||
enum DurationModOp {
|
||||
DURATION_MOD_REFRESH = 0,
|
||||
@@ -70,25 +118,85 @@ bool AddDurationMod(uint32_t triggerSpellId, uint32_t affectedFamily,
|
||||
uint64_t affectedMask, uint32_t affectedIcon, int op,
|
||||
int32_t valueMs);
|
||||
|
||||
// Register a server "triggered application" rule from C++: `triggerSpellId`'s
|
||||
// SMSG_SPELL_GO — local player casts only — arms its hit targets for a short
|
||||
// window, and a caster-less aura application on an armed target matching
|
||||
// `affectedFamily` + a family-flag overlap with `affectedMask` is attributed
|
||||
// to the player at `durationPct` percent of the aura's own player-modified
|
||||
// base duration (the server's `max(1, CalculateDuration(caster) * pct / 100)`).
|
||||
// For server mechanics that AddAura an aura off another spell's hit with no
|
||||
// cast packet of its own (Turtle's Stinging Nettle: Mongoose Bite / fire-trap
|
||||
// effects apply the highest known Serpent Sting rank at 20/40% duration).
|
||||
// A re-trigger over an EXISTING matching aura is also handled: the server's
|
||||
// re-add reuses the descriptor slot (no application event), so the trigger
|
||||
// additionally refreshes the player's existing cached entry in place,
|
||||
// honoring the server's keep-the-longer-remaining guard.
|
||||
// `gateSpellId` (0 = none) keeps the rule live only while the player knows
|
||||
// that spell (a talent-granted passive), read from the spell-knowledge bitmap
|
||||
// at trigger time so respecs are honored; rules matching the same trigger are
|
||||
// tried in registration order and the first live one arms (register the
|
||||
// higher talent rank first). Returns false on bad input or a full table.
|
||||
bool AddTriggeredApplication(uint32_t triggerSpellId, uint32_t gateSpellId,
|
||||
uint32_t affectedFamily, uint64_t affectedMask,
|
||||
int32_t durationPct);
|
||||
|
||||
// Family-mask trigger form of the above: the trigger matches any cast whose
|
||||
// spell is SpellFamilyName `triggerFamily` with a family-flag overlap of
|
||||
// `triggerMask` — rank-proof when the bit selects exactly the trigger set
|
||||
// (verify against the DBC that no unrelated spell shares it first: hunter
|
||||
// 0x4 is exactly the fire-trap effects, but 0x2 is Mongoose Bite AND Raptor
|
||||
// Strike, so that trigger needs the exact-ID form).
|
||||
bool AddTriggeredApplicationByFamily(uint32_t triggerFamily,
|
||||
uint64_t triggerMask, uint32_t gateSpellId,
|
||||
uint32_t affectedFamily,
|
||||
uint64_t affectedMask, int32_t durationPct);
|
||||
|
||||
// Register a tick-speed compression rule from C++: while an aura matching
|
||||
// (triggerFamily, triggerMask) is live on a target, every cached aura on that
|
||||
// target CAST BY THE SAME CASTER matching (affectedFamily, affectedMask) — and
|
||||
// whose record carries a periodic damage/leech effect, the server's aura-type
|
||||
// gate — drains an extra `pct`% of real time: its expiration moves earlier by
|
||||
// pct% of the elapsed overlap. Mirrors a server that preserves an accelerated
|
||||
// dot's tick COUNT, so ticking pct% faster consumes it pct% faster with no
|
||||
// client-visible aura change (Turtle's Dark Harvest; see
|
||||
// src/turtle/DarkHarvest.cpp for the live-behavior derivation). Caster-paired
|
||||
// like the server's own check, so another warlock's observed DH compresses
|
||||
// THEIR dots' cached timing too; caster-less entries never compress. The
|
||||
// trigger's cache-entry lifetime is the compression window — created by its
|
||||
// SpellGo, shortened by channel pushback (RestampPlayerChannel), evicted by
|
||||
// OnAuraRemoved on interrupt / early end — so the window tracks every edge the
|
||||
// server's has. Returns false on bad input or a full table.
|
||||
bool AddTickCompression(uint32_t triggerFamily, uint64_t triggerMask,
|
||||
uint32_t affectedFamily, uint64_t affectedMask,
|
||||
int32_t pct);
|
||||
|
||||
// One cached aura, as returned by `Enumerate`.
|
||||
struct CachedAura {
|
||||
uint32_t spellId;
|
||||
uint64_t casterGuid; // 0 = caster unknown (application hook, no SpellGo)
|
||||
uint32_t expirationMs; // 0 = infinite / unknown
|
||||
uint32_t durationMs; // applied duration (incl. caster mods); 0 = none
|
||||
int slot; // descriptor slot the aura was seated in; feed it
|
||||
// back to `Get` to reach this exact entry again
|
||||
};
|
||||
|
||||
// Evicts every cached entry for `unitGuid` whose spellId is NOT present in
|
||||
// `presentSpellIds[0..count)`. Used to reconcile the cache against a unit's
|
||||
// authoritative descriptor when it is back in view (a populated aura array
|
||||
// means the unit is fully synced): an entry the descriptor no longer lists
|
||||
// was removed while we couldn't observe it — e.g. a buff the owner cancelled
|
||||
// while out of our range, whose `OnAuraRemoved` we never received — so drop
|
||||
// it before the descriptor-drop fallback resurfaces it as a phantom. The
|
||||
// caller must only invoke this when the descriptor is populated (count > 0);
|
||||
// an empty array can't distinguish "out of range" from "genuinely buffless",
|
||||
// so reconciling then would wrongly wipe still-valid out-of-range entries.
|
||||
void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count);
|
||||
// Evicts every cached entry for `unitGuid` the unit's descriptor contradicts.
|
||||
// `slotSpellIds` is the raw `UNIT_FIELD_AURA` array — exactly
|
||||
// `Offsets::UNIT_AURA_TOTAL` spell IDs indexed by absolute slot, 0 for empty.
|
||||
// Used to reconcile the cache against a unit's authoritative descriptor when
|
||||
// it is back in view (a populated aura array means the unit is fully synced):
|
||||
// an entry the descriptor no longer backs was removed while we couldn't
|
||||
// observe it — e.g. a buff the owner cancelled while out of our range, whose
|
||||
// `OnAuraRemoved` we never received — so drop it before the descriptor-drop
|
||||
// fallback resurfaces it as a phantom.
|
||||
//
|
||||
// An entry seated in a slot is checked against THAT slot, so one caster's
|
||||
// copy going away retires its own entry even while another caster keeps the
|
||||
// spell ID present on the unit. Entries never seated are checked against the
|
||||
// whole array. An all-empty array is ignored: it can't distinguish "out of
|
||||
// range" from "genuinely buffless", and reconciling then would wrongly wipe
|
||||
// still-valid out-of-range entries.
|
||||
void EvictAbsent(uint64_t unitGuid, const uint32_t *slotSpellIds);
|
||||
|
||||
// Fills `out` with up to `maxOut` cached, non-expired auras on `unitGuid`
|
||||
// whose helpful/harmful classification matches `harmful`. Returns the count
|
||||
|
||||
@@ -143,7 +143,7 @@ void OnWorldTick() {
|
||||
static_cast<uintptr_t>(Offsets::VAR_IN_WORLD)) == 0)
|
||||
return;
|
||||
g_pending = false;
|
||||
const int slot = Event::Custom::Lookup(kEventName);
|
||||
const int slot = _reserve.Slot();
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "");
|
||||
// Notify C++ subscribers of the bag-change frame (see UpdateDelayed.h).
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,620 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under
|
||||
// the terms of the GNU General Public License as published by the Free Software
|
||||
// Foundation, either version 3 of the License, or (at your option) any later
|
||||
// version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
// A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Backports the direct-action and override binding APIs introduced in 2.0.
|
||||
// Normal bindings are stored by vanilla's SetBinding implementation using the
|
||||
// later-client SPELL/ITEM/MACRO/CLICK command strings. The resolved-command
|
||||
// hook executes those four command families; every native binding command is
|
||||
// passed through unchanged.
|
||||
//
|
||||
// Override bindings live in a separate owner-scoped layer in front of the
|
||||
// native key lookup. They are deliberately never written to the saved binding
|
||||
// table, so clearing an owner immediately reveals the next override or the
|
||||
// unchanged native binding underneath it.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "macro/Execute.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace Bindings::Api {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char kSpellPrefix[] = "SPELL ";
|
||||
constexpr const char kItemPrefix[] = "ITEM ";
|
||||
constexpr const char kMacroPrefix[] = "MACRO ";
|
||||
constexpr const char kClickPrefix[] = "CLICK ";
|
||||
constexpr const char kDefaultMouseButton[] = "LeftButton";
|
||||
|
||||
using ScriptSetBinding_t = int(__fastcall *)(void *L);
|
||||
using KeyDispatch_t = int(__thiscall *)(void *mgr, const char *key, int isDown);
|
||||
using CommandExecute_t = int(__thiscall *)(void *mgr, const char *cmd, int isDown);
|
||||
|
||||
KeyDispatch_t KeyDispatch_o = nullptr;
|
||||
CommandExecute_t CommandExecute_o = nullptr;
|
||||
|
||||
enum class CommandType {
|
||||
None,
|
||||
Spell,
|
||||
Item,
|
||||
Macro,
|
||||
Click,
|
||||
};
|
||||
|
||||
struct ParsedCommand {
|
||||
CommandType type = CommandType::None;
|
||||
const char *argument = nullptr;
|
||||
};
|
||||
|
||||
struct OverrideEntry {
|
||||
const void *owner = nullptr;
|
||||
std::string key;
|
||||
std::string command;
|
||||
bool priority = false;
|
||||
uint64_t sequence = 0;
|
||||
};
|
||||
|
||||
std::vector<OverrideEntry> g_overrides;
|
||||
std::unordered_map<std::string, std::string> g_activeOverrides;
|
||||
uint64_t g_nextSequence = 0;
|
||||
|
||||
// Normalizes a binding key to the engine's own canonical form so a stored
|
||||
// override matches the string FUN_BINDING_KEY_DISPATCH builds at keypress. The
|
||||
// engine's key-string builder (FUN_004b6630) always emits modifiers in the
|
||||
// fixed order ALT-CTRL-SHIFT- ahead of the base key, regardless of the order
|
||||
// the caller passed them; vanilla SetBinding never reorders, so the override
|
||||
// layer does it here. Both the store and the lookup run through this, so they
|
||||
// stay consistent while also matching the engine's keypress form.
|
||||
std::string CanonicalKey(const char *key) {
|
||||
std::string result = key ? key : "";
|
||||
for (char &character : result) {
|
||||
if (character >= 'a' && character <= 'z')
|
||||
character = static_cast<char>(character - 'a' + 'A');
|
||||
}
|
||||
|
||||
// Strip the three modifier prefixes in whatever order/repetition they
|
||||
// appear, then re-emit them in the engine's fixed order. Prefix-stripping
|
||||
// (rather than splitting on '-') keeps the minus base key correct, e.g.
|
||||
// "SHIFT--" -> modifier SHIFT + base "-".
|
||||
bool alt = false, ctrl = false, shift = false;
|
||||
size_t base = 0;
|
||||
for (bool matched = true; matched;) {
|
||||
matched = false;
|
||||
if (result.compare(base, 4, "ALT-") == 0) {
|
||||
alt = true;
|
||||
base += 4;
|
||||
matched = true;
|
||||
} else if (result.compare(base, 5, "CTRL-") == 0) {
|
||||
ctrl = true;
|
||||
base += 5;
|
||||
matched = true;
|
||||
} else if (result.compare(base, 6, "SHIFT-") == 0) {
|
||||
shift = true;
|
||||
base += 6;
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!alt && !ctrl && !shift)
|
||||
return result;
|
||||
|
||||
std::string canonical;
|
||||
if (alt)
|
||||
canonical += "ALT-";
|
||||
if (ctrl)
|
||||
canonical += "CTRL-";
|
||||
if (shift)
|
||||
canonical += "SHIFT-";
|
||||
canonical += result.substr(base);
|
||||
|
||||
return canonical;
|
||||
}
|
||||
|
||||
std::string MakeCommand(const char *prefix, const char *arg) {
|
||||
std::string cmd = prefix;
|
||||
cmd += arg;
|
||||
return cmd;
|
||||
}
|
||||
|
||||
std::string MakeClickCommand(const char *btnName, const char *mouseBtn) {
|
||||
std::string cmd = MakeCommand(kClickPrefix, btnName);
|
||||
if (mouseBtn != nullptr) {
|
||||
cmd += ':';
|
||||
cmd += mouseBtn;
|
||||
}
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
ParsedCommand ParseCommand(const char *cmd) {
|
||||
if (cmd == nullptr)
|
||||
return {};
|
||||
|
||||
struct PrefixEntry {
|
||||
const char *prefix;
|
||||
size_t length;
|
||||
CommandType type;
|
||||
};
|
||||
|
||||
static constexpr PrefixEntry prefixes[] = {
|
||||
{kSpellPrefix, sizeof(kSpellPrefix) - 1, CommandType::Spell},
|
||||
{kItemPrefix, sizeof(kItemPrefix) - 1, CommandType::Item},
|
||||
{kMacroPrefix, sizeof(kMacroPrefix) - 1, CommandType::Macro},
|
||||
{kClickPrefix, sizeof(kClickPrefix) - 1, CommandType::Click},
|
||||
};
|
||||
|
||||
for (const PrefixEntry &entry : prefixes) {
|
||||
if (std::strncmp(cmd, entry.prefix, entry.length) == 0)
|
||||
return {entry.type, cmd + entry.length};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
bool CallGlobalString(void *L, const char *func, const char *arg) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
if (!Game::Lua::PushGlobalFunction(L, func)) {
|
||||
Game::Lua::SetTop(L, top);
|
||||
return false;
|
||||
}
|
||||
|
||||
Game::Lua::PushString(L, arg);
|
||||
const bool ok = Game::Lua::PCall(L, 1, 0, 0) == 0;
|
||||
Game::Lua::SetTop(L, top);
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool CallTableString(void *L, const char *table, const char *func, const char *arg) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
|
||||
Game::Lua::PushString(L, table);
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
|
||||
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_TABLE) {
|
||||
Game::Lua::SetTop(L, top);
|
||||
return false;
|
||||
}
|
||||
|
||||
const int tableIndex = Game::Lua::GetTop(L);
|
||||
|
||||
Game::Lua::PushString(L, func);
|
||||
Game::Lua::GetTable(L, tableIndex);
|
||||
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_FUNCTION) {
|
||||
Game::Lua::SetTop(L, top);
|
||||
return false;
|
||||
}
|
||||
|
||||
Game::Lua::PushString(L, arg);
|
||||
const bool ok = Game::Lua::PCall(L, 1, 0, 0) == 0;
|
||||
Game::Lua::SetTop(L, top);
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
void InvokeButtonClick(void *L, const char *btnName, size_t btnNameLength, const char *mouseBtn) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
|
||||
Game::Lua::PushLString(L, btnName, static_cast<unsigned int>(btnNameLength));
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
|
||||
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_TABLE) {
|
||||
Game::Lua::SetTop(L, top);
|
||||
return;
|
||||
}
|
||||
|
||||
const int frameIndex = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushString(L, "Click");
|
||||
Game::Lua::GetTable(L, frameIndex);
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_FUNCTION) {
|
||||
Game::Lua::PushValue(L, frameIndex);
|
||||
Game::Lua::PushString(L, mouseBtn);
|
||||
Game::Lua::PCall(L, 2, 0, 0);
|
||||
}
|
||||
|
||||
Game::Lua::SetTop(L, top);
|
||||
}
|
||||
|
||||
void ExecuteClick(void *L, const char *arg) {
|
||||
const char *separator = std::strrchr(arg, ':');
|
||||
if (separator == arg || (separator != nullptr && separator[1] == '\0'))
|
||||
return;
|
||||
|
||||
const char *mouseBtn = kDefaultMouseButton;
|
||||
size_t btnNameLength = std::strlen(arg);
|
||||
if (separator != nullptr) {
|
||||
btnNameLength = static_cast<size_t>(separator - arg);
|
||||
mouseBtn = separator + 1;
|
||||
}
|
||||
|
||||
if (btnNameLength == 0)
|
||||
return;
|
||||
|
||||
InvokeButtonClick(L, arg, btnNameLength, mouseBtn);
|
||||
}
|
||||
|
||||
// Returns true for every recognized direct-action command, including malformed
|
||||
// or stale commands. Vanilla cannot execute these namespaces, so a bad command
|
||||
// is consumed as a no-op instead of falling through to an unrelated handler.
|
||||
bool ExecuteSpecialCommand(const char *cmd, int isDown) {
|
||||
const ParsedCommand parsed = ParseCommand(cmd);
|
||||
if (parsed.type == CommandType::None)
|
||||
return false;
|
||||
|
||||
if (!isDown || parsed.argument == nullptr || *parsed.argument == '\0')
|
||||
return true;
|
||||
|
||||
void *L = Game::Lua::State();
|
||||
if (L == nullptr)
|
||||
return true;
|
||||
|
||||
switch (parsed.type) {
|
||||
case CommandType::Spell:
|
||||
CallGlobalString(L, "CastSpellByName", parsed.argument);
|
||||
break;
|
||||
case CommandType::Item:
|
||||
CallTableString(L, "C_Item", "UseItemByName", parsed.argument);
|
||||
break;
|
||||
case CommandType::Macro:
|
||||
Macro::Execute::Saved(L, parsed.argument);
|
||||
break;
|
||||
case CommandType::Click:
|
||||
ExecuteClick(L, parsed.argument);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const OverrideEntry *FindEffectiveOverride(const std::string &key) {
|
||||
const OverrideEntry *best = nullptr;
|
||||
for (const OverrideEntry &entry : g_overrides) {
|
||||
if (entry.key != key)
|
||||
continue;
|
||||
|
||||
if (best == nullptr || entry.priority > best->priority ||
|
||||
(entry.priority == best->priority && entry.sequence > best->sequence))
|
||||
best = &entry;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
void SetOverride(const void *owner, bool priority, const std::string &key, const std::string &cmd) {
|
||||
auto existing =
|
||||
std::find_if(g_overrides.begin(), g_overrides.end(),
|
||||
[&](const OverrideEntry &entry) {
|
||||
return entry.owner == owner && entry.key == key;
|
||||
});
|
||||
|
||||
if (existing == g_overrides.end()) {
|
||||
g_overrides.push_back(
|
||||
{owner, key, cmd, priority, ++g_nextSequence});
|
||||
return;
|
||||
}
|
||||
|
||||
existing->command = cmd;
|
||||
existing->priority = priority;
|
||||
existing->sequence = ++g_nextSequence;
|
||||
}
|
||||
|
||||
void RemoveOverride(const void *owner, const std::string &key) {
|
||||
g_overrides.erase(std::remove_if(g_overrides.begin(), g_overrides.end(),
|
||||
[&](const OverrideEntry &entry) {
|
||||
return entry.owner == owner &&
|
||||
entry.key == key;
|
||||
}),
|
||||
g_overrides.end());
|
||||
}
|
||||
|
||||
int ExecuteCommand(void *mgr, const char *cmd, int isDown) {
|
||||
if (ExecuteSpecialCommand(cmd, isDown))
|
||||
return 1;
|
||||
|
||||
return CommandExecute_o(mgr, cmd, isDown);
|
||||
}
|
||||
|
||||
int __fastcall CommandExecute_h(void *mgr, void *, const char *cmd, int isDown) {
|
||||
return ExecuteCommand(mgr, cmd, isDown);
|
||||
}
|
||||
|
||||
// Runs an override-resolved command with the same execution context the engine
|
||||
// wraps around a native keypress. FUN_BINDING_KEY_DISPATCH saves the frame-
|
||||
// script exec-context, zeroes it for the nested command, marks the manager as
|
||||
// mid-command (`+0xD8`), then restores the context and floors the nesting
|
||||
// depth. Native-table bindings already run inside this (the engine sets it up
|
||||
// before calling FUN_BINDING_COMMAND_EXECUTE, which we co-hook); overrides
|
||||
// bypass that path, so we mirror it here. Without it, a native Bindings.xml
|
||||
// command dispatched through an override would run without the context its
|
||||
// handler expects. Special SPELL/ITEM/MACRO/CLICK commands run harmlessly
|
||||
// inside the same wrapper.
|
||||
int DispatchOverrideCommand(void *mgr, const char *cmd, int isDown) {
|
||||
volatile uint32_t *context =
|
||||
reinterpret_cast<volatile uint32_t *>(Offsets::VAR_FRAMESCRIPT_EXEC_CONTEXT);
|
||||
volatile uint32_t *depth = reinterpret_cast<volatile uint32_t *>(
|
||||
Offsets::VAR_FRAMESCRIPT_EXEC_CONTEXT_DEPTH);
|
||||
volatile uint32_t *executing =
|
||||
Game::Ptr<volatile uint32_t>(mgr, Offsets::OFF_BINDING_MANAGER_EXECUTING);
|
||||
|
||||
const uint32_t savedContext = *context;
|
||||
*depth = *depth + 1;
|
||||
if (*depth != 0)
|
||||
*context = 0;
|
||||
|
||||
*executing = 1;
|
||||
const int result = ExecuteCommand(mgr, cmd, isDown);
|
||||
*executing = 0;
|
||||
|
||||
if (*depth != 0)
|
||||
*context = savedContext;
|
||||
if (static_cast<int32_t>(*depth) - 1 > 0)
|
||||
*depth = *depth - 1;
|
||||
else
|
||||
*depth = 0;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
int __fastcall KeyDispatch_h(void *mgr, void *, const char *key, int isDown) {
|
||||
const std::string canonicalKey = CanonicalKey(key);
|
||||
|
||||
// Keep a key-down paired with its override command even if that override is
|
||||
// cleared while the command executes. The newly revealed native binding
|
||||
// must not receive only the key-up half of the same physical press.
|
||||
if (!isDown) {
|
||||
auto active = g_activeOverrides.find(canonicalKey);
|
||||
if (active != g_activeOverrides.end()) {
|
||||
DispatchOverrideCommand(mgr, active->second.c_str(), 0);
|
||||
g_activeOverrides.erase(active);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
const OverrideEntry *entry = FindEffectiveOverride(canonicalKey);
|
||||
if (entry == nullptr)
|
||||
return KeyDispatch_o(mgr, key, isDown);
|
||||
|
||||
if (isDown)
|
||||
g_activeOverrides[canonicalKey] = entry->command;
|
||||
|
||||
return DispatchOverrideCommand(mgr, entry->command.c_str(), isDown);
|
||||
}
|
||||
|
||||
int SetBindingCommand(void *L, const char *prefix, const char *usage) {
|
||||
if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsString(L, 2)) {
|
||||
Game::Lua::Error(L, usage);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const std::string key = Game::Lua::ToString(L, 1);
|
||||
const std::string command = MakeCommand(prefix, Game::Lua::ToString(L, 2));
|
||||
|
||||
// Preserve the stock SetBinding return value, binding-set selection,
|
||||
// key normalization, UPDATE_BINDINGS event, and SaveBindings behavior.
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::PushString(L, key.c_str());
|
||||
Game::Lua::PushString(L, command.c_str());
|
||||
auto setBinding =
|
||||
reinterpret_cast<ScriptSetBinding_t>(Offsets::FUN_SCRIPT_SET_BINDING);
|
||||
|
||||
return setBinding(L);
|
||||
}
|
||||
|
||||
const char *OptionalMouseButton(void *L, int index, const char *usage) {
|
||||
if (Game::Lua::GetTop(L) < index || Game::Lua::Type(L, index) == Game::Lua::TYPE_NIL)
|
||||
return nullptr;
|
||||
|
||||
if (!Game::Lua::IsString(L, index)) {
|
||||
Game::Lua::Error(L, usage);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
return Game::Lua::ToString(L, index);
|
||||
}
|
||||
|
||||
int __fastcall Script_SetBindingSpell(void *L) {
|
||||
return SetBindingCommand(L, kSpellPrefix, "Usage: SetBindingSpell(key, spell)");
|
||||
}
|
||||
|
||||
int __fastcall Script_SetBindingItem(void *L) {
|
||||
return SetBindingCommand(L, kItemPrefix, "Usage: SetBindingItem(key, item)");
|
||||
}
|
||||
|
||||
int __fastcall Script_SetBindingMacro(void *L) {
|
||||
return SetBindingCommand(L, kMacroPrefix, "Usage: SetBindingMacro(key, macro)");
|
||||
}
|
||||
|
||||
int __fastcall Script_SetBindingClick(void *L) {
|
||||
constexpr const char *usage = "Usage: SetBindingClick(key, buttonName [, mouseButton])";
|
||||
if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsString(L, 2)) {
|
||||
Game::Lua::Error(L, usage);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const std::string key = Game::Lua::ToString(L, 1);
|
||||
const char *buttonName = Game::Lua::ToString(L, 2);
|
||||
const char *mouseButton = OptionalMouseButton(L, 3, usage);
|
||||
const std::string command = MakeClickCommand(buttonName, mouseButton);
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::PushString(L, key.c_str());
|
||||
Game::Lua::PushString(L, command.c_str());
|
||||
auto setBinding =
|
||||
reinterpret_cast<ScriptSetBinding_t>(Offsets::FUN_SCRIPT_SET_BINDING);
|
||||
|
||||
return setBinding(L);
|
||||
}
|
||||
|
||||
bool ReadOverrideHeader(void *L, const char *usage, const void **owner,
|
||||
bool *priority, std::string *key) {
|
||||
*owner = Game::Lua::ResolveObject(L, 1);
|
||||
if (*owner == nullptr || Game::Lua::Type(L, 2) != Game::Lua::TYPE_BOOLEAN ||
|
||||
!Game::Lua::IsString(L, 3)) {
|
||||
Game::Lua::Error(L, usage);
|
||||
return false;
|
||||
}
|
||||
|
||||
*priority = Game::Lua::ToBoolean(L, 2) != 0;
|
||||
*key = CanonicalKey(Game::Lua::ToString(L, 3));
|
||||
return true;
|
||||
}
|
||||
|
||||
int SetOverrideBindingCommand(void *L, const char *prefix, const char *usage) {
|
||||
const void *owner = nullptr;
|
||||
bool priority = false;
|
||||
|
||||
std::string key;
|
||||
if (!ReadOverrideHeader(L, usage, &owner, &priority, &key))
|
||||
return 0;
|
||||
|
||||
if (!Game::Lua::IsString(L, 4)) {
|
||||
Game::Lua::Error(L, usage);
|
||||
return 0;
|
||||
}
|
||||
|
||||
SetOverride(owner, priority, key,
|
||||
MakeCommand(prefix, Game::Lua::ToString(L, 4)));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int __fastcall Script_SetOverrideBinding(void *L) {
|
||||
constexpr const char *usage = "Usage: SetOverrideBinding(owner, isPriority, key [, command])";
|
||||
const void *owner = nullptr;
|
||||
bool priority = false;
|
||||
|
||||
std::string key;
|
||||
if (!ReadOverrideHeader(L, usage, &owner, &priority, &key))
|
||||
return 0;
|
||||
|
||||
if (Game::Lua::GetTop(L) < 4 ||
|
||||
Game::Lua::Type(L, 4) == Game::Lua::TYPE_NIL) {
|
||||
RemoveOverride(owner, key);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!Game::Lua::IsString(L, 4)) {
|
||||
Game::Lua::Error(L, usage);
|
||||
return 0;
|
||||
}
|
||||
|
||||
SetOverride(owner, priority, key, Game::Lua::ToString(L, 4));
|
||||
return 0;
|
||||
}
|
||||
|
||||
int __fastcall Script_SetOverrideBindingSpell(void *L) {
|
||||
return SetOverrideBindingCommand(
|
||||
L, kSpellPrefix,
|
||||
"Usage: SetOverrideBindingSpell(owner, isPriority, key, spell)");
|
||||
}
|
||||
|
||||
int __fastcall Script_SetOverrideBindingItem(void *L) {
|
||||
return SetOverrideBindingCommand(
|
||||
L, kItemPrefix,
|
||||
"Usage: SetOverrideBindingItem(owner, isPriority, key, item)");
|
||||
}
|
||||
|
||||
int __fastcall Script_SetOverrideBindingMacro(void *L) {
|
||||
return SetOverrideBindingCommand(
|
||||
L, kMacroPrefix,
|
||||
"Usage: SetOverrideBindingMacro(owner, isPriority, key, macro)");
|
||||
}
|
||||
|
||||
int __fastcall Script_SetOverrideBindingClick(void *L) {
|
||||
constexpr const char *usage =
|
||||
"Usage: SetOverrideBindingClick(owner, "
|
||||
"isPriority, key, buttonName [, mouseButton])";
|
||||
|
||||
const void *owner = nullptr;
|
||||
bool priority = false;
|
||||
|
||||
std::string key;
|
||||
if (!ReadOverrideHeader(L, usage, &owner, &priority, &key))
|
||||
return 0;
|
||||
|
||||
if (!Game::Lua::IsString(L, 4)) {
|
||||
Game::Lua::Error(L, usage);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const char *buttonName = Game::Lua::ToString(L, 4);
|
||||
const char *mouseButton = OptionalMouseButton(L, 5, usage);
|
||||
SetOverride(owner, priority, key,
|
||||
MakeClickCommand(buttonName, mouseButton));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int __fastcall Script_ClearOverrideBindings(void *L) {
|
||||
const void *owner = Game::Lua::ResolveObject(L, 1);
|
||||
if (owner == nullptr) {
|
||||
Game::Lua::Error(L, "Usage: ClearOverrideBindings(owner)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
g_overrides.erase(std::remove_if(g_overrides.begin(), g_overrides.end(),
|
||||
[&](const OverrideEntry &entry) {
|
||||
return entry.owner == owner;
|
||||
}),
|
||||
g_overrides.end());
|
||||
return 0;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
// A fresh FrameScript state makes every owner pointer from the previous
|
||||
// state stale. Permanent bindings remain in vanilla's binding table.
|
||||
g_overrides.clear();
|
||||
g_activeOverrides.clear();
|
||||
g_nextSequence = 0;
|
||||
|
||||
Game::Lua::RegisterGlobalFunction("SetBindingSpell", &Script_SetBindingSpell);
|
||||
Game::Lua::RegisterGlobalFunction("SetBindingItem", &Script_SetBindingItem);
|
||||
Game::Lua::RegisterGlobalFunction("SetBindingMacro", &Script_SetBindingMacro);
|
||||
Game::Lua::RegisterGlobalFunction("SetBindingClick", &Script_SetBindingClick);
|
||||
Game::Lua::RegisterGlobalFunction("SetOverrideBinding",
|
||||
&Script_SetOverrideBinding);
|
||||
Game::Lua::RegisterGlobalFunction("SetOverrideBindingSpell",
|
||||
&Script_SetOverrideBindingSpell);
|
||||
Game::Lua::RegisterGlobalFunction("SetOverrideBindingItem",
|
||||
&Script_SetOverrideBindingItem);
|
||||
Game::Lua::RegisterGlobalFunction("SetOverrideBindingMacro",
|
||||
&Script_SetOverrideBindingMacro);
|
||||
Game::Lua::RegisterGlobalFunction("SetOverrideBindingClick",
|
||||
&Script_SetOverrideBindingClick);
|
||||
Game::Lua::RegisterGlobalFunction("ClearOverrideBindings",
|
||||
&Script_ClearOverrideBindings);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
const Game::HookAutoRegister _hookKeyDispatch{
|
||||
Offsets::FUN_BINDING_KEY_DISPATCH, reinterpret_cast<void *>(&KeyDispatch_h),
|
||||
reinterpret_cast<void **>(&KeyDispatch_o)};
|
||||
|
||||
const Game::HookAutoRegister _hookCommandExecute{
|
||||
Offsets::FUN_BINDING_COMMAND_EXECUTE,
|
||||
reinterpret_cast<void *>(&CommandExecute_h),
|
||||
reinterpret_cast<void **>(&CommandExecute_o)};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Bindings::Api
|
||||
Vendored
+7
-7
@@ -61,8 +61,8 @@ constexpr int kMaxWaitTicks = 1200; // give up and fire failure after this many
|
||||
|
||||
struct Registered {
|
||||
void *cache;
|
||||
const char *eventName;
|
||||
uintptr_t getRecord; // this cache class's _GetRecord
|
||||
const Event::Custom::AutoReserve *reserve; // completion event's reservation
|
||||
uintptr_t getRecord; // this cache class's _GetRecord
|
||||
Pending pending[kMaxPending];
|
||||
int pendingCount;
|
||||
};
|
||||
@@ -79,8 +79,7 @@ Registered *Find(void *cache) {
|
||||
}
|
||||
|
||||
void Fire(const Registered *r, uint32_t id, bool success) {
|
||||
Event::Custom::FireIdSuccess(Event::Custom::Lookup(r->eventName),
|
||||
static_cast<int>(id), success);
|
||||
Event::Custom::FireIdSuccess(r->reserve->Slot(), static_cast<int>(id), success);
|
||||
}
|
||||
|
||||
int FindPending(const Registered *r, uint32_t id) {
|
||||
@@ -176,15 +175,16 @@ const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
|
||||
|
||||
} // namespace
|
||||
|
||||
void Register(void *cacheInstance, const char *eventName, uintptr_t getRecordAddr) {
|
||||
if (cacheInstance == nullptr || eventName == nullptr || getRecordAddr == 0)
|
||||
void Register(void *cacheInstance, const Event::Custom::AutoReserve *reserve,
|
||||
uintptr_t getRecordAddr) {
|
||||
if (cacheInstance == nullptr || reserve == nullptr || getRecordAddr == 0)
|
||||
return;
|
||||
if (Find(cacheInstance) != nullptr) // idempotent (safe across /reload)
|
||||
return;
|
||||
if (g_cacheCount >= kMaxCaches)
|
||||
return;
|
||||
g_caches[g_cacheCount].cache = cacheInstance;
|
||||
g_caches[g_cacheCount].eventName = eventName;
|
||||
g_caches[g_cacheCount].reserve = reserve;
|
||||
g_caches[g_cacheCount].getRecord = getRecordAddr;
|
||||
g_caches[g_cacheCount].pendingCount = 0;
|
||||
++g_cacheCount;
|
||||
|
||||
Vendored
+14
-10
@@ -15,6 +15,10 @@
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Event::Custom {
|
||||
struct AutoReserve;
|
||||
}
|
||||
|
||||
// Shared async-load machinery for the client query caches that aren't
|
||||
// item/quest — creature, gameobject, and (potentially) the other
|
||||
// SMSG_*_QUERY caches. Modeled on `Item::Data`'s pending/timeout/event
|
||||
@@ -30,10 +34,9 @@
|
||||
// functions, so hooking creature's and gameobject's separately is fine);
|
||||
// item/quest have their own parsers and aren't touched.
|
||||
//
|
||||
// static const char *kEvent = "CREATURE_DATA_LOAD_RESULT";
|
||||
// static const Event::Custom::AutoReserve _r{kEvent}; // reserve the event
|
||||
// static const Event::Custom::AutoReserve _r{"CREATURE_DATA_LOAD_RESULT"};
|
||||
// ...
|
||||
// Cache::QueryLoad::Register(cacheInstance, kEvent,
|
||||
// Cache::QueryLoad::Register(cacheInstance, &_r,
|
||||
// Offsets::FUN_CREATURE_GET_RECORD); // at init
|
||||
// Cache::QueryLoad::RequestLoad(cacheInstance, id); // fire a load
|
||||
//
|
||||
@@ -44,13 +47,14 @@ namespace Cache::QueryLoad {
|
||||
|
||||
// Register a query cache for async-load tracking. `cacheInstance` is the
|
||||
// cache global (e.g. `Offsets::VAR_CREATURE_CACHE` cast to void*);
|
||||
// `eventName` is a Custom event the caller has reserved via
|
||||
// `Event::Custom::AutoReserve`, fired `(id, success)` on completion;
|
||||
// `getRecordAddr` is the cache class's `_GetRecord` (e.g.
|
||||
// `Offsets::FUN_CREATURE_GET_RECORD`). `cacheInstance`/`eventName` must
|
||||
// outlive the process (pass static values). Idempotent — calling again
|
||||
// for the same cache is a no-op (safe across /reload).
|
||||
void Register(void *cacheInstance, const char *eventName, uintptr_t getRecordAddr);
|
||||
// `reserve` is the caller's `Event::Custom::AutoReserve` for the
|
||||
// completion event, fired `(id, success)`; `getRecordAddr` is the cache
|
||||
// class's `_GetRecord` (e.g. `Offsets::FUN_CREATURE_GET_RECORD`).
|
||||
// `cacheInstance`/`reserve` must outlive the process (pass statics).
|
||||
// Idempotent — calling again for the same cache is a no-op (safe across
|
||||
// /reload).
|
||||
void Register(void *cacheInstance, const Event::Custom::AutoReserve *reserve,
|
||||
uintptr_t getRecordAddr);
|
||||
|
||||
// True if `id` is already in the cache (pure peek, no network query).
|
||||
bool IsCached(void *cacheInstance, uint32_t id);
|
||||
|
||||
+17
-49
@@ -18,29 +18,19 @@
|
||||
// against an internal cache to identify the player. With this we
|
||||
// just read the GUID directly.
|
||||
//
|
||||
// Implementation: the SMSG_MESSAGECHAT handler at FUN_0049D560 reads
|
||||
// the senderGUID from the packet (offsets `[ebp-0x10]` / `[ebp-0xC]`
|
||||
// in that function's stack frame) and forwards it as args 9-10 to
|
||||
// the chat dispatcher at FUN_0049A870. The dispatcher in turn fires
|
||||
// the appropriate CHAT_MSG_* event during its execution. We hook the
|
||||
// dispatcher's entry, stash the GUID into a file-scope global, call
|
||||
// the original (which fires the event synchronously), then clear the
|
||||
// global on return. Inside the addon's OnEvent, `GetCurrentChatGUID`
|
||||
// reads the global.
|
||||
//
|
||||
// Why not hook the SMSG handler directly: the GUID isn't in argument
|
||||
// registers/stack at the handler's entry — it's read mid-function
|
||||
// into locals. Hooking the dispatcher (which receives the GUID as
|
||||
// args) is structurally simpler. The trade-off: the dispatcher is
|
||||
// also called for synthetic chat (system notifications, arena team
|
||||
// events, etc.) with GUID = 0; for those, `GetCurrentChatGUID()`
|
||||
// returns nil — matches the modern API's behavior of nil GUID for
|
||||
// non-player-sourced chat.
|
||||
// The GUID is published for the duration of a chat dispatch by a
|
||||
// `DispatchScope` (see the header), which Chat::Dispatch — the single
|
||||
// owner of the FUN_0049A870 chat-dispatch hook — constructs around the
|
||||
// original call. The engine fires the CHAT_MSG_* event synchronously
|
||||
// inside that call, so `GetCurrentChatGUID` reads the right GUID from
|
||||
// the addon's OnEvent. The dispatcher is also called for synthetic chat
|
||||
// (system notifications, arena team events, etc.) with GUID = 0; for
|
||||
// those `GetCurrentChatGUID()` returns nil — matching the modern API's
|
||||
// behavior of a nil GUID for non-player-sourced chat.
|
||||
|
||||
#include "CurrentGUID.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "guid/Guid.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -69,39 +59,17 @@ void RegisterLuaFunctions() {
|
||||
|
||||
} // namespace
|
||||
|
||||
ChatDispatch_t ChatDispatch_o = nullptr;
|
||||
DispatchScope::DispatchScope(uint32_t guidLo, uint32_t guidHi)
|
||||
: prevLo_(g_currentGuidLo), prevHi_(g_currentGuidHi) {
|
||||
g_currentGuidLo = guidLo;
|
||||
g_currentGuidHi = guidHi;
|
||||
}
|
||||
|
||||
void __fastcall ChatDispatch_h(
|
||||
const char *sender, int chatType,
|
||||
int arg3, int arg4, int arg5, int arg6, int arg7,
|
||||
int targetGuidLo, int targetGuidHi, int arg10,
|
||||
int senderGuidLo, int senderGuidHi)
|
||||
{
|
||||
// Save before original — the engine's chat event fires DURING
|
||||
// origCall, and the addon's OnEvent runs synchronously inside
|
||||
// that window. The GUID must be readable at that point.
|
||||
const uint32_t prevLo = g_currentGuidLo;
|
||||
const uint32_t prevHi = g_currentGuidHi;
|
||||
g_currentGuidLo = static_cast<uint32_t>(senderGuidLo);
|
||||
g_currentGuidHi = static_cast<uint32_t>(senderGuidHi);
|
||||
|
||||
ChatDispatch_o(sender, chatType, arg3, arg4, arg5, arg6, arg7,
|
||||
targetGuidLo, targetGuidHi, arg10,
|
||||
senderGuidLo, senderGuidHi);
|
||||
|
||||
// Restore prior value rather than clearing to 0, so nested
|
||||
// dispatch (one chat event firing during another's OnEvent —
|
||||
// e.g. an addon calling SendChatMessage from its handler) leaves
|
||||
// the outer context's GUID intact when the inner returns.
|
||||
g_currentGuidLo = prevLo;
|
||||
g_currentGuidHi = prevHi;
|
||||
DispatchScope::~DispatchScope() {
|
||||
g_currentGuidLo = prevLo_;
|
||||
g_currentGuidHi = prevHi_;
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
static const Game::HookAutoRegister _hookreg{
|
||||
Offsets::FUN_CHAT_DISPATCH,
|
||||
reinterpret_cast<void *>(&ChatDispatch_h),
|
||||
reinterpret_cast<void **>(&ChatDispatch_o)};
|
||||
|
||||
} // namespace Chat::CurrentGUID
|
||||
|
||||
+21
-48
@@ -13,55 +13,28 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Chat::CurrentGUID {
|
||||
|
||||
// Hook for FUN_CHAT_DISPATCH (0x0049A870). Saves the senderGUID into
|
||||
// a file-scope global for the duration of the original call, then
|
||||
// clears it on return. Inside that window the engine fires the
|
||||
// appropriate CHAT_MSG_* event, so `GetCurrentChatGUID()` called from
|
||||
// the addon's OnEvent sees the GUID for the message that triggered it.
|
||||
//
|
||||
// 12-arg __fastcall signature. The function has a stack-probe
|
||||
// prologue (`mov eax, 0x1F0C; call _chkstk`) — its frame is ~8KB.
|
||||
// Verified by disassembling 0x0049A870 in 1.12.1: stack-arg access
|
||||
// reaches `[ebp+0x2C]` (= param 12) and the function exits with
|
||||
// `ret 0x28` (cleans 40 bytes = 10 stack args + 2 register args).
|
||||
//
|
||||
// ECX = arg1 (sender name string)
|
||||
// EDX = arg2 (chat type byte)
|
||||
// [esp+ 4] = arg3
|
||||
// [esp+ 8] = arg4
|
||||
// [esp+ c] = arg5
|
||||
// [esp+10] = arg6
|
||||
// [esp+14] = arg7
|
||||
// [esp+18] = arg8 — target GUID lo (for whisper/channel target)
|
||||
// [esp+1c] = arg9 — target GUID hi
|
||||
// [esp+20] = arg10
|
||||
// [esp+24] = arg11 — sender GUID lo ← captured
|
||||
// [esp+28] = arg12 — sender GUID hi ← captured
|
||||
//
|
||||
// Identified args 11/12 as senderGUID by tracing the AFK auto-reply
|
||||
// path at 0x0049AB39: reads params 11/12, OR's them to check for
|
||||
// non-zero, pushes them onto the packet stream via FUN_00418370
|
||||
// (the "write u64" helper). That's the GUID the AFK whisper-back is
|
||||
// addressed to — i.e. the original message sender.
|
||||
//
|
||||
// An earlier 10-arg signature derived from Ghidra (on the wrong
|
||||
// binary) had `ret 0x20` cleanup. The 8-byte cleanup mismatch with
|
||||
// the engine's `ret 0x28`-shaped call left 8 bytes of stack
|
||||
// uncleaned per chat-dispatch invocation, corrupting the engine's
|
||||
// stack frame and crashing on a later indirect call. See the
|
||||
// commit message for the full diagnosis.
|
||||
using ChatDispatch_t = void(__fastcall *)(
|
||||
const char *sender, int chatType,
|
||||
int arg3, int arg4, int arg5, int arg6, int arg7,
|
||||
int targetGuidLo, int targetGuidHi, int arg10,
|
||||
int senderGuidLo, int senderGuidHi);
|
||||
extern ChatDispatch_t ChatDispatch_o;
|
||||
void __fastcall ChatDispatch_h(
|
||||
const char *sender, int chatType,
|
||||
int arg3, int arg4, int arg5, int arg6, int arg7,
|
||||
int targetGuidLo, int targetGuidHi, int arg10,
|
||||
int senderGuidLo, int senderGuidHi);
|
||||
// RAII scope that publishes the sender GUID of the chat message currently being
|
||||
// dispatched, for `GetCurrentChatGUID()` to read. Chat::Dispatch constructs one
|
||||
// around the original chat-dispatch call: the engine fires the CHAT_MSG_* event
|
||||
// synchronously inside that call, so an addon's OnEvent sees the GUID of the
|
||||
// message that triggered it. Restores the prior value on destruction rather than
|
||||
// clearing to 0, so nested dispatch (a chat event firing during another's
|
||||
// OnEvent — e.g. an addon calling SendChatMessage from its handler) leaves the
|
||||
// outer context's GUID intact when the inner returns.
|
||||
class DispatchScope {
|
||||
public:
|
||||
DispatchScope(uint32_t guidLo, uint32_t guidHi);
|
||||
~DispatchScope();
|
||||
DispatchScope(const DispatchScope &) = delete;
|
||||
DispatchScope &operator=(const DispatchScope &) = delete;
|
||||
|
||||
private:
|
||||
uint32_t prevLo_;
|
||||
uint32_t prevHi_;
|
||||
};
|
||||
|
||||
} // namespace Chat::CurrentGUID
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// 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/>.
|
||||
|
||||
// Single owner of the chat-dispatch hook (FUN_0049A870).
|
||||
//
|
||||
// The engine routes every server-delivered chat message — say/yell/whisper/
|
||||
// channel/emote and synthetic system notifications — through this one function.
|
||||
// (Addon `print`/`AddMessage` does NOT pass through it.) Several independent
|
||||
// concerns need to observe or transform a dispatched message, but MinHook allows
|
||||
// only ONE hook per target, so this module owns the hook and orchestrates them,
|
||||
// mirroring Net::PacketDispatch (one hook, fanned out to independent concerns):
|
||||
//
|
||||
// - Chat::IconFilter::Sanitize — strip player-injected `|T` icon spoofs from
|
||||
// the message before the engine builds the chat line AND the speech bubble
|
||||
// (both come from the same buffer, so one sanitize covers both).
|
||||
// - Chat::RaidMarkers::Substitute — expand `{rt1}`/`{skull}`/… raid-target
|
||||
// tokens into inline marker icons. Runs AFTER Sanitize so a player gets the
|
||||
// fixed marker icons but still can't inject an arbitrary `|T` path.
|
||||
// - Chat::CurrentGUID::DispatchScope — publish the sender GUID for the
|
||||
// synchronous CHAT_MSG_* event so `GetCurrentChatGUID()` can read it.
|
||||
//
|
||||
// Signature — `__fastcall`, `RET 0x28`:
|
||||
// ECX = arg1 — the MESSAGE text (verified from the call site in FUN_0049D560:
|
||||
// `MOV ECX,[EBP-0x10]` = the raw/processed message. An older
|
||||
// note mislabeled this "sender name".) Copied into one buffer
|
||||
// fed to both the chat frame and the bubble spawn FUN_00608AC0.
|
||||
// EDX = arg2 — chat type byte
|
||||
// [esp+04] = arg3 [esp+08] = arg4 [esp+0c] = arg5
|
||||
// [esp+10] = arg6 [esp+14] = arg7
|
||||
// [esp+18] = arg8 — target GUID lo (whisper/channel target)
|
||||
// [esp+1c] = arg9 — target GUID hi
|
||||
// [esp+20] = arg10
|
||||
// [esp+24] = arg11 — sender GUID lo ← published
|
||||
// [esp+28] = arg12 — sender GUID hi ← published
|
||||
//
|
||||
// The `RET 0x28` cleanup (40 bytes = 10 stack args) MUST match the declared
|
||||
// signature exactly; an earlier 10-arg / `ret 0x20` version left 8 bytes
|
||||
// uncleaned per call and crashed the engine on a later indirect call.
|
||||
|
||||
#include "CurrentGUID.h"
|
||||
#include "IconFilter.h"
|
||||
#include "RaidMarkers.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Chat::Dispatch {
|
||||
|
||||
namespace {
|
||||
|
||||
using ChatDispatch_t = void(__fastcall *)(
|
||||
const char *message, int chatType,
|
||||
int arg3, int arg4, int arg5, int arg6, int arg7,
|
||||
int targetGuidLo, int targetGuidHi, int arg10,
|
||||
int senderGuidLo, int senderGuidHi);
|
||||
|
||||
ChatDispatch_t g_original = nullptr;
|
||||
|
||||
void __fastcall ChatDispatch_h(
|
||||
const char *message, int chatType,
|
||||
int arg3, int arg4, int arg5, int arg6, int arg7,
|
||||
int targetGuidLo, int targetGuidHi, int arg10,
|
||||
int senderGuidLo, int senderGuidHi)
|
||||
{
|
||||
// Strip player-injected `|T` icon escapes (covers chat line + speech bubble;
|
||||
// both build from this message). The 0x800 buffer matches the engine's own
|
||||
// message-buffer cap.
|
||||
char iconBuf[0x800];
|
||||
const char *msg = Chat::IconFilter::Sanitize(message, iconBuf, sizeof iconBuf);
|
||||
|
||||
// Expand raid-target tokens ({rt1}/{skull}/…) into inline marker icons —
|
||||
// after the sanitize, so only these fixed, safe escapes are added.
|
||||
char markerBuf[0x800];
|
||||
msg = Chat::RaidMarkers::Substitute(msg, markerBuf, sizeof markerBuf);
|
||||
|
||||
// Publish the sender GUID for the CHAT_MSG_* event the original fires
|
||||
// synchronously; restored when this scope ends (after the original returns).
|
||||
Chat::CurrentGUID::DispatchScope guid(static_cast<uint32_t>(senderGuidLo),
|
||||
static_cast<uint32_t>(senderGuidHi));
|
||||
|
||||
g_original(msg, chatType, arg3, arg4, arg5, arg6, arg7,
|
||||
targetGuidLo, targetGuidHi, arg10,
|
||||
senderGuidLo, senderGuidHi);
|
||||
}
|
||||
|
||||
static const Game::HookAutoRegister _hookreg{
|
||||
Offsets::FUN_CHAT_DISPATCH,
|
||||
reinterpret_cast<void *>(&ChatDispatch_h),
|
||||
reinterpret_cast<void **>(&g_original)};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Chat::Dispatch
|
||||
@@ -0,0 +1,85 @@
|
||||
// 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/>.
|
||||
|
||||
// Inline-texture (`|T`) chat anti-spoof.
|
||||
//
|
||||
// Vanilla neutralizes player-*typed* escapes in chat (a typed `|cff…|r`,
|
||||
// `|Hitem…|h` or `|T…|t` shows as raw text) — only *trusted* text renders them
|
||||
// (addon `AddMessage`, shift-clicked links). Our Text::InlineTexture backport
|
||||
// breaks that parity for `|T` alone: it detects the neutralized `||T` form and
|
||||
// draws the icon anyway, so a player could `/say` `|Tpath|t` and have a real
|
||||
// icon appear in everyone's chat AND in the speech bubble over their head.
|
||||
//
|
||||
// This restores vanilla's behavior for `|T` by defanging it in the chat path,
|
||||
// while leaving `|c`/`|r`/`|H`/`|h` exactly as vanilla (already raw for typed
|
||||
// input). It CANNOT be done in the text emitter: addon `|T` and player `|T`
|
||||
// reach the emitter as byte-identical `||T` (vanilla doubles the unrecognized
|
||||
// `|T` for both), so the only place they're distinguishable is the source —
|
||||
// server-delivered chat flows through the chat dispatcher, addon `print` does
|
||||
// not. Chat::Dispatch owns that hook and calls this on the message.
|
||||
//
|
||||
// Defang = replace the `|` of each `|T` with a space, breaking the pipe→T
|
||||
// adjacency the inline-texture detector keys on (`|T` and `||T` both contain the
|
||||
// `|T` substring, so this catches the doubled form too). The path text stays
|
||||
// readable, the closer `|t` is inert without an opener, and every other escape
|
||||
// is untouched.
|
||||
|
||||
#include "IconFilter.h"
|
||||
|
||||
namespace Chat::IconFilter {
|
||||
|
||||
namespace {
|
||||
|
||||
// True if `s` contains an inline-texture opener (`|T`) anywhere.
|
||||
bool HasIconEscape(const char *s) {
|
||||
for (; *s != '\0'; ++s)
|
||||
if (s[0] == '|' && s[1] == 'T')
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const char *Sanitize(const char *msg, char *buf, size_t bufSize) {
|
||||
if (msg == nullptr || !HasIconEscape(msg))
|
||||
return msg; // common case: forward untouched, no copy
|
||||
|
||||
// NEVER write `msg` in place: several of the chat handler's ~50 callers pass
|
||||
// read-only `.rdata` literals (system notifications), so an in-place edit
|
||||
// would fault. We copy only when a `|T` is actually present.
|
||||
//
|
||||
// Defanging must not leave a LONE `|` followed by a non-escape char — that's
|
||||
// an invalid escape, which the chat frame tolerates but the speech-bubble
|
||||
// text setter chokes on, producing an empty bubble (verified in-game). By the
|
||||
// time chat reaches us the escape is usually the doubled `||T` (vanilla's own
|
||||
// neutralization of typed escapes), where `||` is a VALID literal-pipe escape.
|
||||
// So: keep the pipe(s) and blank the `T` when the pipe completes a `||`; blank
|
||||
// BOTH chars for a lone `|T`. Either way no dangling `|` remains, and no `|T`
|
||||
// opener survives for the inline-texture detector to fire on.
|
||||
size_t j = 0;
|
||||
for (size_t i = 0; msg[i] != '\0' && j + 1 < bufSize;) {
|
||||
if (msg[i] == '|' && msg[i + 1] == 'T') {
|
||||
const bool doubledPipe = (i > 0 && msg[i - 1] == '|');
|
||||
buf[j++] = doubledPipe ? '|' : ' '; // keep pipe only if it completes `||`
|
||||
if (j + 1 < bufSize)
|
||||
buf[j++] = ' '; // blank the `T`
|
||||
i += 2; // consumed `|T`
|
||||
} else {
|
||||
buf[j++] = msg[i++];
|
||||
}
|
||||
}
|
||||
buf[j] = '\0';
|
||||
return buf;
|
||||
}
|
||||
|
||||
} // namespace Chat::IconFilter
|
||||
@@ -0,0 +1,28 @@
|
||||
// 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 Chat::IconFilter {
|
||||
|
||||
// Strips player-injected inline-texture (`|T…|t`) escapes from a chat message.
|
||||
// Returns `msg` unchanged when it contains no `|T` (the common case — no copy),
|
||||
// otherwise a sanitized copy written into `buf`. Called by Chat::Dispatch on the
|
||||
// message the engine's chat handler is about to display. See IconFilter.cpp for
|
||||
// the anti-spoof rationale and why sanitizing here (not in the text emitter) is
|
||||
// the only place player-typed and addon `|T` are distinguishable.
|
||||
const char *Sanitize(const char *msg, char *buf, size_t bufSize);
|
||||
|
||||
} // namespace Chat::IconFilter
|
||||
@@ -0,0 +1,154 @@
|
||||
// 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/>.
|
||||
|
||||
// Raid-target marker chat substitutions ({rt1}..{rt8} / {star} / {skull} / ...).
|
||||
//
|
||||
// Modern WoW lets players type `{skull}` (etc.) in chat and everyone sees the
|
||||
// raid-target icon. Vanilla 1.12 has no such substitution. We backport it onto
|
||||
// the inline-texture (`|T…|t`) renderer: each recognized token is rewritten into
|
||||
// a `|T` escape that crops the shared raid-target sprite sheet
|
||||
// `Interface\TargetingFrame\UI-RaidTargetingIcons` to the matching 64×64 cell.
|
||||
//
|
||||
// Runs in Chat::Dispatch AFTER Chat::IconFilter::Sanitize. Order is deliberate:
|
||||
// sanitize first defangs any player-typed raw `|T` spoof, then we add ONLY the
|
||||
// fixed, known-safe marker escapes — so players get the markers without a path to
|
||||
// inject arbitrary textures.
|
||||
//
|
||||
// Icon→texcoord mapping verified against the client's own UnitPopup.lua
|
||||
// (RAID_TARGET_1..8): the sheet is 256×256 with 64-pixel cells, marker N at
|
||||
// column (N-1)%4, row (N-1)/4. So RT1(star) is the top-left cell and RT8(skull)
|
||||
// is row 1, column 3. The inline renderer's OpenGL v-flip already draws this sheet
|
||||
// right-side-up (it's the sheet that flip was verified against), so top<bottom
|
||||
// pixel coords render correctly.
|
||||
|
||||
#include "RaidMarkers.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
namespace Chat::RaidMarkers {
|
||||
|
||||
namespace {
|
||||
|
||||
struct Token {
|
||||
const char *name; // lowercase alias, no braces
|
||||
int marker; // raid-target index 1..8
|
||||
};
|
||||
|
||||
// Aliases per the modern client, all case-insensitive. `{coin}` is a colloquial
|
||||
// alias for `{circle}` (RT2) and `{x}` for `{cross}` (RT7) — both shown in the
|
||||
// in-game substitution help.
|
||||
constexpr Token kTokens[] = {
|
||||
{"rt1", 1}, {"star", 1},
|
||||
{"rt2", 2}, {"circle", 2}, {"coin", 2},
|
||||
{"rt3", 3}, {"diamond", 3},
|
||||
{"rt4", 4}, {"triangle", 4},
|
||||
{"rt5", 5}, {"moon", 5},
|
||||
{"rt6", 6}, {"square", 6},
|
||||
{"rt7", 7}, {"cross", 7}, {"x", 7},
|
||||
{"rt8", 8}, {"skull", 8},
|
||||
};
|
||||
|
||||
char LowerAscii(char c) { return (c >= 'A' && c <= 'Z') ? static_cast<char>(c + 32) : c; }
|
||||
|
||||
// Case-insensitive match of the n-char token body [s, s+n) against a lowercase
|
||||
// literal — true only when `lit` is exactly n chars long.
|
||||
bool EqCI(const char *s, size_t n, const char *lit) {
|
||||
for (size_t i = 0; i < n; ++i)
|
||||
if (lit[i] == '\0' || LowerAscii(s[i]) != lit[i])
|
||||
return false;
|
||||
return lit[n] == '\0';
|
||||
}
|
||||
|
||||
// Raid-target index (1..8) for the token body [name, name+n), or 0 if unknown.
|
||||
int MatchMarker(const char *name, size_t n) {
|
||||
for (const Token &t : kTokens)
|
||||
if (EqCI(name, n, t.name))
|
||||
return t.marker;
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool HasBrace(const char *s) {
|
||||
for (; *s != '\0'; ++s)
|
||||
if (*s == '{')
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const char *Substitute(const char *msg, char *buf, size_t bufSize) {
|
||||
if (msg == nullptr || !HasBrace(msg))
|
||||
return msg; // common case: forward untouched, no copy
|
||||
|
||||
// A token inflates ~28x (a 3-char `{rtN}` -> ~85-byte `|T` markup), so a
|
||||
// marker-dense message can exceed `buf`. Never DROP message content to fit:
|
||||
// only expand a token when the expansion AND the whole remaining input (worst
|
||||
// case copied 1:1 as literal text) still fit. Past that point later tokens
|
||||
// stay literal `{rtN}`, so the full message text is always preserved — a DLL
|
||||
// user never sees a shorter line than a non-DLL user, just fewer icons.
|
||||
const size_t msgLen = std::strlen(msg);
|
||||
|
||||
size_t j = 0;
|
||||
for (size_t i = 0; msg[i] != '\0' && j + 1 < bufSize;) {
|
||||
if (msg[i] == '{') {
|
||||
// Scan the token body to the closing brace (bounded — longest alias
|
||||
// is 8 chars, so a `{` with no nearby `}` is just literal text).
|
||||
size_t k = i + 1;
|
||||
while (msg[k] != '\0' && msg[k] != '}' && k - i <= 12)
|
||||
++k;
|
||||
if (msg[k] == '}') {
|
||||
const int marker = MatchMarker(msg + i + 1, k - (i + 1));
|
||||
if (marker != 0) {
|
||||
const int col = (marker - 1) % 4;
|
||||
const int row = (marker - 1) / 4;
|
||||
const int l = col * 64, r = l + 64, t = row * 64, b = t + 64;
|
||||
// Emit the pipe-DOUBLED form `||T…||t`. The inline emitter
|
||||
// renders both `|T` and `||T`, but the vanilla speech-bubble
|
||||
// text builder (FUN_004b1600) TRUNCATES at a lone `|T` while
|
||||
// PRESERVING `||` — so the doubled form is what survives into
|
||||
// the bubble's FontString (which pfUI's bubble reskin copies
|
||||
// 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:0:0:0:0:256:256:"
|
||||
"%d:%d:%d:%d||t",
|
||||
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);
|
||||
if (m > 0 && j + static_cast<size_t>(m) + tail + 1 <= bufSize) {
|
||||
std::memcpy(buf + j, markup, static_cast<size_t>(m));
|
||||
j += static_cast<size_t>(m);
|
||||
i = k + 1; // consume through the closing `}`
|
||||
continue;
|
||||
}
|
||||
// No room for the expansion — fall through and copy the
|
||||
// literal `{` so the message is never left truncated mid-token.
|
||||
}
|
||||
}
|
||||
}
|
||||
buf[j++] = msg[i++];
|
||||
}
|
||||
buf[j] = '\0';
|
||||
return buf;
|
||||
}
|
||||
|
||||
} // namespace Chat::RaidMarkers
|
||||
@@ -0,0 +1,30 @@
|
||||
// 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 Chat::RaidMarkers {
|
||||
|
||||
// Expands raid-target marker tokens in a chat message into inline raid-target
|
||||
// icon textures — the modern `{rt1}`..`{rt8}` / `{star}` / `{circle}` / `{skull}`
|
||||
// (etc.) chat substitutions, backported onto the inline-texture renderer. Returns
|
||||
// `msg` unchanged when it contains no `{` (the common case — no copy), otherwise a
|
||||
// rewritten copy in `buf`. Called by Chat::Dispatch AFTER the `|T` anti-spoof
|
||||
// sanitize, so a player can use the fixed, safe marker icons but still can't
|
||||
// inject an arbitrary texture path. See RaidMarkers.cpp for the token table and
|
||||
// the icon→texcoord mapping.
|
||||
const char *Substitute(const char *msg, char *buf, size_t bufSize);
|
||||
|
||||
} // namespace Chat::RaidMarkers
|
||||
@@ -0,0 +1,111 @@
|
||||
// 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/>.
|
||||
|
||||
// `StartAttack([target])` / `StopAttack()` — the non-toggling melee
|
||||
// auto-attack verbs. Vanilla 1.12 ships only `AttackTarget()`, which
|
||||
// *toggles* (start <-> stop each call), so a `/startattack` can't guarantee
|
||||
// it won't cancel an in-progress swing. These split the engine's own attack
|
||||
// machinery into the two explicit, idempotent verbs modern clients expose.
|
||||
//
|
||||
// 1.12's `AttackTarget` (`0x00489B50`) is `FUN_006131A0`: resolve the target,
|
||||
// then `isAttacking ? stop : start`. We call the same start/stop primitives
|
||||
// directly (see the `FUN_ATTACK_*` notes in Offsets.h), skipping the toggle:
|
||||
// StartAttack -> the "start" leg only (idempotent; switches or no-ops on a
|
||||
// valid target, never stops it).
|
||||
// StopAttack -> the "stop" leg only (self-guards, so a no-op when idle).
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Combat::Attack {
|
||||
|
||||
namespace {
|
||||
|
||||
using Start_t = void(__thiscall *)(void *player, const uint64_t *targetGuid);
|
||||
using Stop_t = void(__fastcall *)(void *player);
|
||||
using ResolveTarget_t = int(__thiscall *)(void *player, uint64_t *outGuid);
|
||||
|
||||
// Resolve an optional unit-token argument to a GUID without raising.
|
||||
// `Unit::Identity::GuidForToken` raises for non-token strings (a name or
|
||||
// garbage — 1.12 has no name->GUID resolver), so run it under `pcall`; a raise
|
||||
// or an empty token yields 0, which the caller treats as "use current target".
|
||||
int __fastcall ResolveTokenThunk(void *L) {
|
||||
using namespace Game::Lua;
|
||||
const char *token = ToString(L, 1);
|
||||
const uint64_t g = (token && *token) ? Unit::Identity::GuidForToken(token) : 0;
|
||||
PushNumber(L, static_cast<double>(static_cast<uint32_t>(g)));
|
||||
PushNumber(L, static_cast<double>(static_cast<uint32_t>(g >> 32)));
|
||||
return 2;
|
||||
}
|
||||
|
||||
uint64_t SafeResolveToken(void *L, const char *token) {
|
||||
using namespace Game::Lua;
|
||||
if (token == nullptr || *token == '\0')
|
||||
return 0;
|
||||
|
||||
const int base = GetTop(L);
|
||||
PushCClosure(L, &ResolveTokenThunk, 0);
|
||||
PushString(L, token);
|
||||
uint64_t guid = 0;
|
||||
if (PCall(L, 1, 2, 0) == 0) {
|
||||
const uint32_t lo = static_cast<uint32_t>(ToNumber(L, -2));
|
||||
const uint32_t hi = static_cast<uint32_t>(ToNumber(L, -1));
|
||||
guid = (static_cast<uint64_t>(hi) << 32) | lo;
|
||||
}
|
||||
SetTop(L, base); // drop the results (or the caught error message)
|
||||
return guid;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static int __fastcall Script_StartAttack(void *L) {
|
||||
auto *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
|
||||
if (player == nullptr)
|
||||
return 0;
|
||||
|
||||
uint64_t target = SafeResolveToken(L, Game::Lua::ToString(L, 1));
|
||||
if (target == 0) {
|
||||
// No explicit target given (or unresolvable): validate and take the
|
||||
// current target. Returns 0 — and posts the engine's own UI error —
|
||||
// when there's nothing valid to attack.
|
||||
auto resolve =
|
||||
reinterpret_cast<ResolveTarget_t>(Offsets::FUN_ATTACK_RESOLVE_TARGET);
|
||||
if (resolve(player, &target) == 0)
|
||||
return 0;
|
||||
}
|
||||
|
||||
reinterpret_cast<Start_t>(Offsets::FUN_ATTACK_START)(player, &target);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int __fastcall Script_StopAttack(void *L) {
|
||||
(void)L;
|
||||
auto *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
|
||||
if (player == nullptr)
|
||||
return 0;
|
||||
|
||||
reinterpret_cast<Stop_t>(Offsets::FUN_ATTACK_STOP)(player);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("StartAttack", &Script_StartAttack);
|
||||
Game::Lua::RegisterGlobalFunction("StopAttack", &Script_StopAttack);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace Combat::Attack
|
||||
+19
-19
@@ -80,12 +80,12 @@ bool BuildIconPath(uint32_t displayInfoID, char *out, size_t outSize) {
|
||||
// readable-text id is non-zero (letters / server-authored notes).
|
||||
bool ItemReadable(const uint8_t *item, const uint8_t *record) {
|
||||
if (record != nullptr &&
|
||||
*reinterpret_cast<const uint32_t *>(record + Offsets::OFF_ITEMSTATS_PAGE_TEXT) != 0)
|
||||
Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_PAGE_TEXT) != 0)
|
||||
return true;
|
||||
const uint8_t *descriptor = Item::ObjectFields(item);
|
||||
return descriptor != nullptr &&
|
||||
*reinterpret_cast<const uint32_t *>(
|
||||
descriptor + Offsets::OFF_DESCRIPTOR_READABLE_TEXT_ID) != 0;
|
||||
Game::Read<uint32_t>(
|
||||
descriptor, Offsets::OFF_DESCRIPTOR_READABLE_TEXT_ID) != 0;
|
||||
}
|
||||
|
||||
int __fastcall Script_C_Container_GetContainerItemInfo(void *L) {
|
||||
@@ -116,14 +116,14 @@ int __fastcall Script_C_Container_GetContainerItemInfo(void *L) {
|
||||
int stackCount = 0;
|
||||
bool isBound = false;
|
||||
if (descriptor != nullptr) {
|
||||
stackCount = *reinterpret_cast<const int *>(
|
||||
descriptor + Offsets::OFF_DESCRIPTOR_STACK_COUNT);
|
||||
isBound = (*reinterpret_cast<const uint32_t *>(
|
||||
descriptor + Offsets::OFF_DESCRIPTOR_FLAGS) &
|
||||
stackCount = Game::Read<int>(
|
||||
descriptor, Offsets::OFF_DESCRIPTOR_STACK_COUNT);
|
||||
isBound = (Game::Read<uint32_t>(
|
||||
descriptor, Offsets::OFF_DESCRIPTOR_FLAGS) &
|
||||
Offsets::ITEM_FLAG_SOULBOUND) != 0;
|
||||
}
|
||||
const bool isLocked = (*reinterpret_cast<const uint32_t *>(
|
||||
item + Offsets::OFF_ITEM_CLIENT_LOCK) &
|
||||
const bool isLocked = (Game::Read<uint32_t>(
|
||||
item, Offsets::OFF_ITEM_CLIENT_LOCK) &
|
||||
Offsets::ITEM_CLIENT_LOCK_BIT) != 0;
|
||||
const bool isReadable = ItemReadable(item, record);
|
||||
|
||||
@@ -135,17 +135,17 @@ int __fastcall Script_C_Container_GetContainerItemInfo(void *L) {
|
||||
bool hasLoot = false;
|
||||
bool hasNoValue = false;
|
||||
if (record != nullptr) {
|
||||
const uint32_t displayInfoID = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID);
|
||||
const uint32_t displayInfoID = Game::Read<uint32_t>(
|
||||
record, Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID);
|
||||
haveIcon = BuildIconPath(displayInfoID, iconPath, sizeof(iconPath));
|
||||
quality = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_QUALITY);
|
||||
quality = Game::Read<uint32_t>(
|
||||
record, Offsets::OFF_ITEMSTATS_QUALITY);
|
||||
haveQuality = true;
|
||||
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_FLAGS);
|
||||
const uint32_t flags = Game::Read<uint32_t>(
|
||||
record, Offsets::OFF_ITEMSTATS_FLAGS);
|
||||
hasLoot = (flags & Offsets::ITEM_FLAG_LOOTABLE) != 0;
|
||||
hasNoValue = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SELL_PRICE) == 0;
|
||||
hasNoValue = Game::Read<uint32_t>(
|
||||
record, Offsets::OFF_ITEMSTATS_SELL_PRICE) == 0;
|
||||
}
|
||||
|
||||
const char *hyperlink = Item::Link::FromCGItem(item);
|
||||
@@ -155,8 +155,8 @@ int __fastcall Script_C_Container_GetContainerItemInfo(void *L) {
|
||||
if (Item::Link::NameFromCGItem(item, nameBuf, sizeof(nameBuf))) {
|
||||
itemName = nameBuf;
|
||||
} else if (record != nullptr) {
|
||||
const char *baseName = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSTATS_NAME);
|
||||
const char *baseName = Game::Read<const char *>(
|
||||
record, Offsets::OFF_ITEMSTATS_NAME);
|
||||
if (baseName != nullptr && baseName[0] != '\0')
|
||||
itemName = baseName;
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
+34
-22
@@ -53,6 +53,7 @@
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "cache/QueryLoad.h"
|
||||
#include "creature/Info.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "dbc/Names.h"
|
||||
#include "event/Custom.h"
|
||||
@@ -94,14 +95,14 @@ int __fastcall Script_GetCreatureInfoByID(void *L) {
|
||||
if (rec == nullptr)
|
||||
return 0; // not cached -> nil
|
||||
|
||||
const char *name = *reinterpret_cast<const char *const *>(rec + Offsets::OFF_CREATURE_NAME);
|
||||
const char *name = Game::Read<const char *>(rec, Offsets::OFF_CREATURE_NAME);
|
||||
const char *subName =
|
||||
*reinterpret_cast<const char *const *>(rec + Offsets::OFF_CREATURE_SUBNAME);
|
||||
const uint32_t type = *reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_CREATURE_TYPE);
|
||||
const uint32_t family = *reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_CREATURE_FAMILY);
|
||||
const uint32_t rank = *reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_CREATURE_RANK);
|
||||
Game::Read<const char *>(rec, Offsets::OFF_CREATURE_SUBNAME);
|
||||
const uint32_t type = Game::Read<uint32_t>(rec, Offsets::OFF_CREATURE_TYPE);
|
||||
const uint32_t family = Game::Read<uint32_t>(rec, Offsets::OFF_CREATURE_FAMILY);
|
||||
const uint32_t rank = Game::Read<uint32_t>(rec, Offsets::OFF_CREATURE_RANK);
|
||||
const uint32_t displayID =
|
||||
*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_CREATURE_DISPLAYID);
|
||||
Game::Read<uint32_t>(rec, Offsets::OFF_CREATURE_DISPLAYID);
|
||||
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "creatureID", creatureID);
|
||||
@@ -212,7 +213,7 @@ int __fastcall Script_GetCreatureFamilyIDs(void *L) {
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
const int count =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_CREATUREFAMILY_COUNT);
|
||||
Game::Read<int>(Offsets::VAR_CREATUREFAMILY_COUNT);
|
||||
int n = 0;
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const char *name = DBC::LocalizedField(Offsets::VAR_CREATUREFAMILY_RECORDS,
|
||||
@@ -249,8 +250,8 @@ int __fastcall Script_GetFactionInfo(void *L) {
|
||||
static_cast<uint32_t>(raceID));
|
||||
if (crRec == nullptr)
|
||||
return 0; // unknown race
|
||||
const int ftId = *reinterpret_cast<const int *>(
|
||||
crRec + Offsets::OFF_CHRRACES_FACTION_TEMPLATE);
|
||||
const int ftId = Game::Read<int>(
|
||||
crRec, Offsets::OFF_CHRRACES_FACTION_TEMPLATE);
|
||||
if (ftId <= 0)
|
||||
return 0;
|
||||
|
||||
@@ -259,31 +260,31 @@ int __fastcall Script_GetFactionInfo(void *L) {
|
||||
static_cast<uint32_t>(ftId));
|
||||
if (ftRec == nullptr)
|
||||
return 0;
|
||||
const uint32_t mask = *reinterpret_cast<const uint32_t *>(
|
||||
ftRec + Offsets::OFF_FACTIONTEMPLATE_GROUP_MASK);
|
||||
const uint32_t mask = Game::Read<uint32_t>(
|
||||
ftRec, Offsets::OFF_FACTIONTEMPLATE_GROUP_MASK);
|
||||
|
||||
// FactionGroup is stored contiguously (records-base + i*stride), not as a
|
||||
// pointer array — iterate by hand rather than via DBC::Record.
|
||||
auto *fgBase = *reinterpret_cast<const uint8_t *const *>(
|
||||
auto *fgBase = Game::Read<const uint8_t *>(
|
||||
Offsets::VAR_FACTIONGROUP_RECORDS);
|
||||
const int fgCount =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_FACTIONGROUP_COUNT);
|
||||
const int locale = *reinterpret_cast<const int *>(Offsets::VAR_LOCALE_INDEX);
|
||||
Game::Read<int>(Offsets::VAR_FACTIONGROUP_COUNT);
|
||||
const int locale = Game::Read<int>(Offsets::VAR_LOCALE_INDEX);
|
||||
if (fgBase == nullptr)
|
||||
return 0;
|
||||
|
||||
for (int i = 0; i < fgCount; ++i) {
|
||||
const uint8_t *row = fgBase + i * Offsets::FACTIONGROUP_STRIDE;
|
||||
const int bit = *reinterpret_cast<const int *>(
|
||||
row + Offsets::OFF_FACTIONGROUP_BIT);
|
||||
const int bit = Game::Read<int>(
|
||||
row, Offsets::OFF_FACTIONGROUP_BIT);
|
||||
if ((mask & (1u << (bit & 0x1F))) == 0)
|
||||
continue;
|
||||
const char *loc = *reinterpret_cast<const char *const *>(
|
||||
row + Offsets::OFF_FACTIONGROUP_NAMES + locale * 4);
|
||||
const char *loc = Game::Read<const char *>(
|
||||
row, Offsets::OFF_FACTIONGROUP_NAMES + locale * 4);
|
||||
if (loc == nullptr || *loc == '\0')
|
||||
continue; // "Player" / "Monster" have empty localized names
|
||||
const char *eng = *reinterpret_cast<const char *const *>(
|
||||
row + Offsets::OFF_FACTIONGROUP_ENGLISH);
|
||||
const char *eng = Game::Read<const char *>(
|
||||
row, Offsets::OFF_FACTIONGROUP_ENGLISH);
|
||||
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldString(L, "name", loc);
|
||||
@@ -323,7 +324,7 @@ int __fastcall Script_GetCreatureTypeIDs(void *L) {
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
const int count =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_CREATURETYPE_COUNT);
|
||||
Game::Read<int>(Offsets::VAR_CREATURETYPE_COUNT);
|
||||
int n = 0;
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const char *name = DBC::LocalizedField(Offsets::VAR_CREATURETYPE_RECORDS,
|
||||
@@ -360,11 +361,22 @@ void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetCreatureTypeIDs",
|
||||
&Script_GetCreatureTypeIDs);
|
||||
Cache::QueryLoad::Register(reinterpret_cast<void *>(Offsets::VAR_CREATURE_CACHE),
|
||||
kCreatureDataLoadResult, Offsets::FUN_CREATURE_GET_RECORD);
|
||||
&_reserveLoadResult, Offsets::FUN_CREATURE_GET_RECORD);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
// Public accessor for Model::DisplayInfo's SetCreature — the same peek
|
||||
// GetCreatureInfoByID uses, reduced to the display-ID field. 0 = uncached.
|
||||
uint32_t DisplayID(uint32_t creatureID) {
|
||||
if (creatureID == 0)
|
||||
return 0;
|
||||
const uint8_t *rec = PeekCreature(creatureID);
|
||||
if (rec == nullptr)
|
||||
return 0;
|
||||
return Game::Read<uint32_t>(rec, Offsets::OFF_CREATURE_DISPLAYID);
|
||||
}
|
||||
|
||||
} // namespace Creature::Info
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Creature::Info {
|
||||
|
||||
// The cached model display ID for a creature/NPC entry, or 0 when the creature
|
||||
// is not in the client creature cache (creaturecache.wdb, or queried this
|
||||
// session). A synchronous peek — no network query. Backs `Model:SetCreature`:
|
||||
// the cache being keyed by entry ID and carrying a display ID is exactly the
|
||||
// entry-ID → model mapping vanilla has no DBC for.
|
||||
uint32_t DisplayID(uint32_t creatureID);
|
||||
|
||||
} // namespace Creature::Info
|
||||
@@ -0,0 +1,144 @@
|
||||
// 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/>.
|
||||
|
||||
// `GetCoinTextureString` / `C_CurrencyInfo.GetCoinTextureString` — the coin-icon
|
||||
// money string (e.g. "12<gold> 34<silver> 56<copper>"), a C++ backport of the
|
||||
// retail C function.
|
||||
//
|
||||
// Behaviour derived from 3.3.5's `Script_GetCoinTextureString` (FUN_00510d00 →
|
||||
// formatter FUN_007e7e10): split the copper amount into gold / silver / copper,
|
||||
// emit only the non-zero denominations in that order, space-separated; a zero
|
||||
// amount shows "0" copper. Each denomination is built from a localized
|
||||
// GlobalString — `GOLD_AMOUNT_TEXTURE` / `SILVER_AMOUNT_TEXTURE` /
|
||||
// `COPPER_AMOUNT_TEXTURE` — exactly as the engine does (its formatter
|
||||
// `FUN_0076f070` is a C-format over the same GlobalString). Those strings don't
|
||||
// exist in stock 1.12, so they're backported in `locales/enUS.lua`
|
||||
// (language-independent, so the enUS base covers every locale); the embedded
|
||||
// `!!!ClassicAPI` addon guarantees they're loaded.
|
||||
//
|
||||
// 1.12 has no separate UI-GoldIcon / UI-SilverIcon / UI-CopperIcon textures
|
||||
// (those are a later-expansion split); vanilla uses ONE sprite sheet
|
||||
// `Interface\MoneyFrame\UI-MoneyIcons` cropped per coin (gold 0-0.25, silver
|
||||
// 0.25-0.5, copper 0.5-0.75 across a 4-cell strip — from CoinPickupFrame.xml).
|
||||
// So the format carries the inline-texture texcoord fields (texW=4 texH=1, then
|
||||
// the per-coin left:right cell). The `|T` markup renders as real coin icons via
|
||||
// ClassicAPI's inline-texture backport (`Text::InlineTexture`).
|
||||
|
||||
#include "Game.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
namespace Currency::CoinText {
|
||||
|
||||
namespace {
|
||||
|
||||
// Icon size. Retail defaults fontHeight to 0 = "use the display font's height",
|
||||
// and the inline-texture renderer now honours 0 (it resolves the coin to the
|
||||
// line's font height at draw time), so coins match the surrounding text instead
|
||||
// of a fixed size. Callers may still pass an explicit height.
|
||||
constexpr int kDefaultHeight = 0;
|
||||
|
||||
// Fallbacks byte-identical to the backported GlobalStrings, used only if the
|
||||
// locale layer somehow isn't loaded when this is first called.
|
||||
// Format: count |T sheet : height=0 : width=0 : offsetX=0 : offsetY=0 : texW=4 :
|
||||
// texH=1 : left : right : top=0 : bottom=1 |t (left:right selects the coin cell).
|
||||
// height/width 0 = size to the font. offsetX stays 0: the inline-texture
|
||||
// renderer's font-relative pad spaces the coin from its digits (the historical
|
||||
// "coin sits on the digit" bug was the emitter's lazy pen read-back, fixed in
|
||||
// the DLL). Never grow a per-coin offset here.
|
||||
constexpr const char *kGoldFallback =
|
||||
"%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:0:1:0:1|t";
|
||||
constexpr const char *kSilverFallback =
|
||||
"%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:1:2:0:1|t";
|
||||
// The three coin arts occupy IDENTICAL pixels within their sheet cells (x 0-14,
|
||||
// y 1-14 of 16 — confirmed by decoding the DXT3 alpha of the exported
|
||||
// UI-MoneyIcons.blp); only the left:right cell select differs (gold 0:1,
|
||||
// silver 1:2, copper 2:3).
|
||||
constexpr const char *kCopperFallback =
|
||||
"%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:2:3:0:1|t";
|
||||
|
||||
// Formats one denomination from its localized GlobalString and appends it to
|
||||
// `out`. Reads `_G[globalName]` the way retail reads the coin GlobalStrings; the
|
||||
// format consumes (count, iconHeight, iconWidth). No separator between
|
||||
// denominations — the renderer's symmetric per-icon pad already spaces each coin
|
||||
// from the surrounding digits, so a space here would double the gap.
|
||||
void AppendDenom(void *L, std::string &out, const char *globalName, const char *fallback,
|
||||
int count, int height) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushLocalizedString(L, globalName, fallback); // [.., fmt]
|
||||
const char *fmt = Game::Lua::ToString(L, -1);
|
||||
char piece[256];
|
||||
piece[0] = '\0';
|
||||
if (fmt != nullptr)
|
||||
std::snprintf(piece, sizeof piece, fmt, count, height, height);
|
||||
Game::Lua::SetTop(L, top); // pop the format string
|
||||
out += piece;
|
||||
}
|
||||
|
||||
// GetCoinTextureString(amount [, fontHeight]) -> string
|
||||
int __fastcall Script_GetCoinTextureString(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: GetCoinTextureString(amount [, fontHeight])");
|
||||
return 0; // unreachable — Error long-jumps
|
||||
}
|
||||
const double amt = Game::Lua::ToNumber(L, 1);
|
||||
long long amount = static_cast<long long>(amt < 0.0 ? amt - 0.5 : amt + 0.5); // round
|
||||
if (amount < 0)
|
||||
amount = 0;
|
||||
// Vanilla money is a uint32 copper field, so 0xFFFFFFFF copper (~429,496
|
||||
// gold) is the most the game can represent. Clamp to it: an unclamped huge
|
||||
// input (e.g. 1e15) makes `gold` exceed INT_MAX, and the cast below truncates
|
||||
// it to the low 32 bits, printing a garbage count through the "%d" format.
|
||||
constexpr long long kMaxCopper = 0xFFFFFFFFLL;
|
||||
if (amount > kMaxCopper)
|
||||
amount = kMaxCopper;
|
||||
|
||||
int height = kDefaultHeight;
|
||||
if (Game::Lua::IsNumber(L, 2)) {
|
||||
const int h = static_cast<int>(Game::Lua::ToNumber(L, 2));
|
||||
if (h > 0)
|
||||
height = h;
|
||||
}
|
||||
|
||||
const int gold = static_cast<int>(amount / 10000);
|
||||
const int silver = static_cast<int>((amount % 10000) / 100);
|
||||
const int copper = static_cast<int>(amount % 100);
|
||||
|
||||
std::string out;
|
||||
if (amount == 0) {
|
||||
AppendDenom(L, out, "COPPER_AMOUNT_TEXTURE", kCopperFallback, 0, height);
|
||||
} else {
|
||||
if (gold > 0)
|
||||
AppendDenom(L, out, "GOLD_AMOUNT_TEXTURE", kGoldFallback, gold, height);
|
||||
if (silver > 0)
|
||||
AppendDenom(L, out, "SILVER_AMOUNT_TEXTURE", kSilverFallback, silver, height);
|
||||
if (copper > 0)
|
||||
AppendDenom(L, out, "COPPER_AMOUNT_TEXTURE", kCopperFallback, copper, height);
|
||||
}
|
||||
|
||||
Game::Lua::PushString(L, out.c_str());
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("GetCoinTextureString", &Script_GetCoinTextureString);
|
||||
Game::Lua::RegisterTableFunction("C_CurrencyInfo", "GetCoinTextureString",
|
||||
&Script_GetCoinTextureString);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Currency::CoinText
|
||||
+1
-4
@@ -64,11 +64,8 @@ namespace Debug::Probe {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
|
||||
const uint8_t *ResolvePlayer() {
|
||||
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
return static_cast<const uint8_t *>(fn("player"));
|
||||
return static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
|
||||
}
|
||||
|
||||
const uint8_t *ResolvePlayerDescriptor() {
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Modern EditBox method backports — cursor, selection, focus and text-state
|
||||
// accessors that vanilla's 48-method EditBox registry lacks but modern addons
|
||||
// call unconditionally (the caret bug that motivated this: TwitchEmotes'
|
||||
// autocomplete calls SetCursorPosition after replacing the text).
|
||||
//
|
||||
// SetCursorPosition / GetCursorPosition — caret as a byte offset
|
||||
// GetUTF8CursorPosition — caret as a character index
|
||||
// ClearHighlightText — drop the selection, keep the caret
|
||||
// HasFocus — does this box own keyboard focus
|
||||
// HasText — is the box non-empty
|
||||
// Set/GetHighlightColor — the text-selection highlight color
|
||||
// ClearHistory — empty the up/down input history
|
||||
//
|
||||
// The engine already stores and manipulates all of this; we just expose it.
|
||||
// Every method is registered on the EditBox method registry, so — like any
|
||||
// per-type frame method — only EditBox frames dispatch to them (a Button or
|
||||
// Frame still gets "method not found"), so the resolved `this` is always a
|
||||
// real CSimpleEditBox.
|
||||
//
|
||||
// The caret is a BYTE offset into the UTF-8 text buffer (field +0x36c), which
|
||||
// is exactly what modern WoW's byte-based EditBox cursor uses — for ASCII text
|
||||
// a byte offset equals a character index, so callers passing `#text` land at
|
||||
// the end either way.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace EditBox::Methods {
|
||||
|
||||
namespace {
|
||||
|
||||
using SetCursorByte_t = void(__thiscall *)(void *editbox, int byteOffset);
|
||||
using CollapseSelection_t = void(__thiscall *)(void *editbox);
|
||||
using CountChars_t = int(__thiscall *)(void *editbox, int startByte, int byteCount);
|
||||
using RegionSetColor_t = void(__thiscall *)(void *region, const uint32_t *colorBGRA);
|
||||
using RegionGetColor_t = void(__thiscall *)(void *region, uint32_t *outBGRA);
|
||||
using SetHistoryMax_t = void(__thiscall *)(void *editbox, int maxLines);
|
||||
|
||||
// A Lua color arg (0..1) clamped and scaled to a 0..255 byte, matching the
|
||||
// engine's own float->byte color conversion (truncating, like Texture's
|
||||
// SetVertexColor). `dflt` is used when the arg is absent.
|
||||
uint32_t ColorByte(void *L, int idx, double dflt) {
|
||||
double v = Game::Lua::IsNumber(L, idx) ? Game::Lua::ToNumber(L, idx) : dflt;
|
||||
if (v < 0.0)
|
||||
v = 0.0;
|
||||
else if (v > 1.0)
|
||||
v = 1.0;
|
||||
return static_cast<uint32_t>(v * 255.0);
|
||||
}
|
||||
|
||||
int __fastcall Script_SetCursorPosition(void *L) {
|
||||
void *eb = Game::Lua::ResolveObject(L, 1);
|
||||
if (eb == nullptr || !Game::Lua::IsNumber(L, 2)) {
|
||||
Game::Lua::Error(L, "Usage: EditBox:SetCursorPosition(position)");
|
||||
return 0;
|
||||
}
|
||||
const int pos = static_cast<int>(Game::Lua::ToNumber(L, 2));
|
||||
reinterpret_cast<SetCursorByte_t>(
|
||||
Offsets::FUN_EDITBOX_SET_CURSOR_BYTE)(eb, pos);
|
||||
reinterpret_cast<CollapseSelection_t>(
|
||||
Offsets::FUN_EDITBOX_COLLAPSE_SELECTION)(eb);
|
||||
// COLLAPSE_SELECTION moves selStart/selEnd but doesn't flag them; set the
|
||||
// selection-dirty bit (2) so any highlight from before is cleared on the
|
||||
// next paint. SET_CURSOR_BYTE already set the cursor-dirty bit (4).
|
||||
Game::Ref<uint32_t>(eb, Offsets::OFF_EDITBOX_FLAGS) |= 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetCursorPosition(void *L) {
|
||||
void *eb = Game::Lua::ResolveObject(L, 1);
|
||||
if (eb == nullptr) {
|
||||
Game::Lua::Error(L, "Usage: EditBox:GetCursorPosition()");
|
||||
return 0;
|
||||
}
|
||||
Game::Lua::PushNumber(L, static_cast<double>(
|
||||
Game::Read<int>(eb, Offsets::OFF_EDITBOX_CURSOR_BYTE)));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// The caret as a 0-based CHARACTER index (UTF-8 codepoints before the caret),
|
||||
// vs GetCursorPosition's byte offset. Equal for ASCII; differs once the text
|
||||
// holds multibyte characters.
|
||||
int __fastcall Script_GetUTF8CursorPosition(void *L) {
|
||||
void *eb = Game::Lua::ResolveObject(L, 1);
|
||||
if (eb == nullptr) {
|
||||
Game::Lua::Error(L, "Usage: EditBox:GetUTF8CursorPosition()");
|
||||
return 0;
|
||||
}
|
||||
const int cursorByte = Game::Read<int>(eb, Offsets::OFF_EDITBOX_CURSOR_BYTE);
|
||||
const int chars = reinterpret_cast<CountChars_t>(
|
||||
Offsets::FUN_EDITBOX_COUNT_CHARS)(eb, 0, cursorByte);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(chars));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Drop the current selection without moving the caret. Collapsing selection to
|
||||
// the caret (selStart = selEnd = cursor) is exactly what the engine's own
|
||||
// helper does; vanilla can only clear a selection via HighlightText(0, 0),
|
||||
// which yanks it to offset 0 instead of leaving the caret in place.
|
||||
int __fastcall Script_ClearHighlightText(void *L) {
|
||||
void *eb = Game::Lua::ResolveObject(L, 1);
|
||||
if (eb == nullptr) {
|
||||
Game::Lua::Error(L, "Usage: EditBox:ClearHighlightText()");
|
||||
return 0;
|
||||
}
|
||||
reinterpret_cast<CollapseSelection_t>(
|
||||
Offsets::FUN_EDITBOX_COLLAPSE_SELECTION)(eb);
|
||||
Game::Ref<uint32_t>(eb, Offsets::OFF_EDITBOX_FLAGS) |= 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// True when this edit box currently owns keyboard focus. The engine tracks the
|
||||
// focused box in one global (written by SetFocus / cleared by ClearFocus), so
|
||||
// focus is just pointer identity against it.
|
||||
int __fastcall Script_HasFocus(void *L) {
|
||||
void *eb = Game::Lua::ResolveObject(L, 1);
|
||||
if (eb == nullptr) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
Game::Lua::PushBool(L,
|
||||
eb == Game::Read<void *>(Offsets::VAR_FOCUSED_EDITBOX));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// True when the box holds any text (its byte length is non-zero).
|
||||
int __fastcall Script_HasText(void *L) {
|
||||
void *eb = Game::Lua::ResolveObject(L, 1);
|
||||
if (eb == nullptr) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
Game::Lua::PushBool(L,
|
||||
Game::Read<int>(eb, Offsets::OFF_EDITBOX_TEXT_LENGTH) > 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// SetHighlightColor(r, g, b [, a]) — the color of the text-selection highlight.
|
||||
// The engine paints the selection with three Texture regions (start-line,
|
||||
// middle-block, end-line); their vertex color is the highlight color, so set
|
||||
// it on all three via the engine's own region color setter. Colors are 0..1;
|
||||
// alpha defaults to 1. Takes effect on the next selection paint.
|
||||
int __fastcall Script_SetHighlightColor(void *L) {
|
||||
void *eb = Game::Lua::ResolveObject(L, 1);
|
||||
if (eb == nullptr || !Game::Lua::IsNumber(L, 2) ||
|
||||
!Game::Lua::IsNumber(L, 3) || !Game::Lua::IsNumber(L, 4)) {
|
||||
Game::Lua::Error(L, "Usage: EditBox:SetHighlightColor(r, g, b [, a])");
|
||||
return 0;
|
||||
}
|
||||
const uint32_t r = ColorByte(L, 2, 1.0);
|
||||
const uint32_t g = ColorByte(L, 3, 1.0);
|
||||
const uint32_t b = ColorByte(L, 4, 1.0);
|
||||
const uint32_t a = ColorByte(L, 5, 1.0);
|
||||
const uint32_t packed = b | (g << 8) | (r << 16) | (a << 24); // {b,g,r,a}
|
||||
auto setColor = reinterpret_cast<RegionSetColor_t>(
|
||||
Offsets::FUN_FONTSTRING_SET_COLOR);
|
||||
for (int off = Offsets::OFF_EDITBOX_HIGHLIGHT_REGION;
|
||||
off <= Offsets::OFF_EDITBOX_HIGHLIGHT_REGION + 8; off += 4) {
|
||||
void *region = Game::Read<void *>(eb, off);
|
||||
if (region != nullptr)
|
||||
setColor(region, &packed);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// GetHighlightColor() -> r, g, b, a (each 0..1). Reads the color off the first
|
||||
// highlight region; a region with no explicit color reads back white (opaque).
|
||||
int __fastcall Script_GetHighlightColor(void *L) {
|
||||
void *eb = Game::Lua::ResolveObject(L, 1);
|
||||
if (eb == nullptr) {
|
||||
Game::Lua::Error(L, "Usage: EditBox:GetHighlightColor()");
|
||||
return 0;
|
||||
}
|
||||
uint32_t packed = 0xFFFFFFFF;
|
||||
void *region = Game::Read<void *>(eb, Offsets::OFF_EDITBOX_HIGHLIGHT_REGION);
|
||||
if (region != nullptr)
|
||||
reinterpret_cast<RegionGetColor_t>(Offsets::FUN_REGION_GET_COLOR)(
|
||||
region, &packed);
|
||||
const double inv = 1.0 / 255.0;
|
||||
Game::Lua::PushNumber(L, ((packed >> 16) & 0xff) * inv); // r
|
||||
Game::Lua::PushNumber(L, ((packed >> 8) & 0xff) * inv); // g
|
||||
Game::Lua::PushNumber(L, (packed & 0xff) * inv); // b
|
||||
Game::Lua::PushNumber(L, ((packed >> 24) & 0xff) * inv); // a
|
||||
return 4;
|
||||
}
|
||||
|
||||
// Empties the up/down input history, keeping the line limit so the box keeps
|
||||
// recording afterward. The engine's SetHistoryLines internal frees the whole
|
||||
// ring at max 0, so clear = free (0) then reallocate at the saved max — which
|
||||
// drops every entry and resets the write position. No-op when history was
|
||||
// never enabled (max 0).
|
||||
int __fastcall Script_ClearHistory(void *L) {
|
||||
void *eb = Game::Lua::ResolveObject(L, 1);
|
||||
if (eb == nullptr) {
|
||||
Game::Lua::Error(L, "Usage: EditBox:ClearHistory()");
|
||||
return 0;
|
||||
}
|
||||
const int max = Game::Read<int>(eb, Offsets::OFF_EDITBOX_HISTORY_MAX);
|
||||
if (max > 0) {
|
||||
auto setMax = reinterpret_cast<SetHistoryMax_t>(
|
||||
Offsets::FUN_EDITBOX_SET_HISTORY_MAX);
|
||||
setMax(eb, 0);
|
||||
setMax(eb, max);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const Game::Lua::FrameMethodEntry g_methods[] = {
|
||||
{"SetCursorPosition", &Script_SetCursorPosition},
|
||||
{"GetCursorPosition", &Script_GetCursorPosition},
|
||||
{"GetUTF8CursorPosition", &Script_GetUTF8CursorPosition},
|
||||
{"ClearHighlightText", &Script_ClearHighlightText},
|
||||
{"HasFocus", &Script_HasFocus},
|
||||
{"HasText", &Script_HasText},
|
||||
{"SetHighlightColor", &Script_SetHighlightColor},
|
||||
{"GetHighlightColor", &Script_GetHighlightColor},
|
||||
{"ClearHistory", &Script_ClearHistory},
|
||||
};
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterFrameMethods(
|
||||
reinterpret_cast<void *>(Offsets::VAR_EDITBOX_METHOD_REGISTRY),
|
||||
g_methods,
|
||||
static_cast<int>(sizeof(g_methods) / sizeof(g_methods[0])));
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace EditBox::Methods
|
||||
@@ -386,7 +386,7 @@ int __fastcall Script_UseEquipmentSet(void *L) {
|
||||
const uint32_t setID = ArgSetID(L, 1);
|
||||
const Set *s = Data::FindByID(setID);
|
||||
if (s == nullptr) {
|
||||
const int evt = Event::Custom::Lookup(kSwapFinishedEvent);
|
||||
const int evt = kEvtSwapFinished.Slot();
|
||||
if (evt >= 0)
|
||||
Event::Custom::Fire(evt, "%d%d", 0, static_cast<int>(setID));
|
||||
Game::Lua::PushBoolean(L, 0);
|
||||
@@ -396,7 +396,7 @@ int __fastcall Script_UseEquipmentSet(void *L) {
|
||||
// Fire PENDING right after the set-exists check, before any of
|
||||
// the swap work. Addon UI can use this to gate swap-in-progress
|
||||
// visuals.
|
||||
const int pendingEvt = Event::Custom::Lookup(kSwapPendingEvent);
|
||||
const int pendingEvt = kEvtSwapPending.Slot();
|
||||
if (pendingEvt >= 0)
|
||||
Event::Custom::Fire(pendingEvt, "%d", static_cast<int>(setID));
|
||||
|
||||
@@ -569,7 +569,7 @@ int __fastcall Script_UseEquipmentSet(void *L) {
|
||||
virtualPaperdoll[targetSlot - 1] = 0;
|
||||
}
|
||||
|
||||
const int evt = Event::Custom::Lookup(kSwapFinishedEvent);
|
||||
const int evt = kEvtSwapFinished.Slot();
|
||||
if (evt >= 0)
|
||||
Event::Custom::Fire(evt, "%d%d", 1, static_cast<int>(setID));
|
||||
|
||||
@@ -620,8 +620,5 @@ static void RegisterLuaFunctions() {
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
static const Event::Custom::AutoReserve _reserve{kEventName};
|
||||
static const Event::Custom::AutoReserve _reservePending{kSwapPendingEvent};
|
||||
static const Event::Custom::AutoReserve _reserveFinished{kSwapFinishedEvent};
|
||||
|
||||
} // namespace EquipmentSet::Api
|
||||
|
||||
@@ -75,7 +75,7 @@ uint32_t NextAvailableID() {
|
||||
}
|
||||
|
||||
void FireChanged() {
|
||||
const int slot = Event::Custom::Lookup(kEventName);
|
||||
const int slot = kEvtSetsChanged.Slot();
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "");
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "event/Custom.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
@@ -109,8 +111,9 @@ constexpr int LOC_MISSING = -1;
|
||||
// coincidence, not load-bearing).
|
||||
constexpr int LOC_IGNORED = 1;
|
||||
|
||||
// Custom event names. All three are reserved at module-init time
|
||||
// via `Event::Custom::AutoReserve` in `Api.cpp`.
|
||||
// Custom event reservations, shared by the Data and Api TUs (C++17 inline
|
||||
// variables — one instance each; the AutoReserve ctor dedups by name anyway).
|
||||
// Fire via `kEvt*.Slot()`.
|
||||
//
|
||||
// EQUIPMENT_SETS_CHANGED — no payload; fires on any mutation
|
||||
// (create / save / modify / delete /
|
||||
@@ -130,8 +133,8 @@ constexpr int LOC_IGNORED = 1;
|
||||
// pickup→equip pair actually completed,
|
||||
// matching modern's "we dispatched"
|
||||
// definition of success).
|
||||
constexpr const char *kEventName = "EQUIPMENT_SETS_CHANGED";
|
||||
constexpr const char *kSwapPendingEvent = "EQUIPMENT_SWAP_PENDING";
|
||||
constexpr const char *kSwapFinishedEvent = "EQUIPMENT_SWAP_FINISHED";
|
||||
inline const Event::Custom::AutoReserve kEvtSetsChanged{"EQUIPMENT_SETS_CHANGED"};
|
||||
inline const Event::Custom::AutoReserve kEvtSwapPending{"EQUIPMENT_SWAP_PENDING"};
|
||||
inline const Event::Custom::AutoReserve kEvtSwapFinished{"EQUIPMENT_SWAP_FINISHED"};
|
||||
|
||||
} // namespace EquipmentSet
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
#include "Storage.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -25,7 +26,7 @@ namespace EquipmentSet::Storage {
|
||||
namespace {
|
||||
|
||||
const char *ReadAccountName() {
|
||||
return *reinterpret_cast<const char *const *>(Offsets::VAR_ACCOUNT_NAME_PTR);
|
||||
return Game::Read<const char *>(Offsets::VAR_ACCOUNT_NAME_PTR);
|
||||
}
|
||||
|
||||
const char *ReadCharacterName() {
|
||||
@@ -34,10 +35,10 @@ const char *ReadCharacterName() {
|
||||
}
|
||||
|
||||
const char *ReadRealmName() {
|
||||
auto *info = *reinterpret_cast<uint8_t **>(Offsets::VAR_REALM_INFO_PTR);
|
||||
auto *info = Game::Read<uint8_t *>(Offsets::VAR_REALM_INFO_PTR);
|
||||
if (info == nullptr)
|
||||
return nullptr;
|
||||
return *reinterpret_cast<const char *const *>(info + Offsets::OFF_REALM_INFO_NAME);
|
||||
return Game::Read<const char *>(info, Offsets::OFF_REALM_INFO_NAME);
|
||||
}
|
||||
|
||||
// Removes trailing CR/LF/whitespace. Names from the engine are ASCII
|
||||
|
||||
@@ -210,8 +210,8 @@ int __fastcall Script_GameTooltipSetEquipmentSet(void *L) {
|
||||
unnamedMissing++;
|
||||
continue;
|
||||
}
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSTATS_NAME);
|
||||
const char *name = Game::Read<const char *>(
|
||||
record, Offsets::OFF_ITEMSTATS_NAME);
|
||||
if (name == nullptr || name[0] == '\0') {
|
||||
unnamedMissing++;
|
||||
continue;
|
||||
|
||||
+41
-39
@@ -112,13 +112,13 @@ void SMemFree(void *ptr) {
|
||||
int TryClaim(const char *eventName) {
|
||||
if (!g_writesEnabled)
|
||||
return -1;
|
||||
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
const int count = Game::Read<int>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
if (base == nullptr || count <= 0)
|
||||
return -1;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
auto **namePtr = reinterpret_cast<const char **>(
|
||||
base + i * Offsets::EVENT_ENTRY_STRIDE +
|
||||
auto **namePtr = Game::Ptr<const char *>(
|
||||
base + i * Offsets::EVENT_ENTRY_STRIDE,
|
||||
Offsets::OFF_EVENT_ENTRY_NAME);
|
||||
if (*namePtr == nullptr) {
|
||||
char *copy = SStrDup(eventName);
|
||||
@@ -169,15 +169,15 @@ void Grow() {
|
||||
return;
|
||||
g_growLatched = true; // one attempt per build, whether or not it grows
|
||||
|
||||
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
const int count = Game::Read<int>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
if (base == nullptr || count <= 0 || g_reservedCount <= 0)
|
||||
return;
|
||||
|
||||
int nulls = 0;
|
||||
for (int i = 0; i < count; ++i)
|
||||
if (*reinterpret_cast<const char *const *>(
|
||||
base + i * Offsets::EVENT_ENTRY_STRIDE +
|
||||
if (Game::Read<const char *>(
|
||||
base + i * Offsets::EVENT_ENTRY_STRIDE,
|
||||
Offsets::OFF_EVENT_ENTRY_NAME) == nullptr)
|
||||
++nulls;
|
||||
|
||||
@@ -193,8 +193,8 @@ void Grow() {
|
||||
// moving the buffer would corrupt their subscription). Abort if so and
|
||||
// fall back to claim-NULL.
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const uint32_t head = *reinterpret_cast<const uint32_t *>(
|
||||
base + i * Offsets::EVENT_ENTRY_STRIDE + Offsets::OFF_EVENT_ENTRY_HEAD);
|
||||
const uint32_t head = Game::Read<uint32_t>(
|
||||
base + i * Offsets::EVENT_ENTRY_STRIDE, Offsets::OFF_EVENT_ENTRY_HEAD);
|
||||
if (head != 0 && (head & 1) == 0) { // populated chain (not 0 / sentinel)
|
||||
Debug::Log::Printf(
|
||||
"[event] grow aborted — chain live at slot %d (not first-reg)", i);
|
||||
@@ -231,9 +231,9 @@ void Grow() {
|
||||
// Publish base BEFORE count so the globals never advertise more entries
|
||||
// than the buffer holds. Then free the OLD buffer only — the name
|
||||
// strings live on in newBuf and are freed by the reload teardown.
|
||||
*reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR) = newBuf;
|
||||
*reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_CAP) = newCount;
|
||||
*reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT) = newCount;
|
||||
Game::Ref<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR) = newBuf;
|
||||
Game::Ref<int>(Offsets::VAR_EVENT_TABLE_CAP) = newCount;
|
||||
Game::Ref<int>(Offsets::VAR_EVENT_TABLE_COUNT) = newCount;
|
||||
SMemFree(base);
|
||||
|
||||
Debug::Log::Printf("[event] grew table %d -> %d (%d NULL, %d reserved%s)",
|
||||
@@ -244,37 +244,37 @@ void Grow() {
|
||||
} // namespace
|
||||
|
||||
AutoReserve::AutoReserve(const char *name) {
|
||||
if (name == nullptr || g_reservedCount >= MAX_RESERVED)
|
||||
if (name == nullptr)
|
||||
return;
|
||||
// Dedup: a name reserved twice resolves both instances to the one entry.
|
||||
for (int i = 0; i < g_reservedCount; ++i) {
|
||||
if (std::strcmp(g_reserved[i].name, name) == 0)
|
||||
if (std::strcmp(g_reserved[i].name, name) == 0) {
|
||||
index_ = i;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (g_reservedCount >= MAX_RESERVED)
|
||||
return; // overflow — index_ stays -1, Slot() reports unclaimed
|
||||
g_reserved[g_reservedCount].name = name;
|
||||
g_reserved[g_reservedCount].slot = -1;
|
||||
index_ = g_reservedCount;
|
||||
++g_reservedCount;
|
||||
}
|
||||
|
||||
int Lookup(const char *name) {
|
||||
if (name == nullptr)
|
||||
return -1;
|
||||
for (int i = 0; i < g_reservedCount; ++i) {
|
||||
if (std::strcmp(g_reserved[i].name, name) == 0)
|
||||
return g_reserved[i].slot;
|
||||
}
|
||||
return -1;
|
||||
int AutoReserve::Slot() const {
|
||||
return index_ >= 0 ? g_reserved[index_].slot : -1;
|
||||
}
|
||||
|
||||
int LookupByName(const char *name) {
|
||||
if (name == nullptr)
|
||||
return -1;
|
||||
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
const int count = Game::Read<int>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
if (base == nullptr || count <= 0)
|
||||
return -1;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const char *entryName = *reinterpret_cast<const char *const *>(
|
||||
base + i * Offsets::EVENT_ENTRY_STRIDE +
|
||||
const char *entryName = Game::Read<const char *>(
|
||||
base + i * Offsets::EVENT_ENTRY_STRIDE,
|
||||
Offsets::OFF_EVENT_ENTRY_NAME);
|
||||
if (entryName != nullptr && std::strcmp(entryName, name) == 0)
|
||||
return i;
|
||||
@@ -285,16 +285,16 @@ int LookupByName(const char *name) {
|
||||
bool HasListeners(int slot) {
|
||||
if (slot < 0)
|
||||
return false;
|
||||
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
const int count = Game::Read<int>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
if (base == nullptr || slot >= count)
|
||||
return false;
|
||||
// The entry's chain head (`+0x0C`): a populated subscriber chain is a
|
||||
// non-zero pointer with the low bit clear; `0` or an odd (tagged)
|
||||
// value is the empty self-sentinel — same test the grow-safety scan
|
||||
// uses. So this is true iff at least one frame registered for the event.
|
||||
const uint32_t head = *reinterpret_cast<const uint32_t *>(
|
||||
base + slot * Offsets::EVENT_ENTRY_STRIDE + Offsets::OFF_EVENT_ENTRY_HEAD);
|
||||
const uint32_t head = Game::Read<uint32_t>(
|
||||
base + slot * Offsets::EVENT_ENTRY_STRIDE, Offsets::OFF_EVENT_ENTRY_HEAD);
|
||||
return head != 0 && (head & 1) == 0;
|
||||
}
|
||||
|
||||
@@ -320,6 +320,8 @@ void PrepareForReload() {
|
||||
g_growLatched = false; // re-arm the one-shot grow for the new table
|
||||
}
|
||||
|
||||
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
|
||||
|
||||
// Diagnostic Lua functions — registered via the auto-register pattern
|
||||
// at the bottom of this file. Not on any hot path.
|
||||
namespace {
|
||||
@@ -330,12 +332,12 @@ int __fastcall Script_DumpSlot(void *L) {
|
||||
return 0;
|
||||
}
|
||||
const int slot = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
const int count = Game::Read<int>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
const char *name = nullptr;
|
||||
if (base != nullptr && slot >= 0 && slot < count) {
|
||||
name = *reinterpret_cast<const char *const *>(
|
||||
base + slot * Offsets::EVENT_ENTRY_STRIDE +
|
||||
name = Game::Read<const char *>(
|
||||
base + slot * Offsets::EVENT_ENTRY_STRIDE,
|
||||
Offsets::OFF_EVENT_ENTRY_NAME);
|
||||
}
|
||||
Debug::Log::Printf("[dumpslot] base=0x%08X count=%d slot=%d name='%s'",
|
||||
@@ -345,16 +347,16 @@ int __fastcall Script_DumpSlot(void *L) {
|
||||
}
|
||||
|
||||
int __fastcall Script_DumpAllEvents(void *) {
|
||||
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
const int count = Game::Read<int>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
Debug::Log::Printf("[dumpall] base=0x%08X count=%d",
|
||||
static_cast<unsigned>(reinterpret_cast<uintptr_t>(base)),
|
||||
count);
|
||||
if (base == nullptr)
|
||||
return 0;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
base + i * Offsets::EVENT_ENTRY_STRIDE +
|
||||
const char *name = Game::Read<const char *>(
|
||||
base + i * Offsets::EVENT_ENTRY_STRIDE,
|
||||
Offsets::OFF_EVENT_ENTRY_NAME);
|
||||
if (name != nullptr && *name != '\0')
|
||||
Debug::Log::Printf("[%d] %s", i, name);
|
||||
|
||||
+22
-17
@@ -37,31 +37,36 @@ namespace Event::Custom {
|
||||
// DLL operate on the table independently of the others.
|
||||
//
|
||||
// The name pointer must outlive the engine (a string literal does).
|
||||
// Same name reserved twice is deduped; reserving more than `MAX_RESERVED`
|
||||
// names total (see Custom.cpp) silently drops the overflow — keep that cap
|
||||
// above the codebase-wide `AutoReserve` count.
|
||||
// Same name reserved twice is deduped (both instances resolve to the same
|
||||
// reservation); reserving more than `MAX_RESERVED` names total (see
|
||||
// Custom.cpp) silently drops the overflow — keep that cap above the
|
||||
// codebase-wide `AutoReserve` count.
|
||||
//
|
||||
// Fire through the instance: `Fire(_r.Slot(), …)`. `Slot()` is the O(1)
|
||||
// read of the reservation's current slot id — it resolves through the
|
||||
// shared reservation table, so reload invalidation (`PrepareForReload`)
|
||||
// and re-claiming (`RetryClaims`) stay centralized and the value is never
|
||||
// stale. Returns -1 while unclaimed (before the first Lua-side
|
||||
// `RegisterEvent` triggers `RetryClaims`), which `Fire` treats as a no-op.
|
||||
// Do NOT cache the return across frames — read it at fire time (the read
|
||||
// is two loads; there is nothing to save).
|
||||
struct AutoReserve {
|
||||
explicit AutoReserve(const char *name);
|
||||
};
|
||||
int Slot() const;
|
||||
|
||||
// Returns the slot id currently assigned to `name`, or -1 if not yet
|
||||
// claimed (e.g. before the first Lua-side `RegisterEvent` has
|
||||
// triggered `RetryClaims`). Slot indices may change across `/reload`,
|
||||
// so call this at fire time rather than caching the value.
|
||||
//
|
||||
// Only consults our reservation cache — for engine-defined events
|
||||
// (UNIT_FACTION, BAG_UPDATE, etc.) use `LookupByName`.
|
||||
int Lookup(const char *name);
|
||||
private:
|
||||
int index_ = -1; // into the reservation table; -1 = dropped (overflow)
|
||||
};
|
||||
|
||||
// Walks the live engine event table at `[VAR_EVENT_TABLE_BASE_PTR]`
|
||||
// looking for an entry whose name strcmps equal to `name`. Returns
|
||||
// the slot index, or -1 if no entry matches.
|
||||
//
|
||||
// Works for both engine-defined events AND our `AutoReserve`-claimed
|
||||
// custom events (both populate the same table). Slightly slower than
|
||||
// `Lookup` (O(table size) vs O(reservations)) — prefer `Lookup` for
|
||||
// names we've reserved. Use this for engine-defined events we want to
|
||||
// fire ourselves (e.g., polyfilling missing event dispatches).
|
||||
// custom events (both populate the same table). O(table size) — for
|
||||
// names we've reserved, prefer the O(1) `AutoReserve::Slot()`. Use this
|
||||
// for engine-defined events we want to fire ourselves (e.g., polyfilling
|
||||
// missing event dispatches).
|
||||
int LookupByName(const char *name);
|
||||
|
||||
// True iff at least one frame is currently registered for the event in
|
||||
@@ -88,7 +93,7 @@ bool HasListeners(int slot);
|
||||
// Fire(slot, "%s%d", keyName, down); // MODIFIER_STATE_CHANGED(key, down)
|
||||
//
|
||||
// No-op for `eventID < 0`, which lets callers cheaply guard on the
|
||||
// `Lookup()` result without an explicit if.
|
||||
// `Slot()` result without an explicit if.
|
||||
template <typename... Args>
|
||||
inline void Fire(int eventID, const char *format, Args... args) {
|
||||
if (eventID < 0)
|
||||
|
||||
@@ -50,22 +50,20 @@ int __fastcall Script_GetFramesRegisteredForEvent(void *L) {
|
||||
if (slot < 0)
|
||||
return 0;
|
||||
|
||||
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
if (base == nullptr)
|
||||
return 0;
|
||||
|
||||
const uint8_t *entry = base + slot * Offsets::EVENT_ENTRY_STRIDE;
|
||||
const uintptr_t head = *reinterpret_cast<const uintptr_t *>(
|
||||
entry + Offsets::OFF_EVENT_ENTRY_HEAD);
|
||||
const uintptr_t head = Game::Read<uintptr_t>(entry, Offsets::OFF_EVENT_ENTRY_HEAD);
|
||||
|
||||
// First pass: count subscribers so we can reserve stack up front — an
|
||||
// event with many registered frames would otherwise risk overflowing
|
||||
// the default Lua stack slack as we push.
|
||||
int count = 0;
|
||||
for (uintptr_t node = head; (node & 1) == 0 && node != 0;
|
||||
node = *reinterpret_cast<const uintptr_t *>(
|
||||
node + Offsets::OFF_EVENT_NODE_NEXT)) {
|
||||
if (*reinterpret_cast<void *const *>(node + Offsets::OFF_EVENT_NODE_FRAME))
|
||||
node = Game::Read<uintptr_t>(node + Offsets::OFF_EVENT_NODE_NEXT)) {
|
||||
if (Game::Read<void *>(node + Offsets::OFF_EVENT_NODE_FRAME))
|
||||
++count;
|
||||
}
|
||||
if (count == 0)
|
||||
@@ -74,10 +72,8 @@ int __fastcall Script_GetFramesRegisteredForEvent(void *L) {
|
||||
|
||||
int pushed = 0;
|
||||
for (uintptr_t node = head; (node & 1) == 0 && node != 0;
|
||||
node = *reinterpret_cast<const uintptr_t *>(
|
||||
node + Offsets::OFF_EVENT_NODE_NEXT)) {
|
||||
void *frame = *reinterpret_cast<void *const *>(
|
||||
node + Offsets::OFF_EVENT_NODE_FRAME);
|
||||
node = Game::Read<uintptr_t>(node + Offsets::OFF_EVENT_NODE_NEXT)) {
|
||||
void *frame = Game::Read<void *>(node + Offsets::OFF_EVENT_NODE_FRAME);
|
||||
if (frame != nullptr) {
|
||||
UI::FrameObject::Push(L, frame);
|
||||
++pushed;
|
||||
|
||||
@@ -69,10 +69,7 @@ void PushFlag(void *L, bool value) {
|
||||
// `[player + 0xE68]`. Returns nullptr if "player" isn't resolvable
|
||||
// or the sub-struct is uninitialized (pre-login / glue).
|
||||
const uint8_t *PlayerInfo() {
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
auto resolve = reinterpret_cast<ResolveUnitToken_t>(
|
||||
Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
auto *player = static_cast<const uint8_t *>(resolve("player"));
|
||||
auto *player = static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
|
||||
if (player == nullptr)
|
||||
return nullptr;
|
||||
return *reinterpret_cast<const uint8_t *const *>(
|
||||
|
||||
@@ -98,7 +98,7 @@ void __fastcall FireNotify_h(int factionID, int delta) {
|
||||
// any addon relying on the chat event firing first would expect.
|
||||
FireNotify_o(factionID, delta);
|
||||
|
||||
const int slot = Event::Custom::Lookup(kEventName);
|
||||
const int slot = _reserve.Slot();
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "%d%d%d", factionID, newStanding, delta);
|
||||
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
// 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.
|
||||
|
||||
// FontString metric backports.
|
||||
//
|
||||
// 1.12's FontString method table (32 entries at 0x0087C1D8, registry context
|
||||
// VAR_FONTSTRING_METHOD_REGISTRY) ships GetStringWidth but none of the later
|
||||
// metric bindings. The engine already has every internal getter — these are
|
||||
// the thin Lua bindings Blizzard never wrote for 1.12:
|
||||
//
|
||||
// GetStringHeight() — 2.3.0 binding; internal getter
|
||||
// FUN_FONTSTRING_STRING_HEIGHT (wrap-aware,
|
||||
// lines×fontH + (lines−1)×spacing, cache
|
||||
// fs+0x100, 0 for empty text).
|
||||
// GetUnboundedStringWidth() — the substring measure with the WHOLE string
|
||||
// (FUN_FONTSTRING_MEASURE_SUBSTRING, len 0 =
|
||||
// strlen): the string's width with no wrap cap.
|
||||
// Icon-aware for free — the call routes through
|
||||
// text/InlineTexture.cpp's co-hook.
|
||||
// GetWrappedWidth() — the RENDERED width: the widest wrapped line
|
||||
// (the tooltip auto-size's own recipe — break
|
||||
// array + per-segment substring measures).
|
||||
// GetNumLines() — the node's built line count (render truth,
|
||||
// OFF_TEXT_NODE_BUILT_LINES) once painted; for
|
||||
// an unbuilt node, the engine's own break-array
|
||||
// computer (FUN_FONTSTRING_BREAK_ARRAY — also
|
||||
// icon-aware via the wrap-stepper co-hook).
|
||||
// GetLineHeight() — the fs font height via the same getter the
|
||||
// rebuild feeds the text node (FUN_007727b0);
|
||||
// excludes spacing, like retail (GetSpacing is
|
||||
// its own method).
|
||||
// IsTruncated() — did the engine cut the text off with an
|
||||
// ellipsis? Reads the render's own output: the
|
||||
// built text node's copy of the laid-out string
|
||||
// (OFF_TEXT_NODE_TEXT) vs the source. Same
|
||||
// "displayed vs full" comparison retail does; no
|
||||
// reconstruction of the fit/wrap/maxLines logic.
|
||||
// Reflects the last rendered layout (the display-
|
||||
// text resolver's rect is zero until a render, so
|
||||
// a Lua-time re-call cannot see the bound).
|
||||
// SetMaxLines(n)/GetMaxLines() — cap the fontstring to n wrapped lines; text
|
||||
// past the cap is ellipsized. Writes the engine's
|
||||
// own per-fontstring maxLines field (fs+0x128,
|
||||
// already honored by the truncation resolver) and
|
||||
// re-invalidates the layout like SetText does.
|
||||
// 1.12 has no SetWordWrap, so SetMaxLines(1) is
|
||||
// how you get a single-line, ellipsized label.
|
||||
// SetFormattedText(fmt,...) — string.format + SetText convenience. The format
|
||||
// runs through the engine's OWN str_format C
|
||||
// function (FUN_LUA_STR_FORMAT) under pcall — NOT
|
||||
// _G.string.format, so an addon replacing
|
||||
// string.format can't hijack it (matches retail).
|
||||
// The set goes through FUN_FONTSTRING_SET_TEXT,
|
||||
// keeping the sanitizer/pipe handling identical to
|
||||
// SetText.
|
||||
//
|
||||
// Push conversion mirrors Script_GetStringWidth (0x0079E510): resolve self,
|
||||
// call the internal getter, convert anchor units → UI pixels, push.
|
||||
//
|
||||
// No inline-icon adjustment lives HERE: the width/height internal getters are
|
||||
// already icon-adjusted by their co-hooks (calling the hooked VA routes
|
||||
// through the detour), and icons never change GetLineHeight.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace FontString::Metrics {
|
||||
namespace {
|
||||
|
||||
// FUN_FONTSTRING_STRING_HEIGHT — ECX = fs, no stack args, float in x87 ST0.
|
||||
using StringHeightInternal_t = float(__fastcall *)(void *fs);
|
||||
// FUN_FONTSTRING_MEASURE_SUBSTRING — __thiscall(fs, text, len); dummy-EDX form.
|
||||
using MeasureSubstring_t = float(__fastcall *)(void *fs, void *edx, const char *text, int len);
|
||||
// FUN_FONTSTRING_FONT_HEIGHT — __thiscall(fs, mode-on-stack); dummy-EDX form.
|
||||
using FsFontHeight_t = float(__fastcall *)(void *fs, void *edx, int mode);
|
||||
// FUN_FONTSTRING_BREAK_ARRAY — __thiscall(fs, text, wrapWidth, outBreaks, cap).
|
||||
using BreakArray_t = uint32_t(__fastcall *)(void *fs, void *edx, const char *text, float width,
|
||||
int *outBreaks, int cap);
|
||||
// FUN_FONTSTRING_SET_TEXT — __thiscall(fs, text, flag = 0); dummy-EDX form.
|
||||
using FsSetText_t = void(__fastcall *)(void *fs, void *edx, const char *text, int flag);
|
||||
// FUN_HANDLE_RELEASE — DecRef a ref-counted handle. __fastcall(handle).
|
||||
using HandleRelease_t = void(__fastcall *)(void *handle);
|
||||
// FUN_FONTSTRING_LAYOUT_INVALIDATE — region layout invalidate. __thiscall(region,
|
||||
// int); dummy-EDX form.
|
||||
using LayoutInvalidate_t = void(__fastcall *)(void *region, void *edx, int arg);
|
||||
|
||||
// Invalidate a fontstring's layout exactly as SetText (FUN_00771D80) does after
|
||||
// a content change, so a layout-affecting property change (max lines) takes
|
||||
// effect on the next draw: drop the cached measures, release the built node,
|
||||
// clear the layout-dirty bit, and notify the region to re-lay-out. Re-calling
|
||||
// SetText would NOT work — it early-outs when the text string is unchanged.
|
||||
void InvalidateLayout(void *fs) {
|
||||
Game::Ref<float>(fs, Offsets::OFF_FONTSTRING_WIDTH_CACHE) = 0.0f;
|
||||
Game::Ref<float>(fs, Offsets::OFF_FONTSTRING_HEIGHT_CACHE) = 0.0f;
|
||||
void *block = Game::Read<void *>(fs, Offsets::OFF_FONTSTRING_TEXT_BLOCK);
|
||||
if (block != nullptr) {
|
||||
reinterpret_cast<HandleRelease_t>(Offsets::FUN_HANDLE_RELEASE)(block);
|
||||
Game::Ref<void *>(fs, Offsets::OFF_FONTSTRING_TEXT_BLOCK) = nullptr;
|
||||
}
|
||||
Game::Ref<uint32_t>(fs, Offsets::OFF_FONTSTRING_DIRTY_FLAGS) &= ~1u;
|
||||
void *region = reinterpret_cast<void *>(reinterpret_cast<uintptr_t>(fs) + 0x24);
|
||||
reinterpret_cast<LayoutInvalidate_t>(Offsets::FUN_FONTSTRING_LAYOUT_INVALIDATE)(
|
||||
region, nullptr, 0);
|
||||
}
|
||||
|
||||
// Anchor units → UI pixels: `FUN_0041AE40(FUN_0041AD70() × DAT_007FFD68 × v)`
|
||||
// = v × [VAR_UI_COORD_SCALE_DIV] × 1024 / [VAR_UI_COORD_SCALE_MUL] — the exact
|
||||
// push chain Script_GetStringWidth uses (the inverse of
|
||||
// Tooltip::LinePool::PixelToInternal).
|
||||
double InternalToPixel(float v) {
|
||||
const float mul = Game::Read<float>(Offsets::VAR_UI_COORD_SCALE_MUL);
|
||||
const float div = Game::Read<float>(Offsets::VAR_UI_COORD_SCALE_DIV);
|
||||
if (mul == 0.0f)
|
||||
return 0.0;
|
||||
return static_cast<double>(v) * div * Offsets::UI_COORD_SCALE_UNIT / mul;
|
||||
}
|
||||
|
||||
// The fs's wrap budget in the units FUN_FONTSTRING_BREAK_ARRAY expects
|
||||
// (width-getter units — it multiplies by fs+0x7C itself before gxu, verified
|
||||
// decompile), or 0 when the rect is unresolved. rectWidth ÷ scale, calibrated
|
||||
// EMPIRICALLY against the render with a three-point probe (a 100px box at
|
||||
// scale ~0.91, text whose candidate lines measure 78.1 / 94.6 / 101.6px):
|
||||
// raw rectW → effective budget ~91px → broke one word EARLY,
|
||||
// rectW/scale² → effective budget ~110px → broke one word LATE,
|
||||
// rectW/scale → effective budget 100px → breaks exactly where the
|
||||
// render does (94.6 ≤ 100 < 101.6).
|
||||
// All three observations fit budget_px = pxOf(param) × scale, pinning the
|
||||
// single ÷scale. (A pure paper derivation from the rebuild's node budget
|
||||
// predicted /scale² — one hop of that chain models the live behaviour wrong;
|
||||
// the probe is the arbiter.)
|
||||
float WrapBudget(void *fs) {
|
||||
const float *rc = Game::Ptr<const float>(fs, Offsets::OFF_REGION_RECT);
|
||||
float width = (rc[3] > rc[1]) ? (rc[3] - rc[1]) : (rc[1] - rc[3]);
|
||||
const float s = Game::Read<float>(fs, Offsets::OFF_LAYOUT_SCALE);
|
||||
if (width > 0.0f && s > 0.0f)
|
||||
width /= s;
|
||||
return width;
|
||||
}
|
||||
|
||||
// Shared self-resolve for every method here.
|
||||
void *ResolveSelf(void *L, const char *usage) {
|
||||
void *fs = nullptr;
|
||||
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_TABLE)
|
||||
fs = Game::Lua::ResolveObject(L, 1);
|
||||
if (fs == nullptr)
|
||||
Game::Lua::Error(L, "%s", usage);
|
||||
return fs;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetStringHeight(void *L) {
|
||||
void *fs = ResolveSelf(L, "Usage: fontstring:GetStringHeight()");
|
||||
if (fs == nullptr)
|
||||
return 0;
|
||||
const float h = reinterpret_cast<StringHeightInternal_t>(
|
||||
Offsets::FUN_FONTSTRING_STRING_HEIGHT)(fs);
|
||||
Game::Lua::PushNumber(L, InternalToPixel(h));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetUnboundedStringWidth(void *L) {
|
||||
void *fs = ResolveSelf(L, "Usage: fontstring:GetUnboundedStringWidth()");
|
||||
if (fs == nullptr)
|
||||
return 0;
|
||||
const char *text = Game::Read<const char *>(fs, Offsets::OFF_FONTSTRING_TEXT);
|
||||
if (text == nullptr || *text == '\0') {
|
||||
Game::Lua::PushNumber(L, 0.0);
|
||||
return 1;
|
||||
}
|
||||
const float w = reinterpret_cast<MeasureSubstring_t>(
|
||||
Offsets::FUN_FONTSTRING_MEASURE_SUBSTRING)(fs, nullptr, text, 0);
|
||||
Game::Lua::PushNumber(L, InternalToPixel(w));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetNumLines(void *L) {
|
||||
void *fs = ResolveSelf(L, "Usage: fontstring:GetNumLines()");
|
||||
if (fs == nullptr)
|
||||
return 0;
|
||||
const char *text = Game::Read<const char *>(fs, Offsets::OFF_FONTSTRING_TEXT);
|
||||
if (text == nullptr || *text == '\0') {
|
||||
Game::Lua::PushNumber(L, 0.0);
|
||||
return 1;
|
||||
}
|
||||
// Render truth first: the built node's line count. Zero until the node
|
||||
// paints once (hidden fs, mid-rebuild) — fall back below.
|
||||
void *block = Game::Read<void *>(fs, Offsets::OFF_FONTSTRING_TEXT_BLOCK);
|
||||
if (block != nullptr) {
|
||||
void *node = Game::Read<void *>(block, Offsets::OFF_TEXTBLOCK_NODE);
|
||||
if (node != nullptr) {
|
||||
const int built = Game::Read<int>(node, Offsets::OFF_TEXT_NODE_BUILT_LINES);
|
||||
if (built > 0) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(built));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Unbuilt: the engine's own break-array computer, fed the fs's wrap
|
||||
// budget (see WrapBudget's scale note). An unresolved rect (0 width)
|
||||
// means no wrap constraint exists yet — one line, like a fresh
|
||||
// single-anchor fs.
|
||||
const float width = WrapBudget(fs);
|
||||
if (width <= 0.0f) {
|
||||
Game::Lua::PushNumber(L, 1.0);
|
||||
return 1;
|
||||
}
|
||||
int breaks[64];
|
||||
uint32_t lines = reinterpret_cast<BreakArray_t>(Offsets::FUN_FONTSTRING_BREAK_ARRAY)(
|
||||
fs, nullptr, text, width, breaks, 64);
|
||||
if (lines > 64u)
|
||||
lines = 64u;
|
||||
if (lines == 0u)
|
||||
lines = 1u;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(lines));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetWrappedWidth(void *L) {
|
||||
void *fs = ResolveSelf(L, "Usage: fontstring:GetWrappedWidth()");
|
||||
if (fs == nullptr)
|
||||
return 0;
|
||||
const char *text = Game::Read<const char *>(fs, Offsets::OFF_FONTSTRING_TEXT);
|
||||
if (text == nullptr || *text == '\0') {
|
||||
Game::Lua::PushNumber(L, 0.0);
|
||||
return 1;
|
||||
}
|
||||
auto measure =
|
||||
reinterpret_cast<MeasureSubstring_t>(Offsets::FUN_FONTSTRING_MEASURE_SUBSTRING);
|
||||
// The fs's wrap budget (see WrapBudget's scale note). Without one
|
||||
// (unresolved rect) nothing wraps, and the rendered width IS the
|
||||
// one-line width.
|
||||
const float budget = WrapBudget(fs);
|
||||
if (budget <= 0.0f) {
|
||||
Game::Lua::PushNumber(L, InternalToPixel(measure(fs, nullptr, text, 0)));
|
||||
return 1;
|
||||
}
|
||||
// The tooltip auto-size's own recipe (FUN_00530640): break positions from
|
||||
// the break-array computer, then the widest per-segment measure. Both
|
||||
// callees are icon-aware through their co-hooks, and a segment-final icon
|
||||
// reports its ink edge — so this is the rendered width, wrap and icons
|
||||
// included.
|
||||
int breaks[64];
|
||||
uint32_t segs = reinterpret_cast<BreakArray_t>(Offsets::FUN_FONTSTRING_BREAK_ARRAY)(
|
||||
fs, nullptr, text, budget, breaks, 64);
|
||||
if (segs > 64u)
|
||||
segs = 64u;
|
||||
if (segs <= 1u) {
|
||||
Game::Lua::PushNumber(L, InternalToPixel(measure(fs, nullptr, text, 0)));
|
||||
return 1;
|
||||
}
|
||||
int len = 0;
|
||||
while (len < 0x4000 && text[len] != '\0')
|
||||
++len;
|
||||
float widest = 0.0f;
|
||||
for (uint32_t i = 0; i < segs; ++i) {
|
||||
const int start = breaks[i];
|
||||
int end = (i + 1 < segs) ? breaks[i + 1] : len;
|
||||
if (start < 0 || start >= end || end > len)
|
||||
continue;
|
||||
// A segment runs to the NEXT segment's start, which includes the
|
||||
// whitespace the wrap consumed — the render never draws it, and a
|
||||
// trailing space also flips the last real letter from ink to
|
||||
// full-advance treatment (+~1.5px, measured). Trim to what renders.
|
||||
while (end > start && (text[end - 1] == ' ' || text[end - 1] == '\n' ||
|
||||
text[end - 1] == '\r' || text[end - 1] == '\t'))
|
||||
--end;
|
||||
if (start >= end)
|
||||
continue;
|
||||
const float w = measure(fs, nullptr, text + start, end - start);
|
||||
if (w > widest)
|
||||
widest = w;
|
||||
}
|
||||
Game::Lua::PushNumber(L, InternalToPixel(widest));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetLineHeight(void *L) {
|
||||
void *fs = ResolveSelf(L, "Usage: fontstring:GetLineHeight()");
|
||||
if (fs == nullptr)
|
||||
return 0;
|
||||
const float h = reinterpret_cast<FsFontHeight_t>(Offsets::FUN_FONTSTRING_FONT_HEIGHT)(
|
||||
fs, nullptr, 1);
|
||||
Game::Lua::PushNumber(L, InternalToPixel(h));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_IsTruncated(void *L) {
|
||||
void *fs = ResolveSelf(L, "Usage: fontstring:IsTruncated()");
|
||||
if (fs == nullptr)
|
||||
return 0;
|
||||
// Retail computes this by running the display-text resolver and strcmp'ing
|
||||
// its result against the source (5.4.8 inner fn 0x0045E001). 1.12's resolver
|
||||
// needs the LAID-OUT rect (fs+0x64), which is zero until a render pass, so a
|
||||
// Lua-time re-call always sees an unbounded box and never truncates. Read the
|
||||
// render's OWN output instead: the built text node keeps a copy of exactly
|
||||
// the string it laid out (OFF_TEXT_NODE_TEXT) — the ellipsized "<prefix>..."
|
||||
// form on truncation, else the source verbatim. So this reflects the last
|
||||
// rendered layout (the fontstring must have drawn once), matching retail's
|
||||
// "displayed vs full" semantics.
|
||||
bool truncated = false;
|
||||
void *block = Game::Read<void *>(fs, Offsets::OFF_FONTSTRING_TEXT_BLOCK);
|
||||
const char *src = Game::Read<const char *>(fs, Offsets::OFF_FONTSTRING_TEXT);
|
||||
if (block != nullptr && src != nullptr) {
|
||||
void *node = Game::Read<void *>(block, Offsets::OFF_TEXTBLOCK_NODE);
|
||||
const char *shown =
|
||||
(node != nullptr) ? Game::Read<const char *>(node, Offsets::OFF_TEXT_NODE_TEXT)
|
||||
: nullptr;
|
||||
if (shown != nullptr) {
|
||||
// A truncated display is exactly <source-prefix> + "...". Require the
|
||||
// "..." suffix, a length strictly shorter than the source, and a
|
||||
// matching byte-prefix. This rejects a STALE node — one left from a
|
||||
// previous, different text after a fresh SetText, not yet re-laid-out
|
||||
// — which would otherwise read as a truncation of the new text.
|
||||
const size_t nlen = std::strlen(shown);
|
||||
const size_t slen = std::strlen(src);
|
||||
if (nlen >= 3 && nlen < slen && shown[nlen - 3] == '.' &&
|
||||
shown[nlen - 2] == '.' && shown[nlen - 1] == '.' &&
|
||||
std::memcmp(shown, src, nlen - 3) == 0)
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
Game::Lua::PushBool(L, truncated);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_SetMaxLines(void *L) {
|
||||
void *fs = ResolveSelf(L, "Usage: fontstring:SetMaxLines(maxLines)");
|
||||
if (fs == nullptr)
|
||||
return 0;
|
||||
// <= 0 or a non-number means "no cap" — the engine treats 0 as unlimited.
|
||||
int maxLines = 0;
|
||||
if (Game::Lua::IsNumber(L, 2)) {
|
||||
const double n = Game::Lua::ToNumber(L, 2);
|
||||
if (n > 0.0)
|
||||
maxLines = static_cast<int>(n);
|
||||
}
|
||||
if (maxLines != Game::Read<int>(fs, Offsets::OFF_FONTSTRING_MAX_LINES)) {
|
||||
Game::Ref<int>(fs, Offsets::OFF_FONTSTRING_MAX_LINES) = maxLines;
|
||||
InvalidateLayout(fs);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetMaxLines(void *L) {
|
||||
void *fs = ResolveSelf(L, "Usage: fontstring:GetMaxLines()");
|
||||
if (fs == nullptr)
|
||||
return 0;
|
||||
Game::Lua::PushNumber(
|
||||
L, static_cast<double>(Game::Read<int>(fs, Offsets::OFF_FONTSTRING_MAX_LINES)));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_SetFormattedText(void *L) {
|
||||
void *fs = nullptr;
|
||||
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_TABLE)
|
||||
fs = Game::Lua::ResolveObject(L, 1);
|
||||
if (fs == nullptr || !Game::Lua::IsString(L, 2)) {
|
||||
Game::Lua::Error(L, "Usage: fontstring:SetFormattedText(\"format\"[, ...])");
|
||||
return 0;
|
||||
}
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
Game::Lua::CheckStack(L, top + 4);
|
||||
Game::Lua::PushCClosure(
|
||||
L, reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_STR_FORMAT), 0);
|
||||
for (int i = 2; i <= top; ++i)
|
||||
Game::Lua::PushValue(L, i);
|
||||
if (Game::Lua::PCall(L, top - 1, 1, 0) != 0) {
|
||||
const char *msg = Game::Lua::ToString(L, -1);
|
||||
Game::Lua::Error(L, "%s", (msg != nullptr) ? msg : "SetFormattedText: format failed");
|
||||
return 0;
|
||||
}
|
||||
const char *result = Game::Lua::ToString(L, -1);
|
||||
reinterpret_cast<FsSetText_t>(Offsets::FUN_FONTSTRING_SET_TEXT)(
|
||||
fs, nullptr, (result != nullptr) ? result : "", 0);
|
||||
Game::Lua::SetTop(L, top);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const Game::Lua::FrameMethodEntry g_methods[] = {
|
||||
{"GetStringHeight", &Script_GetStringHeight},
|
||||
{"GetUnboundedStringWidth", &Script_GetUnboundedStringWidth},
|
||||
{"GetWrappedWidth", &Script_GetWrappedWidth},
|
||||
{"GetNumLines", &Script_GetNumLines},
|
||||
{"GetLineHeight", &Script_GetLineHeight},
|
||||
{"IsTruncated", &Script_IsTruncated},
|
||||
{"SetMaxLines", &Script_SetMaxLines},
|
||||
{"GetMaxLines", &Script_GetMaxLines},
|
||||
{"SetFormattedText", &Script_SetFormattedText},
|
||||
};
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterFrameMethods(
|
||||
reinterpret_cast<void *>(Offsets::VAR_FONTSTRING_METHOD_REGISTRY), g_methods,
|
||||
static_cast<int>(sizeof(g_methods) / sizeof(g_methods[0])));
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
} // namespace FontString::Metrics
|
||||
+229
-102
@@ -58,16 +58,25 @@
|
||||
// resolve via ReadModAttr's precedence. Verbs: `target` (with the engine's
|
||||
// default-interaction precedence — pending spell / cursor item cast/drop on the
|
||||
// unit instead of switching target), `assist`, `focus`, `spell` (reads the
|
||||
// `spell` attribute and casts it on the unit via our native C_Spell.CastAtUnit —
|
||||
// the unit's GUID goes straight to the cast dispatcher, no target juggling, and
|
||||
// ground-target spells land at the unit's feet), `item` (uses the `item`
|
||||
// `spell` attribute and casts it: with a `unit` attribute, on that unit via our
|
||||
// native C_Spell.CastAtUnit — the unit's GUID goes straight to the cast
|
||||
// dispatcher, no target juggling, and ground-target spells land at its feet;
|
||||
// with no `unit`, on the current target via the plain CastSpellByName), `item`
|
||||
// (uses the `item`
|
||||
// attribute — a name/itemID/link via C_Item.UseItemByName with the unit as the
|
||||
// use target, or a "bag slot" string like "0 1" via UseContainerItem; the
|
||||
// deprecated `bag`/`slot` attributes also work), `macro` (from the
|
||||
// `macrotext`/`macro` attribute — prefers an addon RunMacro, else runs natively
|
||||
// via the stock ChatEdit_ParseText), `stopcasting`, `menu`/`togglemenu` (pops
|
||||
// the standard unit dropdown at the cursor via the addon's
|
||||
// ClassicAPI_ToggleUnitMenu). Actions call ordinary Lua globals where the entry
|
||||
// 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.
|
||||
@@ -94,10 +103,13 @@
|
||||
// (`FUN_FRAME_SCRIPT_RESOLVER`) and handing out an external per-frame cell for
|
||||
// that name — the `Tooltip::SetEvents` analog, applied to every frame.
|
||||
|
||||
#include "frame/Attributes.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "baselib/Ascii.h"
|
||||
#include "cursor/Info.h"
|
||||
#include "macro/Execute.h"
|
||||
#include "object/Resolve.h"
|
||||
#include "spell/AtCursor.h"
|
||||
#include "spell/AtUnit.h"
|
||||
@@ -208,7 +220,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);
|
||||
@@ -299,78 +316,6 @@ bool CallTableFunc2Str(void *L, const char *table, const char *method,
|
||||
return called;
|
||||
}
|
||||
|
||||
// ---- macrotext execution (stock ChatEdit_ParseText, no addon dependency) ----
|
||||
//
|
||||
// 1.12 has no `RunMacroText`/`RunMacro` global — those are addon shims (pfUI
|
||||
// wraps `ChatEdit_ParseText`; SuperCleveRoidMacros ships its own `RunMacro`).
|
||||
// To run macro text without depending on an addon, we replicate pfUI's shim in
|
||||
// C: build a throwaway "edit box" whose `GetText` returns the line and whose
|
||||
// every other method is a harmless no-op (via an `__index` metamethod), then
|
||||
// hand it to the stock FrameXML `ChatEdit_ParseText(editBox, 1)` — the same
|
||||
// path the real chat box uses to dispatch a slash command / send a line.
|
||||
|
||||
// GetText: returns upvalue(1), the captured line.
|
||||
int __fastcall MacroGetText_c(void *L) {
|
||||
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(1));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// A no-op standing in for any edit-box method the parser happens to call.
|
||||
int __fastcall MacroNoop_c(void *) { return 0; }
|
||||
|
||||
// __index(tab, key): hand back the no-op so `editBox:AnyMethod()` is safe.
|
||||
int __fastcall MacroIndex_c(void *L) {
|
||||
Game::Lua::PushCClosure(L, &MacroNoop_c, 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Runs a single macro line through the stock chat parser.
|
||||
void RunMacroLineC(void *L, const char *line, size_t len) {
|
||||
if (len == 0) return;
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
|
||||
Game::Lua::NewTable(L); // fake editBox
|
||||
const int obj = Game::Lua::GetTop(L);
|
||||
|
||||
Game::Lua::PushString(L, "GetText"); // obj.GetText = closure over line
|
||||
Game::Lua::PushLString(L, line, static_cast<unsigned int>(len));
|
||||
Game::Lua::PushCClosure(L, &MacroGetText_c, 1);
|
||||
Game::Lua::SetTable(L, obj);
|
||||
|
||||
Game::Lua::NewTable(L); // metatable { __index = noop }
|
||||
const int mt = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushString(L, "__index");
|
||||
Game::Lua::PushCClosure(L, &MacroIndex_c, 0);
|
||||
Game::Lua::SetTable(L, mt);
|
||||
|
||||
// No lua_setmetatable binding — use the Lua global.
|
||||
if (Game::Lua::PushGlobalFunction(L, "setmetatable")) {
|
||||
Game::Lua::PushValue(L, obj);
|
||||
Game::Lua::PushValue(L, mt);
|
||||
Game::Lua::Call(L, 2, 0);
|
||||
}
|
||||
|
||||
if (Game::Lua::PushGlobalFunction(L, "ChatEdit_ParseText")) {
|
||||
Game::Lua::PushValue(L, obj);
|
||||
Game::Lua::PushNumber(L, 1); // send = 1
|
||||
Game::Lua::Call(L, 2, 0);
|
||||
}
|
||||
|
||||
Game::Lua::SetTop(L, top);
|
||||
}
|
||||
|
||||
// Runs macro text one line at a time — a real macro is line-delimited, and a
|
||||
// single ChatEdit_ParseText call only dispatches one command.
|
||||
void RunMacroTextC(void *L, const char *text) {
|
||||
if (!text) return;
|
||||
for (const char *p = text; *p;) {
|
||||
const char *nl = p;
|
||||
while (*nl && *nl != '\n') ++nl;
|
||||
RunMacroLineC(L, p, static_cast<size_t>(nl - p));
|
||||
p = (*nl == '\n') ? nl + 1 : nl;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- mouse-focus poll (drives the native mouseover slot) -------------------
|
||||
|
||||
// frame CFrameScriptObject* → current `unit` token, for the mouse-focus poll.
|
||||
@@ -384,12 +329,11 @@ std::unordered_map<const void *, std::string> g_unitByFrame;
|
||||
uint64_t g_lastSet = 0;
|
||||
|
||||
const void *CurrentMouseFocus() {
|
||||
const void *ctx = *reinterpret_cast<const void *const *>(
|
||||
const void *ctx = Game::Read<const void *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_UI_CONTEXT_PTR));
|
||||
if (ctx == nullptr)
|
||||
return nullptr;
|
||||
return *reinterpret_cast<const void *const *>(
|
||||
reinterpret_cast<const uint8_t *>(ctx) + Offsets::OFF_UI_CONTEXT_MOUSE_FOCUS);
|
||||
return Game::Read<const void *>(ctx, Offsets::OFF_UI_CONTEXT_MOUSE_FOCUS);
|
||||
}
|
||||
|
||||
// The engine's real mouseover setter — `__stdcall(guidLo, guidHi, prevLo,
|
||||
@@ -422,7 +366,7 @@ bool GuidHasLiveUnit(uint64_t guid) {
|
||||
// works for an offline member). The engine's setter (FUN_00492890) gates this
|
||||
// call on a live object, so offline members get nothing — we do it here.
|
||||
void BuildRosterUnitTooltip(uint64_t guid) {
|
||||
void *tooltip = *reinterpret_cast<void *const *>(
|
||||
void *tooltip = Game::Read<void *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_GAMETOOLTIP_OBJECT_PTR));
|
||||
if (tooltip == nullptr)
|
||||
return;
|
||||
@@ -434,9 +378,8 @@ void BuildRosterUnitTooltip(uint64_t guid) {
|
||||
// no owner and never re-appears (only a subsequent live-unit hover, which
|
||||
// takes the engine's own anchor path, would). Use the self-contained
|
||||
// invoker FUN_FRAME_INVOKE_SCRIPT, safe to call from the WorldTick.
|
||||
const int anchorHandler = *reinterpret_cast<const int *>(
|
||||
reinterpret_cast<uint8_t *>(tooltip) +
|
||||
Offsets::OFF_TOOLTIP_SET_DEFAULT_ANCHOR_HANDLER);
|
||||
const int anchorHandler = Game::Read<int>(
|
||||
tooltip, Offsets::OFF_TOOLTIP_SET_DEFAULT_ANCHOR_HANDLER);
|
||||
if (anchorHandler != 0)
|
||||
reinterpret_cast<void(__fastcall *)(int, void *)>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_FRAME_INVOKE_SCRIPT))(anchorHandler,
|
||||
@@ -585,10 +528,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).
|
||||
@@ -620,11 +632,32 @@ bool DispatchVerb(void *L, int fi, const char *prefix, const char *suffix,
|
||||
return true;
|
||||
}
|
||||
if (Ascii::EqualCI(verb, "spell")) {
|
||||
if (!unit) return false;
|
||||
// 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;
|
||||
Spell::AtUnit::CastByName(spell, unit);
|
||||
if (unit)
|
||||
Spell::AtUnit::CastByName(spell, unit);
|
||||
else
|
||||
Game::Lua::CallGlobalString(L, "CastSpellByName", spell);
|
||||
return true;
|
||||
}
|
||||
if (Ascii::EqualCI(verb, "item")) {
|
||||
@@ -646,7 +679,29 @@ bool DispatchVerb(void *L, int fi, const char *prefix, const char *suffix,
|
||||
return false;
|
||||
}
|
||||
if (Ascii::EqualCI(verb, "macro")) {
|
||||
// Give the macro a unit to act on. Vanilla macros have no `@unit`
|
||||
// conditional, and unlike the `spell` verb (which feeds a GUID straight
|
||||
// to the cast dispatcher) we can't inject a target into arbitrary macro
|
||||
// text — so a plain `/cast Flash Heal` would hit the current target, not
|
||||
// the clicked unit. Emulate the classic Clique click-heal: snapshot the
|
||||
// current target, retarget the clicked unit, run the macro, then restore
|
||||
// the previous target so the click doesn't leave the player retargeted.
|
||||
// The macro's actions (/cast etc.) dispatch against the clicked unit
|
||||
// synchronously while it's selected, so restoring the target afterward
|
||||
// doesn't affect them.
|
||||
uint64_t prevTarget = 0;
|
||||
const bool swapped = unit != nullptr;
|
||||
if (swapped) {
|
||||
prevTarget =
|
||||
(static_cast<uint64_t>(Game::Read<volatile uint32_t>(
|
||||
Offsets::VAR_CURRENT_SELECTION_GUID_HI))
|
||||
<< 32) |
|
||||
Game::Read<volatile uint32_t>(
|
||||
Offsets::VAR_CURRENT_SELECTION_GUID_LO);
|
||||
Game::Lua::CallGlobalString(L, "TargetUnit", unit);
|
||||
}
|
||||
char macro[512];
|
||||
bool handled = false;
|
||||
// `macrotext` is raw macro text (the modern default). Run it as text
|
||||
// through the stock chat parser so each line dispatches via
|
||||
// SlashCmdList — that routes /cast etc. through any addon slash hooks
|
||||
@@ -654,32 +709,64 @@ bool DispatchVerb(void *L, int fi, const char *prefix, const char *suffix,
|
||||
// (which is what SuperCleveRoidMacros ships) can't interpret text and
|
||||
// would silently resolve nothing.
|
||||
if (ReadModAttr(L, fi, prefix, "macrotext", suffix, macro, sizeof macro)) {
|
||||
RunMacroTextC(L, macro);
|
||||
return true;
|
||||
Macro::Execute::Text(L, macro);
|
||||
handled = true;
|
||||
}
|
||||
// Deprecated `macro` form: a saved-macro name/index. Prefer an
|
||||
// addon-provided RunMacro (SuperCleveRoidMacros, pfUI, …) to run the
|
||||
// named macro's body; fall back to the stock parser when none exists.
|
||||
if (ReadModAttr(L, fi, prefix, "macro", suffix, macro, sizeof macro)) {
|
||||
if (!Game::Lua::CallGlobalString(L, "RunMacro", macro))
|
||||
RunMacroTextC(L, macro);
|
||||
return true;
|
||||
else if (ReadModAttr(L, fi, prefix, "macro", suffix, macro, sizeof macro)) {
|
||||
handled = Macro::Execute::Saved(L, macro);
|
||||
}
|
||||
return false;
|
||||
// Restore the target the click swapped away from. `FUN_TARGET_BY_GUID`
|
||||
// validates the GUID resolves to a live unit and bails otherwise, so a
|
||||
// target that despawned mid-macro is dropped cleanly rather than
|
||||
// committed; a zero prior target means "no target" → ClearTarget.
|
||||
if (swapped) {
|
||||
if (prevTarget)
|
||||
reinterpret_cast<void(__fastcall *)(const uint64_t *)>(
|
||||
Offsets::FUN_TARGET_BY_GUID)(&prevTarget);
|
||||
else
|
||||
Game::Lua::CallGlobal(L, "ClearTarget");
|
||||
}
|
||||
return handled;
|
||||
}
|
||||
if (Ascii::EqualCI(verb, "stop") || Ascii::EqualCI(verb, "stopcasting")) {
|
||||
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
|
||||
@@ -714,7 +801,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);
|
||||
@@ -860,11 +947,30 @@ void FireAttributeChanged(void *L, void *frame, const char *name) {
|
||||
// ---- the methods -----------------------------------------------------------
|
||||
|
||||
// Enables the frame's mouse so it can become the mouse-focus (a bare frame
|
||||
// otherwise never registers as hovered). Operates on self at index 1.
|
||||
// otherwise never registers as hovered).
|
||||
//
|
||||
// Two properties, both deliberate:
|
||||
// * STACK-NEUTRAL. The old form did SetTop(L, 1) to set up (self, true) for
|
||||
// the engine setter, which DESTROYED the (name, value) at stack[2]/[3] that
|
||||
// FireAttributeChanged reads right after — handing any OnAttributeChanged
|
||||
// handler a nil `arg2`. We instead push the call at the top and restore the
|
||||
// caller's stack, so DoSet's (self, name, value) survives intact.
|
||||
// * PROTECTED. SetAttribute is Frame-scoped (VAR_FRAME_METHOD_REGISTRY), so
|
||||
// `self` is a real Frame and the setter's type check normally passes;
|
||||
// invoking through lua_pcall is belt-and-suspenders so a best-effort side
|
||||
// effect can never surface an error in the addon's SetAttribute. (Calling
|
||||
// via pcall is also what lets us pass the args at the callee's own stack
|
||||
// base without clobbering ours.)
|
||||
void EnableFrameMouse(void *L) {
|
||||
Game::Lua::SetTop(L, 1); // (self)
|
||||
Game::Lua::PushBoolean(L, 1);
|
||||
CallScript(Offsets::FUN_SCRIPT_FRAME_ENABLEMOUSE, L);
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushCClosure(
|
||||
L,
|
||||
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_SCRIPT_FRAME_ENABLEMOUSE),
|
||||
0);
|
||||
Game::Lua::PushValue(L, 1); // self (the frame)
|
||||
Game::Lua::PushBoolean(L, 1); // enable = true
|
||||
Game::Lua::PCall(L, 2, 0, 0); // best-effort; swallow a transient type error
|
||||
Game::Lua::SetTop(L, top); // restore the caller's stack
|
||||
}
|
||||
|
||||
int DoSet(void *L, bool fireHandler) { // (self, name, value)
|
||||
@@ -1003,4 +1109,25 @@ const Tick::WorldTick::AutoSubscribe _tick{&MouseoverTick};
|
||||
|
||||
} // namespace
|
||||
|
||||
// Drop every per-frame map before a /reload (or logout) resets the Lua state.
|
||||
// Both are keyed by the frame's C object pointer, which the allocator recycles
|
||||
// across a reload:
|
||||
// * g_attrHandlers holds OnAttributeChanged handler refs. The refs die with
|
||||
// the Lua reset, so a stale entry whose address a new frame reuses would
|
||||
// fire a DEAD ref from FireAttributeChanged — the invoker's protected pcall
|
||||
// catches it, but the engine error handler still prints it as an error "in
|
||||
// SetAttribute" (SecureStateDriverManager sets OnAttributeChanged every
|
||||
// session, so there's always at least one entry to go stale). This was the
|
||||
// "SetAttribute occasionally errors after /reload" report; same class of
|
||||
// bug as Tooltip::SetEvents issue #33.
|
||||
// * g_unitByFrame maps a frame to its `unit` token for the mouse-focus poll;
|
||||
// a stale entry at a reused address would point the mouseover at the wrong
|
||||
// unit and mislead the isNew mouse-enable gate.
|
||||
void PrepareForReload() {
|
||||
g_attrHandlers.clear();
|
||||
g_unitByFrame.clear();
|
||||
}
|
||||
|
||||
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
|
||||
|
||||
} // namespace Frame::Attributes
|
||||
|
||||
@@ -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 Frame::Attributes {
|
||||
|
||||
// Drop the per-frame attribute-handler and unit-token maps before a /reload (or
|
||||
// logout) resets the Lua state and recycles frame object pointers. Without it, a
|
||||
// stale OnAttributeChanged ref fires from a new frame that reused an old
|
||||
// address (surfaces as "SetAttribute occasionally errors after /reload"). Wired
|
||||
// from `FrameScript_Initialize_h` in DllMain.cpp alongside the other cleaners.
|
||||
void PrepareForReload();
|
||||
|
||||
} // namespace Frame::Attributes
|
||||
@@ -0,0 +1,181 @@
|
||||
// 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/>.
|
||||
|
||||
// `button:SetScript("PreClick"|"PostClick", fn)` — backport of the modern
|
||||
// button scripts that fire immediately before and after OnClick, as real frame
|
||||
// scripts settable via the standard SetScript / GetScript / HookScript. Vanilla
|
||||
// 1.12 has only OnClick and OnDoubleClick on buttons.
|
||||
//
|
||||
// 1.12 already has the script-dispatch machinery, just not these two names.
|
||||
// Two co-hooks (same shape as Tooltip::SetEvents):
|
||||
//
|
||||
// - The Button script resolver FUN_BUTTON_SCRIPT_RESOLVER maps a script name
|
||||
// to its handler slot (OnClick -> button+0x4CC, …). SetScript/GetScript/
|
||||
// HookScript all go through it. We co-hook it: for PreClick/PostClick
|
||||
// (which the engine doesn't know) we hand back a pointer to an external
|
||||
// per-button cell — a button object has no free slot for two new scripts,
|
||||
// and the resolver's only contract is "return the address the script lives
|
||||
// at", so external storage works exactly like an in-object slot.
|
||||
//
|
||||
// - The engine fires a button's OnClick through the arg'd script runner
|
||||
// FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT(frame, frame+0x4CC, "%s", &buttonName).
|
||||
// We co-hook that runner and, when the fired slot IS the OnClick slot,
|
||||
// re-invoke the runner for our PreClick cell first, then let OnClick run,
|
||||
// then re-invoke for PostClick — reusing the SAME (fmt, varargs) so
|
||||
// PreClick/PostClick receive arg1 = the button name exactly like OnClick.
|
||||
//
|
||||
// Why the runner and not the button click vmethod (FUN_00779540): SuperWoW
|
||||
// inline-hooks that vmethod for click-casting, and a second MinHook there
|
||||
// corrupts the trampoline (ERROR #132 — see Offsets.h). The runner is
|
||||
// uncontested, fires at the same top-level dispatch level (so its exec-context
|
||||
// stamp is valid), and the OnClick slot address is an exact gate.
|
||||
//
|
||||
// Limitation: because the runner only runs when a slot is non-empty, PreClick/
|
||||
// PostClick fire only when the button also has an OnClick handler set. Modern
|
||||
// fires them regardless; on this client that would require hooking the click
|
||||
// vmethod, which SuperWoW owns. Bracketing an existing OnClick is the common
|
||||
// case and the one this backport serves.
|
||||
|
||||
#include "frame/ClickEvents.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace Frame::ClickEvents {
|
||||
|
||||
namespace {
|
||||
|
||||
enum ScriptKind { SK_PRECLICK = 0, SK_POSTCLICK, SK_COUNT };
|
||||
|
||||
// Matched case-insensitively against the resolver's input name.
|
||||
constexpr const char *kNamesLower[SK_COUNT] = {"preclick", "postclick"};
|
||||
|
||||
// Per-button storage: one 8-byte {handler, context} slot per script kind. Only
|
||||
// buttons that actually had SetScript("PreClick"/"PostClick", …) called get an
|
||||
// entry, so the map stays small. unordered_map nodes are pointer-stable, so a
|
||||
// cell address handed to the engine stays valid until PrepareForReload clears
|
||||
// the map (buttons and their handler refs die on /reload).
|
||||
struct Cell {
|
||||
uint32_t slot[SK_COUNT][2]; // [kind] = {handler ref, exec context}
|
||||
};
|
||||
std::unordered_map<void *, Cell> g_cells;
|
||||
|
||||
uint32_t *SlotFor(void *button, int kind, bool create) {
|
||||
auto it = g_cells.find(button);
|
||||
if (it != g_cells.end())
|
||||
return it->second.slot[kind];
|
||||
if (!create)
|
||||
return nullptr;
|
||||
Cell &c = g_cells[button];
|
||||
for (auto &s : c.slot) {
|
||||
s[0] = 0;
|
||||
s[1] = 0;
|
||||
}
|
||||
return c.slot[kind];
|
||||
}
|
||||
|
||||
bool EqualsIgnoreCase(const char *s, const char *literal) {
|
||||
if (s == nullptr)
|
||||
return false;
|
||||
for (;; ++s, ++literal) {
|
||||
unsigned char a = static_cast<unsigned char>(*s);
|
||||
const unsigned char b = static_cast<unsigned char>(*literal);
|
||||
if (a >= 'A' && a <= 'Z')
|
||||
a = static_cast<unsigned char>(a + 32);
|
||||
if (a != b)
|
||||
return false;
|
||||
if (b == 0)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Recursion guard: a PreClick/OnClick/PostClick handler can programmatically
|
||||
// click a button again. While bracketing, further OnClick fires pass straight
|
||||
// through (the nested click still runs — it just doesn't get its own nested
|
||||
// PreClick/PostClick), which prevents runaway from a self-clicking handler.
|
||||
int g_firing = 0;
|
||||
|
||||
// --- Resolver co-hook (Button script-name -> slot) ---------------------
|
||||
using Resolver_t = int(__fastcall *)(void *self, void *edx, const char *name);
|
||||
Resolver_t g_resolverOriginal = nullptr;
|
||||
|
||||
int __fastcall Resolver_h(void *self, void *edx, const char *name) {
|
||||
const int engineSlot = g_resolverOriginal(self, edx, name);
|
||||
if (engineSlot != 0) // a base-frame or existing button script — leave it
|
||||
return engineSlot;
|
||||
for (int k = 0; k < SK_COUNT; ++k)
|
||||
if (EqualsIgnoreCase(name, kNamesLower[k]))
|
||||
return reinterpret_cast<int>(SlotFor(self, k, /*create*/ true));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --- Runner co-hook (fire PreClick before / PostClick after OnClick) ---
|
||||
using RunScript_t = void(__cdecl *)(void *frame, uint32_t *slotPtr,
|
||||
const char *fmt, void *varargs);
|
||||
RunScript_t g_runOriginal = nullptr;
|
||||
|
||||
// Fire the handler (if any) for `kind` on `frame`, reusing the OnClick fire's
|
||||
// own (fmt, varargs) so arg1 = the button name. We call the original runner
|
||||
// directly (its exec-context stamp is valid at this dispatch level). The runner
|
||||
// balances the Lua stack itself; we snapshot/restore the top as cheap insurance
|
||||
// against any imbalance leaking to the still-running button click code.
|
||||
void FireCell(void *frame, int kind, const char *fmt, void *varargs) {
|
||||
uint32_t *slot = SlotFor(frame, kind, /*create*/ false);
|
||||
if (slot == nullptr || slot[0] == 0)
|
||||
return;
|
||||
void *L = Game::Lua::State();
|
||||
const int savedTop = (L != nullptr) ? Game::Lua::GetTop(L) : 0;
|
||||
g_runOriginal(frame, slot, fmt, varargs);
|
||||
if (L != nullptr)
|
||||
Game::Lua::SetTop(L, savedTop);
|
||||
}
|
||||
|
||||
void __cdecl RunScript_h(void *frame, uint32_t *slotPtr, const char *fmt,
|
||||
void *varargs) {
|
||||
const auto onClickSlot = reinterpret_cast<uint32_t *>(
|
||||
reinterpret_cast<char *>(frame) + Offsets::OFF_BUTTON_ONCLICK_HANDLER);
|
||||
|
||||
if (slotPtr != onClickSlot || g_firing > 0) {
|
||||
g_runOriginal(frame, slotPtr, fmt, varargs);
|
||||
return;
|
||||
}
|
||||
|
||||
++g_firing;
|
||||
FireCell(frame, SK_PRECLICK, fmt, varargs);
|
||||
g_runOriginal(frame, slotPtr, fmt, varargs); // the button's OnClick
|
||||
FireCell(frame, SK_POSTCLICK, fmt, varargs);
|
||||
--g_firing;
|
||||
}
|
||||
|
||||
static const Game::HookAutoRegister _resolverHook{
|
||||
Offsets::FUN_BUTTON_SCRIPT_RESOLVER,
|
||||
reinterpret_cast<void *>(&Resolver_h),
|
||||
reinterpret_cast<void **>(&g_resolverOriginal)};
|
||||
|
||||
static const Game::HookAutoRegister _runnerHook{
|
||||
Offsets::FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT,
|
||||
reinterpret_cast<void *>(&RunScript_h),
|
||||
reinterpret_cast<void **>(&g_runOriginal)};
|
||||
|
||||
} // namespace
|
||||
|
||||
void PrepareForReload() {
|
||||
g_cells.clear();
|
||||
}
|
||||
|
||||
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
|
||||
|
||||
} // namespace Frame::ClickEvents
|
||||
@@ -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 Frame::ClickEvents {
|
||||
|
||||
// Drop every per-button PreClick/PostClick handler cell before a `/reload` (or
|
||||
// logout) tears down the button objects and resets the Lua state. The handler
|
||||
// refs die with the Lua reset, and a recycled button address must not fire a
|
||||
// stale ref. Wired from `FrameScript_Initialize_h` in DllMain.cpp, alongside
|
||||
// the other per-reload cleaners.
|
||||
void PrepareForReload();
|
||||
|
||||
} // namespace Frame::ClickEvents
|
||||
@@ -0,0 +1,99 @@
|
||||
// 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/>.
|
||||
|
||||
// `GetClickFrame(name)` — backport of the C global WoW registers for the
|
||||
// `/click` handler to resolve a frame by its global name. Vanilla 1.12
|
||||
// never shipped it; 3.3.5's `Script_GetClickFrame` (`0x00564130`) is the
|
||||
// reference.
|
||||
//
|
||||
// It lives in the DLL, not addon Lua, because Blizzard shipped it as an
|
||||
// engine-registered C global — the backport mirrors where the real
|
||||
// function lives, not the fact that Lua could fake it.
|
||||
//
|
||||
// Behaviour, matching the 3.3.5 resolver `FUN_00562990` (minus its private
|
||||
// name→frame memoization cache, which is only a perf optimization over the
|
||||
// global lookup):
|
||||
// 1. `_G[name]` — a plain global-table lookup.
|
||||
// 2. The value must be a frame wrapper table backed by a
|
||||
// `CFrameScriptObject`.
|
||||
// 3. Its real `GetName()` must equal `name`. This is the load-bearing
|
||||
// check: it stops a reassigned global (`_G["Foo"] = someOtherFrame`)
|
||||
// from resolving to a differently-named frame.
|
||||
// Return the frame on a match, otherwise nil.
|
||||
//
|
||||
// `GetName()` is the object's vtable slot +4 (`__thiscall`, returns
|
||||
// `const char *`) — verified from 1.12's own `Script_GetName`
|
||||
// (`0x0079FF60` / `0x007A1390`), which resolves self the same way and reads
|
||||
// the name from that slot. It's a base virtual present on every
|
||||
// `CFrameScriptObject` subtype, so calling it after `ResolveObject` is
|
||||
// safe without the RTTI subtype gate `Script_GetName` does first.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace Frame::ClickFrame {
|
||||
|
||||
namespace {
|
||||
// CFrameScriptObject::GetName — vtable slot +4, thiscall, no args.
|
||||
using GetName_t = const char *(__thiscall *)(void *self);
|
||||
|
||||
const char *ObjectName(void *obj) {
|
||||
auto vtable = *reinterpret_cast<void ***>(obj);
|
||||
auto getName = reinterpret_cast<GetName_t>(vtable[1]);
|
||||
return getName(obj);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
static int __fastcall Script_GetClickFrame(void *L) {
|
||||
using namespace Game::Lua;
|
||||
|
||||
if (GetTop(L) < 1) {
|
||||
Error(L, "Usage: GetClickFrame(\"name\")");
|
||||
return 0;
|
||||
}
|
||||
const char *name = ToString(L, 1);
|
||||
if (name == nullptr) {
|
||||
PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// getglobal(name): push _G[name].
|
||||
PushString(L, name);
|
||||
GetTable(L, GLOBALS_INDEX);
|
||||
|
||||
if (Type(L, -1) != TYPE_TABLE) {
|
||||
PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void *obj = ResolveObject(L, -1);
|
||||
if (obj != nullptr) {
|
||||
const char *objName = ObjectName(obj);
|
||||
if (objName != nullptr && std::strcmp(objName, name) == 0)
|
||||
// The wrapper table is still on top — return it as-is.
|
||||
return 1;
|
||||
}
|
||||
|
||||
PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("GetClickFrame", &Script_GetClickFrame);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace Frame::ClickFrame
|
||||
@@ -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
|
||||
+11
-17
@@ -131,8 +131,7 @@ int __fastcall Script_IsMouseOver(void *L) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
const float effScale = *reinterpret_cast<const float *>(
|
||||
reinterpret_cast<const uint8_t *>(obj) + Offsets::OFF_REGION_EFFECTIVE_SCALE);
|
||||
const float effScale = Game::Read<float>(obj, Offsets::OFF_REGION_EFFECTIVE_SCALE);
|
||||
|
||||
double left, right, top, bottom;
|
||||
if (effScale == 0.0f ||
|
||||
@@ -193,13 +192,10 @@ int __fastcall Script_GetRect(void *L) {
|
||||
// there, so it reports false, as it should.
|
||||
int __fastcall Script_IsDragging(void *L) {
|
||||
void *self = Game::Lua::ResolveObject(L, 1);
|
||||
void *ctx = *reinterpret_cast<void *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_UI_CONTEXT_PTR));
|
||||
void *ctx = Game::Read<void *>(static_cast<uintptr_t>(Offsets::VAR_UI_CONTEXT_PTR));
|
||||
void *dragTarget =
|
||||
ctx != nullptr
|
||||
? *reinterpret_cast<void *const *>(
|
||||
reinterpret_cast<const uint8_t *>(ctx) +
|
||||
Offsets::OFF_UI_CONTEXT_DRAG_TARGET)
|
||||
? Game::Read<void *>(ctx, Offsets::OFF_UI_CONTEXT_DRAG_TARGET)
|
||||
: nullptr;
|
||||
Game::Lua::PushBool(L, self != nullptr && self == dragTarget);
|
||||
return 1;
|
||||
@@ -348,9 +344,9 @@ int __fastcall Script_HookScript(void *L) {
|
||||
using SStrCmpI_t = int(__stdcall *)(const char *a, const char *b, int n);
|
||||
|
||||
bool FrameHasEvent(void *frame, const char *eventName) {
|
||||
const uint32_t count = *reinterpret_cast<const uint32_t *>(
|
||||
const uint32_t count = Game::Read<uint32_t>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_EVENT_TABLE_COUNT));
|
||||
auto *base = *reinterpret_cast<const uint8_t *const *>(
|
||||
auto *base = Game::Read<const uint8_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_EVENT_TABLE_BASE_PTR));
|
||||
if (count == 0 || base == nullptr)
|
||||
return false;
|
||||
@@ -360,21 +356,19 @@ bool FrameHasEvent(void *frame, const char *eventName) {
|
||||
|
||||
for (uint32_t i = 0; i < count; ++i) {
|
||||
const uint8_t *entry = base + i * Offsets::EVENT_ENTRY_STRIDE;
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
entry + Offsets::OFF_EVENT_ENTRY_NAME);
|
||||
const char *name = Game::Read<const char *>(
|
||||
entry, Offsets::OFF_EVENT_ENTRY_NAME);
|
||||
if (name == nullptr)
|
||||
continue;
|
||||
if (sstrcmpi(name, eventName, 0x7FFFFFFF) != 0)
|
||||
continue;
|
||||
// Names are unique, so this is the one entry — walk its chain.
|
||||
uintptr_t node = *reinterpret_cast<const uintptr_t *>(
|
||||
entry + Offsets::OFF_EVENT_ENTRY_HEAD);
|
||||
uintptr_t node = Game::Read<uintptr_t>(
|
||||
entry, Offsets::OFF_EVENT_ENTRY_HEAD);
|
||||
while ((node & 1) == 0 && node != 0) {
|
||||
if (*reinterpret_cast<void *const *>(
|
||||
node + Offsets::OFF_EVENT_NODE_FRAME) == frame)
|
||||
if (Game::Read<void *>(node + Offsets::OFF_EVENT_NODE_FRAME) == frame)
|
||||
return true;
|
||||
node = *reinterpret_cast<const uintptr_t *>(
|
||||
node + Offsets::OFF_EVENT_NODE_NEXT);
|
||||
node = Game::Read<uintptr_t>(node + Offsets::OFF_EVENT_NODE_NEXT);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
+6
-12
@@ -54,8 +54,7 @@ int CallScript(uintptr_t fn, void *L) {
|
||||
|
||||
// The UI context is a pointer stored at VAR_UI_CONTEXT_PTR (null pre-world).
|
||||
void *UIContext() {
|
||||
return *reinterpret_cast<void *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_UI_CONTEXT_PTR));
|
||||
return Game::Read<void *>(static_cast<uintptr_t>(Offsets::VAR_UI_CONTEXT_PTR));
|
||||
}
|
||||
|
||||
// Strata name -> draw rank, low (behind) to high (on top). Unknown -> 0.
|
||||
@@ -110,8 +109,7 @@ Cursor ReadCursor(void *L) {
|
||||
|
||||
// With `self` at Lua idx 1, is the cursor within the frame's rect?
|
||||
bool CursorOverSelf(void *L, void *obj, const Cursor &cur) {
|
||||
const float effScale = *reinterpret_cast<const float *>(
|
||||
reinterpret_cast<const uint8_t *>(obj) + Offsets::OFF_REGION_EFFECTIVE_SCALE);
|
||||
const float effScale = Game::Read<float>(obj, Offsets::OFF_REGION_EFFECTIVE_SCALE);
|
||||
if (effScale == 0.0f)
|
||||
return false; // unpositioned region — no rect
|
||||
double left, right, top, bottom;
|
||||
@@ -141,19 +139,16 @@ int __fastcall Script_GetMouseFoci(void *L) {
|
||||
|
||||
// The engine's authoritative topmost focus — the frame GetMouseFocus
|
||||
// returns. Forced to index 1 below.
|
||||
void *focus = *reinterpret_cast<void *const *>(
|
||||
reinterpret_cast<uint8_t *>(ctx) + Offsets::OFF_UI_CONTEXT_MOUSE_FOCUS);
|
||||
void *focus = Game::Read<void *>(ctx, Offsets::OFF_UI_CONTEXT_MOUSE_FOCUS);
|
||||
|
||||
const Cursor cur = ReadCursor(L);
|
||||
|
||||
std::vector<Hit> hits;
|
||||
void *node = *reinterpret_cast<void *const *>(
|
||||
reinterpret_cast<uint8_t *>(ctx) + Offsets::OFF_UI_CONTEXT_FRAME_LIST_HEAD);
|
||||
void *node = Game::Read<void *>(ctx, Offsets::OFF_UI_CONTEXT_FRAME_LIST_HEAD);
|
||||
while (node != nullptr && (reinterpret_cast<uintptr_t>(node) & 1) == 0) {
|
||||
// Read the next link before any Lua work (the walk is engine-list
|
||||
// based, independent of the Lua stack, but this keeps it obvious).
|
||||
void *next = *reinterpret_cast<void *const *>(
|
||||
reinterpret_cast<uint8_t *>(node) + Offsets::OFF_FRAME_ENUM_NEXT);
|
||||
void *next = Game::Read<void *>(node, Offsets::OFF_FRAME_ENUM_NEXT);
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
UI::FrameObject::Push(L, node); // (self)
|
||||
@@ -164,8 +159,7 @@ int __fastcall Script_GetMouseFoci(void *L) {
|
||||
Game::Lua::SetTop(L, 1);
|
||||
CallScript(Offsets::FUN_SCRIPT_FRAME_GET_STRATA, L); // (self, strata)
|
||||
const int rank = StrataRank(Game::Lua::ToString(L, 2));
|
||||
const int level = *reinterpret_cast<const int *>(
|
||||
reinterpret_cast<uint8_t *>(node) + Offsets::OFF_FRAME_LEVEL);
|
||||
const int level = Game::Read<int>(node, Offsets::OFF_FRAME_LEVEL);
|
||||
hits.push_back(Hit{node, rank, level});
|
||||
}
|
||||
|
||||
|
||||
+97
-27
@@ -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;
|
||||
@@ -77,11 +95,10 @@ const auto RawGetI = reinterpret_cast<RawGetI_t>(Offsets::LUA_RAWGETI);
|
||||
// Returns the frame's Lua object ref (frame+0x08), lazily creating it exactly
|
||||
// as the engine runner does (`if (refcount == 0) ScriptRegister(frame, 0)`).
|
||||
int EnsureLuaRef(void *frame) {
|
||||
auto *f = reinterpret_cast<uint8_t *>(frame);
|
||||
if (*reinterpret_cast<int *>(f + Offsets::OFF_COBJECT_LUA_REFCOUNT) == 0)
|
||||
if (Game::Read<int>(frame, Offsets::OFF_COBJECT_LUA_REFCOUNT) == 0)
|
||||
reinterpret_cast<ScriptRegister_t>(
|
||||
Offsets::FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER)(frame, nullptr);
|
||||
return *reinterpret_cast<int *>(f + Offsets::OFF_COBJECT_LUA_REF);
|
||||
return Game::Read<int>(frame, Offsets::OFF_COBJECT_LUA_REF);
|
||||
}
|
||||
|
||||
// Writes "arg" + k (k in 1..19) into buf; buf must hold at least 6 bytes.
|
||||
@@ -193,9 +210,7 @@ bool RunModern(int handlerRef, void *frame, const char *fmt, const void *vaPtr)
|
||||
// as the first positional to mirror retail's (self, event, arg1..argN).
|
||||
const bool isOnEvent =
|
||||
haveThis &&
|
||||
*reinterpret_cast<const int *>(reinterpret_cast<uint8_t *>(frame) +
|
||||
Offsets::OFF_FRAME_ONEVENT_SLOT) ==
|
||||
handlerRef;
|
||||
Game::Read<int>(frame, Offsets::OFF_FRAME_ONEVENT_SLOT) == handlerRef;
|
||||
const int selfRef = haveThis ? EnsureLuaRef(frame) : 0;
|
||||
|
||||
Arg args[kMaxArgs];
|
||||
@@ -230,7 +245,7 @@ bool RunModern(int handlerRef, void *frame, const char *fmt, const void *vaPtr)
|
||||
|
||||
// --- call handler(self, [event,] arg1..argN) ---------------------------
|
||||
const int callBase = GetTop(L); // == base + saved-block size
|
||||
const int errRef = *reinterpret_cast<const int *>(
|
||||
const int errRef = Game::Read<int>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_FRAMESCRIPT_ERROR_HANDLER_REF));
|
||||
int errIdx = 0;
|
||||
if (errRef > 0) {
|
||||
@@ -266,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);
|
||||
@@ -273,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);
|
||||
}
|
||||
|
||||
@@ -286,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
#include "Notes.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -41,14 +42,14 @@ std::string g_path;
|
||||
bool g_loaded = false;
|
||||
|
||||
const char *ReadAccountName() {
|
||||
return *reinterpret_cast<const char *const *>(Offsets::VAR_ACCOUNT_NAME_PTR);
|
||||
return Game::Read<const char *>(Offsets::VAR_ACCOUNT_NAME_PTR);
|
||||
}
|
||||
|
||||
const char *ReadRealmName() {
|
||||
auto *info = *reinterpret_cast<uint8_t **>(Offsets::VAR_REALM_INFO_PTR);
|
||||
auto *info = Game::Read<uint8_t *>(Offsets::VAR_REALM_INFO_PTR);
|
||||
if (info == nullptr)
|
||||
return nullptr;
|
||||
return *reinterpret_cast<const char *const *>(info + Offsets::OFF_REALM_INFO_NAME);
|
||||
return Game::Read<const char *>(info, Offsets::OFF_REALM_INFO_NAME);
|
||||
}
|
||||
|
||||
const char *ReadCharacterName() {
|
||||
|
||||
@@ -34,8 +34,7 @@ namespace FriendList::WhoInfo {
|
||||
namespace {
|
||||
|
||||
int WhoResultCount() {
|
||||
return *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_WHO_RESULT_COUNT));
|
||||
return Game::Read<int>(Offsets::VAR_WHO_RESULT_COUNT);
|
||||
}
|
||||
|
||||
// `C_FriendList.GetNumWhoResults()` → `numWhos, totalCount`. The first is the
|
||||
@@ -44,7 +43,7 @@ int WhoResultCount() {
|
||||
// global's two returns.
|
||||
int __fastcall Script_GetNumWhoResults(void *L) {
|
||||
const int displayed = WhoResultCount();
|
||||
const int total = *reinterpret_cast<const int *>(
|
||||
const int total = Game::Read<int>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_WHO_RESULT_COUNT) + 4);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(displayed));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(total));
|
||||
@@ -67,15 +66,15 @@ int __fastcall Script_GetWhoInfo(void *L) {
|
||||
static_cast<uintptr_t>(Offsets::VAR_WHO_RESULTS) +
|
||||
static_cast<size_t>(index - 1) * Offsets::WHO_RESULT_STRIDE);
|
||||
|
||||
const char *name = reinterpret_cast<const char *>(entry + Offsets::OFF_WHO_NAME);
|
||||
const char *guild = reinterpret_cast<const char *>(entry + Offsets::OFF_WHO_GUILD);
|
||||
const int level = *reinterpret_cast<const int *>(entry + Offsets::OFF_WHO_LEVEL);
|
||||
const char *name = Game::Ptr<const char>(entry, Offsets::OFF_WHO_NAME);
|
||||
const char *guild = Game::Ptr<const char>(entry, Offsets::OFF_WHO_GUILD);
|
||||
const int level = Game::Read<int>(entry, Offsets::OFF_WHO_LEVEL);
|
||||
const uint32_t raceID =
|
||||
*reinterpret_cast<const uint32_t *>(entry + Offsets::OFF_WHO_RACE);
|
||||
Game::Read<uint32_t>(entry, Offsets::OFF_WHO_RACE);
|
||||
const uint32_t classID =
|
||||
*reinterpret_cast<const uint32_t *>(entry + Offsets::OFF_WHO_CLASS);
|
||||
Game::Read<uint32_t>(entry, Offsets::OFF_WHO_CLASS);
|
||||
const uint32_t zoneID =
|
||||
*reinterpret_cast<const uint32_t *>(entry + Offsets::OFF_WHO_ZONE);
|
||||
Game::Read<uint32_t>(entry, Offsets::OFF_WHO_ZONE);
|
||||
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldString(L, "fullName", name); // inline; "" if empty
|
||||
|
||||
@@ -72,10 +72,10 @@ int __fastcall Script_GetGameObjectInfoByID(void *L) {
|
||||
if (rec == nullptr)
|
||||
return 0; // not cached -> nil
|
||||
|
||||
const uint32_t type = *reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_GAMEOBJECT_TYPE);
|
||||
const uint32_t type = Game::Read<uint32_t>(rec, Offsets::OFF_GAMEOBJECT_TYPE);
|
||||
const uint32_t displayID =
|
||||
*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_GAMEOBJECT_DISPLAYID);
|
||||
const char *name = *reinterpret_cast<const char *const *>(rec + Offsets::OFF_GAMEOBJECT_NAME);
|
||||
Game::Read<uint32_t>(rec, Offsets::OFF_GAMEOBJECT_DISPLAYID);
|
||||
const char *name = Game::Read<const char *>(rec, Offsets::OFF_GAMEOBJECT_NAME);
|
||||
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "gameObjectID", goID);
|
||||
@@ -107,7 +107,7 @@ void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_GameObjectInfo", "RequestLoadGameObjectByID",
|
||||
&Script_RequestLoadGameObjectByID);
|
||||
Cache::QueryLoad::Register(reinterpret_cast<void *>(Offsets::VAR_GAMEOBJECT_CACHE),
|
||||
kGameObjectDataLoadResult, Offsets::FUN_GAMEOBJECT_GET_RECORD);
|
||||
&_reserveLoadResult, Offsets::FUN_GAMEOBJECT_GET_RECORD);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user