mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-13 14:47:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bf2c17e0f | ||
|
|
3191c4dcfc | ||
|
|
373dce8202 | ||
|
|
4c7f1c36d6 | ||
|
|
36c367a6d6 | ||
|
|
fe9e7dddcd | ||
|
|
9d8f1b1b5d | ||
|
|
9d1e14410c | ||
|
|
8b06caf708 | ||
|
|
4684088675 | ||
|
|
9a26207df2 | ||
|
|
2adfaff020 | ||
|
|
8a7ab490f4 | ||
|
|
e26f8958e9 | ||
|
|
f0a83f1f31 | ||
|
|
5070c892e2 | ||
|
|
8295ec9442 | ||
|
|
c5b1431073 | ||
|
|
b39c16578b | ||
|
|
7d7e392924 | ||
|
|
14fa610bc8 | ||
|
|
8ca415e9a0 | ||
|
|
becf2f4866 | ||
|
|
28658c2995 | ||
|
|
f5cce25d23 | ||
|
|
1306eaf1b8 | ||
|
|
807afc083f | ||
|
|
c26afcc050 | ||
|
|
a124629669 | ||
|
|
be03d45f0c | ||
|
|
66c878d03e | ||
|
|
3ad87eb2f3 | ||
|
|
a9d2ad73f3 | ||
|
|
db88fca050 | ||
|
|
26af9bd30f | ||
|
|
e7156caa47 | ||
|
|
02ad0ea6ab | ||
|
|
1dee8e5683 | ||
|
|
583f4bb011 | ||
|
|
23ebfbe7ab | ||
|
|
18927551d5 | ||
|
|
2c2f280c51 | ||
|
|
991baa2610 | ||
|
|
e68b690cf1 | ||
|
|
e5690b1c6a | ||
|
|
e91b1457fd | ||
|
|
09c5832ae8 | ||
|
|
4015fee5e2 | ||
|
|
744bfb31d1 | ||
|
|
8706d1d99b | ||
|
|
8637bf05e5 | ||
|
|
802039fd08 | ||
|
|
d14f0b44c1 | ||
|
|
008282d65a | ||
|
|
9a8c314d69 | ||
|
|
4b1bf16ba5 | ||
|
|
5528fb5fa4 | ||
|
|
7ab32df2aa | ||
|
|
8e1429bfac | ||
|
|
9322382596 | ||
|
|
c3cf9e6f3a | ||
|
|
f5d6046523 | ||
|
|
e1ebf6ecce | ||
|
|
698827e9c5 | ||
|
|
60530e32fc | ||
|
|
3cffb71b20 | ||
|
|
7887237a6e | ||
|
|
98af79137b | ||
|
|
622882725f | ||
|
|
0517bb3753 | ||
|
|
fb17488237 | ||
|
|
b1ee8cc183 | ||
|
|
c08ded6db4 | ||
|
|
640f78b2eb | ||
|
|
a51ec40e52 | ||
|
|
2ce5b007af | ||
|
|
be92d721f0 | ||
|
|
b81f672ad7 | ||
|
|
fdbe66b057 | ||
|
|
bd1bd9f5d8 | ||
|
|
b6698ecc7e | ||
|
|
e123a6facc | ||
|
|
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 |
@@ -97,12 +97,46 @@ jobs:
|
||||
- name: Build
|
||||
run: cmake --build build --config Release
|
||||
|
||||
# Dev builds carry their own symbols. With the PDB sitting next to the DLL
|
||||
# the client's crash handler resolves our frames to file and line — a
|
||||
# tester's log reads `Texture::Mask::PrimIndexed_h+12 (Mask.cpp,246)`
|
||||
# instead of a bare address — and the map resolves `0001:offset` addresses
|
||||
# by hand when there is no dump. Both match THIS build and nothing else,
|
||||
# which is why they travel with it rather than being rebuilt later from the
|
||||
# same commit. Unlike the release bundle these need no off-putting name:
|
||||
# the artifact is behind the Actions UI, so whoever downloads it is already
|
||||
# the audience for symbols.
|
||||
- name: Upload artifact
|
||||
if: steps.ver.outputs.is_release != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ClassicAPI-${{ github.sha }}
|
||||
path: build/Release/ClassicAPI.dll
|
||||
# Shared parent is build/Release, so all three land at the zip root.
|
||||
path: |
|
||||
build/Release/ClassicAPI.dll
|
||||
build/Release/ClassicAPI.pdb
|
||||
build/Release/ClassicAPI.map
|
||||
# A symbol-less artifact looks identical to a good one until someone
|
||||
# needs a stack out of it, so fail the build instead of warning.
|
||||
if-no-files-found: error
|
||||
|
||||
# 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'
|
||||
@@ -111,5 +145,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
|
||||
@@ -42,6 +43,11 @@ Util\Rectangle.lua
|
||||
Util\Vector2D.lua
|
||||
Util\Vector3D.lua
|
||||
Util\Vector4D.lua
|
||||
Util\UiMapPoint.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>
|
||||
@@ -1,5 +1,10 @@
|
||||
if C_AddOns.DoesAddOnExist('pfUI') then
|
||||
EventUtil.ContinueOnAddOnLoaded('pfUI', function()
|
||||
-- A pfUI that sets `handlesHookScript` was written against our
|
||||
-- additions and manages them itself, so none of the shims below apply
|
||||
-- to it.
|
||||
if pfUI.handlesHookScript then return end
|
||||
|
||||
-- pfUI\pfUI.lua redeclares the RAID_CLASS_COLORS object at least 14 times (insane!!)
|
||||
-- This stops it from replacing all our ColorMixin values
|
||||
pfUI.UpdateColors = function() end
|
||||
@@ -15,12 +20,14 @@ 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. The maintained "brues" fork, identified by its X-Website tag,
|
||||
-- handles HookScript on its own buttons and is exempt from this part;
|
||||
-- every other fork gets 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 then
|
||||
local _createFrame = CreateFrame
|
||||
local HookedCreateFrame = function(frameType, name, parent, template)
|
||||
local frame = _createFrame(frameType, name, parent, template)
|
||||
@@ -40,13 +47,10 @@ if C_AddOns.DoesAddOnExist('pfUI') then
|
||||
end)
|
||||
end
|
||||
|
||||
if C_AddOns.DoesAddOnExist("SpecialTalentUI") then
|
||||
EventUtil.ContinueOnAddOnLoaded("SpecialTalentUI", CAPI_ApplyStandardColorGlobals)
|
||||
end
|
||||
|
||||
-- ShaguTweaks libpredict register's TBC events that ClassicAPI backports.
|
||||
if C_AddOns.DoesAddOnExist("ShaguTweaks") then
|
||||
EventUtil.ContinueOnAddOnLoaded("ShaguTweaks", function()
|
||||
if not ShaguTweaks or (ShaguTweaks.API and ShaguTweaks.API.classicapi_version) then return end
|
||||
local libp = ShaguTweaks.libpredict
|
||||
if libp then
|
||||
libp.sender:UnregisterEvent("COMBAT_LOG_EVENT_UNFILTERED")
|
||||
@@ -76,8 +80,178 @@ end
|
||||
|
||||
CAPI_MouseoverClearedCompat(GameTooltip)
|
||||
|
||||
-- Restores vanilla calling for ONE frame's script handler. Our DLL hands script
|
||||
-- handlers modern positional arguments (`self`, then the event's own args) on
|
||||
-- top of the `this` / `arg1` globals vanilla uses. That is additive for a
|
||||
-- handler declared `function()`, but a handler declared with a parameter of its
|
||||
-- own -- one written to be BOTH called directly and used as a script -- now
|
||||
-- receives `self` in that parameter instead of the nil it was written for.
|
||||
--
|
||||
-- The wrapper below takes no parameters, so those extra arguments stop here,
|
||||
-- and the original runs with the empty argument list vanilla gave it. The
|
||||
-- globals are untouched by the nesting, so the handler still reads `this` and
|
||||
-- `event` normally.
|
||||
function CAPI_VanillaScriptArgsCompat(frame, script)
|
||||
if not frame then return end
|
||||
local original = frame:GetScript(script)
|
||||
if not original then return end
|
||||
frame:SetScript(script, function()
|
||||
original()
|
||||
end)
|
||||
end
|
||||
|
||||
-- LunaUnitFrames sets `SetupGroupHeader(groupType)` as its group headers'
|
||||
-- OnEvent while also calling it directly with a group name. As a handler it
|
||||
-- expects `groupType` to be nil so it falls back to `this.unitGroup`. Given
|
||||
-- `self` instead, it looks the header up by frame rather than by name, finds
|
||||
-- nothing, and indexes nil (units.lua:358, issue #41). Its raid counterpart
|
||||
-- takes the header frame itself, so `self` is already what it wants -- only the
|
||||
-- group headers need this.
|
||||
if C_AddOns.DoesAddOnExist("LunaUnitFrames") then
|
||||
EventUtil.ContinueOnAddOnLoaded("LunaUnitFrames", function()
|
||||
if not (LunaUF and LunaUF.Units) then return end
|
||||
|
||||
local function FixGroupHeader(unit)
|
||||
CAPI_VanillaScriptArgsCompat(getglobal("LUFHeader" .. (unit or "")),
|
||||
"OnEvent")
|
||||
end
|
||||
|
||||
-- Headers are built on demand, so cover the ones already up and every
|
||||
-- one created later.
|
||||
FixGroupHeader("party")
|
||||
FixGroupHeader("partytarget")
|
||||
FixGroupHeader("partypet")
|
||||
hooksecurefunc(LunaUF.Units, "LoadGroupHeader", function(self, unit)
|
||||
FixGroupHeader(unit)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
if C_AddOns.DoesAddOnExist("Puppeteer") then
|
||||
EventUtil.ContinueOnAddOnLoaded("Puppeteer", function()
|
||||
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
|
||||
|
||||
-- Compost-2.0 (rev 17406+, bundled by BigWigs among others) replaces most of
|
||||
-- its own body with stubs once it believes it is on Lua 5.1, on the reasoning
|
||||
-- that table recycling is pointless when the collector already does it:
|
||||
--
|
||||
-- local lua51 = loadstring("return function(...) return ... end") and true or false
|
||||
-- ...
|
||||
-- if lua51 then function lib:Erase() return {} end
|
||||
-- else function lib:Erase(t) --[[ clears t in place ]] end
|
||||
--
|
||||
-- Our 5.1 syntax backport makes that probe pass, so the stubs win. For
|
||||
-- GetTable and Reclaim that is harmless -- both are pure recycling hints. Erase
|
||||
-- is not: callers hand it a live table and expect its keys gone. The stub takes
|
||||
-- no parameter at all, so every `compost:Erase(t)` that clears in place (rather
|
||||
-- than assigning the returned table) silently stops clearing.
|
||||
--
|
||||
-- Puppeteer is the reported casualty. PTUnit:ClearAuras erases its
|
||||
-- TrackedDebuffTypes, AfflictedDebuffTypes and aura-ID sets in place, so a
|
||||
-- dispel type never leaves the set once applied: a cursed raid member keeps its
|
||||
-- debuff colour after the curse is cleaned, and HasDebuffID answers true for
|
||||
-- auras that are long gone. It takes a second addon shipping the newer Compost
|
||||
-- to show up, because AceLibrary arbitrates on the highest $Revision and
|
||||
-- ignores load order -- so a lone Puppeteer keeps its own working rev 11579.
|
||||
--
|
||||
-- Restore an in-place Erase matching the pre-stub body. Probed by behavior
|
||||
-- rather than by revision or addon name, so a Compost that already clears is
|
||||
-- left alone.
|
||||
--
|
||||
-- Re-checked on every ADDON_LOADED rather than once, because AceLibrary can
|
||||
-- upgrade a library at any point during load -- including from an addon loaded
|
||||
-- on demand mid-session -- and an upgrade installs the new copy's functions
|
||||
-- over ours. We cannot simply wait for a late event either, since this addon
|
||||
-- loads first and AceLibrary does not exist yet at that point.
|
||||
--
|
||||
-- Repeating is cheap because the verdict is cached against the function that
|
||||
-- earned it: a check settles to two identity comparisons once the live Erase
|
||||
-- has been either vetted or replaced, and only an Erase we have never seen
|
||||
-- costs a probe. The probe table is reused across checks (refilled each time,
|
||||
-- so leftover state cannot skew the answer) rather than built per check.
|
||||
local compostProbe = {}
|
||||
local vettedErase
|
||||
|
||||
local function EraseInPlace(self, t)
|
||||
if type(t) ~= "table" then return end
|
||||
setmetatable(t, nil)
|
||||
for k in pairs(t) do
|
||||
t[k] = nil
|
||||
end
|
||||
table.setn(t, 0)
|
||||
return t
|
||||
end
|
||||
|
||||
local function EnsureCompostErasesInPlace()
|
||||
if not (AceLibrary and AceLibrary.HasInstance
|
||||
and AceLibrary:HasInstance("Compost-2.0")) then
|
||||
return
|
||||
end
|
||||
|
||||
local compost = AceLibrary("Compost-2.0")
|
||||
if compost.Erase == EraseInPlace or compost.Erase == vettedErase then
|
||||
return
|
||||
end
|
||||
|
||||
compostProbe[1] = "array"
|
||||
compostProbe.key = "hash"
|
||||
compost:Erase(compostProbe)
|
||||
if next(compostProbe) == nil then
|
||||
vettedErase = compost.Erase -- clears in place; leave it alone
|
||||
return
|
||||
end
|
||||
|
||||
compost.Erase = EraseInPlace
|
||||
end
|
||||
|
||||
EventRegistry:RegisterFrameEventAndCallback("ADDON_LOADED",
|
||||
EnsureCompostErasesInPlace)
|
||||
EventRegistry:RegisterFrameEventAndCallback("PLAYER_LOGIN",
|
||||
EnsureCompostErasesInPlace)
|
||||
EnsureCompostErasesInPlace()
|
||||
|
||||
-- Some addons carry a copy of Blizzard's own color definitions -- a plain
|
||||
-- `NORMAL_FONT_COLOR = {r=1.0, g=0.82, b=0}` and its friends -- and assign
|
||||
-- them at file scope. That takes ColorMixin off those globals for every addon
|
||||
-- that loads later, which is what breaks pfUI's who list when it calls
|
||||
-- `NORMAL_FONT_COLOR:GetRGB()` (brues-code/pfUI#56).
|
||||
--
|
||||
-- Re-apply after each addon loads, so a clobbered color is repaired before the
|
||||
-- next addon reads it. This also covers an addon loaded on demand mid-session.
|
||||
-- The pass mixes the methods back into the table that is there, and costs one
|
||||
-- lookup per color when nothing is broken.
|
||||
EventRegistry:RegisterFrameEventAndCallback("ADDON_LOADED",
|
||||
CAPI_ApplyStandardColorGlobals)
|
||||
EventRegistry:RegisterFrameEventAndCallback("PLAYER_LOGIN",
|
||||
CAPI_ApplyStandardColorGlobals)
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
-- 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)
|
||||
--
|
||||
-- Iteration batches through C_UnitAuras.GetAuraSlots + a by-slot fetch
|
||||
-- (continuation tokens), exactly as retail's does: one enumeration per batch,
|
||||
-- then a direct fetch per aura -- linear in the aura count. The by-index getters
|
||||
-- (GetAuraDataByIndex / UnitAura) re-walk the aura array from slot 0 on every
|
||||
-- call, so iterating by index is quadratic; don't go back to it here.
|
||||
--
|
||||
-- Two deliberate differences from that source:
|
||||
-- 1. Retail's helpers receive GetAuraSlots' return list as varargs
|
||||
-- (ForEachAuraHelper / FindAuraRecurse). Lua 5.0 builds an `arg` table for
|
||||
-- EVERY vararg call, so that shape allocates one table per batch here --
|
||||
-- exactly the per-frame garbage this library exists to avoid. We use
|
||||
-- GetAuraSlots' fill-a-table form instead (a table as its 5th argument,
|
||||
-- a ClassicAPI extension): the slot ids land in a reusable buffer and the
|
||||
-- call returns `continuationToken, n`. No vararg frame anywhere.
|
||||
-- 2. The non-packed ForEachAura and FindAura fetch each aura through the
|
||||
-- positional C_UnitAuras.UnitAuraBySlot (no table), the way retail's
|
||||
-- AuraUtil did before it retired the positional by-slot accessor. So both
|
||||
-- are zero-allocation here -- FindAura in particular, where the current
|
||||
-- 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 GetAuraSlots = C_UnitAuras.GetAuraSlots
|
||||
local GetAuraDataBySlot = C_UnitAuras.GetAuraDataBySlot
|
||||
local UnitAuraBySlot = C_UnitAuras.UnitAuraBySlot
|
||||
|
||||
AuraUtil = AuraUtil or {};
|
||||
|
||||
-- Slot-id buffers for GetAuraSlots' fill form, one per nesting level: a `func`
|
||||
-- or `predicate` that itself iterates another unit's auras gets its own buffer
|
||||
-- instead of clobbering the outer scan's. Costs one table per nesting level for
|
||||
-- the session (retail's varargs are re-entrant for free; ours must be explicit).
|
||||
-- An error thrown out of `func` skips the release and leaves `depth` one higher
|
||||
-- for the rest of the session -- harmless: later scans use the next buffer up,
|
||||
-- allocated once.
|
||||
local slotBufs = {};
|
||||
local depth = 0;
|
||||
|
||||
local function AcquireSlotBuf()
|
||||
depth = depth + 1;
|
||||
local buf = slotBufs[depth];
|
||||
if not buf then
|
||||
buf = {};
|
||||
slotBufs[depth] = buf;
|
||||
end
|
||||
return buf;
|
||||
end
|
||||
|
||||
local function ReleaseSlotBuf()
|
||||
depth = depth - 1;
|
||||
end
|
||||
|
||||
-- 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 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. `batchSize` is the number of slots fetched per GetAuraSlots call (nil =
|
||||
-- all at once); it never caps how many auras are visited. `batchSize <= 0` does
|
||||
-- nothing.
|
||||
function AuraUtil.ForEachAura(unit, filter, batchSize, func, usePackedAura)
|
||||
if batchSize and batchSize <= 0 then
|
||||
return;
|
||||
end
|
||||
local buf = AcquireSlotBuf();
|
||||
local continuationToken, n;
|
||||
repeat
|
||||
continuationToken, n = GetAuraSlots(unit, filter, batchSize, continuationToken, buf);
|
||||
for i = 1, n do
|
||||
local done;
|
||||
if usePackedAura then
|
||||
done = func(GetAuraDataBySlot(unit, buf[i]));
|
||||
else
|
||||
done = func(UnitAuraBySlot(unit, buf[i]));
|
||||
end
|
||||
if done then
|
||||
ReleaseSlotBuf();
|
||||
return;
|
||||
end
|
||||
end
|
||||
until continuationToken == nil;
|
||||
ReleaseSlotBuf();
|
||||
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 by-slot accessor, so no table is allocated while
|
||||
-- scanning.
|
||||
function AuraUtil.FindAura(predicate, unit, filter, arg1, arg2, arg3)
|
||||
local buf = AcquireSlotBuf();
|
||||
local continuationToken, n;
|
||||
repeat
|
||||
continuationToken, n = GetAuraSlots(unit, filter, nil, continuationToken, buf);
|
||||
for i = 1, n do
|
||||
local slot = buf[i];
|
||||
if predicate(arg1, arg2, arg3, UnitAuraBySlot(unit, slot)) then
|
||||
ReleaseSlotBuf();
|
||||
return UnitAuraBySlot(unit, slot);
|
||||
end
|
||||
end
|
||||
until continuationToken == nil;
|
||||
ReleaseSlotBuf();
|
||||
return nil;
|
||||
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
|
||||
@@ -81,18 +81,89 @@ function ColorMixin:WrapTextInColorTableCode(text)
|
||||
return WrapTextInColorCode(text, self:GenerateHexColor())
|
||||
end
|
||||
|
||||
for _, v in pairs(ITEM_QUALITY_COLORS) do
|
||||
v.color = CreateColor(v.r, v.g, v.b, 1)
|
||||
-- Publishes the standard color globals (NORMAL_FONT_COLOR and friends) as
|
||||
-- ColorMixin objects, the way Blizzard_SharedXMLBase/Color.lua does.
|
||||
--
|
||||
-- Runs at load and again after every addon loads (see Util/AddOnCompat.lua).
|
||||
-- An addon that carries a copy of Blizzard's own definition -- a plain
|
||||
-- `NORMAL_FONT_COLOR = {r=1.0, g=0.82, b=0}` -- assigns it over ours at file
|
||||
-- scope, and every addon that loads after it then finds a color with no
|
||||
-- methods. A later pass mixes ColorMixin INTO the table that is there instead
|
||||
-- of replacing it, so the values that addon chose stay, and each reference it
|
||||
-- gave out gets the methods too.
|
||||
--
|
||||
-- Only a color that is absent or has lost its methods is touched. A tag that
|
||||
-- holds something other than a color table belongs to some other addon, so it
|
||||
-- is left alone. A `<TAG>_CODE` string that is already set is never rewritten:
|
||||
-- FrameXML writes those together with the values they go with.
|
||||
local standardColors
|
||||
|
||||
-- True for a table that carries r/g/b numbers.
|
||||
local function HasRGB(value)
|
||||
return type(value) == "table"
|
||||
and type(value.r) == "number"
|
||||
and type(value.g) == "number"
|
||||
and type(value.b) == "number"
|
||||
end
|
||||
|
||||
function CAPI_ApplyStandardColorGlobals()
|
||||
local envTbl = _G
|
||||
for _, dbColor in ipairs(C_UIColor.GetColors()) do
|
||||
if not envTbl[dbColor.baseTag] or not envTbl[dbColor.baseTag].GetRGB then
|
||||
local color = CreateColor(dbColor.color.r, dbColor.color.g, dbColor.color.b, dbColor.color.a)
|
||||
envTbl[dbColor.baseTag] = color
|
||||
envTbl[dbColor.baseTag.."_CODE"] = color:GenerateHexColorMarkup()
|
||||
-- True for a color table that still needs the mixin.
|
||||
local function IsPlainColorTable(value)
|
||||
return HasRGB(value) and not value.GetRGB
|
||||
end
|
||||
|
||||
-- ITEM_QUALITY_COLORS is the same story with one more level: FrameXML builds
|
||||
-- each entry as a plain {r, g, b, hex} table, and the `color` object on it is
|
||||
-- ours. An addon that carries its own copy of the table takes those objects
|
||||
-- with it, so repair the entries on the same pass. Keys run from -1, so this
|
||||
-- walks the table with pairs.
|
||||
local function ApplyItemQualityColors()
|
||||
if type(ITEM_QUALITY_COLORS) ~= "table" then return end
|
||||
|
||||
for _, quality in pairs(ITEM_QUALITY_COLORS) do
|
||||
if type(quality) == "table" then
|
||||
if IsPlainColorTable(quality.color) then
|
||||
quality.color = Mixin(quality.color, ColorMixin)
|
||||
quality.color.a = quality.color.a or 1
|
||||
elseif quality.color == nil and HasRGB(quality) then
|
||||
quality.color = CreateColor(quality.r, quality.g, quality.b, 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function CAPI_ApplyStandardColorGlobals()
|
||||
-- `C_UIColor.GetColors()` makes a color object for each of its 187 rows,
|
||||
-- so keep the values from the first call. Each later pass is then one
|
||||
-- lookup per tag.
|
||||
if not standardColors then
|
||||
standardColors = {}
|
||||
for _, dbColor in ipairs(C_UIColor.GetColors()) do
|
||||
table.insert(standardColors, {
|
||||
tag = dbColor.baseTag,
|
||||
r = dbColor.color.r,
|
||||
g = dbColor.color.g,
|
||||
b = dbColor.color.b,
|
||||
a = dbColor.color.a,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
for _, entry in ipairs(standardColors) do
|
||||
local current = _G[entry.tag]
|
||||
local color
|
||||
if current == nil then
|
||||
color = CreateColor(entry.r, entry.g, entry.b, entry.a)
|
||||
_G[entry.tag] = color
|
||||
elseif IsPlainColorTable(current) then
|
||||
color = Mixin(current, ColorMixin)
|
||||
color.a = color.a or 1
|
||||
end
|
||||
|
||||
if color and _G[entry.tag.."_CODE"] == nil then
|
||||
_G[entry.tag.."_CODE"] = color:GenerateHexColorMarkup()
|
||||
end
|
||||
end
|
||||
|
||||
ApplyItemQualityColors()
|
||||
end
|
||||
CAPI_ApplyStandardColorGlobals()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
-- Only the dependency-light, commonly-called helpers are ported. Omitted:
|
||||
-- - the fade/flash functions (UIFrameFade*/UIFrameFlash*) are already
|
||||
-- native in 1.12.
|
||||
-- - RegisterFrameForUnitEvents (no RegisterUnitEvent in 1.12), and the
|
||||
-- scale/layout/secure/EventRegistry helpers (rely on APIs absent here).
|
||||
-- - the scale/layout/secure/EventRegistry helpers (rely on APIs absent
|
||||
-- here).
|
||||
|
||||
FrameUtil = FrameUtil or {};
|
||||
|
||||
@@ -14,6 +14,12 @@ function FrameUtil.RegisterFrameForEvents(frame, events)
|
||||
end
|
||||
end
|
||||
|
||||
function FrameUtil.RegisterFrameForUnitEvents(frame, events, ...)
|
||||
for i, event in ipairs(events) do
|
||||
frame:RegisterUnitEvent(event, unpack(arg));
|
||||
end
|
||||
end
|
||||
|
||||
function FrameUtil.UnregisterFrameForEvents(frame, events)
|
||||
for i, event in ipairs(events) do
|
||||
frame:UnregisterEvent(event);
|
||||
|
||||
@@ -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,600 @@
|
||||
-- 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 = {};
|
||||
|
||||
-- Keywords met during the current `ParseOptions` run that this parser does
|
||||
-- not own. A condition we cannot evaluate means the options were written for
|
||||
-- another macro parser's dialect, so the fact is recorded on the parsed
|
||||
-- clause set rather than reported here. The caller decides what to do with
|
||||
-- it: a command the player ran names the keyword, and the macro-display scan
|
||||
-- leaves the macro alone.
|
||||
local parseUnknowns;
|
||||
|
||||
-- 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 parseUnknowns then
|
||||
parseUnknowns = { n = 0 };
|
||||
end
|
||||
parseUnknowns.n = parseUnknowns.n + 1;
|
||||
parseUnknowns[parseUnknowns.n] = keyword;
|
||||
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, unknown = { n, [i] = keyword } or nil }.
|
||||
-- Splits on ';' at bracket depth 0.
|
||||
local function ParseOptions(options)
|
||||
parseUnknowns = nil;
|
||||
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
|
||||
clauses.unknown = parseUnknowns;
|
||||
parseUnknowns = nil;
|
||||
return clauses;
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Evaluation
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local cache = {};
|
||||
|
||||
-- `@unit` takes a character name as well as a token (`[target=Feral]`), and a
|
||||
-- name has to be searched for in the object manager. That search is the one
|
||||
-- expensive step here, so a pass remembers what it already found. The table
|
||||
-- is only built once a pass meets a name, and the next pass drops it: a token
|
||||
-- kept across passes could name a different unit by then.
|
||||
local passNames;
|
||||
|
||||
local function TokenForName(name)
|
||||
if passNames then
|
||||
local cached = passNames[name];
|
||||
if cached ~= nil then
|
||||
return cached or nil; -- false records "nobody there"
|
||||
end
|
||||
else
|
||||
passNames = {};
|
||||
end
|
||||
local token = UnitTokenFromName(name);
|
||||
passNames[name] = token or false;
|
||||
return token;
|
||||
end
|
||||
|
||||
-- A `@unit` piece as something the engine's unit functions accept, with no
|
||||
-- question asked about whether a unit is there. `IsUnitToken` tells a token
|
||||
-- from a name by asking the engine's own resolver, and it does not go looking
|
||||
-- for the unit -- which is what the conditions do next anyway.
|
||||
local function TokenForTarget(target)
|
||||
if IsUnitToken(target) then
|
||||
return target;
|
||||
end
|
||||
return TokenForName(target);
|
||||
end
|
||||
|
||||
-- Whether a `@unit` piece has a unit behind it right now. The pcall tells a
|
||||
-- token from a name in one step: the engine raises for a string that names no
|
||||
-- token, which is exactly the case to hand to the by-name search. That also
|
||||
-- keeps a typo failing the group instead of the caller.
|
||||
local function TargetExists(target)
|
||||
local ok, exists = pcall(UnitExists, target);
|
||||
if ok then
|
||||
return exists and true or false;
|
||||
end
|
||||
local token = TokenForName(target);
|
||||
return token ~= nil and UnitExists(token) and true or false;
|
||||
end
|
||||
|
||||
-- Targets that do not name a unit. `none` clears the target and `cursor`
|
||||
-- names a world position, so neither can be tested with UnitExists and both
|
||||
-- always pass the existence gate below.
|
||||
local NON_UNIT_TARGETS = { none = true, cursor = true };
|
||||
|
||||
local function GroupPasses(group)
|
||||
local target = group.target;
|
||||
local named = target and not NON_UNIT_TARGETS[strlower(target)];
|
||||
if group.n == 0 then
|
||||
-- A group that only names a unit (`[@mouseover]`) passes only while
|
||||
-- that unit exists, so `[@mouseover][] Spell` falls through to the
|
||||
-- next group when nothing is moused over. A bare `[]` and the
|
||||
-- non-unit targets always pass. Groups with conditions leave
|
||||
-- existence to them (`[@focus,noexists]` still works).
|
||||
if not named then
|
||||
return true;
|
||||
end
|
||||
return TargetExists(target);
|
||||
end
|
||||
if named then
|
||||
-- The conditions ask the engine about this unit, so a name has to
|
||||
-- become a token first. Existence is theirs to test, so it is not
|
||||
-- asked for here.
|
||||
target = TokenForTarget(target);
|
||||
if not target then
|
||||
return false;
|
||||
end
|
||||
end
|
||||
target = 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
|
||||
|
||||
-- Returns the matched value, the passing group's target, and the name of the
|
||||
-- first condition the options use that this parser does not own.
|
||||
--
|
||||
-- That third value marks the options as another macro parser's dialect. A
|
||||
-- caller that scans macro bodies it did not write passes `quiet` and reads it,
|
||||
-- so it can leave such a macro alone instead of naming a keyword the player
|
||||
-- has no reason to fix.
|
||||
function SecureCmdOptionParse(options, quiet)
|
||||
if type(options) ~= "string" then
|
||||
error("Usage: SecureCmdOptionParse(\"options\")");
|
||||
return nil; -- WoW's error() may not unwind
|
||||
end
|
||||
|
||||
-- Start of a pass: drop what the last one resolved by name. Nothing is
|
||||
-- allocated unless a name was actually met, so the all-token case (the
|
||||
-- common one, polled several times a second) costs nothing here.
|
||||
if passNames then
|
||||
passNames = nil;
|
||||
end
|
||||
|
||||
local clauses = cache[options];
|
||||
if not clauses then
|
||||
clauses = ParseOptions(options);
|
||||
cache[options] = clauses;
|
||||
end
|
||||
|
||||
-- Name each keyword once, for a caller that wants to hear about it.
|
||||
local unknown = clauses.unknown;
|
||||
if unknown and not quiet then
|
||||
for i = 1, unknown.n do
|
||||
local keyword = unknown[i];
|
||||
if not warned[keyword] then
|
||||
warned[keyword] = true;
|
||||
print("SecureCmdOptionParse: unknown condition '" .. keyword .. "'");
|
||||
end
|
||||
end
|
||||
end
|
||||
local foreign = unknown and unknown[1] or nil;
|
||||
|
||||
for i = 1, clauses.n do
|
||||
local clause = clauses[i];
|
||||
local groups = clause.groups;
|
||||
if groups.n == 0 then
|
||||
return clause.value, nil, foreign;
|
||||
end
|
||||
for j = 1, groups.n do
|
||||
if GroupPasses(groups[j]) then
|
||||
return clause.value, groups[j].target, foreign;
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil, nil, foreign;
|
||||
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
|
||||
@@ -6,20 +6,559 @@ function RegisterNewSlashCommand(callback, command, commandAlias)
|
||||
SlashCmdList[name] = callback;
|
||||
end
|
||||
|
||||
-- `/cast` and `/use` (the FrameXML ChatFrame.lua shape): conditionals through
|
||||
-- SecureCmdOptionParse, items before spells, `[@unit]` cast targets.
|
||||
-- A bare number is an inventory slot only when it is one (1..19); any other
|
||||
-- number is a spellID (`/cast 5019`), which the spell resolver already
|
||||
-- accepts anywhere a spell name goes.
|
||||
local INVSLOT_FIRST, INVSLOT_LAST = 1, 19;
|
||||
|
||||
function SecureCmdItemParse(item)
|
||||
if ( not item ) then
|
||||
return nil, nil, nil;
|
||||
end
|
||||
local bag, slot = string.match(item, "^(%d+)%s+(%d+)$");
|
||||
if ( not bag ) then
|
||||
slot = string.match(item, "^(%d+)$");
|
||||
if ( slot ) then
|
||||
local n = tonumber(slot);
|
||||
if ( n < INVSLOT_FIRST or n > INVSLOT_LAST ) then
|
||||
slot = nil;
|
||||
end
|
||||
end
|
||||
end
|
||||
if ( bag ) then
|
||||
item = GetContainerItemLink(bag, slot);
|
||||
elseif ( slot ) then
|
||||
item = GetInventoryItemLink("player", slot);
|
||||
end
|
||||
return item, bag, slot;
|
||||
end
|
||||
|
||||
-- A number with no bag or inventory slot behind it. `/cast 5019` names a
|
||||
-- spell, so the item lookup must never see it: an item reference reads a
|
||||
-- number as an itemID, and a carried item with that ID would be used in
|
||||
-- place of the spell.
|
||||
local function IsBareNumber(text)
|
||||
return string.match(text, "^%d+$") ~= nil;
|
||||
end
|
||||
|
||||
-- Targets that name no unit. `@cursor` names the position under the mouse,
|
||||
-- which a ground-target spell or item is placed at; `@none` asks for no unit
|
||||
-- at all, so it becomes the untargeted form of whatever verb ran.
|
||||
local NON_UNIT_TARGETS = { none = true, cursor = true };
|
||||
|
||||
-- `@unit` and `target=unit` accept a character name as well as a unit token
|
||||
-- (`[target=Feral]`), and the engine's unit functions take only tokens.
|
||||
-- Resolve once here so every command below hands them something they accept.
|
||||
-- Returns nil for a name with nobody around to match it.
|
||||
local function SecureCmdTargetUnit(target)
|
||||
if ( not target or target == "" or IsUnitToken(target) ) then
|
||||
return target;
|
||||
end
|
||||
-- Keep the non-unit targets out of the by-name search, which matches on
|
||||
-- the START of a name: `@none` would happily find a nearby Nonek.
|
||||
if ( NON_UNIT_TARGETS[strlower(target)] ) then
|
||||
return nil;
|
||||
end
|
||||
return UnitTokenFromName(target);
|
||||
end
|
||||
|
||||
-- One target normalization for every verb below: an empty target and `@none`
|
||||
-- become nil (no unit), a non-unit target keeps its own name, and anything
|
||||
-- else has to resolve to a token. Returns `false` for a name with nobody
|
||||
-- around to match it, which the callers treat as "do nothing".
|
||||
local function SecureCmdNormalizeTarget(target)
|
||||
if ( not target or target == "" ) then
|
||||
return nil;
|
||||
end
|
||||
local lower = strlower(target);
|
||||
if ( lower == "none" ) then
|
||||
return nil;
|
||||
end
|
||||
if ( NON_UNIT_TARGETS[lower] ) then
|
||||
return lower;
|
||||
end
|
||||
return SecureCmdTargetUnit(target) or false;
|
||||
end
|
||||
|
||||
-- The item a `/use` line names, in the form the three calls below take. An
|
||||
-- explicit bag or inventory slot becomes a location, which names ONE item:
|
||||
-- looking the item up by name again would take the first match anywhere, and
|
||||
-- with two stacks of the same thing that is the wrong one.
|
||||
local function SecureCmdItemLocation(name, bag, slot)
|
||||
if ( bag ) then
|
||||
return { bagID = tonumber(bag), slotIndex = tonumber(slot) };
|
||||
end
|
||||
if ( slot ) then
|
||||
return { equipmentSlotIndex = tonumber(slot) };
|
||||
end
|
||||
return name;
|
||||
end
|
||||
|
||||
function SecureCmdUseItem(name, bag, slot, target)
|
||||
local item = SecureCmdItemLocation(name, bag, slot);
|
||||
if ( not item ) then
|
||||
return;
|
||||
end
|
||||
if ( target == "cursor" ) then
|
||||
-- `@cursor` names a world position, so a ground-target item places
|
||||
-- its effect there. An item with no ground effect is used normally.
|
||||
C_Item.UseAtCursor(item);
|
||||
elseif ( target == "player" ) then
|
||||
-- `@player` drops a ground-target item at your own feet. Only your
|
||||
-- own position is offered this way; aiming one at another unit is
|
||||
-- not something you can do by hand either. An item with no ground
|
||||
-- effect is used on you, as before.
|
||||
C_Item.UseAtUnit(item, "player");
|
||||
else
|
||||
-- Every form goes through one call, so a slot is aimed at `@unit`
|
||||
-- just like a name is. `/use` also always means use: clicking a bag
|
||||
-- slot sells the item at a merchant and repairs it under the repair
|
||||
-- cursor, which a typed command should never do.
|
||||
C_Item.UseItemByName(item, target);
|
||||
end
|
||||
end
|
||||
|
||||
local function SecureCmdCast(msg)
|
||||
local action, target = SecureCmdOptionParse(msg);
|
||||
if ( not action or action == "" ) then
|
||||
return;
|
||||
end
|
||||
-- `!Name` asks for the spell to be started but never turned off, for the
|
||||
-- abilities that toggle: auto-repeat (Shoot, Auto Shot) and the self-auras
|
||||
-- (stances, aspects, seals, forms, tracking). Strip the prefix here and
|
||||
-- cast through `CastSpellNoToggle`, which asks the engine whether the
|
||||
-- ability is already up before it casts.
|
||||
local noToggle;
|
||||
if ( string.sub(action, 1, 1) == "!" ) then
|
||||
noToggle = true;
|
||||
action = string.sub(action, 2);
|
||||
if ( action == "" ) then
|
||||
return;
|
||||
end
|
||||
end
|
||||
target = SecureCmdNormalizeTarget(target);
|
||||
if ( target == false ) then
|
||||
return; -- a named unit with nobody around to match it
|
||||
end
|
||||
local name, bag, slot = SecureCmdItemParse(action);
|
||||
if ( slot or (name and not IsBareNumber(action) and C_Item.GetItemCount(name) > 0) ) then
|
||||
SecureCmdUseItem(name, bag, slot, target);
|
||||
elseif ( target == "cursor" ) then
|
||||
C_Spell.CastAtCursor(action);
|
||||
elseif ( not target or target == "target" ) then
|
||||
if ( noToggle ) then
|
||||
CastSpellNoToggle(action);
|
||||
else
|
||||
CastSpellByName(action);
|
||||
end
|
||||
elseif ( noToggle ) then
|
||||
-- Same unit rules as below: your own feet take a ground-target
|
||||
-- spell, another unit does not.
|
||||
CastSpellNoToggle(action, target, target == "player");
|
||||
elseif ( target == "player" ) then
|
||||
-- A ground-target spell lands at your own feet; a normal one is
|
||||
-- cast on you.
|
||||
C_Spell.CastAtUnit(action, target, true);
|
||||
else
|
||||
-- Any other unit has the spell cast on it, but a ground-target one
|
||||
-- is not placed there: that is aim you do not have by hand. The
|
||||
-- reticle comes up as usual.
|
||||
C_Spell.CastAtUnit(action, target, false);
|
||||
end
|
||||
end
|
||||
|
||||
SlashCmdList["CAST"] = SecureCmdCast;
|
||||
SlashCmdList["USE"] = SecureCmdCast;
|
||||
|
||||
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
|
||||
target = SecureCmdTargetUnit(target);
|
||||
if ( target ) then
|
||||
FocusUnit(target);
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
SlashCmdList["CLEARFOCUS"] = function(msg)
|
||||
ClearFocus()
|
||||
if ( SecureCmdOptionParse(msg) ) then
|
||||
ClearFocus();
|
||||
end
|
||||
end
|
||||
|
||||
SlashCmdList["STARTATTACK"] = function(msg)
|
||||
local action, target = SecureCmdOptionParse(msg);
|
||||
if ( action ) then
|
||||
if ( not target or target == "target" ) then
|
||||
target = action;
|
||||
end
|
||||
target = SecureCmdTargetUnit(target);
|
||||
if ( target ) then
|
||||
StartAttack(target);
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
SlashCmdList["STOPATTACK"] = function(msg)
|
||||
if ( SecureCmdOptionParse(msg) ) then
|
||||
StopAttack();
|
||||
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
|
||||
|
||||
-- Runs the conditions and calls `fn()` when a clause matched. The shape of
|
||||
-- every command whose only argument is its conditions.
|
||||
local function SecureCmdGated(msg, fn)
|
||||
if ( SecureCmdOptionParse(msg) ) then
|
||||
fn();
|
||||
end
|
||||
end
|
||||
|
||||
-- Strips a trailing "(Rank N)" so a rank-qualified name still matches.
|
||||
local function PlainSpellName(spell)
|
||||
local plain = string.match(spell, "^([^(]+)%(");
|
||||
if ( plain ) then
|
||||
return strtrim(plain);
|
||||
end
|
||||
return spell;
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Targeting
|
||||
--
|
||||
-- Each verb has one entry that takes a unit token and another that takes a
|
||||
-- character name, and each errors on the other kind. `SecureCmdOptionParse`
|
||||
-- hands back either, so pick with `IsUnitToken`.
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
local function SecureCmdTarget(msg, exactMatch)
|
||||
local action, target = SecureCmdOptionParse(msg);
|
||||
if ( not action ) then
|
||||
return;
|
||||
end
|
||||
if ( target ) then
|
||||
local lower = strlower(target);
|
||||
if ( lower == "none" ) then
|
||||
ClearTarget(); -- `@none` clears the target
|
||||
return;
|
||||
end
|
||||
if ( NON_UNIT_TARGETS[lower] ) then
|
||||
return; -- `@cursor` names no unit to target
|
||||
end
|
||||
end
|
||||
if ( not target or target == "target" ) then
|
||||
target = action;
|
||||
end
|
||||
if ( not target or target == "" ) then
|
||||
return;
|
||||
end
|
||||
if ( IsUnitToken(target) ) then
|
||||
TargetUnit(target);
|
||||
else
|
||||
TargetByName(target, exactMatch);
|
||||
end
|
||||
end
|
||||
|
||||
SlashCmdList["TARGET"] = function(msg)
|
||||
SecureCmdTarget(msg);
|
||||
end
|
||||
|
||||
SlashCmdList["TARGET_EXACT"] = function(msg)
|
||||
SecureCmdTarget(msg, 1);
|
||||
end
|
||||
|
||||
SlashCmdList["ASSIST"] = function(msg)
|
||||
if ( msg == "" ) then
|
||||
AssistUnit("target");
|
||||
return;
|
||||
end
|
||||
local action, target = SecureCmdOptionParse(msg);
|
||||
if ( not action ) then
|
||||
return;
|
||||
end
|
||||
if ( target and NON_UNIT_TARGETS[strlower(target)] ) then
|
||||
return; -- `@none` and `@cursor` name no unit to assist
|
||||
end
|
||||
-- An explicit `@target` leaves the trailing name in charge, the same way
|
||||
-- `/target` and `/follow` read it.
|
||||
if ( not target or target == "target" ) then
|
||||
target = action;
|
||||
end
|
||||
if ( target == "" ) then
|
||||
AssistUnit("target");
|
||||
elseif ( IsUnitToken(target) ) then
|
||||
AssistUnit(target);
|
||||
else
|
||||
AssistByName(target);
|
||||
end
|
||||
end
|
||||
|
||||
SlashCmdList["FOLLOW"] = function(msg)
|
||||
if ( msg == "" ) then
|
||||
FollowUnit("target");
|
||||
return;
|
||||
end
|
||||
local action, target = SecureCmdOptionParse(msg);
|
||||
if ( not action ) then
|
||||
return;
|
||||
end
|
||||
if ( target and NON_UNIT_TARGETS[strlower(target)] ) then
|
||||
return; -- `@none` and `@cursor` name no unit to follow
|
||||
end
|
||||
if ( not target or target == "target" ) then
|
||||
target = action;
|
||||
end
|
||||
if ( target == "" ) then
|
||||
FollowUnit("target");
|
||||
elseif ( IsUnitToken(target) ) then
|
||||
FollowUnit(target);
|
||||
else
|
||||
FollowByName(target);
|
||||
end
|
||||
end
|
||||
|
||||
SlashCmdList["CLEARTARGET"] = function(msg)
|
||||
SecureCmdGated(msg, ClearTarget);
|
||||
end
|
||||
|
||||
SlashCmdList["TARGET_LAST_TARGET"] = function(msg)
|
||||
SecureCmdGated(msg, TargetLastTarget);
|
||||
end
|
||||
|
||||
-- The cycling selectors take the parsed value as their "reverse" flag, so
|
||||
-- `/targetenemy [mod:shift] 1` steps backwards while shift is held. A clause
|
||||
-- that matched with no value has to arrive as nil: an empty string is a true
|
||||
-- boolean in Lua, which would make every bare `/targetenemy` step backwards.
|
||||
local function SecureCmdTargetCycle(msg, fn)
|
||||
local action = SecureCmdOptionParse(msg);
|
||||
if ( action ) then
|
||||
fn(action ~= "" and action or nil);
|
||||
end
|
||||
end
|
||||
|
||||
SlashCmdList["TARGET_LAST_ENEMY"] = function(msg)
|
||||
SecureCmdTargetCycle(msg, TargetLastEnemy);
|
||||
end
|
||||
|
||||
SlashCmdList["TARGET_NEAREST_ENEMY"] = function(msg)
|
||||
SecureCmdTargetCycle(msg, TargetNearestEnemy);
|
||||
end
|
||||
|
||||
SlashCmdList["TARGET_NEAREST_ENEMY_PLAYER"] = function(msg)
|
||||
SecureCmdTargetCycle(msg, TargetNearestEnemyPlayer);
|
||||
end
|
||||
|
||||
SlashCmdList["TARGET_NEAREST_FRIEND"] = function(msg)
|
||||
SecureCmdTargetCycle(msg, TargetNearestFriend);
|
||||
end
|
||||
|
||||
SlashCmdList["TARGET_NEAREST_FRIEND_PLAYER"] = function(msg)
|
||||
SecureCmdTargetCycle(msg, TargetNearestFriendPlayer);
|
||||
end
|
||||
|
||||
SlashCmdList["TARGET_NEAREST_PARTY"] = function(msg)
|
||||
SecureCmdTargetCycle(msg, TargetNearestPartyMember);
|
||||
end
|
||||
|
||||
SlashCmdList["TARGET_NEAREST_RAID"] = function(msg)
|
||||
SecureCmdTargetCycle(msg, TargetNearestRaidMember);
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Casting and player state
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
SlashCmdList["STOPCASTING"] = function(msg)
|
||||
SecureCmdGated(msg, SpellStopCasting);
|
||||
end
|
||||
|
||||
SlashCmdList["CANCELFORM"] = function(msg)
|
||||
SecureCmdGated(msg, CancelShapeshiftForm);
|
||||
end
|
||||
|
||||
SlashCmdList["DISMOUNT"] = function(msg)
|
||||
SecureCmdGated(msg, Dismount);
|
||||
end
|
||||
|
||||
SlashCmdList["CANCELAURA"] = function(msg)
|
||||
local spell = SecureCmdOptionParse(msg);
|
||||
if ( spell and spell ~= "" ) then
|
||||
CancelSpellByName(PlainSpellName(spell));
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Equipment
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
SlashCmdList["EQUIP"] = function(msg)
|
||||
local action = SecureCmdOptionParse(msg);
|
||||
if ( action and action ~= "" ) then
|
||||
local item = SecureCmdItemParse(action);
|
||||
if ( item ) then
|
||||
C_Item.EquipItemByName(item);
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
SlashCmdList["EQUIP_TO_SLOT"] = function(msg)
|
||||
local action = SecureCmdOptionParse(msg);
|
||||
if ( not action ) then
|
||||
return;
|
||||
end
|
||||
local slot, name = string.match(action, "^(%d+)%s+(.+)");
|
||||
slot = tonumber(slot);
|
||||
if ( not slot or slot < INVSLOT_FIRST or slot > INVSLOT_LAST ) then
|
||||
return;
|
||||
end
|
||||
local item = SecureCmdItemParse(name);
|
||||
if ( item ) then
|
||||
C_Item.EquipItemByName(item, slot);
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Action bars
|
||||
--
|
||||
-- `ChangeActionBarPage` takes no argument here. It announces the page in
|
||||
-- `CURRENT_ACTIONBAR_PAGE`, so set that first.
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
local function SetActionBarPage(page)
|
||||
if ( page and page >= 1 and page <= NUM_ACTIONBAR_PAGES ) then
|
||||
CURRENT_ACTIONBAR_PAGE = page;
|
||||
ChangeActionBarPage();
|
||||
end
|
||||
end
|
||||
|
||||
SlashCmdList["CHANGEACTIONBAR"] = function(msg)
|
||||
local action = SecureCmdOptionParse(msg);
|
||||
if ( action and action ~= "" ) then
|
||||
SetActionBarPage(tonumber(action));
|
||||
end
|
||||
end
|
||||
|
||||
SlashCmdList["SWAPACTIONBAR"] = function(msg)
|
||||
local action = SecureCmdOptionParse(msg);
|
||||
if ( not action ) then
|
||||
return;
|
||||
end
|
||||
local a, b = string.match(action, "(%d+)%s+(%d+)");
|
||||
a, b = tonumber(a), tonumber(b);
|
||||
if ( a and b ) then
|
||||
if ( CURRENT_ACTIONBAR_PAGE == a ) then
|
||||
SetActionBarPage(b);
|
||||
else
|
||||
SetActionBarPage(a);
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Pet
|
||||
--
|
||||
-- `PetAttack` takes no target here: it always sends the pet at the
|
||||
-- player's current target. Conditions still decide whether it fires, but
|
||||
-- `/petattack [@mouseover]` cannot aim at the mouseover.
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
SlashCmdList["PET_ATTACK"] = function(msg)
|
||||
SecureCmdGated(msg, PetAttack);
|
||||
end
|
||||
|
||||
SlashCmdList["PET_FOLLOW"] = function(msg)
|
||||
SecureCmdGated(msg, PetFollow);
|
||||
end
|
||||
|
||||
SlashCmdList["PET_STAY"] = function(msg)
|
||||
SecureCmdGated(msg, PetWait);
|
||||
end
|
||||
|
||||
SlashCmdList["PET_PASSIVE"] = function(msg)
|
||||
SecureCmdGated(msg, PetPassiveMode);
|
||||
end
|
||||
|
||||
SlashCmdList["PET_DEFENSIVE"] = function(msg)
|
||||
SecureCmdGated(msg, PetDefensiveMode);
|
||||
end
|
||||
|
||||
SlashCmdList["PET_AGGRESSIVE"] = function(msg)
|
||||
SecureCmdGated(msg, PetAggressiveMode);
|
||||
end
|
||||
|
||||
-- Autocast is a spellbook-slot toggle here, so a name has to be resolved
|
||||
-- against the pet book, and on/off read the current state and toggle only
|
||||
-- when it differs.
|
||||
local function PetSpellSlot(name)
|
||||
local numPetSpells = HasPetSpells();
|
||||
if ( not numPetSpells ) then
|
||||
return nil;
|
||||
end
|
||||
name = strlower(name);
|
||||
for i = 1, numPetSpells do
|
||||
local spell = GetSpellName(i, BOOKTYPE_PET);
|
||||
if ( spell and strlower(spell) == name ) then
|
||||
return i;
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function SecureCmdPetAutocast(msg, enable)
|
||||
local spell = SecureCmdOptionParse(msg);
|
||||
if ( not spell or spell == "" ) then
|
||||
return;
|
||||
end
|
||||
local slot = PetSpellSlot(PlainSpellName(spell));
|
||||
if ( not slot ) then
|
||||
return;
|
||||
end
|
||||
if ( enable == nil ) then
|
||||
ToggleSpellAutocast(slot, BOOKTYPE_PET);
|
||||
return;
|
||||
end
|
||||
local allowed, enabled = GetSpellAutocast(slot, BOOKTYPE_PET);
|
||||
if ( allowed and ((enabled and true or false) ~= enable) ) then
|
||||
ToggleSpellAutocast(slot, BOOKTYPE_PET);
|
||||
end
|
||||
end
|
||||
|
||||
SlashCmdList["PET_AUTOCASTON"] = function(msg)
|
||||
SecureCmdPetAutocast(msg, true);
|
||||
end
|
||||
|
||||
SlashCmdList["PET_AUTOCASTOFF"] = function(msg)
|
||||
SecureCmdPetAutocast(msg, false);
|
||||
end
|
||||
|
||||
SlashCmdList["PET_AUTOCASTTOGGLE"] = function(msg)
|
||||
SecureCmdPetAutocast(msg, nil);
|
||||
end
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
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
|
||||
|
||||
function CreateAtlasMarkup(atlasName, width, height, offsetX, offsetY)
|
||||
return string.format("|A:%s:%d:%d:%d:%d|a",
|
||||
atlasName
|
||||
, height or 0
|
||||
, width or 0
|
||||
, offsetX or 0
|
||||
, offsetY or 0
|
||||
);
|
||||
end
|
||||
|
||||
-- Raid target markers, published as atlas names.
|
||||
--
|
||||
-- All eight markers live in one 4x4 sheet, so naming a single marker means
|
||||
-- naming a cell inside that sheet — exactly what an atlas is for. Blizzard
|
||||
-- publishes no atlas names for these, so the names here follow the standalone
|
||||
-- marker files that other clients ship. An addon written against those files
|
||||
-- finds the art under the name it already knows.
|
||||
--
|
||||
-- Marker N sits at column (N-1) mod 4 and row floor((N-1) / 4), counting from
|
||||
-- the top left. That is the same cell math the chat marker tokens use.
|
||||
local RAID_TARGET_SHEET = "Interface\\TargetingFrame\\UI-RaidTargetingIcons";
|
||||
local RAID_TARGET_COLUMNS = 4;
|
||||
local RAID_TARGET_CELL = 0.25; -- a 64 pixel cell in a 256 pixel sheet
|
||||
|
||||
for marker = 1, 8 do
|
||||
local index = marker - 1;
|
||||
local left = math.mod(index, RAID_TARGET_COLUMNS) * RAID_TARGET_CELL;
|
||||
local top = math.floor(index / RAID_TARGET_COLUMNS) * RAID_TARGET_CELL;
|
||||
C_Texture.RegisterAtlas("UI-RaidTargetingIcon_"..marker, RAID_TARGET_SHEET,
|
||||
64, 64,
|
||||
left, left + RAID_TARGET_CELL,
|
||||
top, top + RAID_TARGET_CELL);
|
||||
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
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Backport of retail's Blizzard_ObjectAPI/UiMapPoint.lua. A UiMapPoint is the
|
||||
-- plain struct the C_Map waypoint calls take and return:
|
||||
-- { uiMapID = number, position = Vector2DMixin, z = number? }
|
||||
local CreateVector2D = CreateVector2D
|
||||
|
||||
UiMapPoint = {};
|
||||
|
||||
function UiMapPoint.CreateFromCoordinates(uiMapID, x, y, z)
|
||||
return { uiMapID = uiMapID, position = CreateVector2D(x, y), z = z };
|
||||
end
|
||||
|
||||
function UiMapPoint.CreateFromVector2D(uiMapID, position, z)
|
||||
return { uiMapID = uiMapID, position = CreateVector2D(position.x, position.y), z = z };
|
||||
end
|
||||
|
||||
function UiMapPoint.CreateFromVector3D(uiMapID, position)
|
||||
return { uiMapID = uiMapID, position = CreateVector2D(position.x, position.y), z = position.z };
|
||||
end
|
||||
@@ -78,10 +78,72 @@ PERCENTAGE_STRING = "%d%%";
|
||||
SET_FRIENDNOTE_LABEL = "Notizen für %s eintragen:";
|
||||
SET_NOTE = "Notiz eintragen";
|
||||
SILVER_AMOUNT_SYMBOL = "S";
|
||||
SLASH_CANCELAURA1 = "/auraabbruch";
|
||||
SLASH_CANCELAURA2 = "/cancelaura";
|
||||
SLASH_CANCELFORM1 = "/gestaltabbrechen";
|
||||
SLASH_CANCELFORM2 = "/cancelform";
|
||||
SLASH_CHANGEACTIONBAR1 = "/aktionsleistewechsel";
|
||||
SLASH_CHANGEACTIONBAR2 = "/changeactionbar";
|
||||
SLASH_CLEARFOCUS1 = "/fokusaufheben";
|
||||
SLASH_CLEARFOCUS2 = "/clearfocus";
|
||||
SLASH_CLEARTARGET1 = "/zielaufheben";
|
||||
SLASH_CLEARTARGET2 = "/cleartarget";
|
||||
SLASH_CLICK1 = "/klick";
|
||||
SLASH_CLICK2 = "/click";
|
||||
SLASH_DISMOUNT1 = "/absitzen";
|
||||
SLASH_DISMOUNT2 = "/dismount";
|
||||
SLASH_EQUIP1 = "/anlegen";
|
||||
SLASH_EQUIP2 = "/equip";
|
||||
SLASH_EQUIP_SET1 = "/Ausrüstungsset";
|
||||
SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_EQUIP_TO_SLOT1 = "/platzanlegen";
|
||||
SLASH_EQUIP_TO_SLOT2 = "/equipslot";
|
||||
SLASH_FOCUS1 = "/fokus";
|
||||
SLASH_FOCUS2 = "/focus";
|
||||
SLASH_PET_AGGRESSIVE1 = "/baggressiv";
|
||||
SLASH_PET_AGGRESSIVE2 = "/petaggressive";
|
||||
SLASH_PET_ATTACK1 = "/bangriff";
|
||||
SLASH_PET_ATTACK2 = "/petattack";
|
||||
SLASH_PET_AUTOCASTOFF1 = "/bautozauberaus";
|
||||
SLASH_PET_AUTOCASTOFF2 = "/petautocastoff";
|
||||
SLASH_PET_AUTOCASTON1 = "/bautozauberan";
|
||||
SLASH_PET_AUTOCASTON2 = "/petautocaston";
|
||||
SLASH_PET_AUTOCASTTOGGLE1 = "/bautozauberanaus";
|
||||
SLASH_PET_AUTOCASTTOGGLE2 = "/petautocasttoggle";
|
||||
SLASH_PET_DEFENSIVE1 = "/bdefensiv";
|
||||
SLASH_PET_DEFENSIVE2 = "/petdefensive";
|
||||
SLASH_PET_FOLLOW1 = "/bfolgen";
|
||||
SLASH_PET_FOLLOW2 = "/petfollow";
|
||||
SLASH_PET_PASSIVE1 = "/bpassiv";
|
||||
SLASH_PET_PASSIVE2 = "/petpassive";
|
||||
SLASH_PET_STAY1 = "/bbleib";
|
||||
SLASH_PET_STAY2 = "/petstay";
|
||||
SLASH_STARTATTACK1 = "/angriffstart";
|
||||
SLASH_STARTATTACK2 = "/startattack";
|
||||
SLASH_STOPATTACK1 = "/angriffstop";
|
||||
SLASH_STOPATTACK2 = "/stopattack";
|
||||
SLASH_STOPCASTING1 = "/stoppwirken";
|
||||
SLASH_STOPCASTING2 = "/stopcasting";
|
||||
SLASH_SWAPACTIONBAR1 = "/aktionsleisteumschalten";
|
||||
SLASH_SWAPACTIONBAR2 = "/swapactionbar";
|
||||
SLASH_TARGET_EXACT1 = "/spezifischanvisieren";
|
||||
SLASH_TARGET_EXACT2 = "/targetexact";
|
||||
SLASH_TARGET_LAST_ENEMY1 = "/vorherigerfeind";
|
||||
SLASH_TARGET_LAST_ENEMY2 = "/targetlastenemy";
|
||||
SLASH_TARGET_LAST_TARGET1 = "/vorherigesziel";
|
||||
SLASH_TARGET_LAST_TARGET2 = "/targetlasttarget";
|
||||
SLASH_TARGET_NEAREST_ENEMY1 = "/feindanvisieren";
|
||||
SLASH_TARGET_NEAREST_ENEMY2 = "/targetenemy";
|
||||
SLASH_TARGET_NEAREST_ENEMY_PLAYER1 = "/feindlichenspielerwählen";
|
||||
SLASH_TARGET_NEAREST_ENEMY_PLAYER2 = "/targetenemyplayer";
|
||||
SLASH_TARGET_NEAREST_FRIEND1 = "/freundanvisieren";
|
||||
SLASH_TARGET_NEAREST_FRIEND2 = "/targetfriend";
|
||||
SLASH_TARGET_NEAREST_FRIEND_PLAYER1 = "/freundlichenspielerwählen";
|
||||
SLASH_TARGET_NEAREST_FRIEND_PLAYER2 = "/targetfriendplayer";
|
||||
SLASH_TARGET_NEAREST_PARTY1 = "/gruppeanvisieren";
|
||||
SLASH_TARGET_NEAREST_PARTY2 = "/targetparty";
|
||||
SLASH_TARGET_NEAREST_RAID1 = "/schlachtzuganvisieren";
|
||||
SLASH_TARGET_NEAREST_RAID2 = "/targetraid";
|
||||
SLASH_USE1 = "/benutzen";
|
||||
SLASH_USE2 = "/use";
|
||||
LINK_TRADESKILL_TOOLTIP = "Hier klicken, um einen Link zu Eurem Beruf zu erstellen.";
|
||||
|
||||
@@ -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,11 +78,74 @@ 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_CANCELAURA1 = "/cancelaura";
|
||||
SLASH_CANCELAURA2 = "/cancelaura";
|
||||
SLASH_CANCELFORM1 = "/cancelform";
|
||||
SLASH_CANCELFORM2 = "/cancelform";
|
||||
SLASH_CHANGEACTIONBAR1 = "/changeactionbar";
|
||||
SLASH_CHANGEACTIONBAR2 = "/changeactionbar";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/clearfocus";
|
||||
SLASH_CLEARTARGET1 = "/cleartarget";
|
||||
SLASH_CLEARTARGET2 = "/cleartarget";
|
||||
SLASH_CLICK1 = "/click";
|
||||
SLASH_CLICK2 = "/click";
|
||||
SLASH_DISMOUNT1 = "/dismount";
|
||||
SLASH_DISMOUNT2 = "/dismount";
|
||||
SLASH_EQUIP1 = "/equip";
|
||||
SLASH_EQUIP2 = "/equip";
|
||||
SLASH_EQUIP_SET1 = "/equipset";
|
||||
SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_EQUIP_TO_SLOT1 = "/equipslot";
|
||||
SLASH_EQUIP_TO_SLOT2 = "/equipslot";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
SLASH_FOCUS2 = "/focus";
|
||||
SLASH_PET_AGGRESSIVE1 = "/petaggressive";
|
||||
SLASH_PET_AGGRESSIVE2 = "/petaggressive";
|
||||
SLASH_PET_ATTACK1 = "/petattack";
|
||||
SLASH_PET_ATTACK2 = "/petattack";
|
||||
SLASH_PET_AUTOCASTOFF1 = "/petautocastoff";
|
||||
SLASH_PET_AUTOCASTOFF2 = "/petautocastoff";
|
||||
SLASH_PET_AUTOCASTON1 = "/petautocaston";
|
||||
SLASH_PET_AUTOCASTON2 = "/petautocaston";
|
||||
SLASH_PET_AUTOCASTTOGGLE1 = "/petautocasttoggle";
|
||||
SLASH_PET_AUTOCASTTOGGLE2 = "/petautocasttoggle";
|
||||
SLASH_PET_DEFENSIVE1 = "/petdefensive";
|
||||
SLASH_PET_DEFENSIVE2 = "/petdefensive";
|
||||
SLASH_PET_FOLLOW1 = "/petfollow";
|
||||
SLASH_PET_FOLLOW2 = "/petfollow";
|
||||
SLASH_PET_PASSIVE1 = "/petpassive";
|
||||
SLASH_PET_PASSIVE2 = "/petpassive";
|
||||
SLASH_PET_STAY1 = "/petstay";
|
||||
SLASH_PET_STAY2 = "/petstay";
|
||||
SLASH_STARTATTACK1 = "/startattack";
|
||||
SLASH_STARTATTACK2 = "/startattack";
|
||||
SLASH_STOPATTACK1 = "/stopattack";
|
||||
SLASH_STOPATTACK2 = "/stopattack";
|
||||
SLASH_STOPCASTING1 = "/stopcasting";
|
||||
SLASH_STOPCASTING2 = "/stopcasting";
|
||||
SLASH_SWAPACTIONBAR1 = "/swapactionbar";
|
||||
SLASH_SWAPACTIONBAR2 = "/swapactionbar";
|
||||
SLASH_TARGET_EXACT1 = "/targetexact";
|
||||
SLASH_TARGET_EXACT2 = "/targetexact";
|
||||
SLASH_TARGET_LAST_ENEMY1 = "/targetlastenemy";
|
||||
SLASH_TARGET_LAST_ENEMY2 = "/targetlastenemy";
|
||||
SLASH_TARGET_LAST_TARGET1 = "/targetlasttarget";
|
||||
SLASH_TARGET_LAST_TARGET2 = "/targetlasttarget";
|
||||
SLASH_TARGET_NEAREST_ENEMY1 = "/targetenemy";
|
||||
SLASH_TARGET_NEAREST_ENEMY2 = "/targetenemy";
|
||||
SLASH_TARGET_NEAREST_ENEMY_PLAYER1 = "/targetenemyplayer";
|
||||
SLASH_TARGET_NEAREST_ENEMY_PLAYER2 = "/targetenemyplayer";
|
||||
SLASH_TARGET_NEAREST_FRIEND1 = "/targetfriend";
|
||||
SLASH_TARGET_NEAREST_FRIEND2 = "/targetfriend";
|
||||
SLASH_TARGET_NEAREST_FRIEND_PLAYER1 = "/targetfriendplayer";
|
||||
SLASH_TARGET_NEAREST_FRIEND_PLAYER2 = "/targetfriendplayer";
|
||||
SLASH_TARGET_NEAREST_PARTY1 = "/targetparty";
|
||||
SLASH_TARGET_NEAREST_PARTY2 = "/targetparty";
|
||||
SLASH_TARGET_NEAREST_RAID1 = "/targetraid";
|
||||
SLASH_TARGET_NEAREST_RAID2 = "/targetraid";
|
||||
SLASH_USE1 = "/use";
|
||||
SLASH_USE2 = "/use";
|
||||
TARGETFOCUS = "Target Focus";
|
||||
LINK_TRADESKILL_TOOLTIP = "Click here to create a link to your profession.";
|
||||
@@ -78,10 +78,72 @@ PERCENTAGE_STRING = "%d%%";
|
||||
SET_FRIENDNOTE_LABEL = "Establecer nota para %s:";
|
||||
SET_NOTE = "Establecer nota";
|
||||
SILVER_AMOUNT_SYMBOL = "p";
|
||||
SLASH_CANCELAURA1 = "/cancelaraura";
|
||||
SLASH_CANCELAURA2 = "/cancelaura";
|
||||
SLASH_CANCELFORM1 = "/cancelforma";
|
||||
SLASH_CANCELFORM2 = "/cancelform";
|
||||
SLASH_CHANGEACTIONBAR1 = "/cambiarbarraccion";
|
||||
SLASH_CHANGEACTIONBAR2 = "/changeactionbar";
|
||||
SLASH_CLEARFOCUS1 = "/quitarenfoque";
|
||||
SLASH_CLEARFOCUS2 = "/clearfocus";
|
||||
SLASH_CLEARTARGET1 = "/quitarobjetivo";
|
||||
SLASH_CLEARTARGET2 = "/cleartarget";
|
||||
SLASH_CLICK1 = "/clic";
|
||||
SLASH_CLICK2 = "/click";
|
||||
SLASH_DISMOUNT1 = "/desmontar";
|
||||
SLASH_DISMOUNT2 = "/dismount";
|
||||
SLASH_EQUIP1 = "/equipar";
|
||||
SLASH_EQUIP2 = "/equip";
|
||||
SLASH_EQUIP_SET1 = "/conjequip";
|
||||
SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_EQUIP_TO_SLOT1 = "/equipranu";
|
||||
SLASH_EQUIP_TO_SLOT2 = "/equipslot";
|
||||
SLASH_FOCUS1 = "/enfoque";
|
||||
SLASH_FOCUS2 = "/focus";
|
||||
SLASH_PET_AGGRESSIVE1 = "/mascagresiva";
|
||||
SLASH_PET_AGGRESSIVE2 = "/petaggressive";
|
||||
SLASH_PET_ATTACK1 = "/mascataque";
|
||||
SLASH_PET_ATTACK2 = "/petattack";
|
||||
SLASH_PET_AUTOCASTOFF1 = "/nomascotaautohechizo";
|
||||
SLASH_PET_AUTOCASTOFF2 = "/petautocastoff";
|
||||
SLASH_PET_AUTOCASTON1 = "/mascotautohechizo";
|
||||
SLASH_PET_AUTOCASTON2 = "/petautocaston";
|
||||
SLASH_PET_AUTOCASTTOGGLE1 = "/alternarmascotautohechizo";
|
||||
SLASH_PET_AUTOCASTTOGGLE2 = "/petautocasttoggle";
|
||||
SLASH_PET_DEFENSIVE1 = "/mascdefensiva";
|
||||
SLASH_PET_DEFENSIVE2 = "/petdefensive";
|
||||
SLASH_PET_FOLLOW1 = "/mascsigue";
|
||||
SLASH_PET_FOLLOW2 = "/petfollow";
|
||||
SLASH_PET_PASSIVE1 = "/mascpasiva";
|
||||
SLASH_PET_PASSIVE2 = "/petpassive";
|
||||
SLASH_PET_STAY1 = "/mascqueda";
|
||||
SLASH_PET_STAY2 = "/petstay";
|
||||
SLASH_STARTATTACK1 = "/startattack";
|
||||
SLASH_STARTATTACK2 = "/empezarataque";
|
||||
SLASH_STOPATTACK1 = "/stopattack";
|
||||
SLASH_STOPATTACK2 = "/pararataque";
|
||||
SLASH_STOPCASTING1 = "/pararlanzamiento";
|
||||
SLASH_STOPCASTING2 = "/stopcasting";
|
||||
SLASH_SWAPACTIONBAR1 = "/intercambiarbarraccion";
|
||||
SLASH_SWAPACTIONBAR2 = "/swapactionbar";
|
||||
SLASH_TARGET_EXACT1 = "/objetivoexacto";
|
||||
SLASH_TARGET_EXACT2 = "/targetexact";
|
||||
SLASH_TARGET_LAST_ENEMY1 = "/ultimoobjetivoenemigo";
|
||||
SLASH_TARGET_LAST_ENEMY2 = "/targetlastenemy";
|
||||
SLASH_TARGET_LAST_TARGET1 = "/ultimobjetivo";
|
||||
SLASH_TARGET_LAST_TARGET2 = "/targetlasttarget";
|
||||
SLASH_TARGET_NEAREST_ENEMY1 = "/objetivoenemigo";
|
||||
SLASH_TARGET_NEAREST_ENEMY2 = "/targetenemy";
|
||||
SLASH_TARGET_NEAREST_ENEMY_PLAYER1 = "/objetivojugadorenemigo";
|
||||
SLASH_TARGET_NEAREST_ENEMY_PLAYER2 = "/targetenemyplayer";
|
||||
SLASH_TARGET_NEAREST_FRIEND1 = "/objetivoamistoso";
|
||||
SLASH_TARGET_NEAREST_FRIEND2 = "/targetfriend";
|
||||
SLASH_TARGET_NEAREST_FRIEND_PLAYER1 = "/objetivojugadoraliado";
|
||||
SLASH_TARGET_NEAREST_FRIEND_PLAYER2 = "/targetfriendplayer";
|
||||
SLASH_TARGET_NEAREST_PARTY1 = "/objetivogrupo";
|
||||
SLASH_TARGET_NEAREST_PARTY2 = "/targetparty";
|
||||
SLASH_TARGET_NEAREST_RAID1 = "/objetivobanda";
|
||||
SLASH_TARGET_NEAREST_RAID2 = "/targetraid";
|
||||
SLASH_USE1 = "/use";
|
||||
SLASH_USE2 = "/usar";
|
||||
LINK_TRADESKILL_TOOLTIP = "Haz clic aquí para crear un vínculo a tu profesión.";
|
||||
|
||||
@@ -78,10 +78,72 @@ PERCENTAGE_STRING = "%d%%";
|
||||
SET_FRIENDNOTE_LABEL = "Écrire une note pour %s :";
|
||||
SET_NOTE = "Écrire une note";
|
||||
SILVER_AMOUNT_SYMBOL = "a";
|
||||
SLASH_CANCELAURA1 = "/annuleaura";
|
||||
SLASH_CANCELAURA2 = "/cancelaura";
|
||||
SLASH_CANCELFORM1 = "/annuleform";
|
||||
SLASH_CANCELFORM2 = "/cancelform";
|
||||
SLASH_CHANGEACTIONBAR1 = "/changebarreaction";
|
||||
SLASH_CHANGEACTIONBAR2 = "/changeactionbar";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/annulerfocal";
|
||||
SLASH_CLEARTARGET1 = "/effacercible";
|
||||
SLASH_CLEARTARGET2 = "/cleartarget";
|
||||
SLASH_CLICK1 = "/click";
|
||||
SLASH_CLICK2 = "/cliquer";
|
||||
SLASH_DISMOUNT1 = "/descendre";
|
||||
SLASH_DISMOUNT2 = "/dismount";
|
||||
SLASH_EQUIP1 = "/équiper";
|
||||
SLASH_EQUIP2 = "/equip";
|
||||
SLASH_EQUIP_SET1 = "/equipset";
|
||||
SLASH_EQUIP_SET2 = "/équipens";
|
||||
SLASH_EQUIP_TO_SLOT1 = "/equipempl";
|
||||
SLASH_EQUIP_TO_SLOT2 = "/equipslot";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
SLASH_FOCUS2 = "/focal";
|
||||
SLASH_PET_AGGRESSIVE1 = "/famagresse";
|
||||
SLASH_PET_AGGRESSIVE2 = "/petaggressive";
|
||||
SLASH_PET_ATTACK1 = "/famattaque";
|
||||
SLASH_PET_ATTACK2 = "/petattack";
|
||||
SLASH_PET_AUTOCASTOFF1 = "/lancautofamoff";
|
||||
SLASH_PET_AUTOCASTOFF2 = "/petautocastoff";
|
||||
SLASH_PET_AUTOCASTON1 = "/lancautofamon";
|
||||
SLASH_PET_AUTOCASTON2 = "/petautocaston";
|
||||
SLASH_PET_AUTOCASTTOGGLE1 = "/lancautofamchange";
|
||||
SLASH_PET_AUTOCASTTOGGLE2 = "/petautocasttoggle";
|
||||
SLASH_PET_DEFENSIVE1 = "/famdéfend";
|
||||
SLASH_PET_DEFENSIVE2 = "/petdefensive";
|
||||
SLASH_PET_FOLLOW1 = "/famsuit";
|
||||
SLASH_PET_FOLLOW2 = "/petfollow";
|
||||
SLASH_PET_PASSIVE1 = "/fampassif";
|
||||
SLASH_PET_PASSIVE2 = "/petpassive";
|
||||
SLASH_PET_STAY1 = "/famreste";
|
||||
SLASH_PET_STAY2 = "/petstay";
|
||||
SLASH_STARTATTACK1 = "/startattack";
|
||||
SLASH_STARTATTACK2 = "/lanceattaque";
|
||||
SLASH_STOPATTACK1 = "/stopattack";
|
||||
SLASH_STOPATTACK2 = "/stopattaque";
|
||||
SLASH_STOPCASTING1 = "/stopsort";
|
||||
SLASH_STOPCASTING2 = "/stopcasting";
|
||||
SLASH_SWAPACTIONBAR1 = "/échangerbarreaction";
|
||||
SLASH_SWAPACTIONBAR2 = "/swapactionbar";
|
||||
SLASH_TARGET_EXACT1 = "/cibleexacte";
|
||||
SLASH_TARGET_EXACT2 = "/targetexact";
|
||||
SLASH_TARGET_LAST_ENEMY1 = "/dernierennemi";
|
||||
SLASH_TARGET_LAST_ENEMY2 = "/targetlastenemy";
|
||||
SLASH_TARGET_LAST_TARGET1 = "/dernièrecible";
|
||||
SLASH_TARGET_LAST_TARGET2 = "/targetlasttarget";
|
||||
SLASH_TARGET_NEAREST_ENEMY1 = "/ciblerennemi";
|
||||
SLASH_TARGET_NEAREST_ENEMY2 = "/targetenemy";
|
||||
SLASH_TARGET_NEAREST_ENEMY_PLAYER1 = "/ciblerjoueurennemi";
|
||||
SLASH_TARGET_NEAREST_ENEMY_PLAYER2 = "/targetenemyplayer";
|
||||
SLASH_TARGET_NEAREST_FRIEND1 = "/ciblerami";
|
||||
SLASH_TARGET_NEAREST_FRIEND2 = "/targetfriend";
|
||||
SLASH_TARGET_NEAREST_FRIEND_PLAYER1 = "/ciblerjoueurami";
|
||||
SLASH_TARGET_NEAREST_FRIEND_PLAYER2 = "/targetfriendplayer";
|
||||
SLASH_TARGET_NEAREST_PARTY1 = "/ciblergroupe";
|
||||
SLASH_TARGET_NEAREST_PARTY2 = "/targetparty";
|
||||
SLASH_TARGET_NEAREST_RAID1 = "/ciblerraid";
|
||||
SLASH_TARGET_NEAREST_RAID2 = "/targetraid";
|
||||
SLASH_USE1 = "/use";
|
||||
SLASH_USE2 = "/utiliser";
|
||||
LINK_TRADESKILL_TOOLTIP = "Cliquez ici pour créer un lien vers votre profession.";
|
||||
|
||||
@@ -78,10 +78,72 @@ PERCENTAGE_STRING = "%d%%";
|
||||
SET_FRIENDNOTE_LABEL = "Заметка о |3-5(%s):";
|
||||
SET_NOTE = "Добавить заметку";
|
||||
SILVER_AMOUNT_SYMBOL = "с";
|
||||
SLASH_CANCELAURA1 = "/снятьауру";
|
||||
SLASH_CANCELAURA2 = "/cancelaura";
|
||||
SLASH_CANCELFORM1 = "/нормоблик";
|
||||
SLASH_CANCELFORM2 = "/cancelform";
|
||||
SLASH_CHANGEACTIONBAR1 = "/сменитьпанель";
|
||||
SLASH_CHANGEACTIONBAR2 = "/changeactionbar";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/сбросфокуса";
|
||||
SLASH_CLEARTARGET1 = "/сбросцели";
|
||||
SLASH_CLEARTARGET2 = "/cleartarget";
|
||||
SLASH_CLICK1 = "/click";
|
||||
SLASH_CLICK2 = "/щелчок";
|
||||
SLASH_DISMOUNT1 = "/спешиться";
|
||||
SLASH_DISMOUNT2 = "/dismount";
|
||||
SLASH_EQUIP1 = "/экип";
|
||||
SLASH_EQUIP2 = "/equip";
|
||||
SLASH_EQUIP_SET1 = "/комплект";
|
||||
SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_EQUIP_TO_SLOT1 = "/надеть";
|
||||
SLASH_EQUIP_TO_SLOT2 = "/equipslot";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
SLASH_FOCUS2 = "/фокус";
|
||||
SLASH_PET_AGGRESSIVE1 = "/питомецагрессивный";
|
||||
SLASH_PET_AGGRESSIVE2 = "/petaggressive";
|
||||
SLASH_PET_ATTACK1 = "/питомецатака";
|
||||
SLASH_PET_ATTACK2 = "/petattack";
|
||||
SLASH_PET_AUTOCASTOFF1 = "/питомецзаклинаниеоткл";
|
||||
SLASH_PET_AUTOCASTOFF2 = "/petautocastoff";
|
||||
SLASH_PET_AUTOCASTON1 = "/питомецзаклинаниевкл";
|
||||
SLASH_PET_AUTOCASTON2 = "/petautocaston";
|
||||
SLASH_PET_AUTOCASTTOGGLE1 = "/питомецавтозаклинание";
|
||||
SLASH_PET_AUTOCASTTOGGLE2 = "/petautocasttoggle";
|
||||
SLASH_PET_DEFENSIVE1 = "/питомецзащита";
|
||||
SLASH_PET_DEFENSIVE2 = "/petdefensive";
|
||||
SLASH_PET_FOLLOW1 = "/питомецзамной";
|
||||
SLASH_PET_FOLLOW2 = "/petfollow";
|
||||
SLASH_PET_PASSIVE1 = "/питомецпассивный";
|
||||
SLASH_PET_PASSIVE2 = "/petpassive";
|
||||
SLASH_PET_STAY1 = "/питомецстоять";
|
||||
SLASH_PET_STAY2 = "/petstay";
|
||||
SLASH_STARTATTACK1 = "/startattack";
|
||||
SLASH_STARTATTACK2 = "/начатьатаку";
|
||||
SLASH_STOPATTACK1 = "/stopattack";
|
||||
SLASH_STOPATTACK2 = "/стопатака";
|
||||
SLASH_STOPCASTING1 = "/стопзаклинание";
|
||||
SLASH_STOPCASTING2 = "/stopcasting";
|
||||
SLASH_SWAPACTIONBAR1 = "/сменитьпанелькоманд";
|
||||
SLASH_SWAPACTIONBAR2 = "/swapactionbar";
|
||||
SLASH_TARGET_EXACT1 = "/цельточно";
|
||||
SLASH_TARGET_EXACT2 = "/targetexact";
|
||||
SLASH_TARGET_LAST_ENEMY1 = "/цельпослвраг";
|
||||
SLASH_TARGET_LAST_ENEMY2 = "/targetlastenemy";
|
||||
SLASH_TARGET_LAST_TARGET1 = "/цельпослцель";
|
||||
SLASH_TARGET_LAST_TARGET2 = "/targetlasttarget";
|
||||
SLASH_TARGET_NEAREST_ENEMY1 = "/цельвраг";
|
||||
SLASH_TARGET_NEAREST_ENEMY2 = "/targetenemy";
|
||||
SLASH_TARGET_NEAREST_ENEMY_PLAYER1 = "/targetenemyplayer";
|
||||
SLASH_TARGET_NEAREST_ENEMY_PLAYER2 = "/targetenemyplayer";
|
||||
SLASH_TARGET_NEAREST_FRIEND1 = "/цельдруг";
|
||||
SLASH_TARGET_NEAREST_FRIEND2 = "/targetfriend";
|
||||
SLASH_TARGET_NEAREST_FRIEND_PLAYER1 = "/targetfriendplayer";
|
||||
SLASH_TARGET_NEAREST_FRIEND_PLAYER2 = "/targetfriendplayer";
|
||||
SLASH_TARGET_NEAREST_PARTY1 = "/цельгруппа";
|
||||
SLASH_TARGET_NEAREST_PARTY2 = "/targetparty";
|
||||
SLASH_TARGET_NEAREST_RAID1 = "/цельрейд";
|
||||
SLASH_TARGET_NEAREST_RAID2 = "/targetraid";
|
||||
SLASH_USE1 = "/use";
|
||||
SLASH_USE2 = "/использовать";
|
||||
LINK_TRADESKILL_TOOLTIP = "Щелкните здесь, чтобы создать ссылку на вашу профессию.";
|
||||
|
||||
@@ -78,10 +78,72 @@ PERCENTAGE_STRING = "%d%%";
|
||||
SET_FRIENDNOTE_LABEL = "为%s设置备注:";
|
||||
SET_NOTE = "设置备注";
|
||||
SILVER_AMOUNT_SYMBOL = "银";
|
||||
SLASH_CANCELAURA1 = "/cancelaura";
|
||||
SLASH_CANCELAURA2 = "/cancelaura";
|
||||
SLASH_CANCELFORM1 = "/cancelform";
|
||||
SLASH_CANCELFORM2 = "/cancelform";
|
||||
SLASH_CHANGEACTIONBAR1 = "/changeactionbar";
|
||||
SLASH_CHANGEACTIONBAR2 = "/changeactionbar";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/clearfocus";
|
||||
SLASH_CLEARTARGET1 = "/cleartarget";
|
||||
SLASH_CLEARTARGET2 = "/cleartarget";
|
||||
SLASH_CLICK1 = "/click";
|
||||
SLASH_CLICK2 = "/click";
|
||||
SLASH_DISMOUNT1 = "/dismount";
|
||||
SLASH_DISMOUNT2 = "/dismount";
|
||||
SLASH_EQUIP1 = "/equip";
|
||||
SLASH_EQUIP2 = "/equip";
|
||||
SLASH_EQUIP_SET1 = "/equipset";
|
||||
SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_EQUIP_TO_SLOT1 = "/equipslot";
|
||||
SLASH_EQUIP_TO_SLOT2 = "/equipslot";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
SLASH_FOCUS2 = "/focus";
|
||||
SLASH_PET_AGGRESSIVE1 = "/petaggressive";
|
||||
SLASH_PET_AGGRESSIVE2 = "/petaggressive";
|
||||
SLASH_PET_ATTACK1 = "/petattack";
|
||||
SLASH_PET_ATTACK2 = "/petattack";
|
||||
SLASH_PET_AUTOCASTOFF1 = "/petautocastoff";
|
||||
SLASH_PET_AUTOCASTOFF2 = "/petautocastoff";
|
||||
SLASH_PET_AUTOCASTON1 = "/petautocaston";
|
||||
SLASH_PET_AUTOCASTON2 = "/petautocaston";
|
||||
SLASH_PET_AUTOCASTTOGGLE1 = "/petautocasttoggle";
|
||||
SLASH_PET_AUTOCASTTOGGLE2 = "/petautocasttoggle";
|
||||
SLASH_PET_DEFENSIVE1 = "/petdefensive";
|
||||
SLASH_PET_DEFENSIVE2 = "/petdefensive";
|
||||
SLASH_PET_FOLLOW1 = "/petfollow";
|
||||
SLASH_PET_FOLLOW2 = "/petfollow";
|
||||
SLASH_PET_PASSIVE1 = "/petpassive";
|
||||
SLASH_PET_PASSIVE2 = "/petpassive";
|
||||
SLASH_PET_STAY1 = "/petstay";
|
||||
SLASH_PET_STAY2 = "/petstay";
|
||||
SLASH_STARTATTACK1 = "/startattack";
|
||||
SLASH_STARTATTACK2 = "/startattack";
|
||||
SLASH_STOPATTACK1 = "/stopattack";
|
||||
SLASH_STOPATTACK2 = "/stopattack";
|
||||
SLASH_STOPCASTING1 = "/stopcasting";
|
||||
SLASH_STOPCASTING2 = "/stopcasting";
|
||||
SLASH_SWAPACTIONBAR1 = "/swapactionbar";
|
||||
SLASH_SWAPACTIONBAR2 = "/swapactionbar";
|
||||
SLASH_TARGET_EXACT1 = "/targetexact";
|
||||
SLASH_TARGET_EXACT2 = "/targetexact";
|
||||
SLASH_TARGET_LAST_ENEMY1 = "/targetlastenemy";
|
||||
SLASH_TARGET_LAST_ENEMY2 = "/targetlastenemy";
|
||||
SLASH_TARGET_LAST_TARGET1 = "/targetlasttarget";
|
||||
SLASH_TARGET_LAST_TARGET2 = "/targetlasttarget";
|
||||
SLASH_TARGET_NEAREST_ENEMY1 = "/targetenemy";
|
||||
SLASH_TARGET_NEAREST_ENEMY2 = "/targetenemy";
|
||||
SLASH_TARGET_NEAREST_ENEMY_PLAYER1 = "/targetenemyplayer";
|
||||
SLASH_TARGET_NEAREST_ENEMY_PLAYER2 = "/targetenemyplayer";
|
||||
SLASH_TARGET_NEAREST_FRIEND1 = "/targetfriend";
|
||||
SLASH_TARGET_NEAREST_FRIEND2 = "/targetfriend";
|
||||
SLASH_TARGET_NEAREST_FRIEND_PLAYER1 = "/targetfriendplayer";
|
||||
SLASH_TARGET_NEAREST_FRIEND_PLAYER2 = "/targetfriendplayer";
|
||||
SLASH_TARGET_NEAREST_PARTY1 = "/targetparty";
|
||||
SLASH_TARGET_NEAREST_PARTY2 = "/targetparty";
|
||||
SLASH_TARGET_NEAREST_RAID1 = "/targetraid";
|
||||
SLASH_TARGET_NEAREST_RAID2 = "/targetraid";
|
||||
SLASH_USE1 = "/use";
|
||||
SLASH_USE2 = "/use";
|
||||
LINK_TRADESKILL_TOOLTIP = "点击这里以创建一个链接到你的专业的快捷方式。";
|
||||
|
||||
@@ -78,10 +78,72 @@ PERCENTAGE_STRING = "%d%%";
|
||||
SET_FRIENDNOTE_LABEL = "設定%s的註記:";
|
||||
SET_NOTE = "設定註記";
|
||||
SILVER_AMOUNT_SYMBOL = "銀";
|
||||
SLASH_CANCELAURA1 = "/取消光環";
|
||||
SLASH_CANCELAURA2 = "/cancelaura";
|
||||
SLASH_CANCELFORM1 = "/取消形態";
|
||||
SLASH_CANCELFORM2 = "/cancelform";
|
||||
SLASH_CHANGEACTIONBAR1 = "/改變快捷列";
|
||||
SLASH_CHANGEACTIONBAR2 = "/changeactionbar";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/清除專注目標";
|
||||
SLASH_CLEARTARGET1 = "/清除目標";
|
||||
SLASH_CLEARTARGET2 = "/cleartarget";
|
||||
SLASH_CLICK1 = "/click";
|
||||
SLASH_CLICK2 = "/點擊";
|
||||
SLASH_DISMOUNT1 = "/下馬";
|
||||
SLASH_DISMOUNT2 = "/dismount";
|
||||
SLASH_EQUIP1 = "/裝備";
|
||||
SLASH_EQUIP2 = "/equip";
|
||||
SLASH_EQUIP_SET1 = "/equipset";
|
||||
SLASH_EQUIP_SET2 = "/裝備套裝";
|
||||
SLASH_EQUIP_TO_SLOT1 = "/裝備欄位";
|
||||
SLASH_EQUIP_TO_SLOT2 = "/equipslot";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
SLASH_FOCUS2 = "/專注目標";
|
||||
SLASH_PET_AGGRESSIVE1 = "/寵物攻擊型";
|
||||
SLASH_PET_AGGRESSIVE2 = "/petaggressive";
|
||||
SLASH_PET_ATTACK1 = "/寵物攻擊";
|
||||
SLASH_PET_ATTACK2 = "/petattack";
|
||||
SLASH_PET_AUTOCASTOFF1 = "/關閉寵物自動施法";
|
||||
SLASH_PET_AUTOCASTOFF2 = "/petautocastoff";
|
||||
SLASH_PET_AUTOCASTON1 = "/開啟寵物自動施法";
|
||||
SLASH_PET_AUTOCASTON2 = "/petautocaston";
|
||||
SLASH_PET_AUTOCASTTOGGLE1 = "/切換寵物自動施法";
|
||||
SLASH_PET_AUTOCASTTOGGLE2 = "/petautocasttoggle";
|
||||
SLASH_PET_DEFENSIVE1 = "/寵物防禦型";
|
||||
SLASH_PET_DEFENSIVE2 = "/petdefensive";
|
||||
SLASH_PET_FOLLOW1 = "/寵物跟隨";
|
||||
SLASH_PET_FOLLOW2 = "/petfollow";
|
||||
SLASH_PET_PASSIVE1 = "/寵物被動型";
|
||||
SLASH_PET_PASSIVE2 = "/petpassive";
|
||||
SLASH_PET_STAY1 = "/寵物停留";
|
||||
SLASH_PET_STAY2 = "/petstay";
|
||||
SLASH_STARTATTACK1 = "/開始攻擊";
|
||||
SLASH_STARTATTACK2 = "/startattack";
|
||||
SLASH_STOPATTACK1 = "/停止攻擊";
|
||||
SLASH_STOPATTACK2 = "/stopattack";
|
||||
SLASH_STOPCASTING1 = "/停止施法";
|
||||
SLASH_STOPCASTING2 = "/stopcasting";
|
||||
SLASH_SWAPACTIONBAR1 = "/切換快捷列";
|
||||
SLASH_SWAPACTIONBAR2 = "/swapactionbar";
|
||||
SLASH_TARGET_EXACT1 = "/選定指定目標";
|
||||
SLASH_TARGET_EXACT2 = "/targetexact";
|
||||
SLASH_TARGET_LAST_ENEMY1 = "/選定前一敵人";
|
||||
SLASH_TARGET_LAST_ENEMY2 = "/targetlastenemy";
|
||||
SLASH_TARGET_LAST_TARGET1 = "/選定前一目標";
|
||||
SLASH_TARGET_LAST_TARGET2 = "/targetlasttarget";
|
||||
SLASH_TARGET_NEAREST_ENEMY1 = "/目標敵人";
|
||||
SLASH_TARGET_NEAREST_ENEMY2 = "/targetenemy";
|
||||
SLASH_TARGET_NEAREST_ENEMY_PLAYER1 = "/選定敵方玩家";
|
||||
SLASH_TARGET_NEAREST_ENEMY_PLAYER2 = "/targetenemyplayer";
|
||||
SLASH_TARGET_NEAREST_FRIEND1 = "/目標友方";
|
||||
SLASH_TARGET_NEAREST_FRIEND2 = "/targetfriend";
|
||||
SLASH_TARGET_NEAREST_FRIEND_PLAYER1 = "/選定友方玩家";
|
||||
SLASH_TARGET_NEAREST_FRIEND_PLAYER2 = "/targetfriendplayer";
|
||||
SLASH_TARGET_NEAREST_PARTY1 = "/目標隊伍";
|
||||
SLASH_TARGET_NEAREST_PARTY2 = "/targetparty";
|
||||
SLASH_TARGET_NEAREST_RAID1 = "/目標團隊";
|
||||
SLASH_TARGET_NEAREST_RAID2 = "/targetraid";
|
||||
SLASH_USE1 = "/使用";
|
||||
SLASH_USE2 = "/use";
|
||||
LINK_TRADESKILL_TOOLTIP = "點擊這裡以製造一個你專業技能的連結。";
|
||||
|
||||
+39
-7
@@ -2,6 +2,21 @@ cmake_minimum_required(VERSION 3.10)
|
||||
|
||||
project(ClassicAPI LANGUAGES CXX RC)
|
||||
|
||||
# WoW.exe is x86, so the injected DLL must be x86 too — there is no valid
|
||||
# 64-bit configure of this project. Fail here rather than let one through:
|
||||
# a 64-bit build's first visible symptom is a static_assert deep in a
|
||||
# struct-layout check, which reads as an unrelated code fault. An IDE that
|
||||
# configures on its own (VS Code's CMake Tools picks an architecture from a
|
||||
# scanned kit) can flip an existing build directory this way, which is what
|
||||
# CMakePresets.json's `win32` preset exists to prevent.
|
||||
if(NOT CMAKE_SIZEOF_VOID_P EQUAL 4)
|
||||
message(FATAL_ERROR
|
||||
"ClassicAPI builds 32-bit only. This configure uses "
|
||||
"${CMAKE_SIZEOF_VOID_P}-byte pointers. Delete the build directory, "
|
||||
"because CMake cannot switch platform in place. Then configure with "
|
||||
"`cmake --preset win32` or `cmake -B build -A Win32`.")
|
||||
endif()
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded")
|
||||
@@ -142,12 +157,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"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"version": 3,
|
||||
"cmakeMinimumRequired": {
|
||||
"major": 3,
|
||||
"minor": 21,
|
||||
"patch": 0
|
||||
},
|
||||
"configurePresets": [
|
||||
{
|
||||
"name": "win32",
|
||||
"displayName": "Win32 (x86)",
|
||||
"description": "The only supported configuration: WoW.exe is 32-bit, so the DLL must be 32-bit too. No generator is named, so CMake picks the newest installed Visual Studio, matching the documented command line.",
|
||||
"binaryDir": "${sourceDir}/build",
|
||||
"architecture": "Win32"
|
||||
}
|
||||
],
|
||||
"buildPresets": [
|
||||
{
|
||||
"name": "release",
|
||||
"displayName": "Release",
|
||||
"configurePreset": "win32",
|
||||
"configuration": "Release"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,41 +1,79 @@
|
||||
# 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 = ...`). |
|
||||
| [Upvalue limit](docs/API.md#upvalue-limit) | A function can use up to 60 upvalues, the outer locals it refers to, matching Lua 5.1. Lua 5.0 stops at 32 and rejects the whole file, so a large handler that reads many file-level locals can be valid 5.1 and still fail to load here. Nothing changes for a function within 32. |
|
||||
| [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`) and atlas markup (`\|A…\|a`) 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. |
|
||||
| [Macro conditions](docs/API.md#more-commands-with-conditions) | `[conditions]` and `@unit` work on 38 slash commands, from `/cast` and `/use` to `/target`, `/follow`, `/cancelaura` and the `/pet` family. With `/cast [@mouseover,harm][] Fireball`, the spell goes to the mouseover unit. With no mouseover unit, it goes to your target. `@cursor` places a ground-target spell or item under the mouse and `@player` places it at your feet. A character name works where a unit token does (`[target=Feral]`). Addons read the same rules through `SecureCmdOptionParse`. |
|
||||
| [`#showtooltip`](docs/API.md#showtooltip-and-show) | A macro button shows what the macro does. The icon, tooltip, cooldown, range, usable state and auto-repeat glow all follow the spell or item that the directive resolves to, conditions included. They update as the conditions change. The result goes into the engine field that action-bar addons already read, so they need no code of their own. An addon with its own macro parser can publish its result through `C_Macro.SetMacroDisplay`. It then does not need to replace the action globals. |
|
||||
| [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. |
|
||||
| [Launch switches](docs/API.md#launch) | Options you add to the shortcut that starts the game. `-config <name>` reads and writes `WTF\<name>` in place of `WTF\Config.wtf`, so one install can hold several settings profiles. `-gluescript` and `-gluescriptFile` run Lua at the login and character-select screens, every time those screens appear. `-gamescript` and `-gamescriptFile` run it one time, after you enter the world. Each script runs after the interface has loaded, so it can call interface functions and use frames. |
|
||||
| [Any texture size](docs/API.md#texture-size-and-shape) | Textures load at any size and any shape. Width and height do not need to be powers of two, and there is no fixed upper limit. The only limit is the maximum texture size of your graphics card. This applies to every texture path: `SetTexture`, sprite sheets, inline textures, tooltips, and masks. The client allocates memory for a large texture only when an addon loads one, so small textures cost nothing extra. |
|
||||
|
||||
## 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`, `C_AddOns.LoadAddOn` |
|
||||
| [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` |
|
||||
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
|
||||
| [Combat](docs/API.md#combat) | `InCombatLockdown`, `StartAttack`, `StopAttack` |
|
||||
| [Console](docs/API.md#console) | `CalculateStringEditDistance`, `ConsoleEcho`, `ConsoleExec`, `ConsoleGetAllCommands`, `ConsoleGetColorFromType`, `ConsoleGetFontHeight`, `ConsoleIsActive`, `ConsolePrintAllMatchingCommands`, `SetConsoleKey` |
|
||||
| [CVar](docs/API.md#cvar) | `C_CVar.AreCVarsLoaded`, `C_CVar.DoesCVarExist`, `C_CVar.GetCVarBitfield`, `C_CVar.GetCVarBool`, `C_CVar.GetCVarInfo`, `C_CVar.SetCVarBitfield` |
|
||||
| [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` |
|
||||
| [Container](docs/API.md#container) | `C_Container.AutoStoreItem`, `C_Container.CalculateTotalNumberOfFreeBagSlots`, `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemInfo`, `C_Container.GetContainerItemEquipmentSetInfo`, `C_Container.GetContainerItemQuestInfo`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerFreeSlots`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetBackpackAutosortDisabled`, `C_Container.GetBankAutosortDisabled`, `C_Container.GetItemCooldown`, `C_Container.GetSortBagsRightToLeft`, `C_Container.HasContainerItem`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SetBackpackAutosortDisabled`, `C_Container.SetBankAutosortDisabled`, `C_Container.SetSortBagsRightToLeft`, `C_Container.SortBags`, `C_Container.SortBankBags`, `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` |
|
||||
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByID`, `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.IsFactionActive`, `C_Reputation.IsFactionActiveByID`, `C_Reputation.IsFactionInactive`, `C_Reputation.SetFactionActive`, `C_Reputation.SetFactionActiveByID`, `C_Reputation.SetFactionInactive`, `C_Reputation.SetFactionInactiveByID`, `C_Reputation.SetSelectedFaction`, `C_Reputation.SetSelectedFactionByID`, `C_Reputation.SetWatchedFactionByID`, `C_Reputation.ToggleFactionAtWar`, `C_Reputation.ToggleFactionAtWarByID`, `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:SetAtlas`, `texture:GetAtlas`, `texture:ResetTexCoord`, `texture:SetSpriteSheetCell`, `texture:SetDesaturation`, `texture:GetDesaturation`, `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:RegisterUnitEvent`, `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` |
|
||||
@@ -44,14 +82,14 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
|
||||
| [Input](docs/API.md#input) | `GetMouseButtonClicked`, `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
|
||||
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
|
||||
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfo`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemClassInfo`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsConsumableItem`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemInRange`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.PickupItem`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemClassInfo`, `GetItemIcon`, `GetItemSubClassInfo`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
|
||||
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfo`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemClassInfo`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemTempEnchantInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsConsumableItem`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemInRange`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.PickupItem`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemClassInfo`, `GetItemIcon`, `GetItemSubClassInfo`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
|
||||
| [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` |
|
||||
| [Macros](docs/API.md#macros) | `C_Macro.CreateMacro`, `C_Macro.EditMacro`, `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
|
||||
| [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), `xpcall` (argument forwarding) |
|
||||
| [Macros](docs/API.md#macros) | `C_Macro.CreateMacro`, `C_Macro.EditMacro`, `C_Macro.SetMacroDisplay`, `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItem`, `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` |
|
||||
| [Map](docs/API.md#map) | `C_Map.CanSetUserWaypointOnMap`, `C_Map.ClearUserWaypoint`, `C_Map.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetFallbackWorldMapID`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapArtLayers`, `C_Map.GetMapArtLayerTextures`, `C_Map.GetMapChildrenInfo`, `C_Map.GetMapInfo`, `C_Map.GetMapInfoAtPosition`, `C_Map.GetMapOverlays`, `C_Map.GetMapPosFromWorldPos`, `C_Map.GetMapRectOnMap`, `C_Map.GetMapWorldSize`, `C_Map.GetPlayerMapPosition`, `C_Map.GetUserWaypoint`, `C_Map.GetUserWaypointFromHyperlink`, `C_Map.GetUserWaypointHyperlink`, `C_Map.GetUserWaypointPositionForMap`, `C_Map.GetWorldPosFromMapPos`, `C_Map.HasUserWaypoint`, `C_Map.MapHasArt`, `C_Map.SetUserWaypoint` |
|
||||
| [MapExplorationInfo](docs/API.md#mapexplorationinfo) | `C_MapExplorationInfo.GetExploredMapTextures`, `C_MapExplorationInfo.GetUnexploredMapTextures` |
|
||||
| [MerchantFrame](docs/API.md#merchantframe) | `C_MerchantFrame.GetBuybackItemID`, `C_MerchantFrame.GetItemInfo`, `C_MerchantFrame.GetNumJunkItems`, `C_MerchantFrame.IsMerchantItemRefundable`, `C_MerchantFrame.IsSellAllJunkEnabled`, `C_MerchantFrame.SellAllJunkItems` |
|
||||
| [NamePlate](docs/API.md#nameplate) | `C_NamePlate.GetNamePlateForGUID`, `C_NamePlate.GetNamePlateForUnit`, `C_NamePlate.GetNamePlateGUIDs`, `C_NamePlate.GetNamePlates` |
|
||||
@@ -59,30 +97,33 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [NewItems](docs/API.md#newitems) | `C_NewItems.ClearAll`, `C_NewItems.IsNewItem`, `C_NewItems.RemoveNewItem` |
|
||||
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GetClass`, `C_PlayerInfo.GetName`, `C_PlayerInfo.GetRace`, `C_PlayerInfo.GetSex`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `C_PlayerInfo.IsConnected` |
|
||||
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetHeaderIndexForQuest`, `C_QuestLog.GetLogIndexForQuestID`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
|
||||
| [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` |
|
||||
| [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.GetSpellCastCount`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellDispelType`, `C_Spell.GetSpellEffectInfo`, `C_Spell.GetSpellEffectMechanics`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellLossOfControlCooldown`, `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.ContainsAnyDisenchantSpell`, `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetNumSpellBookSkillLines`, `C_SpellBook.GetPlayerSpellsByAura`, `C_SpellBook.GetSkillLineIndexByID`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellBookItemCastCount`, `C_SpellBook.GetSpellBookItemInfo`, `C_SpellBook.GetSpellBookItemLossOfControlCooldownDuration`, `C_SpellBook.GetSpellBookItemLossOfControlCooldownInfo`, `C_SpellBook.GetSpellBookItemSkillLineIndex`, `C_SpellBook.GetSpellBookSkillLineInfo`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsClassTalentSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
|
||||
| [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` |
|
||||
| [Texture](docs/API.md#texture) | `C_Texture.GetAtlasElementID`, `C_Texture.GetAtlasElements`, `C_Texture.GetAtlasExists`, `C_Texture.GetAtlasID`, `C_Texture.GetAtlasInfo`, `C_Texture.RegisterAtlas` |
|
||||
| [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`, `IsUnitToken`, `UnitClassBase`, `UnitCreatedBySpell`, `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`, `UnitTokenFromName` |
|
||||
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySlot`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetAuraSlots`, `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.UnitAuraBySlot`, `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,19 +136,48 @@ 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>
|
||||
|
||||
Engine-level extensions to macro parsing and dispatch — no new Lua
|
||||
functions, just behavior the stock 1.12 engine didn't have. See the
|
||||
[Macros section in the Lua reference](docs/API.md#macros) for details.
|
||||
<details>
|
||||
<summary><b>Macros</b> — forms a macro body accepts</summary>
|
||||
|
||||
The DLL parses these forms, so an action-bar addon needs no code for them.
|
||||
See the [Macros section in the Lua reference](docs/API.md#macros) for
|
||||
details.
|
||||
|
||||
| Form | What it does |
|
||||
|------|--------------|
|
||||
| `/cast <spellID>` | `/cast 5019` casts Shoot if known; macro slot tags correctly for action-bar UI |
|
||||
| `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 |
|
||||
| `#showtooltip [conditions] Value` | First line of a macro. Sets what the button shows. Bare `#showtooltip` follows the first `/cast` or `/use` line instead. `#show` is the same form. |
|
||||
| `[conditions]` and `@unit` | Accepted by 38 commands. The slash-command list below names them. |
|
||||
| `!Name` | Starts an ability but never turns it off. Use it for auto-repeat shots and for the self-buffs (stance, aspect, seal, form, tracking). |
|
||||
| A line that starts with `#` | A comment. The line never reaches chat. |
|
||||
| `/cast <spellID>` | For a spell you know, `/cast 5019` casts it by ID. The macro slot also tags correctly for an action-bar addon. |
|
||||
| `CastSpellByName("<spellID>")` | A numeric string resolves through the engine name resolver, the same as `/cast`. |
|
||||
| `CastSpellNoToggle("<name>")` in a macro | The engine macro parser reads this as a primary-spell line. The macro slot in an addon such as pfUI then highlights while the spell is on auto-repeat or its self-aura is active. |
|
||||
|
||||
### Console commands
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Slash commands</b> — <code>[conditions]</code> on 38 commands</summary>
|
||||
|
||||
The bundled addon registers these commands. Each one takes the same
|
||||
`[conditions]` and `@unit` syntax as `/cast`. Each client language has its
|
||||
own command names next to the English ones. See
|
||||
[More commands with `[conditions]`](docs/API.md#more-commands-with-conditions)
|
||||
for what each command does.
|
||||
|
||||
| Group | Commands |
|
||||
|-------|----------|
|
||||
| Casting | `/cast`, `/use`, `/stopcasting`, `/cancelaura`, `/cancelform`, `/dismount` |
|
||||
| Targeting | `/target`, `/targetexact`, `/cleartarget`, `/targetlasttarget`, `/targetlastenemy`, `/targetenemy`, `/targetfriend`, `/targetenemyplayer`, `/targetfriendplayer`, `/targetparty`, `/targetraid`, `/assist`, `/follow`, `/focus`, `/clearfocus`, `/startattack`, `/stopattack` |
|
||||
| Equipment | `/equip`, `/equipslot`, `/equipset` |
|
||||
| Action bars | `/changeactionbar`, `/swapactionbar`, `/click` |
|
||||
| Pet | `/petattack`, `/petfollow`, `/petstay`, `/petpassive`, `/petdefensive`, `/petaggressive`, `/petautocaston`, `/petautocastoff`, `/petautocasttoggle` |
|
||||
|
||||
</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,12 +189,16 @@ 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 |
|
||||
|-------|---------|
|
||||
| `BAG_NEW_ITEMS_UPDATED` | *(none)* |
|
||||
| `BAG_UPDATE_DELAYED` | *(none)* |
|
||||
| `CURSOR_CHANGED` | `isDefault, newCursorType, oldCursorType, oldCursorVirtualID` |
|
||||
| `EQUIPMENT_SETS_CHANGED` | *(none)* |
|
||||
| `EQUIPMENT_SWAP_PENDING` | `setID` |
|
||||
| `EQUIPMENT_SWAP_FINISHED` | `success, setID` |
|
||||
@@ -133,6 +207,7 @@ when launching with `-console`), not as Lua functions. See the
|
||||
| `GLOBAL_MOUSE_UP` | `button` |
|
||||
| `HEARTHSTONE_BOUND` | *(none)* |
|
||||
| `ITEM_DATA_LOAD_RESULT` | `itemID, success` |
|
||||
| `LEARNED_SPELL_IN_SKILL_LINE` | `spellID, skillLineIndex, isGuildPerkSpell` |
|
||||
| `LOOT_HISTORY_ROLL_CHANGED` | `itemIndex, playerIndex` |
|
||||
| `LOOT_HISTORY_ROLL_COMPLETE` | `itemIndex` |
|
||||
| `LOOT_SCAN_COMPLETED` | *(none)* |
|
||||
@@ -170,16 +245,20 @@ when launching with `-console`), not as Lua functions. See the
|
||||
| `UNIT_SPELLCAST_RETICLE_CLEAR` | `"player", "", spellID, spellName, rank` |
|
||||
| `UPDATE_INVENTORY_DURABILITY` | *(none)* |
|
||||
| `UPDATE_SHAPESHIFT_FORM` | *(none)* |
|
||||
| `USER_WAYPOINT_UPDATED` | *(none)* |
|
||||
| `VOICE_CHAT_TTS_PLAYBACK_STARTED` | `numConsumers, utteranceID, durationMS, destination` |
|
||||
| `VOICE_CHAT_TTS_PLAYBACK_FINISHED` | `numConsumers, utteranceID, destination` |
|
||||
| `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` |
|
||||
@@ -188,10 +267,14 @@ when launching with `-console`), not as Lua functions. See the
|
||||
| Inventory type | `Enum.InventoryType.Index*Type` (0–34, e.g. `IndexHeadType`=1 … `IndexRelicType`=28) |
|
||||
| Item class | `Enum.ItemClass.{Consumable,Container,Weapon,Gem,Armor,Reagent,Projectile,Tradegoods,ItemEnhancement,Recipe,Quiver,Questitem,Key,Miscellaneous,…}` (0–19) |
|
||||
| Item quality | `Enum.ItemQuality.{Poor,Common,Uncommon,Rare,Epic,Legendary,Artifact}` (0–6) |
|
||||
| Cursor type | `Enum.UICursorType.{Default,Item,Money,Spell,PetAction,Merchant,Macro,Pet,…}` (0–20; this client yields 0–5, 7 and 9) |
|
||||
| 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 +282,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
|
||||
|
||||
@@ -244,7 +335,9 @@ functions but that consumer code still expects to find as globals —
|
||||
`CallbackRegistryMixin`, `EventRegistry`, `ColorMixin` + `CreateColor`,
|
||||
`Item` / `ItemLocation`, `MathUtil`(`Lerp` / `Clamp` / `CreateCounter`),
|
||||
`TableUtil` (`tCompare`, `MergeTable`, `SafePack`, etc.), and `EventUtil`
|
||||
(`ContinueOnAddOnLoaded` etc.).
|
||||
(`ContinueOnAddOnLoaded` and more). It also holds the conditional slash
|
||||
commands and `SecureCmdOptionParse`, the parser that reads `[conditions]`
|
||||
and `@unit` for them.
|
||||
|
||||
**You don't have to install the addon manually** — the DLL embeds
|
||||
the contents of [`AddOns/!!!ClassicAPI/`](AddOns/!!!ClassicAPI/) and
|
||||
@@ -277,7 +370,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 +385,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.
|
||||
|
||||
+5391
-1544
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,795 @@
|
||||
# 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 is also supported:
|
||||
|
||||
```
|
||||
|A:atlasName:height:width[:offsetX:offsetY]|a
|
||||
```
|
||||
|
||||
It shares every scanner, measure, wrap and placement stage with `|T` — only the
|
||||
payload parser differs. `ParseAtlasIcon` resolves the name through
|
||||
`Texture::Atlas` and fills the SAME `IconDesc`, because an atlas is exactly the
|
||||
"path + normalized sub-rect" that struct already carries for a `|T` span with
|
||||
texcoords. A `height`/`width` of `0` resolves to the atlas's own pixel size at
|
||||
PARSE time (not the line font height `|T` falls back to), which is what keeps the
|
||||
rest of the pipeline marker-agnostic. Tiling atlases draw one quad, so
|
||||
`tilesHorizontally` / `tilesVertically` are not honored here.
|
||||
|
||||
Adding any further marker means touching `IconStartLen`, `FindIconClose`,
|
||||
`InlineSpanLen`, the ghost guard in `FlushLayout` — and, mandatory,
|
||||
`chat/IconFilter.cpp`, or the marker becomes a chat-spoof vector.
|
||||
|
||||
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.
|
||||
@@ -213,6 +213,8 @@ filenames in Lua 5.0 by behaviour.
|
||||
|--------------|--------------------------|---------------------------------------------------|
|
||||
| `0x006F3140` | `setobj` / TValue copy | 16-byte memcpy |
|
||||
| `0x006F58B0` | `equalobj` | called by `lua_equal` |
|
||||
| `0x006F6050` | `luaD_precall` | frame setup for a call; Lua closure: reads Proto (`+0xc` of the closure), `is_vararg` at Proto `+0x46` → `adjust_varargs`, `numparams` `+0x45`, `maxstacksize` `+0x47`; C closure: reserves 0x14 slots and calls the C function |
|
||||
| `0x006F6200` | `adjust_varargs` | stock 5.0: pads missing fixed params with nil, then `luaH_new(L, nvar, 1)` + `luaH_setnum` per extra arg + sets field `"n"` and pushes the table. So EVERY call to a vararg Lua function allocates one table, whether the body uses `arg` or `...` — keep vararg helpers off per-frame paths |
|
||||
| `0x006F65A0` | `luaD_call` | the actual call dispatcher |
|
||||
| `0x006F6960` | `luaD_pcall` | protected call dispatcher |
|
||||
| `0x006F7340` | `luaD_growstack` (or checkstack) | invoked when stack needs space |
|
||||
|
||||
@@ -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
|
||||
@@ -31,4 +31,13 @@ bool PatchBytes(void *dst, const void *src, size_t len) {
|
||||
return true;
|
||||
}
|
||||
|
||||
void BoundedCopy(char *dst, const char *src, size_t n) {
|
||||
if (dst == nullptr || n == 0)
|
||||
return;
|
||||
size_t i = 0;
|
||||
for (; src != nullptr && src[i] != '\0' && i + 1 < n; ++i)
|
||||
dst[i] = src[i];
|
||||
dst[i] = '\0';
|
||||
}
|
||||
|
||||
} // namespace Common
|
||||
|
||||
+8
-20
@@ -11,28 +11,16 @@
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
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);
|
||||
|
||||
// Bounded, always-terminated string copy: writes at most `n - 1`
|
||||
// characters plus the terminator, and treats a null `src` as empty. `n`
|
||||
// is the size of `dst`. Stands in for `strncpy`, which MSVC deprecates
|
||||
// (C4996) and which does not terminate on truncation.
|
||||
void BoundedCopy(char *dst, const char *src, size_t n);
|
||||
|
||||
} // namespace Common
|
||||
|
||||
+172
-53
@@ -15,9 +15,13 @@
|
||||
#include "Game.h"
|
||||
#include "MinHook.h"
|
||||
#include "Offsets.h"
|
||||
#include "config/FileSwitch.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 +37,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 +68,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 +91,161 @@ 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;
|
||||
// The ONE thing that has to happen this early, and the only module this
|
||||
// file names directly. The engine reads its config file during boot,
|
||||
// several calls before VanillaFixes invokes `Load`, so a `-config`
|
||||
// switch cannot be applied from the normal install path — by then the
|
||||
// file has been read. This is a single 4-byte store, not a hook, so it
|
||||
// carries none of the loader-lock cost described above.
|
||||
Config::FileSwitch::Apply();
|
||||
|
||||
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
|
||||
|
||||
+2666
-49
File diff suppressed because it is too large
Load Diff
+2
-14
@@ -47,6 +47,7 @@
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "action/Slot.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
@@ -62,19 +63,6 @@ uint32_t ReadEntry(int slot0) {
|
||||
return base[slot0];
|
||||
}
|
||||
|
||||
// Returns 1-based macro slot index if `key` matches any entry in the
|
||||
// macro-slot map, or 0 if no match. `key` is the action entry with the
|
||||
// `0x40000000` type bit stripped (i.e. `entry & 0xBFFFFFFF`).
|
||||
int FindMacroSlot(uint32_t key) {
|
||||
auto *map = reinterpret_cast<const uint32_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_MACRO_SLOT_MAP));
|
||||
for (int i = 0; i < Offsets::MACRO_SLOT_MAP_COUNT; ++i) {
|
||||
if (map[i] == key)
|
||||
return i + 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetActionInfo(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: GetActionInfo(slot)");
|
||||
@@ -117,7 +105,7 @@ int __fastcall Script_GetActionInfo(void *L) {
|
||||
|
||||
if (type == Offsets::ACTION_TYPE_BAG_OR_MACRO) {
|
||||
const uint32_t key = entry & Offsets::ACTION_PAYLOAD_MASK_BAG_OR_MACRO;
|
||||
const int macroSlot = FindMacroSlot(key);
|
||||
const int macroSlot = Action::Slot::MacroSlotForID(key);
|
||||
if (macroSlot > 0) {
|
||||
Game::Lua::PushString(L, "macro");
|
||||
Game::Lua::PushNumber(L, static_cast<double>(macroSlot));
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// 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/>.
|
||||
|
||||
// `GetActionCooldown` / `GetActionCount` / `IsConsumableAction` for macro
|
||||
// slots whose `#showtooltip` / `#show` resolved to an ITEM. A spell
|
||||
// resolution needs none of this — the engine reads a macro slot's spell from
|
||||
// its primary-spell cache, which `Macro::ShowTooltip` writes — but the engine
|
||||
// has no per-macro item field, so the item forms of these three are answered
|
||||
// here, each with the engine's own helper for the item-by-ID slot case:
|
||||
//
|
||||
// - cooldown → `FUN_ITEM_QUERY_COOLDOWN` via `Item::Cooldown::PushCooldown`
|
||||
// (the same triple `GetContainerItemCooldown` returns);
|
||||
// - count → `FUN_ACTION_ITEM_COUNT`, the value `UseAction` caches per
|
||||
// item-by-ID slot;
|
||||
// - consumable → the rule of the inner `IsConsumableAction` (`0x004E5250`):
|
||||
// ammo / thrown, or any on-use spell slot with negative
|
||||
// charges.
|
||||
//
|
||||
// Registered in front of the engine globals (last registration wins) and a
|
||||
// strict superset of them: every other slot tail-calls the engine function
|
||||
// with the untouched stack.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "item/Cooldown.h"
|
||||
#include "item/Record.h"
|
||||
#include "macro/ShowTooltip.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Action::ItemState {
|
||||
|
||||
namespace {
|
||||
|
||||
using ScriptFn_t = int(__fastcall *)(void *L);
|
||||
using ItemCount_t = uint32_t(__fastcall *)(uint32_t itemID);
|
||||
|
||||
int Engine(uintptr_t fn, void *L) {
|
||||
return reinterpret_cast<ScriptFn_t>(fn)(L);
|
||||
}
|
||||
|
||||
// True (and fills `info`) when stack[1] is a slot whose macro resolved to an item.
|
||||
bool ItemTarget(void *L, Macro::ShowTooltip::Info *info) {
|
||||
if (!Game::Lua::IsNumber(L, 1))
|
||||
return false;
|
||||
const int slot0 = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
|
||||
return Macro::ShowTooltip::ForSlot(slot0, info) &&
|
||||
info->target == Macro::ShowTooltip::Target::Item;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetActionCooldown(void *L) {
|
||||
Macro::ShowTooltip::Info info;
|
||||
if (!ItemTarget(L, &info))
|
||||
return Engine(Offsets::FUN_SCRIPT_GET_ACTION_COOLDOWN, L);
|
||||
Item::Cooldown::PushCooldown(L, info.itemID);
|
||||
return 3;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetActionCount(void *L) {
|
||||
Macro::ShowTooltip::Info info;
|
||||
if (!ItemTarget(L, &info))
|
||||
return Engine(Offsets::FUN_SCRIPT_GET_ACTION_COUNT, L);
|
||||
const uint32_t count = reinterpret_cast<ItemCount_t>(Offsets::FUN_ACTION_ITEM_COUNT)(
|
||||
static_cast<uint32_t>(info.itemID));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(count));
|
||||
return 1;
|
||||
}
|
||||
|
||||
bool IsConsumable(int itemID) {
|
||||
const uint8_t *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
|
||||
if (record == nullptr)
|
||||
return false;
|
||||
const uint32_t invType = Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_INVENTORY_TYPE);
|
||||
if (invType == Offsets::INVTYPE_AMMO || invType == Offsets::INVTYPE_THROWN)
|
||||
return true;
|
||||
for (int i = 0; i < Offsets::ITEMSTATS_SPELL_SLOT_COUNT; ++i) {
|
||||
const uint32_t spellID = Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_SPELL_ID + i * 4);
|
||||
const uint32_t trigger = Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_SPELL_TRIGGER + i * 4);
|
||||
const int32_t charges = Game::Read<int32_t>(record, Offsets::OFF_ITEMSTATS_SPELL_CHARGES + i * 4);
|
||||
if (spellID != 0 && trigger == Offsets::ITEM_SPELLTRIGGER_ON_USE && charges < 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
int __fastcall Script_IsConsumableAction(void *L) {
|
||||
Macro::ShowTooltip::Info info;
|
||||
if (!ItemTarget(L, &info))
|
||||
return Engine(Offsets::FUN_SCRIPT_IS_CONSUMABLE_ACTION, L);
|
||||
if (IsConsumable(info.itemID))
|
||||
Game::Lua::PushNumber(L, 1.0);
|
||||
else
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("GetActionCooldown", &Script_GetActionCooldown);
|
||||
Game::Lua::RegisterGlobalFunction("GetActionCount", &Script_GetActionCount);
|
||||
Game::Lua::RegisterGlobalFunction("IsConsumableAction", &Script_IsConsumableAction);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Action::ItemState
|
||||
@@ -0,0 +1,44 @@
|
||||
// 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 "action/Slot.h"
|
||||
|
||||
#include "Offsets.h"
|
||||
|
||||
namespace Action::Slot {
|
||||
|
||||
int MacroSlotForID(uint32_t macroID) {
|
||||
if (macroID == 0)
|
||||
return 0;
|
||||
auto *map = reinterpret_cast<const uint32_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_MACRO_SLOT_MAP));
|
||||
for (int i = 0; i < Offsets::MACRO_SLOT_MAP_COUNT; ++i) {
|
||||
if (map[i] == macroID)
|
||||
return i + 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint32_t MacroIDForSlot(int slot0) {
|
||||
if (slot0 < 0 || slot0 >= Offsets::ACTION_TABLE_MAX_SLOTS)
|
||||
return 0;
|
||||
auto *table = reinterpret_cast<const uint32_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_ACTION_TABLE));
|
||||
const uint32_t entry = table[slot0];
|
||||
if (entry == 0 || (entry & Offsets::ACTION_TYPE_MASK) != Offsets::ACTION_TYPE_BAG_OR_MACRO)
|
||||
return 0;
|
||||
const uint32_t key = entry & Offsets::ACTION_PAYLOAD_MASK_BAG_OR_MACRO;
|
||||
return MacroSlotForID(key) != 0 ? key : 0;
|
||||
}
|
||||
|
||||
} // namespace Action::Slot
|
||||
@@ -0,0 +1,36 @@
|
||||
// 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>
|
||||
|
||||
// Action-slot ↔ macro decoding shared by every module that has to answer
|
||||
// "which macro sits in this action slot" (`GetActionInfo`, the
|
||||
// `#showtooltip` display overrides). The action table stores macroIDs under
|
||||
// the ambiguous `0x40000000` tag; the macro-slot map says which of those ids
|
||||
// are macros (the rest are bag items). Pure C — no Lua stack.
|
||||
|
||||
namespace Action::Slot {
|
||||
|
||||
// 1-based macro slot holding `macroID` — the same linear scan of
|
||||
// `VAR_MACRO_SLOT_MAP` the engine's `FUN_MACRO_ID_TO_SLOT` does — or 0 when
|
||||
// no macro has that id (0 is never a macroID).
|
||||
int MacroSlotForID(uint32_t macroID);
|
||||
|
||||
// The macroID in action slot `slot0` (0-based, `< ACTION_TABLE_MAX_SLOTS`),
|
||||
// or 0 when the slot is empty, isn't `0x40000000`-tagged, or holds a bag
|
||||
// item rather than a macro.
|
||||
uint32_t MacroIDForSlot(int slot0);
|
||||
|
||||
} // namespace Action::Slot
|
||||
+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
|
||||
+24
-10
@@ -26,6 +26,7 @@
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "addons/LoadState.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
@@ -251,19 +252,23 @@ int __fastcall Script_GetAddOnSecurity(void *L) {
|
||||
|
||||
// `C_AddOns.IsAddOnLoaded(indexOrName)` → `(loadedOrLoading, loaded)`.
|
||||
//
|
||||
// Modern WoW distinguishes "load-in-progress" from "fully loaded" —
|
||||
// matters for LoD addons whose load is split across multiple
|
||||
// `LoadAddOn` callbacks. Vanilla 1.12's `FUN_0051F240` is fully
|
||||
// synchronous: the loaded byte at `entry+0x18` flips from 0 → 1
|
||||
// inside a single call, so the "loading" state is never observable
|
||||
// from Lua. We return the same boolean twice — `loadedOrLoading`
|
||||
// and `loaded` are identical here.
|
||||
// Modern WoW distinguishes "load-in-progress" from "fully loaded", and
|
||||
// the two really are distinguishable here: the engine's loaded byte
|
||||
// (`entry+0x18`) is set at the START of a load, before the addon's files
|
||||
// run, so the byte alone answers `loadedOrLoading`, not `loaded`. The
|
||||
// window is reachable from Lua — an addon's own file-scope code, and any
|
||||
// dependency pulled in during it, run while the byte already reads set.
|
||||
//
|
||||
// So `loadedOrLoading` is the engine's byte, and `loaded` additionally
|
||||
// requires the addon not to be mid-load, which `AddOns::LoadState`
|
||||
// tracks from the loader itself.
|
||||
//
|
||||
// Resolves both numeric indices (via `FUN_ADDON_GET_BY_INDEX`) and
|
||||
// string names. `IS_LOADED` accepts an entry pointer as its name arg
|
||||
// because the entry's first 12 bytes are the inline NUL-terminated
|
||||
// directory name (same as the other per-field accessors), so we can
|
||||
// feed it either an entry or a Lua-side string transparently.
|
||||
// feed it either an entry or a Lua-side string transparently — and that
|
||||
// same pointer is the name `LoadState` matches on.
|
||||
int __fastcall Script_IsAddOnLoaded(void *L) {
|
||||
const uint8_t *entry = ResolveAddOnName(L);
|
||||
if (entry == nullptr) {
|
||||
@@ -273,8 +278,11 @@ int __fastcall Script_IsAddOnLoaded(void *L) {
|
||||
}
|
||||
auto isLoaded = reinterpret_cast<EntryByteFn_t>(
|
||||
Offsets::FUN_ADDON_IS_LOADED);
|
||||
const bool loaded = isLoaded(entry) != 0;
|
||||
Game::Lua::PushBool(L, loaded);
|
||||
const bool loadedOrLoading = isLoaded(entry) != 0;
|
||||
const bool loaded =
|
||||
loadedOrLoading &&
|
||||
!AddOns::LoadState::IsLoading(reinterpret_cast<const char *>(entry));
|
||||
Game::Lua::PushBool(L, loadedOrLoading);
|
||||
Game::Lua::PushBool(L, loaded);
|
||||
return 2;
|
||||
}
|
||||
@@ -385,6 +393,12 @@ static void RegisterLuaFunctions() {
|
||||
&Script_DoesAddOnExist);
|
||||
Game::Lua::RegisterTableFunction("C_AddOns", "IsAddOnLoaded",
|
||||
&Script_IsAddOnLoaded);
|
||||
// The stock global, mirrored into the namespace — the engine's own
|
||||
// handler already has the Lua C ABI and the modern return shape.
|
||||
Game::Lua::RegisterTableFunction(
|
||||
"C_AddOns", "LoadAddOn",
|
||||
reinterpret_cast<Game::Lua::CFunction>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_SCRIPT_LOAD_ADDON)));
|
||||
Game::Lua::RegisterTableFunction("C_AddOns", "GetAddOnOptionalDependencies",
|
||||
&Script_GetAddOnOptionalDependencies);
|
||||
Game::Lua::RegisterIntegerEnum(
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Tracks in-progress addon loads so `C_AddOns.IsAddOnLoaded` can report
|
||||
// `loadedOrLoading` and `loaded` separately. See `LoadState.h` for why the
|
||||
// engine's own byte can't answer both.
|
||||
//
|
||||
// Loads nest — an addon's dependencies load inside its own call — so this
|
||||
// keeps a stack rather than a single name. The name is copied rather than
|
||||
// stored by pointer: `LoadAddOn` passes a Lua string, which does not
|
||||
// outlive the call.
|
||||
|
||||
#include "addons/LoadState.h"
|
||||
|
||||
#include "Common.h"
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
namespace AddOns::LoadState {
|
||||
|
||||
namespace {
|
||||
|
||||
// Dependency chains are shallow; anything deeper simply isn't tracked
|
||||
// (the depth counter still balances, so the stack can't desync).
|
||||
constexpr int kMaxDepth = 16;
|
||||
constexpr int kMaxName = 64;
|
||||
|
||||
char g_stack[kMaxDepth][kMaxName];
|
||||
int g_depth = 0;
|
||||
|
||||
bool EqualsIgnoreCase(const char *a, const char *b) {
|
||||
for (; *a != '\0' && *b != '\0'; ++a, ++b) {
|
||||
char ca = *a;
|
||||
char cb = *b;
|
||||
if (ca >= 'A' && ca <= 'Z')
|
||||
ca = static_cast<char>(ca - 'A' + 'a');
|
||||
if (cb >= 'A' && cb <= 'Z')
|
||||
cb = static_cast<char>(cb - 'A' + 'a');
|
||||
if (ca != cb)
|
||||
return false;
|
||||
}
|
||||
return *a == *b;
|
||||
}
|
||||
|
||||
using LoadOne_t = unsigned int(__fastcall *)(const char *name, char flag,
|
||||
void *progressCtx);
|
||||
LoadOne_t s_loadOne_o = nullptr;
|
||||
|
||||
unsigned int __fastcall LoadOne_h(const char *name, char flag, void *progressCtx) {
|
||||
const int slot = g_depth;
|
||||
if (slot >= 0 && slot < kMaxDepth)
|
||||
Common::BoundedCopy(g_stack[slot], name, kMaxName);
|
||||
++g_depth;
|
||||
|
||||
const unsigned int result = s_loadOne_o(name, flag, progressCtx);
|
||||
|
||||
--g_depth;
|
||||
if (g_depth < 0)
|
||||
g_depth = 0; // defensive: never let the stack run negative
|
||||
return result;
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookLoadOne{
|
||||
Offsets::FUN_ADDON_LOAD_ONE,
|
||||
reinterpret_cast<void *>(&LoadOne_h),
|
||||
reinterpret_cast<void **>(&s_loadOne_o)};
|
||||
|
||||
} // namespace
|
||||
|
||||
bool IsLoading(const char *name) {
|
||||
if (name == nullptr || *name == '\0')
|
||||
return false;
|
||||
const int depth = (g_depth < kMaxDepth) ? g_depth : kMaxDepth;
|
||||
for (int i = 0; i < depth; ++i)
|
||||
if (EqualsIgnoreCase(g_stack[i], name))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace AddOns::LoadState
|
||||
@@ -0,0 +1,33 @@
|
||||
// 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
|
||||
|
||||
// `AddOns::LoadState` — tracks which addons are mid-load.
|
||||
//
|
||||
// The engine's own "loaded" byte (`entry+0x18`) is set at the START of a
|
||||
// load, before the addon's files run, so on its own it can't tell
|
||||
// "loading" from "loaded". That distinction is observable: an addon's
|
||||
// file-scope code, and every dependency pulled in during it, execute
|
||||
// while the byte already reads set.
|
||||
//
|
||||
// A co-hook on the per-addon loader keeps a stack of the names currently
|
||||
// being loaded, which is the missing half of
|
||||
// `C_AddOns.IsAddOnLoaded`'s `(loadedOrLoading, loaded)` pair.
|
||||
namespace AddOns::LoadState {
|
||||
|
||||
// True while `name` is inside the engine's per-addon load call. Matching
|
||||
// is case-insensitive, as addon-name matching is everywhere else.
|
||||
bool IsLoading(const char *name);
|
||||
|
||||
} // namespace AddOns::LoadState
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+211
-20
@@ -21,7 +21,8 @@
|
||||
// tokens separated by `|` and/or whitespace, each optionally negated with a
|
||||
// leading `!`. Whole-token matching, so `RAID_PLAYER_DISPELLABLE` is not
|
||||
// mistaken for `PLAYER` and `!PLAYER` is a negation rather than a match.
|
||||
// Honored: `HELPFUL` / `HARMFUL` (descriptor slot ranges), `PLAYER` /
|
||||
// Honored: `HELPFUL` / `HARMFUL` (the aura's real polarity, read however this
|
||||
// server encodes it — see `Aura::Data::IsSlotHarmful`), `PLAYER` /
|
||||
// `!PLAYER` (caster == / != the local player, from the Aura::Source cache),
|
||||
// `DISPELLABLE` / `!DISPELLABLE` (dispel type is / isn't one a
|
||||
// dispel/purge/steal can remove — Spell.dbc Dispel ∈ Magic/Curse/Disease/
|
||||
@@ -46,14 +47,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 +180,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 +237,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);
|
||||
@@ -287,22 +353,135 @@ int __fastcall Script_GetPlayerAuraBySpellID(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Iterates one slot range and pushes AuraData tables into `outer` at
|
||||
// sequential keys starting from `nextKey`. Updates `nextKey` so a
|
||||
// follow-up call can append to the same outer table.
|
||||
// C_UnitAuras.GetAuraSlots(unit [, filter [, maxSlots [, continuationToken]]])
|
||||
// -> continuationToken, slot1, slot2, ...
|
||||
//
|
||||
// Enumerates the opaque slot ids of every aura on `unit` matching `filter`, in
|
||||
// the order the by-index getters visit them, `maxSlots` at a time (nil / 0 =
|
||||
// all). The first return is the token to pass back for the next batch, or nil
|
||||
// when this batch reached the end — retail's batching contract, and what makes
|
||||
// AuraUtil.ForEachAura linear: one enumeration per batch plus a direct by-slot
|
||||
// fetch per aura, instead of a fresh from-slot-0 walk per index. The token is
|
||||
// the 1-based ordinal to resume at; opaque to callers. Encoding of the slot ids
|
||||
// is documented at `Data::OPAQUE_STRIDE`.
|
||||
int __fastcall Script_GetAuraSlots(void *L) {
|
||||
const char *unitToken = ArgUnit(L, 1);
|
||||
const char *filterStr = ArgOptString(L, 2);
|
||||
const int maxSlots = ArgInt(L, 3);
|
||||
const int token = ArgInt(L, 4);
|
||||
// ClassicAPI extension: a table as the 5th argument selects FILL mode —
|
||||
// the slot ids are written into it (t[1..n], stale tail cleared, t.n = n)
|
||||
// and the call returns `continuationToken, n` instead of the vararg list.
|
||||
// Lua 5.0 builds an `arg` table for every vararg CALL, so a Lua helper
|
||||
// that receives the retail multi-return allocates one table per batch;
|
||||
// filling the caller's table here is what keeps a per-frame scan
|
||||
// allocation-free (AuraUtil and pfUI's ScanAuraSlots use this form).
|
||||
const bool fill = Game::Lua::Type(L, 5) == Game::Lua::TYPE_TABLE;
|
||||
|
||||
int slots[Data::OPAQUE_SLOTS_MAX];
|
||||
int total = 0;
|
||||
if (unitToken != nullptr) {
|
||||
const uint8_t *unit = ResolveUnit(unitToken);
|
||||
const uint64_t guid = (unit == nullptr) ? GuidForOutOfRange(unitToken) : 0;
|
||||
const ParsedFilter pf = ParseFilters(filterStr);
|
||||
total = Data::CollectSlots(unit, guid, RangeFilter(pf), pf.ToMatch(), slots,
|
||||
Data::OPAQUE_SLOTS_MAX);
|
||||
}
|
||||
int start = (token > 0) ? token - 1 : 0;
|
||||
if (start > total)
|
||||
start = total;
|
||||
int n = total - start;
|
||||
if (maxSlots > 0 && n > maxSlots)
|
||||
n = maxSlots;
|
||||
const bool more = start + n < total;
|
||||
|
||||
if (fill) {
|
||||
for (int i = 0; i < n; ++i) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(slots[start + i]));
|
||||
Game::Lua::RawSet(L, 5);
|
||||
}
|
||||
// Clear what a previous, longer fill left past n. The array is dense
|
||||
// by construction, so stop at the first hole.
|
||||
for (int k = n + 1;; ++k) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(k));
|
||||
Game::Lua::RawGet(L, 5);
|
||||
const bool hole = Game::Lua::Type(L, -1) == Game::Lua::TYPE_NIL;
|
||||
Game::Lua::SetTop(L, -2);
|
||||
if (hole)
|
||||
break;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(k));
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::RawSet(L, 5);
|
||||
}
|
||||
Game::Lua::PushString(L, "n");
|
||||
Game::Lua::PushNumber(L, static_cast<double>(n));
|
||||
Game::Lua::RawSet(L, 5);
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (more)
|
||||
Game::Lua::PushNumber(L, static_cast<double>(start + n + 1));
|
||||
else
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(n));
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Vararg form. Everything below only pushes, so the args can go; a full
|
||||
// batch is up to OPAQUE_SLOTS_MAX + 1 values, past Lua's guaranteed C-call
|
||||
// headroom.
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (Game::Lua::CheckStack(L, n + 1) == 0) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
if (more)
|
||||
Game::Lua::PushNumber(L, static_cast<double>(start + n + 1));
|
||||
else
|
||||
Game::Lua::PushNil(L);
|
||||
for (int i = 0; i < n; ++i)
|
||||
Game::Lua::PushNumber(L, static_cast<double>(slots[start + i]));
|
||||
return 1 + n;
|
||||
}
|
||||
|
||||
// Shared body of GetAuraDataBySlot (table) / UnitAuraBySlot (positional):
|
||||
// pushes the aura an opaque slot id from GetAuraSlots names, or a single nil
|
||||
// for an id that no longer names one.
|
||||
int PushAuraBySlot(void *L, Data::Emit emit) {
|
||||
const char *unitToken = ArgUnit(L, 1);
|
||||
if (unitToken == nullptr || !Game::Lua::IsNumber(L, 2)) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
const int slot = ArgInt(L, 2);
|
||||
const uint8_t *unit = ResolveUnit(unitToken);
|
||||
const uint64_t guid = (unit == nullptr) ? GuidForOutOfRange(unitToken) : 0;
|
||||
if (Data::PushBySlot(L, unit, guid, slot, emit))
|
||||
return (emit == Data::Emit::Positional) ? 15 : 1;
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// C_UnitAuras.GetAuraDataBySlot(unit, slot) -> AuraData | nil
|
||||
int __fastcall Script_GetAuraDataBySlot(void *L) {
|
||||
return PushAuraBySlot(L, Data::Emit::Table);
|
||||
}
|
||||
|
||||
// C_UnitAuras.UnitAuraBySlot(unit, slot) -> the 15 positional UnitAura values | nil
|
||||
// Zero-allocation sibling of GetAuraDataBySlot, namespaced under C_UnitAuras
|
||||
// like C_UnitAuras.UnitAura. What AuraUtil's non-packed iteration reads.
|
||||
int __fastcall Script_UnitAuraBySlot(void *L) {
|
||||
return PushAuraBySlot(L, Data::Emit::Positional);
|
||||
}
|
||||
|
||||
// Iterates the auras of one polarity (in `SlotInFilterOrder`, the same order
|
||||
// the by-index getters use) and pushes AuraData tables into `outer` at
|
||||
// sequential keys starting from `nextKey`. Updates `nextKey` so a follow-up
|
||||
// call can append to the same outer table.
|
||||
void AppendRangeToArray(void *L, const uint8_t *unit, int outerIdx,
|
||||
Data::Filter filter, int &nextKey, Data::Match match) {
|
||||
const int start = (filter == Data::Filter::Harmful)
|
||||
? Offsets::UNIT_AURA_BUFF_COUNT
|
||||
: 0;
|
||||
const int end = (filter == Data::Filter::Harmful)
|
||||
? Offsets::UNIT_AURA_TOTAL
|
||||
: Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
if (!Data::IsSlotPopulated(unit, slot))
|
||||
continue;
|
||||
if (!Data::MatchesAura(match, Data::IsPlayerCast(unit, slot),
|
||||
Data::ReadSpellID(unit, slot)))
|
||||
for (int i = 0; i < Offsets::UNIT_AURA_TOTAL; ++i) {
|
||||
const int slot = Data::SlotInFilterOrder(filter, i);
|
||||
if (!Data::SlotMatchesFilter(unit, slot, filter, match))
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
|
||||
Data::Push(L, unit, slot);
|
||||
@@ -426,6 +605,18 @@ 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", "GetAuraSlots",
|
||||
&Script_GetAuraSlots);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetAuraDataBySlot",
|
||||
&Script_GetAuraDataBySlot);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "UnitAuraBySlot",
|
||||
&Script_UnitAuraBySlot);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetUnitAuraBySpellID",
|
||||
&Script_GetUnitAuraBySpellID);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetPlayerAuraBySpellID",
|
||||
|
||||
+557
-236
File diff suppressed because it is too large
Load Diff
+123
-20
@@ -10,6 +10,8 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// Shared aura-table primitives consumed by `C_UnitAuras.*` (and any
|
||||
@@ -18,21 +20,48 @@
|
||||
// `unit + OFF_CGUNIT_OBJECT_FIELDS`; the layout is documented in
|
||||
// `Offsets.h` under `OFF_UNIT_FIELD_AURA*`.
|
||||
//
|
||||
// Slot indexing convention used here matches the engine's:
|
||||
// Slots are absolute 0..47, and how polarity is read out of them DEPENDS ON THE
|
||||
// SERVER. `IsSlotHarmful` owns that decision; nothing else should look at either
|
||||
// signal directly.
|
||||
//
|
||||
// slot 0..31 → buffs (helpful)
|
||||
// slot 32..47 → debuffs (harmful)
|
||||
// Normally the slot range IS the polarity: positive auras are written to 0..31
|
||||
// and negative ones to 32..47 by a strict either/or, so nothing crosses over.
|
||||
// The flag nibble is no help — in a stock descriptor those bits record which
|
||||
// spell effect indices carry an aura, not whether the aura is good or bad.
|
||||
//
|
||||
// Callers that take a 1-based Lua index into "buffs" or "debuffs"
|
||||
// translate to the absolute 0..47 slot before calling in.
|
||||
// Turtle changes both halves. Once the 16 harmful slots are full it spills
|
||||
// further debuffs into 0..31 (setting UNIT_FLAG_AURAS_VISIBLE so the client
|
||||
// still renders them), which makes the range unreliable — and it repurposes the
|
||||
// nibble to carry the true polarity so the information survives the spill.
|
||||
// There, the nibble is authoritative. See `Offsets::UNIT_AURA_FLAG_HARMFUL`.
|
||||
//
|
||||
// Either way `Filter` selects on the aura's REAL polarity, so a spilled debuff
|
||||
// is reported as a debuff. The engine's own UnitBuff/UnitDebuff split purely by
|
||||
// slot range and would call it a buff; that is a 1.12 UI limitation we
|
||||
// deliberately do not mirror, since retail's contract is actual polarity. A
|
||||
// polarity walk visits its home range first and then the other range, so
|
||||
// indices are unchanged wherever no spill has happened — which is everywhere,
|
||||
// on a server that does not spill.
|
||||
//
|
||||
// Callers that take a 1-based Lua index into "buffs" or "debuffs" translate
|
||||
// to the absolute 0..47 slot before calling in.
|
||||
|
||||
namespace Aura::Data {
|
||||
|
||||
// Filter for slot iteration. The engine's `UnitBuff` / `UnitDebuff`
|
||||
// pick one direction; modern `C_UnitAuras.GetAuraDataByIndex` defaults
|
||||
// to helpful when no filter is specified.
|
||||
// Filter for slot iteration: the aura's polarity per its flag nibble (see the
|
||||
// file header). The engine's `UnitBuff` / `UnitDebuff` pick one direction;
|
||||
// modern `C_UnitAuras.GetAuraDataByIndex` defaults 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
|
||||
@@ -75,11 +104,33 @@ uint32_t ReadSpellID(const uint8_t *unit, int slot);
|
||||
// to decide whether to surface an aura through Lua.
|
||||
bool IsSlotPopulated(const uint8_t *unit, int slot);
|
||||
|
||||
// Finds the absolute slot of the `oneBasedIndex`-th populated aura
|
||||
// matching `filter`. Returns -1 if no such aura. Walks
|
||||
// 0..UNIT_AURA_BUFF_COUNT-1 for Helpful, UNIT_AURA_BUFF_COUNT..
|
||||
// UNIT_AURA_TOTAL-1 for Harmful. When `playerOnly` is true, only auras
|
||||
// the local player cast (per `IsPlayerCast`) are counted/returned.
|
||||
// The aura's real polarity, read the way this server encodes it: the slot range
|
||||
// normally, the flag nibble on Turtle (see the file header). False for an empty
|
||||
// slot or a null unit. This is what `Filter` selects on, and the ONE place the
|
||||
// two encodings are chosen between.
|
||||
bool IsSlotHarmful(const uint8_t *unit, int slot);
|
||||
|
||||
// The engine's TOOLTIP visibility gate for a slot: occupied, visible nibble,
|
||||
// and the spell passes `FUN_GAMETOOLTIP_AURA_VISIBLE` — exactly what
|
||||
// `SetUnitBuff` / `SetUnitDebuff` count while turning an index into a slot.
|
||||
// Differs from `IsSlotPopulated` (the UnitBuff gate), so use THIS to compute
|
||||
// the index the engine's tooltip methods expect for a given slot.
|
||||
bool IsSlotTooltipVisible(const uint8_t *unit, int slot);
|
||||
|
||||
// The i-th slot (0..UNIT_AURA_TOTAL-1) in the order a `filter` walk visits the
|
||||
// descriptor: the polarity's home range first (helpful 0..31, harmful 32..47),
|
||||
// then the other range, where spilled debuffs live. Every enumeration that
|
||||
// hands out indices or slot lists uses this so they agree on order.
|
||||
int SlotInFilterOrder(Filter filter, int i);
|
||||
|
||||
// `SlotMatches` plus the polarity test: populated, of `filter`'s polarity, and
|
||||
// passing `match`.
|
||||
bool SlotMatchesFilter(const uint8_t *unit, int slot, Filter filter,
|
||||
const Match &match);
|
||||
|
||||
// Finds the absolute slot of the `oneBasedIndex`-th populated aura of
|
||||
// `filter`'s polarity that passes `match`. Returns -1 if no such aura. Walks
|
||||
// all 48 slots in `SlotInFilterOrder`.
|
||||
int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
|
||||
Match match = {});
|
||||
|
||||
@@ -116,6 +167,12 @@ bool IsDispellable(uint32_t spellID);
|
||||
// per-aura "was cast by the local player" answer and its spell ID.
|
||||
bool MatchesAura(const Match &match, bool isPlayerCast, uint32_t spellID);
|
||||
|
||||
// True iff descriptor `slot` on `unit` is populated (per `IsSlotPopulated`) AND
|
||||
// passes `match`. The caster answer (`IsPlayerCast` — a scan of the
|
||||
// `Aura::Source` cache, ~1000 entries in a raid) is computed ONLY when `match`
|
||||
// restricts on caster, so the common filter-less walk never pays for it.
|
||||
bool SlotMatches(const uint8_t *unit, int slot, const Match &match);
|
||||
|
||||
// Display stack count for the given slot (engine stores stacks-1,
|
||||
// we add 1). Returns 0 if the unit is null.
|
||||
int ReadStacks(const uint8_t *unit, int slot);
|
||||
@@ -154,8 +211,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 +223,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 +256,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
|
||||
@@ -218,4 +279,46 @@ bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
|
||||
void AppendGroupAuras(void *L, uint64_t guid, Filter filter, Match match,
|
||||
int outerIdx, int &nextKey);
|
||||
|
||||
// ── Opaque aura slots (GetAuraSlots / GetAuraDataBySlot) ────────────────────
|
||||
//
|
||||
// The by-index getters re-walk the descriptor from slot 0 on every call, so a
|
||||
// full scan through them is quadratic in the aura count. `CollectSlots`
|
||||
// enumerates once into opaque slot ids and `PushBySlot` fetches one aura by id
|
||||
// with no walk — the modern `C_UnitAuras.GetAuraSlots` / `GetAuraDataBySlot`
|
||||
// batching contract. An id is valid for the frame it was enumerated in.
|
||||
//
|
||||
// Encoding (kind = id / OPAQUE_STRIDE, k = id % OPAQUE_STRIDE):
|
||||
// kind 0 live descriptor slot k (0..UNIT_AURA_TOTAL-1)
|
||||
// kind 1 k-th eligible helpful cache-fallback entry (OPAQUE_CACHE_HELPFUL)
|
||||
// kind 2 k-th eligible harmful cache-fallback entry (OPAQUE_CACHE_HARMFUL)
|
||||
// kind 3 out-of-range group-array slot k (0..47) (OPAQUE_GROUP)
|
||||
// kind 4 k-th helpful group cache-fallback entry (OPAQUE_GROUP_CACHE_HELPFUL)
|
||||
// kind 5 k-th harmful group cache-fallback entry (OPAQUE_GROUP_CACHE_HARMFUL)
|
||||
// Fallback ordinals count the UNFILTERED eligible sequence (visible, not
|
||||
// already in the live array), so `PushBySlot` needs no filter; `CollectSlots`
|
||||
// applies the caller's `match` only when choosing which ids to emit.
|
||||
constexpr int OPAQUE_STRIDE = 0x100;
|
||||
constexpr int OPAQUE_CACHE_HELPFUL = 1 * OPAQUE_STRIDE;
|
||||
constexpr int OPAQUE_CACHE_HARMFUL = 2 * OPAQUE_STRIDE;
|
||||
constexpr int OPAQUE_GROUP = 3 * OPAQUE_STRIDE;
|
||||
constexpr int OPAQUE_GROUP_CACHE_HELPFUL = 4 * OPAQUE_STRIDE;
|
||||
constexpr int OPAQUE_GROUP_CACHE_HARMFUL = 5 * OPAQUE_STRIDE;
|
||||
|
||||
// Upper bound on ids one `CollectSlots` call can yield: the live array (or the
|
||||
// group array) plus its cache fallback, each at most UNIT_AURA_TOTAL entries.
|
||||
constexpr int OPAQUE_SLOTS_MAX = 2 * Offsets::UNIT_AURA_TOTAL;
|
||||
|
||||
// Writes the opaque ids of every aura on the unit matching `filter` + `match`
|
||||
// into `out` (at most `cap`), in the order the by-index getters visit them:
|
||||
// live slots (or, with no live CGUnit, the group array) first, then the cache
|
||||
// fallback. `unit` null selects the out-of-range group path for `guid`. Returns
|
||||
// the count written.
|
||||
int CollectSlots(const uint8_t *unit, uint64_t guid, Filter filter, Match match,
|
||||
int *out, int cap);
|
||||
|
||||
// Pushes the aura an opaque id names — table or positional per `emit` — and
|
||||
// returns true; pushes nothing and returns false for an id that names nothing
|
||||
// (stale, malformed, or the wrong path for the unit's current state).
|
||||
bool PushBySlot(void *L, const uint8_t *unit, uint64_t guid, int slot, Emit emit);
|
||||
|
||||
} // namespace Aura::Data
|
||||
|
||||
@@ -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
|
||||
+1045
-120
File diff suppressed because it is too large
Load Diff
+138
-27
@@ -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,26 +39,67 @@
|
||||
|
||||
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
|
||||
// duration rules use — SpellFamilyName + family-flag overlap + optional icon
|
||||
// (0 = any) — to a full duration from now. Returns the spellID refreshed, or 0
|
||||
// if nothing matched. For a mechanic whose duration edit reaches the client
|
||||
// too late to be attributed to a cast packet, so no rule can express it.
|
||||
// duration rules use — SpellFamilyName plus a family-flag overlap and/or a
|
||||
// SpellIconID, at least one of the two — to a full duration from now. Returns
|
||||
// the spellID refreshed, or 0 if nothing matched. For a mechanic whose
|
||||
// duration edit reaches the client too late to be attributed to a cast packet,
|
||||
// so no rule can express it.
|
||||
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,
|
||||
@@ -60,8 +109,10 @@ enum DurationModOp {
|
||||
};
|
||||
|
||||
// Register a server duration-modifier rule from C++. The trigger is matched by
|
||||
// exact `triggerSpellId`; the affected aura by SpellFamilyName + a family-flag
|
||||
// overlap (+ optional `affectedIcon`, 0 = any). `valueMs` is the reduce/set
|
||||
// exact `triggerSpellId`; the affected aura by SpellFamilyName plus an
|
||||
// `affectedMask` family-flag overlap and/or an `affectedIcon`, at least one of
|
||||
// the two (0 drops that half of the test; a custom spell with no family flags
|
||||
// is named by its icon alone). `valueMs` is the reduce/set
|
||||
// amount in milliseconds (ignored by refresh/remove). Used by src/turtle
|
||||
// modules for the server's built-in mods (the family/school-matched variant is
|
||||
// still Lua-registerable via C_UnitAuras.RegisterAuraDurationModifierByTrigger).
|
||||
@@ -70,25 +121,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,94 @@
|
||||
// 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/>.
|
||||
|
||||
// `success, ... = xpcall(f, msgh [, arg1, ...])` — call `f` under `msgh`,
|
||||
// forwarding the trailing arguments to it.
|
||||
//
|
||||
// 1.12's Lua takes only `xpcall(f, msgh)`. Its implementation is explicit
|
||||
// about it: `lua_settop(L, 2)` throws any further arguments away, then
|
||||
// `lua_pcall(L, 0, ...)` calls `f` with none. So the argument-forwarding form
|
||||
// doesn't merely fail — it silently calls `f` with nothing, and a function
|
||||
// that reads its parameters sees nil. (Note this is NOT a 5.0-vs-5.1
|
||||
// difference: both take `(f, msgh)`. Forwarding arrived later.)
|
||||
//
|
||||
// Callers work around it with a capturing closure —
|
||||
// `xpcall(function() return f(a, b) end, msgh)` — which is what
|
||||
// `ExecuteFrameScript` in the embedded addon does. That stays correct; this
|
||||
// just removes the need for it.
|
||||
//
|
||||
// Why a C function and not a Lua wrapper over the original: a Lua wrapper
|
||||
// would allocate a closure AND an `arg` table on every call (see the vararg
|
||||
// note in FunctionUtil.lua), putting an allocation on any error-guarded path,
|
||||
// including per-frame ones. `HookSecureFunc`'s `kUnhookableNames` also lists
|
||||
// `xpcall` precisely because shadowing a core primitive with a Lua closure
|
||||
// diverges from what C-side callers get. Replacing it with a real C function
|
||||
// has neither problem, and matches how `select` and `unpack` are backported
|
||||
// in this directory.
|
||||
//
|
||||
// A two-argument call is bit-for-bit the old behavior (zero forwarded args),
|
||||
// so this is purely additive for existing code.
|
||||
|
||||
#include "Game.h"
|
||||
|
||||
namespace BaseLib::XPCall {
|
||||
|
||||
namespace {
|
||||
|
||||
int __fastcall Script_xpcall(void *L) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
if (top < 2) {
|
||||
Game::Lua::Error(L, "bad argument #2 to 'xpcall' (value expected)");
|
||||
return 0; // unreachable
|
||||
}
|
||||
|
||||
// `lua_pcall` wants the callee and its arguments contiguous on top, and
|
||||
// the handler at a fixed index. Incoming order is
|
||||
// `f(1), msgh(2), a1(3)..aN(top)`, so the handler sits between the two —
|
||||
// shuffle it below `f`:
|
||||
//
|
||||
// PushValue(2) → f, msgh, a1..aN, msgh
|
||||
// Insert(1) → msgh, f, msgh, a1..aN (top moved down to index 1)
|
||||
// Remove(3) → msgh, f, a1..aN
|
||||
//
|
||||
// 5.2 does the same reshuffle with a single `lua_rotate`, which this Lua
|
||||
// has no equivalent of.
|
||||
Game::Lua::PushValue(L, 2);
|
||||
Game::Lua::Insert(L, 1);
|
||||
Game::Lua::Remove(L, 3);
|
||||
|
||||
const int status =
|
||||
Game::Lua::PCall(L, /*nargs*/ top - 2, Game::Lua::MULTRET, /*errfunc*/ 1);
|
||||
|
||||
// The stack now holds the handler plus either f's results or the handler's
|
||||
// return. Both cases want the status boolean in front of them, and the
|
||||
// handler itself dropped from the return set — which returning
|
||||
// `GetTop - 1` does, since results are taken from the top.
|
||||
Game::Lua::PushBool(L, status == 0);
|
||||
Game::Lua::Insert(L, 2);
|
||||
return Game::Lua::GetTop(L) - 1;
|
||||
}
|
||||
|
||||
void RegisterInGame() {
|
||||
Game::Lua::RegisterGlobalFunction("xpcall", &Script_xpcall);
|
||||
}
|
||||
|
||||
void RegisterGlue() {
|
||||
Game::Lua::RegisterGlueFunction("xpcall", &Script_xpcall);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
|
||||
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace BaseLib::XPCall
|
||||
@@ -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,95 @@
|
||||
// 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`, `|A`) 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 the two texture markers: it detects the neutralized
|
||||
// `||T` / `||A` form and draws the art 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 both markers by defanging them 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 escape 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 opener with a space, breaking the pipe→letter
|
||||
// adjacency the inline-texture detector keys on (`|T` and `||T` both contain the
|
||||
// `|T` substring, so this catches the doubled form too). The payload text stays
|
||||
// readable, the closer is inert without an opener, and every other escape is
|
||||
// untouched.
|
||||
//
|
||||
// ANY new marker the emitter learns MUST be added here in the same change, or it
|
||||
// becomes a chat-spoof vector.
|
||||
|
||||
#include "IconFilter.h"
|
||||
|
||||
namespace Chat::IconFilter {
|
||||
|
||||
namespace {
|
||||
|
||||
// True if `s` contains an inline-texture opener anywhere. Both markers count:
|
||||
// `|T` names a texture path and `|A` names an atlas, and either one would render
|
||||
// art from player-typed text if it survived to the fontstring.
|
||||
bool IsIconOpener(const char *s) {
|
||||
return s[0] == '|' && (s[1] == 'T' || s[1] == 'A');
|
||||
}
|
||||
|
||||
bool HasIconEscape(const char *s) {
|
||||
for (; *s != '\0'; ++s)
|
||||
if (IsIconOpener(s))
|
||||
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 (IsIconOpener(msg + i)) {
|
||||
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` / `A`
|
||||
i += 2; // consumed the opener
|
||||
} 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
|
||||
@@ -86,13 +86,18 @@ std::string StripMarkup(const char *s, unsigned len) {
|
||||
++i;
|
||||
break; // the |h is dropped by the 'h' case on the next iteration
|
||||
case 't': // stray texture closer — drop
|
||||
case 'a': // stray atlas closer — drop
|
||||
i += 2;
|
||||
break;
|
||||
case 'T': // texture opener — skip through to the next |t
|
||||
case 'A': // atlas opener — skip through to the next |a
|
||||
{
|
||||
const char closer = (s[i + 1] == 'A') ? 'a' : 't';
|
||||
i += 2;
|
||||
while (i < len && !(s[i] == '|' && i + 1 < len && s[i + 1] == 't'))
|
||||
while (i < len && !(s[i] == '|' && i + 1 < len && s[i + 1] == closer))
|
||||
++i;
|
||||
break; // the |t is dropped by the 't' case on the next iteration
|
||||
break; // the closer is dropped by the 't'/'a' case next iteration
|
||||
}
|
||||
default: // unknown sequence — keep the pipe literally
|
||||
out.push_back('|');
|
||||
++i;
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,123 @@
|
||||
// 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 "CommandLine.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
namespace Config::CommandLine {
|
||||
|
||||
namespace {
|
||||
|
||||
bool IsSpace(char c) { return c == ' ' || c == '\t'; }
|
||||
|
||||
char Lower(char c) { return (c >= 'A' && c <= 'Z') ? static_cast<char>(c - 'A' + 'a') : c; }
|
||||
|
||||
// Stops at `a`'s terminator rather than reading `len` bytes unconditionally, so
|
||||
// a switch name at the very end of the command line cannot read past it.
|
||||
bool EqualsIgnoreCase(const char *a, const char *b, size_t len) {
|
||||
for (size_t i = 0; i < len; ++i) {
|
||||
if (a[i] == '\0' || Lower(a[i]) != Lower(b[i]))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t Length(const char *s) {
|
||||
size_t n = 0;
|
||||
while (s[n] != '\0')
|
||||
++n;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Start of the value token for `-name`, or null when the switch is absent.
|
||||
const char *FindValue(const char *name) {
|
||||
const char *cmdLine = ::GetCommandLineA();
|
||||
if (cmdLine == nullptr || name == nullptr)
|
||||
return nullptr;
|
||||
const size_t nameLen = Length(name);
|
||||
if (nameLen == 0)
|
||||
return nullptr;
|
||||
|
||||
for (const char *p = cmdLine; *p != '\0'; ++p) {
|
||||
if (*p != '-' && *p != '/')
|
||||
continue;
|
||||
// Only at a token boundary, so a path containing the name cannot match.
|
||||
if (p != cmdLine && !IsSpace(p[-1]) && p[-1] != '"')
|
||||
continue;
|
||||
if (!EqualsIgnoreCase(p + 1, name, nameLen))
|
||||
continue;
|
||||
|
||||
const char *after = p + 1 + nameLen;
|
||||
if (*after == '=') {
|
||||
++after;
|
||||
} else if (IsSpace(*after)) {
|
||||
while (IsSpace(*after))
|
||||
++after;
|
||||
} else {
|
||||
continue; // a longer switch that merely starts with `name`
|
||||
}
|
||||
return after;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Walks the token at `p`, honoring double quotes so a value containing spaces
|
||||
// arrives whole. `emit` takes each character and returns whether it was kept;
|
||||
// the walk stops as a failure the moment one is not.
|
||||
template <typename Emit> bool ReadToken(const char *p, Emit emit) {
|
||||
const bool quoted = (*p == '"');
|
||||
if (quoted)
|
||||
++p;
|
||||
size_t n = 0;
|
||||
while (*p != '\0' && (quoted ? (*p != '"') : !IsSpace(*p))) {
|
||||
if (!emit(*p++))
|
||||
return false;
|
||||
++n;
|
||||
}
|
||||
return n > 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool Value(const char *name, char *out, size_t outSize) {
|
||||
if (out == nullptr || outSize == 0)
|
||||
return false;
|
||||
out[0] = '\0';
|
||||
const char *p = FindValue(name);
|
||||
if (p == nullptr)
|
||||
return false;
|
||||
|
||||
size_t n = 0;
|
||||
const bool ok = ReadToken(p, [&](char c) {
|
||||
if (n + 1 >= outSize)
|
||||
return false;
|
||||
out[n++] = c;
|
||||
return true;
|
||||
});
|
||||
out[n] = '\0';
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool Value(const char *name, std::string &out) {
|
||||
out.clear();
|
||||
const char *p = FindValue(name);
|
||||
if (p == nullptr)
|
||||
return false;
|
||||
return ReadToken(p, [&](char c) {
|
||||
out.push_back(c);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace Config::CommandLine
|
||||
@@ -0,0 +1,42 @@
|
||||
// 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 <string>
|
||||
|
||||
// Launch-switch parsing, shared by every module that reads one.
|
||||
//
|
||||
// A switch is written `-name value` or `-name=value`, and the `/name` form
|
||||
// Windows tools also accept is honored. The value may be double-quoted so it can
|
||||
// contain spaces, which a Lua snippet passed on the command line always will.
|
||||
// The name is matched case-insensitively and only at a token boundary, so a path
|
||||
// that happens to contain it cannot be mistaken for the switch itself.
|
||||
//
|
||||
// One parser for all of them on purpose: a second copy would drift, and the
|
||||
// quoting and boundary rules are exactly where that drift would go unnoticed.
|
||||
|
||||
namespace Config::CommandLine {
|
||||
|
||||
// Copies the value of `-name` into `out`. False when the switch is absent, its
|
||||
// value is empty, or the value does not fit -- a value too long to hold is a
|
||||
// failure rather than a silent truncation, since a half-copied filename or
|
||||
// snippet would be acted on as though it were complete.
|
||||
bool Value(const char *name, char *out, size_t outSize);
|
||||
|
||||
// Same, bounded only by the command line itself. For values with no natural
|
||||
// length, such as a script body.
|
||||
bool Value(const char *name, std::string &out);
|
||||
|
||||
} // namespace Config::CommandLine
|
||||
@@ -0,0 +1,68 @@
|
||||
// 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.
|
||||
|
||||
// `-config <name>` — a launch switch that swaps which settings file the client
|
||||
// uses, so one install can hold several profiles.
|
||||
//
|
||||
// The engine already names the config file through a single pointer: boot passes
|
||||
// `"Config.wtf"` to the config initializer, which keeps it and uses it to both
|
||||
// read the file at startup and write it at exit. Supplying a different string is
|
||||
// therefore the whole feature — no second code path, and no way for reading and
|
||||
// writing to disagree. See `Offsets::PATCH_CONFIG_FILENAME_PTR`.
|
||||
|
||||
#include "FileSwitch.h"
|
||||
|
||||
#include "CommandLine.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace Config::FileSwitch {
|
||||
|
||||
namespace {
|
||||
|
||||
// The engine holds this pointer for the life of the process and reads through it
|
||||
// again at exit to save, so the storage has to outlive everything — hence a
|
||||
// file-static buffer rather than anything owned by a scope.
|
||||
char g_configName[64];
|
||||
|
||||
// A filename is required to be a plain name. The engine builds the save path as
|
||||
// `WTF\` + this, and a subdirectory that does not exist makes the save fail
|
||||
// silently, which would throw away a session's settings with nothing to show for
|
||||
// it. Refusing the switch is the loud failure that a lost config file is not.
|
||||
bool IsPlainFilename(const char *name) {
|
||||
if (*name == '\0')
|
||||
return false;
|
||||
for (const char *p = name; *p != '\0'; ++p) {
|
||||
if (*p == '\\' || *p == '/' || *p == ':')
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void Apply() {
|
||||
if (!Config::CommandLine::Value("config", g_configName, sizeof g_configName))
|
||||
return;
|
||||
if (!IsPlainFilename(g_configName))
|
||||
return;
|
||||
|
||||
void *target = reinterpret_cast<void *>(Offsets::PATCH_CONFIG_FILENAME_PTR);
|
||||
DWORD previous = 0;
|
||||
if (!::VirtualProtect(target, sizeof(const char *), PAGE_EXECUTE_READWRITE, &previous))
|
||||
return;
|
||||
*reinterpret_cast<const char **>(target) = g_configName;
|
||||
::VirtualProtect(target, sizeof(const char *), previous, &previous);
|
||||
}
|
||||
|
||||
} // namespace Config::FileSwitch
|
||||
@@ -0,0 +1,37 @@
|
||||
// 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
|
||||
|
||||
// `-config <name>` — makes the client read and write `WTF\<name>` in place of
|
||||
// `WTF\Config.wtf`, so several settings profiles can live side by side.
|
||||
namespace Config::FileSwitch {
|
||||
|
||||
// Reads the switch from the process command line and, when present, points the
|
||||
// engine's config filename at it.
|
||||
//
|
||||
// MUST be called from DllMain, and is the one thing in this project that is.
|
||||
// The engine loads the config during boot, several calls before the point where
|
||||
// VanillaFixes invokes our `Load` export, so by the time the normal
|
||||
// initialization path runs the file has already been read — see
|
||||
// `Offsets::PATCH_CONFIG_FILENAME_PTR` for the full call ordering. DllMain is
|
||||
// early enough because VanillaFixes injects every DLL while the game process is
|
||||
// still suspended and resumes it only afterwards.
|
||||
//
|
||||
// This does not install a hook. It is a single 4-byte store into an immediate
|
||||
// operand, so it costs none of what makes hooking from DllMain a problem: no
|
||||
// trampoline, no thread suspension, no loader-lock work, and nothing for another
|
||||
// DLL's hook to collide with.
|
||||
void Apply();
|
||||
|
||||
} // namespace Config::FileSwitch
|
||||
@@ -0,0 +1,170 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Lua from the launch command line:
|
||||
//
|
||||
// -gluescript <code> run at the glue screens
|
||||
// -gluescriptFile <path> the file's contents, run at the glue screens
|
||||
// -gamescript <code> run once in-game
|
||||
// -gamescriptFile <path> the file's contents, run once in-game
|
||||
//
|
||||
// None of these exist in 1.12: the strings do not appear anywhere in the
|
||||
// binary, so there is nothing to parse them and nothing to un-gate. The whole
|
||||
// feature is therefore ours -- read the command line, and hand the source to
|
||||
// the engine's own compile-and-run funnel at the right moment.
|
||||
//
|
||||
// WHEN. Registration time is too early for either state. The in-game module
|
||||
// callback runs from FUN_LOAD_SCRIPT_FUNCTIONS, before FrameXML is loaded, and
|
||||
// the glue callback runs from FUN_LOAD_GLUE_SCRIPT_FUNCTIONS, before GlueXML
|
||||
// is. A script that ran there could not touch a frame, call a FrameXML
|
||||
// function, or see an addon, which is most of what anyone would write one for.
|
||||
// So each script is armed at registration and run from a tick, where the UI is
|
||||
// demonstrably up:
|
||||
//
|
||||
// glue -> Tick::FrameTick, whose hook is the UI render root and so fires at
|
||||
// the login and character-select screens. Armed by the glue module
|
||||
// callback, which runs once per glue boot -- initial launch and every
|
||||
// return from the world on logout -- so the glue script runs each
|
||||
// time the glue screens come up, matching what "run at the glue
|
||||
// screens" says. A script that logs in automatically therefore keeps
|
||||
// working after a logout, which is the point of it.
|
||||
// game -> Tick::WorldTick, which fires only in the world, so its first tick
|
||||
// is past FrameXML, past the addon load pass, and past enter-world
|
||||
// init. Latched for the life of the process, never re-armed on
|
||||
// /reload or on a second login, because "run once in-game" says once.
|
||||
//
|
||||
// HOW. FUN_LUA_RUN_STRING resolves the Lua state itself, so one call reaches
|
||||
// whichever state is current and the two paths differ only in when they fire.
|
||||
// It also installs the FrameScript error handler as the pcall's errfunc, so a
|
||||
// faulty script reports itself the way any script error does instead of failing
|
||||
// silently. Because every chunk goes through the shared compile chokepoint,
|
||||
// these scripts get the same 5.1 syntax support the rest of the client's Lua
|
||||
// has.
|
||||
//
|
||||
// A `*File` is read with Win32 rather than through the engine's file layer, on
|
||||
// purpose. The engine resolves relative paths against a loose-file index built
|
||||
// once at boot, so it cannot see a file created afterwards and cannot open an
|
||||
// absolute path at all -- and an absolute path is exactly what someone puts in
|
||||
// a shortcut. Reading it ourselves also means both switches share one execution
|
||||
// path, so inline code and file contents cannot diverge in behaviour.
|
||||
|
||||
#include "CommandLine.h"
|
||||
|
||||
#include "Offsets.h"
|
||||
#include "debug/Log.h"
|
||||
#include "Game.h"
|
||||
#include "tick/FrameTick.h"
|
||||
#include "tick/WorldTick.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace Config::LaunchScript {
|
||||
|
||||
namespace {
|
||||
|
||||
using RunString_t = int(__fastcall *)(const char *source, uint32_t size, const char *chunkName,
|
||||
void *errCtx);
|
||||
|
||||
// A chunk name carries a leading `=` or `@` to tell Lua how to present it in an
|
||||
// error. That marker is for Lua, not for a reader, so the log drops it.
|
||||
const char *Displayed(const std::string &chunkName) {
|
||||
const char *s = chunkName.c_str();
|
||||
return (*s == '=' || *s == '@') ? s + 1 : s;
|
||||
}
|
||||
|
||||
void Run(const std::string &source, const std::string &chunkName) {
|
||||
if (source.empty())
|
||||
return;
|
||||
const int ok = reinterpret_cast<RunString_t>(Offsets::FUN_LUA_RUN_STRING)(
|
||||
source.c_str(), static_cast<uint32_t>(source.size()), chunkName.c_str(), nullptr);
|
||||
// The engine's error handler has already reported the detail to the user by
|
||||
// now; this only records that the switch was the source of it.
|
||||
if (ok == 0)
|
||||
Debug::Log::Printf("[launchscript] %s did not run to completion", Displayed(chunkName));
|
||||
}
|
||||
|
||||
// Whole-file read. Refuses a file larger than the compiler's own size parameter
|
||||
// can describe, which is the engine's bound rather than one of ours.
|
||||
bool ReadWholeFile(const char *path, std::string &out) {
|
||||
out.clear();
|
||||
const HANDLE file = ::CreateFileA(path, GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING,
|
||||
FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (file == INVALID_HANDLE_VALUE)
|
||||
return false;
|
||||
|
||||
bool ok = false;
|
||||
LARGE_INTEGER size{};
|
||||
if (::GetFileSizeEx(file, &size) != 0 && size.QuadPart >= 0 &&
|
||||
size.QuadPart <= static_cast<long long>(0xFFFFFFFFu)) {
|
||||
if (size.QuadPart == 0) {
|
||||
ok = true; // an empty file is not a failure, just nothing to run
|
||||
} else {
|
||||
out.assign(static_cast<size_t>(size.QuadPart), '\0');
|
||||
DWORD read = 0;
|
||||
ok = ::ReadFile(file, &out[0], static_cast<DWORD>(out.size()), &read, nullptr) != 0 &&
|
||||
read == out.size();
|
||||
}
|
||||
}
|
||||
::CloseHandle(file);
|
||||
if (!ok)
|
||||
out.clear();
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Runs the inline switch and then the file switch, so a launch that passes both
|
||||
// gets both, in the order they are written above.
|
||||
void RunPair(const char *codeSwitch, const char *fileSwitch) {
|
||||
std::string code;
|
||||
if (Config::CommandLine::Value(codeSwitch, code))
|
||||
Run(code, std::string("=-") + codeSwitch); // `=` shows the switch, not the source
|
||||
|
||||
std::string path;
|
||||
if (!Config::CommandLine::Value(fileSwitch, path))
|
||||
return;
|
||||
std::string contents;
|
||||
if (!ReadWholeFile(path.c_str(), contents)) {
|
||||
Debug::Log::Printf("[launchscript] -%s could not read '%s'", fileSwitch, path.c_str());
|
||||
return;
|
||||
}
|
||||
Run(contents, "@" + path); // `@` shows the path, so errors carry line numbers
|
||||
}
|
||||
|
||||
bool g_glueArmed = false;
|
||||
bool g_gameRan = false;
|
||||
|
||||
void OnFrameTick() {
|
||||
if (!g_glueArmed)
|
||||
return;
|
||||
g_glueArmed = false;
|
||||
RunPair("gluescript", "gluescriptFile");
|
||||
}
|
||||
|
||||
void OnWorldTick() {
|
||||
if (g_gameRan)
|
||||
return;
|
||||
g_gameRan = true;
|
||||
RunPair("gamescript", "gamescriptFile");
|
||||
}
|
||||
|
||||
void ArmGlue() { g_glueArmed = true; }
|
||||
|
||||
const Game::GlueModuleAutoRegister _glueAutoreg{&ArmGlue};
|
||||
const Tick::FrameTick::AutoSubscribe _frameTick{&OnFrameTick};
|
||||
const Tick::WorldTick::AutoSubscribe _worldTick{&OnWorldTick};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Config::LaunchScript
|
||||
@@ -0,0 +1,197 @@
|
||||
// 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/>.
|
||||
|
||||
// `ConsoleGetAllCommands()` — every console command and console variable the
|
||||
// client currently has, as `ConsoleCommandInfo` tables.
|
||||
//
|
||||
// A plain global, not a `C_Console` member: the wiki files this under
|
||||
// `C_Console.GetAllCommands`, but on a live client that namespace does not
|
||||
// exist and `ConsoleGetAllCommands` is the real name.
|
||||
//
|
||||
// The engine keeps them in one registry, filled by
|
||||
// FUN_CONSOLE_COMMAND_REGISTER: a Storm intrusive list for iteration plus a
|
||||
// by-name hash for dispatch. This walks the list the same way the `help`
|
||||
// command does, which is the only other thing that enumerates it.
|
||||
//
|
||||
// ONE LIST, NOT TWO. Commands and CVars do not live separately. The CVar
|
||||
// registrar's last act is to register the CVar as a console command as well —
|
||||
// `FUN_CONSOLE_COMMAND_REGISTER(cvar->name, <shared handler>, categoryId,
|
||||
// help)` — which is what makes `/console <cvarName> <value>` work. So the
|
||||
// command list already holds every CVar, and walking the CVar list too (the one
|
||||
// `cvarlist` and the config saver use) would report each one twice.
|
||||
//
|
||||
// That also gives an exact `commandType`: every CVar shares one handler
|
||||
// address, so a node pointing at it is a CVar and anything else is a command.
|
||||
// It is a pointer comparison against a value the registrar itself writes, not a
|
||||
// name or category heuristic.
|
||||
//
|
||||
// FIELDS WE CANNOT FILL. `scriptContents` and `scriptParameters` describe
|
||||
// console macros and scripts, which this client has no notion of — there is no
|
||||
// registration path that could produce one. They are always the empty string,
|
||||
// so a caller reading them gets a string rather than a nil surprise. `category`
|
||||
// is the engine's own number, and the engine's table (debug, graphics, console,
|
||||
// combat, game, default, net, sound, gm) is Enum.ConsoleCategory's 0..8 exactly,
|
||||
// so it needs no translation.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Console::Commands {
|
||||
|
||||
namespace {
|
||||
|
||||
// Enum.ConsoleCommandType. Macro (2) and Script (3) exist in the contract but
|
||||
// cannot occur here.
|
||||
constexpr int kCommandTypeCvar = 0;
|
||||
constexpr int kCommandTypeCommand = 1;
|
||||
|
||||
using ConsoleWrite_t = void(__fastcall *)(const char *line, int colorType);
|
||||
|
||||
// A list node, or 0 once the walk reaches the end. The low bit tags the
|
||||
// sentinel, the same convention the addon registry and the parser lists use.
|
||||
bool IsNode(uintptr_t node) { return node != 0 && (node & 1) == 0; }
|
||||
|
||||
char Lower(char c) { return (c >= 'A' && c <= 'Z') ? static_cast<char>(c - 'A' + 'a') : c; }
|
||||
|
||||
bool StartsWithIgnoreCase(const char *name, const char *prefix) {
|
||||
for (size_t i = 0; prefix[i] != '\0'; ++i) {
|
||||
if (name[i] == '\0' || Lower(name[i]) != Lower(prefix[i]))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void SetStringField(void *L, const char *key, const char *value) {
|
||||
Game::Lua::PushString(L, key);
|
||||
Game::Lua::PushString(L, value != nullptr ? value : "");
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
|
||||
void SetIntField(void *L, const char *key, int value) {
|
||||
Game::Lua::PushString(L, key);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(value));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
|
||||
// `ConsoleGetAllCommands() -> ConsoleCommandInfo[]`
|
||||
int __fastcall Script_GetAllCommands(void *L) {
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
const uintptr_t linkOffset =
|
||||
Game::Read<uint32_t>(Offsets::VAR_CONSOLE_COMMAND_LINK_OFFSET);
|
||||
uintptr_t node = Game::Read<uint32_t>(Offsets::VAR_CONSOLE_COMMAND_LIST_HEAD);
|
||||
|
||||
int count = 0;
|
||||
while (IsNode(node)) {
|
||||
const auto *name =
|
||||
Game::Read<const char *>(node + Offsets::OFF_CONSOLE_COMMAND_NAME);
|
||||
const auto *help =
|
||||
Game::Read<const char *>(node + Offsets::OFF_CONSOLE_COMMAND_HELP);
|
||||
const uintptr_t handler =
|
||||
Game::Read<uint32_t>(node + Offsets::OFF_CONSOLE_COMMAND_HANDLER);
|
||||
const int category =
|
||||
Game::Read<int32_t>(node + Offsets::OFF_CONSOLE_COMMAND_CATEGORY);
|
||||
|
||||
// Step before building the entry: the walk stays correct even if a
|
||||
// field read below is skipped.
|
||||
node = Game::Read<uint32_t>(node + linkOffset + 4);
|
||||
|
||||
if (name == nullptr)
|
||||
continue; // a node the registrar never finished; nothing to report
|
||||
|
||||
Game::Lua::PushNumber(L, ++count);
|
||||
Game::Lua::NewTable(L);
|
||||
SetStringField(L, "command", name);
|
||||
SetStringField(L, "help", help);
|
||||
SetIntField(L, "category", category);
|
||||
SetIntField(L, "commandType",
|
||||
handler == Offsets::FUN_CONSOLE_CVAR_COMMAND_HANDLER
|
||||
? kCommandTypeCvar
|
||||
: kCommandTypeCommand);
|
||||
SetStringField(L, "scriptContents", "");
|
||||
SetStringField(L, "scriptParameters", "");
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Both enums are Blizzard's, stated in full rather than derived from the
|
||||
// engine's own category table. They are an API contract, not a description of
|
||||
// what this client happens to have: an addon asking for a value we can never
|
||||
// return still deserves the number instead of a nil. `Macro` and `Script` have
|
||||
// no registration path here, and `Reveal` / `None` are not among the engine's
|
||||
// nine categories -- everything else lines up, which is the point.
|
||||
// `ConsolePrintAllMatchingCommands(prefix)` — write every command whose name
|
||||
// starts with `prefix` to the console, the way its own tab completion lists
|
||||
// candidates. Case-insensitive, matching how the engine resolves a command
|
||||
// name.
|
||||
//
|
||||
// Prints nothing for an empty prefix rather than dumping all 500-odd entries;
|
||||
// ConsoleGetAllCommands is the way to ask for everything.
|
||||
int __fastcall Script_ConsolePrintAllMatchingCommands(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1))
|
||||
return 0;
|
||||
const char *prefix = Game::Lua::ToString(L, 1);
|
||||
if (prefix == nullptr || prefix[0] == '\0')
|
||||
return 0;
|
||||
|
||||
auto write = reinterpret_cast<ConsoleWrite_t>(Offsets::FUN_CONSOLE_WRITE);
|
||||
const uintptr_t linkOffset =
|
||||
Game::Read<uint32_t>(Offsets::VAR_CONSOLE_COMMAND_LINK_OFFSET);
|
||||
uintptr_t node = Game::Read<uint32_t>(Offsets::VAR_CONSOLE_COMMAND_LIST_HEAD);
|
||||
while (IsNode(node)) {
|
||||
const auto *name =
|
||||
Game::Read<const char *>(node + Offsets::OFF_CONSOLE_COMMAND_NAME);
|
||||
node = Game::Read<uint32_t>(node + linkOffset + 4);
|
||||
if (name != nullptr && StartsWithIgnoreCase(name, prefix))
|
||||
write(name, 0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const Game::Lua::EnumIntegerEntry kCommandTypeEntries[] = {
|
||||
{"Cvar", kCommandTypeCvar},
|
||||
{"Command", kCommandTypeCommand},
|
||||
{"Macro", 2},
|
||||
{"Script", 3},
|
||||
};
|
||||
|
||||
// The engine's table at VAR_CONSOLE_CATEGORY_TABLE holds ids 0..8 named debug,
|
||||
// graphics, console, combat, game, default, net, sound, gm, in that order --
|
||||
// so a `category` read off a command node is already one of these.
|
||||
const Game::Lua::EnumIntegerEntry kCategoryEntries[] = {
|
||||
{"Debug", 0}, {"Graphics", 1}, {"Console", 2}, {"Combat", 3},
|
||||
{"Game", 4}, {"Default", 5}, {"Net", 6}, {"Sound", 7},
|
||||
{"Gm", 8}, {"Reveal", 9}, {"None", 10},
|
||||
};
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("ConsoleGetAllCommands", &Script_GetAllCommands);
|
||||
Game::Lua::RegisterGlobalFunction("ConsolePrintAllMatchingCommands",
|
||||
&Script_ConsolePrintAllMatchingCommands);
|
||||
Game::Lua::RegisterIntegerEnum("Enum", "ConsoleCommandType", kCommandTypeEntries,
|
||||
sizeof(kCommandTypeEntries) /
|
||||
sizeof(kCommandTypeEntries[0]));
|
||||
Game::Lua::RegisterIntegerEnum("Enum", "ConsoleCategory", kCategoryEntries,
|
||||
sizeof(kCategoryEntries) /
|
||||
sizeof(kCategoryEntries[0]));
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Console::Commands
|
||||
@@ -0,0 +1,240 @@
|
||||
// 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/>.
|
||||
|
||||
// The developer console, as Lua functions: running commands, writing lines,
|
||||
// and reading the console's own state and appearance.
|
||||
//
|
||||
// `ConsoleExec` is the one with real reach. Nothing in this client's Lua could
|
||||
// run a console command before, so every entry ConsoleGetAllCommands returns
|
||||
// was visible and unreachable at once. It is the engine's own executor, the
|
||||
// same one that replays each `SET` line of Config.wtf at boot.
|
||||
//
|
||||
// THAT INCLUDES `set`, AND `set` IS NOT SetCVar. The console's set handler
|
||||
// looks a cvar up with the unfiltered lookup and performs no read-only check,
|
||||
// while Script_SetCVar tests flag bit 0x4 first and refuses. So
|
||||
// `ConsoleExec("set <name> <value>")` can write a cvar that
|
||||
// `SetCVar` rejects, and can reach cvars no Lua getter can see (Offsets.h at
|
||||
// CVAR_FLAG_READ_ONLY has the mechanism). That is inherent to exposing the
|
||||
// console rather than something added here -- an addon already runs arbitrary
|
||||
// Lua -- but it does mean the read-only guarantee C_CVar.GetCVarInfo reports
|
||||
// stops being absolute once this exists. Deliberate: a console you cannot run
|
||||
// commands through is not a console.
|
||||
//
|
||||
// Everything else is state the console module already keeps. The colours are
|
||||
// its own nine-entry table, the font height its own global, and the toggle key
|
||||
// its own byte, so each is read or written where the engine reads or writes it
|
||||
// rather than being modelled again here.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Console::Shell {
|
||||
|
||||
namespace {
|
||||
|
||||
using ConsoleExec_t = void(__fastcall *)(const char *line, int addToHistory);
|
||||
using ConsoleWrite_t = void(__fastcall *)(const char *line, int colorType);
|
||||
using ConsoleSetKey_t = void(__fastcall *)(int keyCode);
|
||||
|
||||
// `ConsoleExec(command)` — run a console command line.
|
||||
//
|
||||
// Passes addToHistory = 1 so a command run from Lua appears in the console's
|
||||
// own history, which is what someone stepping through the console afterwards
|
||||
// expects to find.
|
||||
int __fastcall Script_ConsoleExec(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1))
|
||||
return 0;
|
||||
reinterpret_cast<ConsoleExec_t>(Offsets::FUN_CONSOLE_EXEC)(Game::Lua::ToString(L, 1), 1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// `ConsoleEcho(message [, colorType])` — write one line to the console.
|
||||
//
|
||||
// The engine no-ops cleanly when the graphics device is not up, so this is safe
|
||||
// to call at any time, and the line is kept whether or not the console is
|
||||
// currently open.
|
||||
int __fastcall Script_ConsoleEcho(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1))
|
||||
return 0;
|
||||
int colorType = 0;
|
||||
if (Game::Lua::IsNumber(L, 2)) {
|
||||
colorType = static_cast<int>(Game::Lua::ToNumber(L, 2));
|
||||
if (colorType < 0 || colorType >= Offsets::CONSOLE_COLOR_COUNT)
|
||||
colorType = 0;
|
||||
}
|
||||
reinterpret_cast<ConsoleWrite_t>(Offsets::FUN_CONSOLE_WRITE)(Game::Lua::ToString(L, 1),
|
||||
colorType);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// `ConsoleIsActive()` — whether the console is open right now.
|
||||
//
|
||||
// False when the client was started without `-console`, since the toggle key
|
||||
// is gated on that and the console can then never open.
|
||||
int __fastcall Script_ConsoleIsActive(void *L) {
|
||||
const uint32_t enabled = Game::Read<uint32_t>(Offsets::VAR_CONSOLE_ENABLED);
|
||||
const uint32_t visible = Game::Read<uint32_t>(Offsets::VAR_CONSOLE_VISIBLE);
|
||||
Game::Lua::PushBool(L, enabled != 0 && visible != 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `ConsoleGetColorFromType(colorType)` -> r, g, b, a
|
||||
//
|
||||
// The colour the console draws that type of line in, as four 0..1 components to
|
||||
// match every other colour in this API. Nil for a type outside the table.
|
||||
int __fastcall Script_ConsoleGetColorFromType(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
const int type = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (type < 0 || type >= Offsets::CONSOLE_COLOR_COUNT) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
const uint32_t argb = *reinterpret_cast<const uint32_t *>(
|
||||
Offsets::VAR_CONSOLE_COLORS + static_cast<uintptr_t>(type) * sizeof(uint32_t));
|
||||
Game::Lua::PushNumber(L, ((argb >> 16) & 0xFF) / 255.0);
|
||||
Game::Lua::PushNumber(L, ((argb >> 8) & 0xFF) / 255.0);
|
||||
Game::Lua::PushNumber(L, (argb & 0xFF) / 255.0);
|
||||
Game::Lua::PushNumber(L, ((argb >> 24) & 0xFF) / 255.0);
|
||||
return 4;
|
||||
}
|
||||
|
||||
// `ConsoleGetFontHeight()` — the console's text height, as a fraction of the
|
||||
// screen height.
|
||||
int __fastcall Script_ConsoleGetFontHeight(void *L) {
|
||||
Game::Lua::PushNumber(L, *reinterpret_cast<const float *>(Offsets::VAR_CONSOLE_FONT_HEIGHT));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// There is deliberately no ConsoleSetFontHeight. Writing the height global
|
||||
// alone changes nothing, since the font was already built at the old size, and
|
||||
// the only thing that rebuilds it is the console's font init — which has no
|
||||
// code callers at all, just one entry in a table at 0x0080E170. It runs once,
|
||||
// during startup, before a single console line exists.
|
||||
//
|
||||
// Re-running it crashes, on a later frame rather than in the call: it frees the
|
||||
// old font handle out from under the cached, font-dependent draw object every
|
||||
// existing line holds at +0x20, and the next console render walks those. The
|
||||
// console's own shutdown frees both together, which is what that pairing is
|
||||
// for. Making a setter safe would mean replaying that teardown across every
|
||||
// line, to change a text size nothing asks to change.
|
||||
|
||||
// `SetConsoleKey(keyCode)` — the key that opens and closes the console.
|
||||
//
|
||||
// Takes the engine's own key code, the value its key events carry, rather than
|
||||
// a key name: the console module stores and compares exactly that, and nothing
|
||||
// in it translates names.
|
||||
int __fastcall Script_SetConsoleKey(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1))
|
||||
return 0;
|
||||
reinterpret_cast<ConsoleSetKey_t>(Offsets::FUN_CONSOLE_SET_KEY)(
|
||||
static_cast<int>(Game::Lua::ToNumber(L, 1)));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// `CalculateStringEditDistance(a, b)` — Levenshtein distance, the number of
|
||||
// single-character insertions, deletions or substitutions between two strings.
|
||||
// The console uses it to suggest a command when one is mistyped.
|
||||
//
|
||||
// Two rolling rows rather than a full matrix, so the cost is bounded by the
|
||||
// shorter string; the longer one only ever drives the loop.
|
||||
int __fastcall Script_CalculateStringEditDistance(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsString(L, 2)) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
const char *a = Game::Lua::ToString(L, 1);
|
||||
const char *b = Game::Lua::ToString(L, 2);
|
||||
|
||||
size_t lenA = 0, lenB = 0;
|
||||
while (a[lenA] != '\0')
|
||||
++lenA;
|
||||
while (b[lenB] != '\0')
|
||||
++lenB;
|
||||
if (lenA == 0 || lenB == 0) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(lenA + lenB));
|
||||
return 1;
|
||||
}
|
||||
// Index the rows by the shorter string so the buffers stay small whichever
|
||||
// way round the arguments came in.
|
||||
if (lenB > lenA) {
|
||||
const char *t = a;
|
||||
a = b;
|
||||
b = t;
|
||||
const size_t n = lenA;
|
||||
lenA = lenB;
|
||||
lenB = n;
|
||||
}
|
||||
constexpr size_t kMaxRow = 256; // beyond this the answer is of no practical use
|
||||
if (lenB >= kMaxRow) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
size_t prev[kMaxRow], curr[kMaxRow];
|
||||
for (size_t j = 0; j <= lenB; ++j)
|
||||
prev[j] = j;
|
||||
for (size_t i = 1; i <= lenA; ++i) {
|
||||
curr[0] = i;
|
||||
for (size_t j = 1; j <= lenB; ++j) {
|
||||
const size_t cost = (a[i - 1] == b[j - 1]) ? 0u : 1u;
|
||||
size_t best = prev[j] + 1; // deletion
|
||||
if (curr[j - 1] + 1 < best)
|
||||
best = curr[j - 1] + 1; // insertion
|
||||
if (prev[j - 1] + cost < best)
|
||||
best = prev[j - 1] + cost; // substitution
|
||||
curr[j] = best;
|
||||
}
|
||||
for (size_t j = 0; j <= lenB; ++j)
|
||||
prev[j] = curr[j];
|
||||
}
|
||||
Game::Lua::PushNumber(L, static_cast<double>(prev[lenB]));
|
||||
return 1;
|
||||
}
|
||||
|
||||
void Register() {
|
||||
Game::Lua::RegisterGlobalFunction("ConsoleExec", &Script_ConsoleExec);
|
||||
Game::Lua::RegisterGlobalFunction("ConsoleEcho", &Script_ConsoleEcho);
|
||||
Game::Lua::RegisterGlobalFunction("ConsoleIsActive", &Script_ConsoleIsActive);
|
||||
Game::Lua::RegisterGlobalFunction("ConsoleGetColorFromType",
|
||||
&Script_ConsoleGetColorFromType);
|
||||
Game::Lua::RegisterGlobalFunction("ConsoleGetFontHeight", &Script_ConsoleGetFontHeight);
|
||||
Game::Lua::RegisterGlobalFunction("SetConsoleKey", &Script_SetConsoleKey);
|
||||
Game::Lua::RegisterGlobalFunction("CalculateStringEditDistance",
|
||||
&Script_CalculateStringEditDistance);
|
||||
}
|
||||
|
||||
// The console exists before login, so the same surface is registered on the
|
||||
// glue state.
|
||||
void RegisterGlue() {
|
||||
Game::Lua::RegisterGlueFunction("ConsoleExec", &Script_ConsoleExec);
|
||||
Game::Lua::RegisterGlueFunction("ConsoleEcho", &Script_ConsoleEcho);
|
||||
Game::Lua::RegisterGlueFunction("ConsoleIsActive", &Script_ConsoleIsActive);
|
||||
Game::Lua::RegisterGlueFunction("ConsoleGetColorFromType",
|
||||
&Script_ConsoleGetColorFromType);
|
||||
Game::Lua::RegisterGlueFunction("ConsoleGetFontHeight", &Script_ConsoleGetFontHeight);
|
||||
Game::Lua::RegisterGlueFunction("SetConsoleKey", &Script_SetConsoleKey);
|
||||
Game::Lua::RegisterGlueFunction("CalculateStringEditDistance",
|
||||
&Script_CalculateStringEditDistance);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&Register};
|
||||
const Game::GlueModuleAutoRegister _glueAutoreg{&RegisterGlue};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Console::Shell
|
||||
@@ -0,0 +1,74 @@
|
||||
// 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_Container.AutoStoreItem(srcBag, srcSlot [, dstBag])` — move an item
|
||||
// and let the SERVER choose the destination slot. A ClassicAPI-only
|
||||
// extension, like its siblings `SwapItems` and `MoveItem`; the modern
|
||||
// API has no equivalent, because it has no need for one.
|
||||
//
|
||||
// The reason to want it: the server does not just find an empty slot.
|
||||
// It first merges the stack into existing non-full stacks of the same
|
||||
// item, splitting it across several of them when that is what fits, and
|
||||
// only places a remainder if any is left over. So this is a one-call
|
||||
// stack consolidator that needs no stack-size lookup — the caller never
|
||||
// has to know what fits. A merge computed on this side has to size
|
||||
// every stack first, and `C_Item.GetItemMaxStackSizeByID` is nil until
|
||||
// that item's data has arrived, so a cold item cache silently skips
|
||||
// exactly the stacks it could not size.
|
||||
//
|
||||
// `dstBag` defaults to 0, which means "anywhere in the main inventory
|
||||
// that it fits" rather than "the backpack" — on the wire the backpack
|
||||
// IS the player container, so the two cannot be distinguished. Pass
|
||||
// 1..4 to confine the item to one equipped bag. Bank destinations are
|
||||
// rejected (see [[item/Swap.h]] `AutoStore`).
|
||||
//
|
||||
// Returns true once the packet is away — like the other two, the send
|
||||
// is fire-and-forget and the server confirms through the normal
|
||||
// BAG_UPDATE / SMSG_INVENTORY_CHANGE_FAILURE flow.
|
||||
|
||||
#include "Game.h"
|
||||
#include "item/Swap.h"
|
||||
|
||||
namespace Container::AutoStoreItem {
|
||||
|
||||
namespace {
|
||||
|
||||
int __fastcall Script_C_Container_AutoStoreItem(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
|
||||
Game::Lua::Error(L,
|
||||
"Usage: C_Container.AutoStoreItem(srcBag, srcSlot [, dstBag])");
|
||||
return 0;
|
||||
}
|
||||
// Read every arg before calling AutoStore — its source lookup goes
|
||||
// through ResolveBag, which stomps the Lua stack via PackBagSlot.
|
||||
const int srcBag = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
const int srcSlot = static_cast<int>(Game::Lua::ToNumber(L, 2));
|
||||
const int dstBag = Game::Lua::IsNumber(L, 3)
|
||||
? static_cast<int>(Game::Lua::ToNumber(L, 3))
|
||||
: 0;
|
||||
|
||||
const bool ok = Item::Swap::AutoStore(L, srcBag, srcSlot, dstBag);
|
||||
Game::Lua::PushBool(L, ok);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Container", "AutoStoreItem",
|
||||
&Script_C_Container_AutoStoreItem);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Container::AutoStoreItem
|
||||
@@ -0,0 +1,106 @@
|
||||
// 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_Container.GetContainerItemEquipmentSetInfo(containerIndex, slotIndex)`
|
||||
// — whether the item in a bag slot belongs to an equipment set, and the
|
||||
// names of the sets it belongs to:
|
||||
//
|
||||
// inSet, setList = C_Container.GetContainerItemEquipmentSetInfo(bag, slot)
|
||||
//
|
||||
// The lookup is by the item's per-instance GUID against the GUIDs
|
||||
// `EquipmentSet::Data` saved into each set, so it names the one physical
|
||||
// item that was saved rather than every copy of that itemID — a spare
|
||||
// of the same item sitting in the next slot reports `false`. The set's
|
||||
// reserved empty / ignored slot markers can never match a real GUID,
|
||||
// and `Data::SetsContainingItem` rejects them outright.
|
||||
//
|
||||
// `setList` is the set names joined with `", "`, in set order, which is
|
||||
// the shape a caller formats straight into a tooltip line. It is `nil`
|
||||
// rather than an empty string when the item is in no set, so the usual
|
||||
// `if inSet then … setList … end` guard is all a caller needs.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "equipmentset/Data.h"
|
||||
#include "equipmentset/Set.h"
|
||||
#include "item/CGItem.h"
|
||||
#include "item/Location.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace Container::EquipmentSetInfo {
|
||||
|
||||
namespace {
|
||||
|
||||
// The separator between names in `setList`. One place, so the string a
|
||||
// caller splits on is stated once.
|
||||
constexpr const char *kSeparator = ", ";
|
||||
|
||||
uint64_t ItemGuid(const uint8_t *cgItem) {
|
||||
auto *instance = Item::InstanceBlock(cgItem);
|
||||
if (instance == nullptr)
|
||||
return 0;
|
||||
return *reinterpret_cast<const uint64_t *>(
|
||||
instance + Offsets::OFF_INSTANCE_BLOCK_GUID);
|
||||
}
|
||||
|
||||
int __fastcall Script_C_Container_GetContainerItemEquipmentSetInfo(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
|
||||
Game::Lua::Error(L,
|
||||
"Usage: C_Container.GetContainerItemEquipmentSetInfo(containerIndex, slotIndex)");
|
||||
return 0;
|
||||
}
|
||||
const int bagID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
const int slotIndex = static_cast<int>(Game::Lua::ToNumber(L, 2));
|
||||
|
||||
// `ResolveBag` stomps stack slots 1 and 2 through the engine's
|
||||
// `PackBagSlot`, so both arguments are already in locals above and
|
||||
// nothing we need survives on the stack across it.
|
||||
const uint64_t guid =
|
||||
ItemGuid(Item::Location::ResolveBag(L, bagID, slotIndex));
|
||||
|
||||
const std::vector<const EquipmentSet::Set *> sets =
|
||||
EquipmentSet::Data::SetsContainingItem(guid);
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (sets.empty()) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
Game::Lua::PushNil(L);
|
||||
return 2;
|
||||
}
|
||||
|
||||
std::string setList;
|
||||
for (size_t i = 0; i < sets.size(); ++i) {
|
||||
if (i > 0)
|
||||
setList += kSeparator;
|
||||
setList += sets[i]->name;
|
||||
}
|
||||
|
||||
Game::Lua::PushBool(L, true);
|
||||
Game::Lua::PushString(L, setList.c_str());
|
||||
return 2;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction(
|
||||
"C_Container", "GetContainerItemEquipmentSetInfo",
|
||||
&Script_C_Container_GetContainerItemEquipmentSetInfo);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Container::EquipmentSetInfo
|
||||
@@ -19,6 +19,7 @@
|
||||
#include "item/Record.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace Container::FreeSlots {
|
||||
|
||||
@@ -115,6 +116,46 @@ int __fastcall Script_C_Container_GetContainerNumFreeSlots(void *L) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
// `C_Container.GetContainerFreeSlots(bagID)` — which slots are empty,
|
||||
// as an array of slot numbers in ascending order, rather than
|
||||
// `GetContainerNumFreeSlots`'s count of them. A full bag returns an
|
||||
// empty table; a bag that isn't there returns nothing, which is how
|
||||
// callers tell "no free slots" from "no such bag".
|
||||
//
|
||||
// `bagID` is 0..4 like the rest of this file (0 = backpack, 1..4 =
|
||||
// equipped bag slots); bank and keyring indices are the "no such bag"
|
||||
// case.
|
||||
int __fastcall Script_C_Container_GetContainerFreeSlots(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L,
|
||||
"Usage: C_Container.GetContainerFreeSlots(bagID)");
|
||||
return 0;
|
||||
}
|
||||
const int bagID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
|
||||
const BagInfo info = ResolveBagInfo(bagID);
|
||||
if (info.slotCount <= 0)
|
||||
return 0; // nothing — no such bag
|
||||
|
||||
// Collect before building the table. Each `ResolveBag` stomps stack
|
||||
// slots 1 and 2, so the table cannot be on the stack during the walk.
|
||||
std::vector<int> freeSlots;
|
||||
freeSlots.reserve(static_cast<size_t>(info.slotCount));
|
||||
for (int slot = 1; slot <= info.slotCount; ++slot) {
|
||||
if (Item::Location::ResolveBag(L, bagID, slot) == nullptr)
|
||||
freeSlots.push_back(slot);
|
||||
}
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
for (size_t i = 0; i < freeSlots.size(); ++i) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(freeSlots[i]));
|
||||
Game::Lua::RawSet(L, -3);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Container.CalculateTotalNumberOfFreeBagSlots()` — the total number of
|
||||
// free slots across the player's *general-purpose* bags (backpack + equipped
|
||||
// bags 0..4 whose BagFamily is 0). Specialty bags (quivers, soul bags, herb
|
||||
@@ -146,6 +187,8 @@ int __fastcall Script_C_Container_CalculateTotalNumberOfFreeBagSlots(void *L) {
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Container", "GetContainerNumFreeSlots",
|
||||
&Script_C_Container_GetContainerNumFreeSlots);
|
||||
Game::Lua::RegisterTableFunction("C_Container", "GetContainerFreeSlots",
|
||||
&Script_C_Container_GetContainerFreeSlots);
|
||||
Game::Lua::RegisterTableFunction("C_Container", "CalculateTotalNumberOfFreeBagSlots",
|
||||
&Script_C_Container_CalculateTotalNumberOfFreeBagSlots);
|
||||
}
|
||||
|
||||
+21
-33
@@ -49,43 +49,31 @@
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "item/CGItem.h"
|
||||
#include "item/ID.h"
|
||||
#include "item/Icon.h"
|
||||
#include "item/Link.h"
|
||||
#include "item/Location.h"
|
||||
#include "item/Record.h"
|
||||
#include "item/StatAccum.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
|
||||
namespace Container::ItemInfo {
|
||||
|
||||
namespace {
|
||||
|
||||
bool BuildIconPath(uint32_t displayInfoID, char *out, size_t outSize) {
|
||||
out[0] = '\0';
|
||||
const char *iconName = DBC::StringField(
|
||||
Offsets::VAR_ITEMDISPLAYINFO_RECORDS, Offsets::VAR_ITEMDISPLAYINFO_COUNT,
|
||||
displayInfoID, Offsets::OFF_ITEMDISPLAYINFO_ICON);
|
||||
if (iconName == nullptr || iconName[0] == '\0')
|
||||
return false;
|
||||
std::snprintf(out, outSize, "Interface\\Icons\\%s", iconName);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Mirrors the engine's `readable` decision (0x004F97BD..0x004F97CF): a slot
|
||||
// is readable when its static page text is set OR the per-instance
|
||||
// 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 +104,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 +123,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);
|
||||
haveIcon = BuildIconPath(displayInfoID, iconPath, sizeof(iconPath));
|
||||
quality = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_QUALITY);
|
||||
const uint32_t displayInfoID = Game::Read<uint32_t>(
|
||||
record, Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID);
|
||||
haveIcon = Item::Icon::PathForDisplayInfoID(displayInfoID, iconPath, sizeof(iconPath));
|
||||
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 +143,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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// 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_Container.GetContainerItemQuestInfo(containerIndex, slotIndex)` —
|
||||
// the quest state of a bag slot, as one table:
|
||||
//
|
||||
// { isQuestItem = bool, questID = number|nil, isActive = bool }
|
||||
//
|
||||
// This is what draws the two overlays on a bag button: a `questID` the
|
||||
// player has not accepted yet gets the exclamation mark, and either an
|
||||
// accepted `questID` or a plain quest item gets the question-mark
|
||||
// border.
|
||||
//
|
||||
// The three fields come from three places:
|
||||
//
|
||||
// isQuestItem the item's class is Quest — an item that exists to be
|
||||
// a quest objective.
|
||||
// questID the quest the item BEGINS (`m_startQuest` in the
|
||||
// cached item record). Absent, not zero, when the item
|
||||
// starts nothing — callers test `if questInfo.questID`.
|
||||
// isActive that started quest is already in the quest log.
|
||||
//
|
||||
// The table is always returned, even for an empty slot or an item whose
|
||||
// data has not arrived yet, so callers can index it without a nil test.
|
||||
// An uncached item warms the cache, so the next bag update draws the
|
||||
// overlay the first hover could not.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "item/Data.h"
|
||||
#include "item/ID.h"
|
||||
#include "item/Location.h"
|
||||
#include "item/Record.h"
|
||||
#include "quest/Log.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Container::QuestInfo {
|
||||
|
||||
namespace {
|
||||
|
||||
// ItemClass.dbc row 12. Only this file cares which class means "quest",
|
||||
// so the code stays local per the single-use rule in the offsets policy.
|
||||
constexpr uint32_t kItemClassQuest = 12;
|
||||
|
||||
int __fastcall Script_C_Container_GetContainerItemQuestInfo(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
|
||||
Game::Lua::Error(L,
|
||||
"Usage: C_Container.GetContainerItemQuestInfo(containerIndex, slotIndex)");
|
||||
return 0;
|
||||
}
|
||||
const int bagID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
const int slotIndex = static_cast<int>(Game::Lua::ToNumber(L, 2));
|
||||
|
||||
// Resolve before building the table: `ResolveBag` stomps stack
|
||||
// slots 1 and 2 through the engine's `PackBagSlot`, so nothing we
|
||||
// want to keep may be on the stack across this call.
|
||||
const int itemID =
|
||||
Item::ID::FromCGItem(Item::Location::ResolveBag(L, bagID, slotIndex));
|
||||
|
||||
bool isQuestItem = false;
|
||||
uint32_t startQuest = 0;
|
||||
if (itemID != 0) {
|
||||
auto *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
|
||||
if (record == nullptr) {
|
||||
// Cold cache. Report the empty answer now and fetch, so the
|
||||
// overlay is right on the next bag update.
|
||||
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
|
||||
} else {
|
||||
isQuestItem = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_CLASS) ==
|
||||
kItemClassQuest;
|
||||
startQuest = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_START_QUEST);
|
||||
}
|
||||
}
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldBool(L, "isQuestItem", isQuestItem);
|
||||
if (startQuest > 0) {
|
||||
Game::Lua::SetFieldNumber(L, "questID",
|
||||
static_cast<double>(startQuest));
|
||||
Game::Lua::SetFieldBool(
|
||||
L, "isActive", Quest::Log::IsOnQuest(static_cast<int>(startQuest)));
|
||||
} else {
|
||||
// `questID` stays absent rather than 0 — the field is nilable and
|
||||
// every caller tests it for truth.
|
||||
Game::Lua::SetFieldBool(L, "isActive", false);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Container", "GetContainerItemQuestInfo",
|
||||
&Script_C_Container_GetContainerItemQuestInfo);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Container::QuestInfo
|
||||
@@ -0,0 +1,936 @@
|
||||
// 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_Container.SortBags()` — arrange the player's bags.
|
||||
//
|
||||
// Two phases, because every item move is a server round trip and the
|
||||
// second phase has to read slot contents the first phase changed.
|
||||
//
|
||||
// PHASE ONE consolidates partial stacks, and hands that entirely to the
|
||||
// server: one `CMSG_AUTOSTORE_BAG_ITEM` per redundant stack, which
|
||||
// merges into existing stacks of the same item and splits across several
|
||||
// of them when that is what fits. Nothing here computes what fits, so
|
||||
// nothing here needs an item's max stack size — which matters, because
|
||||
// that number is unavailable until the item's data has arrived from the
|
||||
// server, and a merge that sizes stacks first silently skips the ones it
|
||||
// could not size.
|
||||
//
|
||||
// PHASE TWO places items. It runs on the next `Bag::UpdateDelayed` — the
|
||||
// engine's own "a bag changed this frame" signal — so the merges are
|
||||
// settled before anything is read. It plans the whole layout up front
|
||||
// and then tracks each item's live position as swaps fire, rather than
|
||||
// re-reading a layout it is in the middle of mutating. Our swap is
|
||||
// fire-and-forget and sets no client-side item lock, so the whole batch
|
||||
// goes out in one frame.
|
||||
//
|
||||
// Neither phase may touch the Lua stack: phase two runs from a bag-update
|
||||
// callback, outside any Lua call, where the engine may have its own
|
||||
// values on the stack. Hence the `*From` senders and the direct invMgr
|
||||
// walk rather than `ResolveBag`.
|
||||
//
|
||||
// THE ORDERING IS OURS. Retail computes it in code we cannot read, and
|
||||
// its categories (per-bag assignment flags, expansion filters) have no
|
||||
// counterpart here, so this is a defensible order rather than a
|
||||
// reproduction of Blizzard's: hearthstone, gear by descending quality,
|
||||
// consumables, reagents, trade goods, quest items, everything else by
|
||||
// quality, junk last. Within a category, by item class, subclass, name,
|
||||
// then fuller stacks first.
|
||||
//
|
||||
// An item whose data has NOT arrived is pinned: left where it is, with
|
||||
// its slot withheld from the destination pool so nothing else is planned
|
||||
// into it. It cannot be ranked without a class, quality and name, and
|
||||
// guessing scatters it. A later call places it once the data lands. Phase
|
||||
// one is unaffected, since it needs only the item ID.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "bag/UpdateDelayed.h"
|
||||
#include "cvar/Factory.h"
|
||||
#include "item/BagFamily.h"
|
||||
#include "item/CGItem.h"
|
||||
#include "item/ID.h"
|
||||
#include "item/Location.h"
|
||||
#include "item/Record.h"
|
||||
#include "item/Swap.h"
|
||||
#include "object/Resolve.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace Container::SortBags {
|
||||
|
||||
namespace {
|
||||
|
||||
// The two sets this can arrange. Both go through the same snapshot,
|
||||
// comparator and placement; they differ only in how phase one merges
|
||||
// (see ConsolidateViaServer / ConsolidateLocally).
|
||||
constexpr int kInventoryBags[] = {0, 1, 2, 3, 4};
|
||||
constexpr int kBankBags[] = {-1, 5, 6, 7, 8, 9, 10};
|
||||
|
||||
// Fill direction, persisted like every other engine setting: the cvar
|
||||
// carries it to Config.wtf and `GetCVar("sortBagsRightToLeft")` reads it,
|
||||
// so it survives a session the way the retail setting does.
|
||||
CVar::Factory::Handle g_rightToLeftCVar = nullptr;
|
||||
|
||||
bool RightToLeft() {
|
||||
return CVar::Factory::GetInt(g_rightToLeftCVar, 0) != 0;
|
||||
}
|
||||
|
||||
// The two containers a sort can be told to leave alone. An equipped bag
|
||||
// is excluded per-bag through its own `DisableAutoSort` slot flag; the
|
||||
// backpack and the main bank container have no such slot, so each gets
|
||||
// its own setting. Persisted as cvars for the same reason the fill
|
||||
// direction is.
|
||||
CVar::Factory::Handle g_backpackAutosortCVar = nullptr;
|
||||
CVar::Factory::Handle g_bankAutosortCVar = nullptr;
|
||||
|
||||
bool BackpackAutosortDisabled() {
|
||||
return CVar::Factory::GetInt(g_backpackAutosortCVar, 0) != 0;
|
||||
}
|
||||
|
||||
bool BankAutosortDisabled() {
|
||||
return CVar::Factory::GetInt(g_bankAutosortCVar, 0) != 0;
|
||||
}
|
||||
|
||||
// Merge packets sent by the most recent phase one, reset per sort.
|
||||
int g_mergePackets = 0;
|
||||
|
||||
// The source slots phase one asked the server to empty. Phase two counts
|
||||
// how many are still occupied when it runs: any at all means it is
|
||||
// planning against a half-merged snapshot, because the server's replies
|
||||
// did not all arrive in the bag update that woke it.
|
||||
struct SourceSlot {
|
||||
int bag, slot;
|
||||
};
|
||||
std::vector<SourceSlot> g_mergeSources;
|
||||
|
||||
// Item.dbc `m_class` values we rank on. Per-module codes, the way
|
||||
// `Item::BagFamily` keeps its own.
|
||||
constexpr uint32_t kClassConsumable = 0;
|
||||
constexpr uint32_t kClassWeapon = 2;
|
||||
constexpr uint32_t kClassArmor = 4;
|
||||
constexpr uint32_t kClassReagent = 5;
|
||||
constexpr uint32_t kClassTradeGoods = 7;
|
||||
constexpr uint32_t kClassQuest = 12;
|
||||
|
||||
constexpr uint32_t kQualityPoor = 0;
|
||||
constexpr uint32_t kQualityUncommon = 2;
|
||||
constexpr uint32_t kQualityRare = 3;
|
||||
constexpr uint32_t kQualityEpic = 4;
|
||||
|
||||
// Primary key. Lower sorts nearer the first slot.
|
||||
enum Category : uint8_t {
|
||||
kCatHearthstone = 0,
|
||||
kCatGearEpic,
|
||||
kCatGearRare,
|
||||
kCatGearUncommon,
|
||||
kCatGearPlain,
|
||||
kCatConsumable,
|
||||
kCatReagent,
|
||||
kCatTradeGoods,
|
||||
kCatQuest,
|
||||
kCatOtherEpic,
|
||||
kCatOtherRare,
|
||||
kCatOtherUncommon,
|
||||
kCatOtherPlain,
|
||||
kCatJunk,
|
||||
};
|
||||
|
||||
uint8_t CategoryFor(uint32_t itemID, uint32_t itemClass, uint32_t quality) {
|
||||
if (itemID == Offsets::HEARTHSTONE_ITEM_ID)
|
||||
return kCatHearthstone;
|
||||
if (quality == kQualityPoor)
|
||||
return kCatJunk;
|
||||
if (itemClass == kClassWeapon || itemClass == kClassArmor) {
|
||||
if (quality >= kQualityEpic)
|
||||
return kCatGearEpic;
|
||||
if (quality == kQualityRare)
|
||||
return kCatGearRare;
|
||||
if (quality == kQualityUncommon)
|
||||
return kCatGearUncommon;
|
||||
return kCatGearPlain;
|
||||
}
|
||||
if (itemClass == kClassConsumable)
|
||||
return kCatConsumable;
|
||||
if (itemClass == kClassReagent)
|
||||
return kCatReagent;
|
||||
if (itemClass == kClassTradeGoods)
|
||||
return kCatTradeGoods;
|
||||
if (itemClass == kClassQuest)
|
||||
return kCatQuest;
|
||||
if (quality >= kQualityEpic)
|
||||
return kCatOtherEpic;
|
||||
if (quality == kQualityRare)
|
||||
return kCatOtherRare;
|
||||
if (quality == kQualityUncommon)
|
||||
return kCatOtherUncommon;
|
||||
return kCatOtherPlain;
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
const void *item; // CGItem — stable across the swap batch
|
||||
int curBag, curSlot; // live position, updated as swaps fire
|
||||
|
||||
// Destination, or unassigned. The unassigned sentinel is destSLOT,
|
||||
// never destBag: slots are 1-based so a negative one is impossible,
|
||||
// whereas bagID -1 is the main bank. Testing destBag < 0 here treats
|
||||
// every item bound for the main bank as unplaced and silently drops
|
||||
// its move.
|
||||
int destBag, destSlot;
|
||||
uint32_t itemID;
|
||||
uint32_t family; // the item's own bag family (0 = fits anywhere)
|
||||
uint32_t itemClass, subClass, count;
|
||||
const char *name;
|
||||
uint8_t category;
|
||||
};
|
||||
|
||||
struct Cell {
|
||||
int bag, slot;
|
||||
};
|
||||
|
||||
char Lower(char c) {
|
||||
return (c >= 'A' && c <= 'Z') ? static_cast<char>(c - 'A' + 'a') : c;
|
||||
}
|
||||
|
||||
// A missing name sorts after every present one. Substituting a
|
||||
// placeholder string instead (libbagsort uses "zzz") wedges the unnamed
|
||||
// item in among items actually named with those letters; this keeps it
|
||||
// deterministic and at the end. In practice a nameless item is pinned
|
||||
// before it reaches here, so this is the belt to that braces.
|
||||
int CompareNames(const char *a, const char *b) {
|
||||
if (a == b)
|
||||
return 0;
|
||||
if (a == nullptr)
|
||||
return 1;
|
||||
if (b == nullptr)
|
||||
return -1;
|
||||
for (size_t i = 0;; ++i) {
|
||||
const char ca = Lower(a[i]), cb = Lower(b[i]);
|
||||
if (ca != cb)
|
||||
return ca < cb ? -1 : 1;
|
||||
if (ca == '\0')
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
bool Precedes(const Entry &a, const Entry &b) {
|
||||
if (a.category != b.category)
|
||||
return a.category < b.category;
|
||||
if (a.itemClass != b.itemClass)
|
||||
return a.itemClass < b.itemClass;
|
||||
if (a.subClass != b.subClass)
|
||||
return a.subClass < b.subClass;
|
||||
const int byName = CompareNames(a.name, b.name);
|
||||
if (byName != 0)
|
||||
return byName < 0;
|
||||
if (a.count != b.count)
|
||||
return a.count > b.count; // fuller stacks first
|
||||
return a.itemID < b.itemID; // total order, so the result is stable
|
||||
}
|
||||
|
||||
// A bag's storage, addressed without Lua: the invMgr holding its GUID
|
||||
// array, the linear index its first slot sits at, and the family it
|
||||
// accepts. The backpack lives inside the player's own invMgr; bags 1..4
|
||||
// each have one of their own.
|
||||
struct BagView {
|
||||
const uint8_t *invMgr;
|
||||
int firstLinear;
|
||||
int slotCount;
|
||||
uint32_t family;
|
||||
};
|
||||
|
||||
// The backpack and the main bank both live inside the player's own
|
||||
// invMgr, at different linear bases. Equipped bags and bank bags each
|
||||
// have an invMgr of their own, reached through the bag item.
|
||||
bool ViewForBag(int bagID, BagView *out) {
|
||||
if (bagID == 0) {
|
||||
auto *inv = Unit::Identity::PlayerInventoryManager();
|
||||
if (inv == nullptr)
|
||||
return false;
|
||||
*out = {inv, Offsets::INVMGR_BACKPACK_FIRST_SLOT,
|
||||
Offsets::BACKPACK_NUM_SLOTS, 0};
|
||||
return true;
|
||||
}
|
||||
if (bagID == -1) {
|
||||
auto *inv = Unit::Identity::PlayerInventoryManager();
|
||||
if (inv == nullptr)
|
||||
return false;
|
||||
*out = {inv, Offsets::INVMGR_BANK_MAIN_FIRST_SLOT,
|
||||
Offsets::INVMGR_BANK_MAIN_LAST_SLOT -
|
||||
Offsets::INVMGR_BANK_MAIN_FIRST_SLOT + 1,
|
||||
0};
|
||||
return true;
|
||||
}
|
||||
|
||||
// Resolve the bag item that owns the container. Bank bags resolve
|
||||
// only once the bank window has been opened this session, which is
|
||||
// the right gate: the server refuses bank moves without it anyway,
|
||||
// so a bank sort attempted with the bank closed finds no cells and
|
||||
// does nothing rather than firing moves that would all be rejected.
|
||||
int equipSlot = 0;
|
||||
if (bagID >= 1 && bagID <= 4)
|
||||
equipSlot = Offsets::INVSLOT_BAG1 + bagID - 1;
|
||||
else if (bagID >= 5 && bagID <= 10)
|
||||
equipSlot = Offsets::INVMGR_BANK_BAG_FIRST_SLOT + 1 + (bagID - 5);
|
||||
else
|
||||
return false;
|
||||
|
||||
auto *bagItem = Item::Location::ResolveEquipmentSlot(equipSlot);
|
||||
if (bagItem == nullptr)
|
||||
return false;
|
||||
auto *bagInv = static_cast<const uint8_t *>(
|
||||
Item::Location::ContainerInventory(bagItem));
|
||||
if (bagInv == nullptr)
|
||||
return false;
|
||||
const int slots = static_cast<int>(
|
||||
Game::Read<uint32_t>(bagInv, Offsets::OFF_INVMGR_SLOT_COUNT));
|
||||
if (slots <= 0)
|
||||
return false;
|
||||
|
||||
// A specialty bag only accepts its own family, and Turtle leaves the
|
||||
// raw field empty on bags — `BitmaskForRecord` derives it from the
|
||||
// container subclass, so a quiver reports a family here rather than
|
||||
// reading as general-purpose. Shared with `C_Item.GetItemFamily`, so
|
||||
// a bag and the items it holds agree.
|
||||
uint32_t family = 0;
|
||||
const int bagItemID = Item::ID::FromCGItem(bagItem);
|
||||
if (bagItemID > 0) {
|
||||
auto *rec = Item::PeekRecord(static_cast<uint32_t>(bagItemID));
|
||||
if (rec != nullptr)
|
||||
family = Item::BagFamily::BitmaskForRecord(rec);
|
||||
}
|
||||
*out = {bagInv, 0, slots, family};
|
||||
return true;
|
||||
}
|
||||
|
||||
const uint8_t *ItemAt(const BagView &v, int slotIndex0) {
|
||||
auto *guids = *reinterpret_cast<const uint64_t *const *>(
|
||||
v.invMgr + Offsets::OFF_INVMGR_GUID_ARRAY);
|
||||
if (guids == nullptr)
|
||||
return nullptr;
|
||||
const uint64_t guid = guids[v.firstLinear + slotIndex0];
|
||||
if (guid == 0)
|
||||
return nullptr;
|
||||
return static_cast<const uint8_t *>(
|
||||
Object::ByGuid(Offsets::TYPEMASK_ITEM, guid, "SortBags", 0));
|
||||
}
|
||||
|
||||
uint32_t StackCount(const uint8_t *item) {
|
||||
auto *fields = Item::ObjectFields(item);
|
||||
if (fields == nullptr)
|
||||
return 1;
|
||||
const uint32_t n =
|
||||
Game::Read<uint32_t>(fields, Offsets::OFF_DESCRIPTOR_STACK_COUNT);
|
||||
return n == 0 ? 1 : n;
|
||||
}
|
||||
|
||||
// --- phase one: ask the server to consolidate -------------------------------
|
||||
|
||||
// Returns true if any packet went out. Every stack after the first of
|
||||
// its item ID is offered to the server, which merges what fits and
|
||||
// rehomes the rest.
|
||||
//
|
||||
// When the item's record is cached we skip the ones that provably cannot
|
||||
// stack, purely to save a packet. When it is NOT cached we send anyway:
|
||||
// the alternative is silently skipping a merge we could not size, which
|
||||
// is the failure this whole design avoids.
|
||||
// One stack per group of the same item, as gathered for either merge.
|
||||
struct Stack {
|
||||
const uint8_t *item;
|
||||
int bag, slot;
|
||||
uint32_t count;
|
||||
};
|
||||
struct Group {
|
||||
uint32_t itemID;
|
||||
uint32_t maxStack; // 0 when the item's data has not arrived
|
||||
std::vector<Stack> stacks;
|
||||
};
|
||||
|
||||
// Collects every stackable item across `bags`, grouped by item ID.
|
||||
// `maxStack` is 0 for an item whose data has not arrived; the callers
|
||||
// differ in what they do about that.
|
||||
std::vector<Group> GatherGroups(const int *bags, int bagCount) {
|
||||
std::vector<Group> groups;
|
||||
for (int b = 0; b < bagCount; ++b) {
|
||||
const int bag = bags[b];
|
||||
BagView view;
|
||||
if (!ViewForBag(bag, &view))
|
||||
continue;
|
||||
for (int i = 0; i < view.slotCount; ++i) {
|
||||
const uint8_t *item = ItemAt(view, i);
|
||||
if (item == nullptr)
|
||||
continue;
|
||||
const int itemID = Item::ID::FromCGItem(item);
|
||||
if (itemID <= 0)
|
||||
continue;
|
||||
const uint32_t id = static_cast<uint32_t>(itemID);
|
||||
|
||||
uint32_t maxStack = 0;
|
||||
auto *rec = Item::PeekRecord(id);
|
||||
if (rec != nullptr) {
|
||||
maxStack =
|
||||
Game::Read<uint32_t>(rec, Offsets::OFF_ITEMSTATS_STACKABLE);
|
||||
if (maxStack <= 1)
|
||||
continue; // cannot stack at all, so never a merge candidate
|
||||
}
|
||||
|
||||
Group *group = nullptr;
|
||||
for (auto &candidate : groups) {
|
||||
if (candidate.itemID == id) {
|
||||
group = &candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (group == nullptr) {
|
||||
groups.push_back({id, maxStack, {}});
|
||||
group = &groups.back();
|
||||
}
|
||||
group->stacks.push_back({item, bag, i + 1, StackCount(item)});
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
// Why this is computed here rather than delegated to the server's own
|
||||
// autostore, which can merge a stack into several destinations for one
|
||||
// packet and would otherwise be cheaper:
|
||||
//
|
||||
// `CMSG_AUTOSTORE_BAG_ITEM` names its destination with a container byte,
|
||||
// and the only value the engine's builder will emit for "the player" is
|
||||
// `INVENTORY_SLOT_BAG_0` (255). Server-side that is NOT the search-every-
|
||||
// bag path — that one is `NULL_BAG` (0), which the builder cannot
|
||||
// express. 255 takes the specific-container branch, which searches the
|
||||
// keyring and the backpack and nothing else. So autostore can never
|
||||
// merge a stack in one equipped bag with a stack in another, which is
|
||||
// most of the real cases.
|
||||
//
|
||||
// Addressing both endpoints explicitly costs a packet per transfer
|
||||
// instead of per emptied source — occasionally one more, when a stack
|
||||
// has to be split across several destinations — and works everywhere.
|
||||
|
||||
// Bank consolidation has to be computed here. `CMSG_AUTOSTORE_BANK_ITEM`
|
||||
// always CROSSES the inventory/bank boundary — a bank-sourced item
|
||||
// travels out to the bags — so there is no "find this a fuller stack
|
||||
// inside the bank" packet to delegate to. This is the one place the
|
||||
// protocol forces a client-side merge, and it carries the cost that
|
||||
// comes with that: it needs each item's max stack size, which is
|
||||
// unavailable until that item's data has arrived, so a stack we cannot
|
||||
// size is left alone.
|
||||
//
|
||||
// Two rolling pointers per item: stacks fullest-first, then poured from
|
||||
// the back into the front until they meet.
|
||||
bool Consolidate(const int *bags, int bagCount) {
|
||||
std::vector<Group> groups = GatherGroups(bags, bagCount);
|
||||
|
||||
bool fired = false;
|
||||
for (auto &group : groups) {
|
||||
if (group.stacks.size() < 2)
|
||||
continue;
|
||||
|
||||
// Pack fully, not just enough to free a slot: 9 and 2 with a cap
|
||||
// of 10 become 10 and 1, even though that still occupies two
|
||||
// slots. Topping stacks up is the point of consolidating.
|
||||
//
|
||||
// This settles after one pass and does not re-fire on the next
|
||||
// call, because a maximally packed group has every stack full
|
||||
// except the last — so `space == 0` walks `lo` straight up to
|
||||
// `hi` and nothing is sent.
|
||||
std::sort(group.stacks.begin(), group.stacks.end(),
|
||||
[](const Stack &a, const Stack &b) { return a.count > b.count; });
|
||||
size_t lo = 0, hi = group.stacks.size() - 1;
|
||||
while (lo < hi) {
|
||||
const uint32_t space = group.maxStack - group.stacks[lo].count;
|
||||
if (space == 0) {
|
||||
++lo;
|
||||
continue;
|
||||
}
|
||||
const bool wholeStack = space >= group.stacks[hi].count;
|
||||
const uint32_t moved =
|
||||
wholeStack ? group.stacks[hi].count : space;
|
||||
|
||||
// Choose the packet from OUR model, never from the item's
|
||||
// live stack count. The whole batch goes out in one frame,
|
||||
// so no reply has arrived and every descriptor still holds
|
||||
// its original count while the model has moved on. Asking
|
||||
// the descriptor would send a split for what is really the
|
||||
// last of a stack, and the server rejects a split that would
|
||||
// empty its source ("Couldn't split those items").
|
||||
const bool sent =
|
||||
wholeStack
|
||||
? Item::Swap::ContainersFrom(
|
||||
group.stacks[hi].item, group.stacks[hi].bag,
|
||||
group.stacks[hi].slot, group.stacks[lo].bag,
|
||||
group.stacks[lo].slot)
|
||||
: Item::Swap::MoveCountFrom(
|
||||
group.stacks[hi].item, group.stacks[hi].bag,
|
||||
group.stacks[hi].slot, group.stacks[lo].bag,
|
||||
group.stacks[lo].slot, static_cast<int>(moved));
|
||||
if (sent) {
|
||||
fired = true;
|
||||
++g_mergePackets;
|
||||
// Only a whole-stack transfer empties its source, and
|
||||
// `mergesPending` means "sources that should be empty by
|
||||
// now but are not".
|
||||
if (wholeStack)
|
||||
g_mergeSources.push_back(
|
||||
{group.stacks[hi].bag, group.stacks[hi].slot});
|
||||
}
|
||||
group.stacks[lo].count += moved;
|
||||
group.stacks[hi].count -= moved;
|
||||
if (group.stacks[hi].count == 0)
|
||||
--hi; // that stack is gone
|
||||
else
|
||||
++lo; // this one is full
|
||||
}
|
||||
}
|
||||
return fired;
|
||||
}
|
||||
|
||||
// --- phase two: plan a layout and fire the swaps ----------------------------
|
||||
|
||||
// Counters from the most recent Place(), read back by
|
||||
// `_classicapi_SortBagsStats()`. They exist to tell apart the three ways
|
||||
// a pass can fail to converge: swaps the engine refused to send, items
|
||||
// the planner found no cell for, and a plan that was complete and fully
|
||||
// sent yet still left the bags unsorted (which would mean a swap did
|
||||
// something other than swap).
|
||||
struct Stats {
|
||||
int entries;
|
||||
int cells;
|
||||
int pinned;
|
||||
int planned;
|
||||
int unplanned;
|
||||
int swapsSent;
|
||||
int swapsFailed;
|
||||
int sameItemAvoided;
|
||||
int generalCells; // destination cells in general-purpose containers
|
||||
int specialtyCells; // cells confined to one bag family
|
||||
int familyItems; // items that want a specialty bag
|
||||
int mergesPending; // phase-one sources still occupied at plan time
|
||||
};
|
||||
Stats g_lastRun{};
|
||||
|
||||
void Place(const int *bags, int bagCount) {
|
||||
Stats stats{};
|
||||
std::vector<Entry> items;
|
||||
std::vector<Cell> general;
|
||||
|
||||
// Vanilla items carry at most one family bit, so a flat list keyed by
|
||||
// family is enough — no bitmask intersection needed.
|
||||
struct FamilyPool {
|
||||
uint32_t family;
|
||||
std::vector<Cell> cells;
|
||||
size_t next;
|
||||
};
|
||||
std::vector<FamilyPool> specialty;
|
||||
|
||||
for (int b = 0; b < bagCount; ++b) {
|
||||
const int bag = bags[b];
|
||||
BagView view;
|
||||
if (!ViewForBag(bag, &view))
|
||||
continue;
|
||||
for (int i = 0; i < view.slotCount; ++i) {
|
||||
const int slot = i + 1;
|
||||
const uint8_t *item = ItemAt(view, i);
|
||||
|
||||
const uint8_t *rec = nullptr;
|
||||
int itemID = 0;
|
||||
if (item != nullptr) {
|
||||
itemID = Item::ID::FromCGItem(item);
|
||||
if (itemID > 0)
|
||||
rec = Item::PeekRecord(static_cast<uint32_t>(itemID));
|
||||
if (rec == nullptr) {
|
||||
++stats.pinned;
|
||||
continue; // pinned: unrankable, and its slot is withheld
|
||||
}
|
||||
}
|
||||
|
||||
if (view.family == 0) {
|
||||
general.push_back({bag, slot});
|
||||
} else {
|
||||
FamilyPool *pool = nullptr;
|
||||
for (auto &p : specialty) {
|
||||
if (p.family == view.family) {
|
||||
pool = &p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (pool == nullptr) {
|
||||
specialty.push_back({view.family, {}, 0});
|
||||
pool = &specialty.back();
|
||||
}
|
||||
pool->cells.push_back({bag, slot});
|
||||
}
|
||||
|
||||
if (item == nullptr)
|
||||
continue; // an empty slot contributes a cell and nothing else
|
||||
|
||||
Entry e{};
|
||||
e.item = item;
|
||||
e.curBag = bag;
|
||||
e.curSlot = slot;
|
||||
e.destBag = -1;
|
||||
e.destSlot = -1;
|
||||
e.itemID = static_cast<uint32_t>(itemID);
|
||||
e.family = Item::BagFamily::BitmaskForRecord(rec);
|
||||
e.itemClass = Game::Read<uint32_t>(rec, Offsets::OFF_ITEMSTATS_CLASS);
|
||||
e.subClass = Game::Read<uint32_t>(rec, Offsets::OFF_ITEMSTATS_SUBCLASS);
|
||||
e.count = StackCount(item);
|
||||
e.name = *reinterpret_cast<const char *const *>(
|
||||
rec + Offsets::OFF_ITEMSTATS_NAME);
|
||||
e.category = CategoryFor(
|
||||
e.itemID, e.itemClass,
|
||||
Game::Read<uint32_t>(rec, Offsets::OFF_ITEMSTATS_QUALITY));
|
||||
items.push_back(e);
|
||||
}
|
||||
}
|
||||
|
||||
stats.entries = static_cast<int>(items.size());
|
||||
stats.generalCells = static_cast<int>(general.size());
|
||||
for (const auto &p : specialty)
|
||||
stats.specialtyCells += static_cast<int>(p.cells.size());
|
||||
stats.cells = stats.generalCells + stats.specialtyCells;
|
||||
for (const auto &e : items)
|
||||
if (e.family != 0)
|
||||
++stats.familyItems;
|
||||
|
||||
// Any source phase one asked the server to empty that is still
|
||||
// occupied means this snapshot predates some of the merges.
|
||||
for (const auto &src : g_mergeSources) {
|
||||
for (const auto &e : items) {
|
||||
if (e.curBag == src.bag && e.curSlot == src.slot) {
|
||||
++stats.mergesPending;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (items.empty()) {
|
||||
g_lastRun = stats;
|
||||
return;
|
||||
}
|
||||
|
||||
std::sort(items.begin(), items.end(), Precedes);
|
||||
|
||||
// Right-to-left is expressed entirely as reversed cell lists: the
|
||||
// passes below still fill from the "front" and the "far end", those
|
||||
// ends have just swapped which physical slot they mean. So the
|
||||
// sorted run starts at the last slot of the last bag and junk
|
||||
// collects at the first slot of the first, both flipping together.
|
||||
if (RightToLeft()) {
|
||||
std::reverse(general.begin(), general.end());
|
||||
for (auto &p : specialty)
|
||||
std::reverse(p.cells.begin(), p.cells.end());
|
||||
}
|
||||
|
||||
// Forward pass for everything but junk: a matching specialty bag
|
||||
// first, overflowing into the general cells. Junk then fills the
|
||||
// general cells from the far end, so it collects away from the rest
|
||||
// and the two passes cannot claim the same cell.
|
||||
size_t genFront = 0;
|
||||
size_t genBack = general.size();
|
||||
for (auto &e : items) {
|
||||
if (e.category == kCatJunk)
|
||||
continue;
|
||||
Cell *cell = nullptr;
|
||||
if (e.family != 0) {
|
||||
for (auto &p : specialty) {
|
||||
if (p.family == e.family && p.next < p.cells.size()) {
|
||||
cell = &p.cells[p.next++];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cell == nullptr && genFront < genBack)
|
||||
cell = &general[genFront++];
|
||||
if (cell == nullptr)
|
||||
continue; // nowhere left to put it; leave it where it is
|
||||
e.destBag = cell->bag;
|
||||
e.destSlot = cell->slot;
|
||||
}
|
||||
for (auto it = items.rbegin(); it != items.rend(); ++it) {
|
||||
if (it->category != kCatJunk)
|
||||
continue;
|
||||
if (genBack <= genFront)
|
||||
break;
|
||||
const Cell &cell = general[--genBack];
|
||||
it->destBag = cell.bag;
|
||||
it->destSlot = cell.slot;
|
||||
}
|
||||
|
||||
// NEVER plan a swap between two stacks of the same item. The server's
|
||||
// swap handler tries a MERGE first and only falls through to a real
|
||||
// swap if the merge is impossible: if the two counts fit one stack it
|
||||
// collapses them into a single slot, and if they do not it
|
||||
// redistributes the counts and leaves both items where they are.
|
||||
// Neither is a swap, so the bookkeeping below would be wrong from
|
||||
// that point on and every later move in the batch would compound it.
|
||||
//
|
||||
// Two stacks of one item are interchangeable, though, so this costs
|
||||
// nothing: within each same-item run, hand each destination cell to
|
||||
// whichever member already occupies it. The run is contiguous because
|
||||
// identical items agree on every key ahead of count.
|
||||
for (size_t i = 0; i < items.size();) {
|
||||
size_t j = i;
|
||||
while (j < items.size() && items[j].itemID == items[i].itemID)
|
||||
++j;
|
||||
for (size_t m = i; m < j; ++m) {
|
||||
for (size_t n = i; n < j; ++n) {
|
||||
if (n == m)
|
||||
continue; // its own destination matching is just "home"
|
||||
if (items[n].destSlot < 0)
|
||||
continue; // unassigned; nothing to trade
|
||||
if (items[n].destBag == items[m].curBag &&
|
||||
items[n].destSlot == items[m].curSlot) {
|
||||
std::swap(items[m].destBag, items[n].destBag);
|
||||
std::swap(items[m].destSlot, items[n].destSlot);
|
||||
++stats.sameItemAvoided;
|
||||
break; // m now targets the slot it is already in
|
||||
}
|
||||
}
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
|
||||
// Fire the batch. Each entry carries its live position, so the plan
|
||||
// stays valid as slots shuffle and nothing re-reads the bags. The
|
||||
// occupant of a destination takes the mover's old slot, which is what
|
||||
// the engine's swap does anyway.
|
||||
for (auto &e : items) {
|
||||
if (e.destSlot < 0) { // see the sentinel note on Entry
|
||||
++stats.unplanned;
|
||||
continue;
|
||||
}
|
||||
++stats.planned;
|
||||
if (e.destBag == e.curBag && e.destSlot == e.curSlot)
|
||||
continue;
|
||||
Entry *occupant = nullptr;
|
||||
for (auto &o : items) {
|
||||
if (&o != &e && o.curBag == e.destBag && o.curSlot == e.destSlot) {
|
||||
occupant = &o;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const int fromBag = e.curBag, fromSlot = e.curSlot;
|
||||
if (!Item::Swap::ContainersFrom(e.item, fromBag, fromSlot, e.destBag,
|
||||
e.destSlot)) {
|
||||
++stats.swapsFailed;
|
||||
continue;
|
||||
}
|
||||
++stats.swapsSent;
|
||||
e.curBag = e.destBag;
|
||||
e.curSlot = e.destSlot;
|
||||
if (occupant != nullptr) {
|
||||
occupant->curBag = fromBag;
|
||||
occupant->curSlot = fromSlot;
|
||||
}
|
||||
}
|
||||
g_lastRun = stats;
|
||||
}
|
||||
|
||||
// --- sequencing -------------------------------------------------------------
|
||||
|
||||
enum Phase { kIdle, kAwaitingMerge };
|
||||
Phase g_phase = kIdle;
|
||||
|
||||
// Which set the in-flight sort is arranging, so the deferred placement
|
||||
// pass knows what to walk. Points at one of the two static lists, so
|
||||
// there is nothing to own or free.
|
||||
const int *g_activeBags = nullptr;
|
||||
int g_activeBagCount = 0;
|
||||
|
||||
void OnBagUpdate() {
|
||||
if (g_phase != kAwaitingMerge)
|
||||
return;
|
||||
// Clear the phase BEFORE placing: the swaps we are about to fire will
|
||||
// signal this callback again next frame, and we must be idle by then
|
||||
// or the sort would restart forever.
|
||||
g_phase = kIdle;
|
||||
Place(g_activeBags, g_activeBagCount);
|
||||
}
|
||||
|
||||
const Bag::UpdateDelayed::AutoSubscribe _bagSub{&OnBagUpdate};
|
||||
|
||||
// Shared entry point. Bags and bank differ only in the list.
|
||||
void StartSort(const int *bags, int bagCount) {
|
||||
if (g_phase != kIdle)
|
||||
return; // a sort is already in flight
|
||||
g_activeBags = bags;
|
||||
g_activeBagCount = bagCount;
|
||||
g_mergePackets = 0;
|
||||
g_mergeSources.clear();
|
||||
if (Consolidate(bags, bagCount))
|
||||
g_phase = kAwaitingMerge; // let the merges land, then place
|
||||
else
|
||||
Place(bags, bagCount); // nothing to merge; the layout reads clean
|
||||
}
|
||||
|
||||
// `C_Container.SortBags()` / `C_Container.SortBankBags()` — no
|
||||
// arguments, no return, matching the modern signatures. Fire-and-forget:
|
||||
// the work spans at least two frames. A call while a sort is already in
|
||||
// flight is ignored rather than queued, so a held-down keybind cannot
|
||||
// stack them up — and neither can sorting bags and bank at once.
|
||||
// Runs a sort over `bags`, dropping `excluded` when its autosort setting
|
||||
// says to leave that container alone. Dropping a bag from the set is all
|
||||
// it takes: the snapshot, the comparator and the placement only ever see
|
||||
// the bags they are handed, so an excluded bag is neither read from nor
|
||||
// written to.
|
||||
void StartSortExcluding(const int *bags, int count, int excluded,
|
||||
bool excludeIt) {
|
||||
if (!excludeIt) {
|
||||
StartSort(bags, count);
|
||||
return;
|
||||
}
|
||||
std::vector<int> kept;
|
||||
kept.reserve(static_cast<size_t>(count));
|
||||
for (int i = 0; i < count; ++i) {
|
||||
if (bags[i] != excluded)
|
||||
kept.push_back(bags[i]);
|
||||
}
|
||||
if (!kept.empty())
|
||||
StartSort(kept.data(), static_cast<int>(kept.size()));
|
||||
}
|
||||
|
||||
int __fastcall Script_C_Container_SortBags(void * /*L*/) {
|
||||
StartSortExcluding(kInventoryBags,
|
||||
sizeof(kInventoryBags) / sizeof(kInventoryBags[0]),
|
||||
/*excluded*/ 0, BackpackAutosortDisabled());
|
||||
return 0;
|
||||
}
|
||||
|
||||
int __fastcall Script_C_Container_SortBankBags(void * /*L*/) {
|
||||
StartSortExcluding(kBankBags, sizeof(kBankBags) / sizeof(kBankBags[0]),
|
||||
/*excluded*/ -1, BankAutosortDisabled());
|
||||
return 0;
|
||||
}
|
||||
|
||||
// `_classicapi_SortBagsStats()` -> entries, cells, pinned, planned,
|
||||
// unplanned, swapsSent, swapsFailed, sameItemAvoided — from the last
|
||||
// placement pass. Diagnostic only; not part of the API surface.
|
||||
int __fastcall Script_SortBagsStats(void *L) {
|
||||
Game::Lua::PushNumber(L, g_lastRun.entries);
|
||||
Game::Lua::PushNumber(L, g_lastRun.cells);
|
||||
Game::Lua::PushNumber(L, g_lastRun.pinned);
|
||||
Game::Lua::PushNumber(L, g_lastRun.planned);
|
||||
Game::Lua::PushNumber(L, g_lastRun.unplanned);
|
||||
Game::Lua::PushNumber(L, g_lastRun.swapsSent);
|
||||
Game::Lua::PushNumber(L, g_lastRun.swapsFailed);
|
||||
Game::Lua::PushNumber(L, g_lastRun.sameItemAvoided);
|
||||
Game::Lua::PushNumber(L, g_lastRun.generalCells);
|
||||
Game::Lua::PushNumber(L, g_lastRun.specialtyCells);
|
||||
Game::Lua::PushNumber(L, g_lastRun.familyItems);
|
||||
Game::Lua::PushNumber(L, g_mergePackets);
|
||||
Game::Lua::PushNumber(L, g_lastRun.mergesPending);
|
||||
return 13;
|
||||
}
|
||||
|
||||
// `_classicapi_SortBagsBags(which)` -> bagID, slotCount, family for each
|
||||
// bag that resolves in that set (0 = the inventory set, 1 = the bank
|
||||
// set). A bag that does not resolve is skipped entirely, so a missing ID
|
||||
// in the output is itself the answer. Diagnostic only.
|
||||
int __fastcall Script_SortBagsBags(void *L) {
|
||||
const bool bank = Game::Lua::IsNumber(L, 1) &&
|
||||
static_cast<int>(Game::Lua::ToNumber(L, 1)) == 1;
|
||||
const int *bags = bank ? kBankBags : kInventoryBags;
|
||||
const int count = bank
|
||||
? sizeof(kBankBags) / sizeof(kBankBags[0])
|
||||
: sizeof(kInventoryBags) / sizeof(kInventoryBags[0]);
|
||||
int pushed = 0;
|
||||
for (int b = 0; b < count; ++b) {
|
||||
BagView view;
|
||||
if (!ViewForBag(bags[b], &view))
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, bags[b]);
|
||||
Game::Lua::PushNumber(L, view.slotCount);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(view.family));
|
||||
pushed += 3;
|
||||
}
|
||||
return pushed;
|
||||
}
|
||||
|
||||
// `C_Container.SetSortBagsRightToLeft(enable)` — no return, matching the
|
||||
// modern signature. Takes effect on the next sort; it does not re-sort.
|
||||
int __fastcall Script_SetSortBagsRightToLeft(void *L) {
|
||||
CVar::Factory::SetInt(g_rightToLeftCVar,
|
||||
Game::Lua::ToBoolean(L, 1) != 0 ? 1 : 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// `C_Container.GetSortBagsRightToLeft()` -> isEnabled
|
||||
int __fastcall Script_GetSortBagsRightToLeft(void *L) {
|
||||
Game::Lua::PushBool(L, RightToLeft());
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Container.SetBackpackAutosortDisabled(disable)` /
|
||||
// `C_Container.SetBankAutosortDisabled(disable)` — keep that container
|
||||
// out of `SortBags` / `SortBankBags`. No return, matching the modern
|
||||
// signatures; the paired getters report the stored setting.
|
||||
int __fastcall Script_SetBackpackAutosortDisabled(void *L) {
|
||||
CVar::Factory::SetInt(g_backpackAutosortCVar,
|
||||
Game::Lua::ToBoolean(L, 1) != 0 ? 1 : 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetBackpackAutosortDisabled(void *L) {
|
||||
Game::Lua::PushBool(L, BackpackAutosortDisabled());
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_SetBankAutosortDisabled(void *L) {
|
||||
CVar::Factory::SetInt(g_bankAutosortCVar,
|
||||
Game::Lua::ToBoolean(L, 1) != 0 ? 1 : 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetBankAutosortDisabled(void *L) {
|
||||
Game::Lua::PushBool(L, BankAutosortDisabled());
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
// Dedups by name, so re-registering each reload is harmless.
|
||||
g_rightToLeftCVar = CVar::Factory::Register("sortBagsRightToLeft", "0");
|
||||
g_backpackAutosortCVar =
|
||||
CVar::Factory::Register("backpackAutosortDisabled", "0");
|
||||
g_bankAutosortCVar = CVar::Factory::Register("bankAutosortDisabled", "0");
|
||||
|
||||
Game::Lua::RegisterTableFunction("C_Container", "SortBags",
|
||||
&Script_C_Container_SortBags);
|
||||
Game::Lua::RegisterTableFunction("C_Container", "SetSortBagsRightToLeft",
|
||||
&Script_SetSortBagsRightToLeft);
|
||||
Game::Lua::RegisterTableFunction("C_Container", "GetSortBagsRightToLeft",
|
||||
&Script_GetSortBagsRightToLeft);
|
||||
Game::Lua::RegisterTableFunction("C_Container", "SortBankBags",
|
||||
&Script_C_Container_SortBankBags);
|
||||
Game::Lua::RegisterTableFunction("C_Container", "SetBackpackAutosortDisabled",
|
||||
&Script_SetBackpackAutosortDisabled);
|
||||
Game::Lua::RegisterTableFunction("C_Container", "GetBackpackAutosortDisabled",
|
||||
&Script_GetBackpackAutosortDisabled);
|
||||
Game::Lua::RegisterTableFunction("C_Container", "SetBankAutosortDisabled",
|
||||
&Script_SetBankAutosortDisabled);
|
||||
Game::Lua::RegisterTableFunction("C_Container", "GetBankAutosortDisabled",
|
||||
&Script_GetBankAutosortDisabled);
|
||||
Game::Lua::RegisterGlobalFunction("_classicapi_SortBagsStats",
|
||||
&Script_SortBagsStats);
|
||||
Game::Lua::RegisterGlobalFunction("_classicapi_SortBagsBags",
|
||||
&Script_SortBagsBags);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Container::SortBags
|
||||
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user