Compare commits

...
21 Commits
Author SHA1 Message Date
Brues a2d25cef8b fix(object): gate ByGuid on the registry root to survive out-of-world calls
The engine resolver FUN_OBJECT_RESOLVE_BY_GUID funnels unconditionally
into FUN_00464890, whose first action loads the registry root from
[VAR_LOCAL_PLAYER_PTR] and derefs its +0x24 hash mask with no null
check. That root is NULL out of world (login, loading screens, logout
teardown), so any resolve reaching it mid-transition dies with ERROR
#132 (MOV EAX,[EDX+0x24], EDX=0) - hit in the wild by Aura::Source's
SMSG_SPELL_GO path, whose Store/Claim eviction consults
DescriptorListsAura while packets still dispatch during transitions.

Promote the readiness predicate from Unit::TokenObserver's private
RegistryReady into Object::RegistryReady (object/Resolve.h) and gate
ByGuid on it, returning nullptr out of world. That is both the
contract ByGuid's comment already promised ("pre-world ... safe from
any context") and provably behavior-preserving: a zero result is the
engine's own handled "not found" case (verified in Ghidra - 0x468460
null-checks the node; 0x464870 itself returns 0 for guid==0), and out
of world the call had no non-crashing outcome. Every ByGuid call site
already null-checks. TokenObserver now delegates to the shared helper.

Fixes #34
2026-08-24 08:43:03 -05:00
Brues c3c79356be fix(tooltip): clear per-tooltip OnTooltipSet* cells on reload
Tooltip::SetEvents kept its handler cells in a fixed 32-entry table keyed
by tooltip pointer, assuming tooltips are permanent. They are not: every
/reload (and logout) destroys and recreates the tooltip objects, so the
table gained ~2 dead cells per reload with no eviction. After ~18 reloads
it filled, the resolver co-hook returned 0 for new tooltips, and
SetScript/HookScript failed with "doesn't have a 'OnTooltipSetItem'
script". Worse, a stale cell whose address the allocator recycled into a
new tooltip fired its old handler ref into the reset Lua registry - a
dead-epoch ref, garbage call, crash.

Add Tooltip::SetEvents::PrepareForReload() - a full cell-table clear -
and call it from FrameScript_Initialize_h alongside the other per-reload
resets. The clear is semantically right, not just leak plumbing: handler
refs never survive the Lua reset, so post-reload every cell held garbage
regardless; addons re-register in OnLoad and get fresh cells.

Fixes #33
2026-08-24 08:17:12 -05:00
PeapandBrues 6ae3299041 Add Secure Action Button Templates and Comma-Split Support (#32)
* feat: port SecureActionButtonTemplate + CreateFrame comma-split

- Hook Script_CreateFrame (0x007060B0) to split comma-separated template
  strings. Vanilla only accepts a single template name; modern addons pass
  lists like 'UIPanelButtonTemplate, SecureActionButtonTemplate'.

- Add SecureTemplates.xml registering SecureFrameTemplate,
  SecureActionButtonTemplate, SecureUnitButtonTemplate, and
  InsecureActionButtonTemplate as real virtual XML templates.

- Add SecureTemplates.lua with modifier/button attribute resolution helpers
  (SecureButton_GetModifiedAttribute, GetModifierPrefix, GetButtonSuffix,
  GetModifiedUnit) and OnClick dispatchers.

- Remove duplicate SecureButton_GetAttribute/GetUnit from SecureStateDriver.lua
  (now provided by SecureTemplates.lua which loads earlier).

- Include SecureTemplateTest debug addon for verification.

* Include AuraUtil.lua in ClassicAPI.toc

mistake from merge

* fix(frame): apply every template in a CreateFrame comma list

Vanilla 1.12 CreateFrame resolves the 4th arg as a single template name.
The initial comma-split hook only applied one template, and its tokenizer
dropped everything after the first entry for the common "A, B" format
(writing the NUL terminator clobbered the delimiter the advance step
checked). Rework it into real multi-inheritance:

- SplitTemplates: record whether a comma was hit before writing the
  terminator, so "A, B" / "A,,B" / leading-comma inputs all split.
- CreateFrame_h: build the frame with the first resolved template through
  the engine, then graft each remaining template onto the created object
  with the engine's own inherit primitive - content (frameObj->vtable[+8],
  FUN_00769820) and child <Frames> ((frameObj+0x24)->vtable[+8],
  FUN_0076a060), the two __thiscall calls the builder FUN_006ee280 emits
  per inherited node. Regions, backdrop, attributes, script handlers, and
  child frames all apply, in left-to-right override order; OnLoad runs once.
- Offsets: add FUN_FRAMESCRIPT_STATUS_RESET (the XML-build error
  accumulator reset, __thiscall) and document the status object layout.

Also fix the relocated SecureButton_GetUnit/GetModifiedUnit pet-token
pattern: "[^%%d]"/"[%%d]" matched literal % and d instead of the digit
class %d, so raid1pet never normalized to raidpet1. Restore single %d.

Verified in-game: a comma list applies both templates' regions/text in
either order; pet-suffix normalization works.

* refactor(secure): tidy SecureTemplates review follow-ups

- Guard RunMacroText (absent on this client) so a type=macro button with
  only a macrotext attribute no-ops instead of erroring.
- Honor a SecureUnitButton_OnLoad menufunc: the menu/togglemenu action now
  calls self.menu when set, else falls back to ClassicAPI_ToggleUnitMenu
  (self.menu was previously stored but never read).
- InsecureActionButtonTemplate now inherits SecureActionButtonTemplate
  instead of duplicating its OnClick block.
- Drop the committed SecureTemplateTest debug addon; test harnesses live in
  the WoW install, not the repo.

* refactor(secure): make Frame::Attributes the single click dispatcher

The Lua SecureActionButton_OnClick from the secure-templates work
duplicated the native C++ dispatcher and only backfilled the verbs C++
lacked. Fold those into DispatchVerb so C++ is the sole dispatcher for
every secure button (template or plain SetAttribute), and drop the Lua
redundancy.

- DispatchVerb gains `action` (UseAction), `pet` (CastPetAction), `click`
  (forward to the frame in the `clickbutton` attribute via
  delegate:Click), and a custom fallback (a function stored as a raw
  field on the frame named by the verb). `menu`/`togglemenu` now cancels
  spell-targeting when a spell is on the cursor (the old
  SecureUnitButton_OnClick nuance).
- Numeric spell IDs work as the `spell` attribute: SetAttribute("spell",
  id) (a Lua number) or a numeric string cast the EXACT rank by ID via
  the spellbook-slot resolver (Spell::AtUnit::CastByID at a unit,
  Spell::AtCursor::DispatchSpellCast with none) - not name resolution,
  which fired the highest known rank.
- CopyAttr coerces a number-valued attribute to its digits, so numeric
  IDs also read for `item` and any other string attribute.
- Remove SecureActionButton_OnClick / SecureUnitButton_OnClick from
  SecureTemplates.lua, simplify SecureUnitButton_OnLoad, and strip the
  <OnClick> from the templates (plain Button templates now; the C++
  closure installs on SetAttribute("type*")). The attribute-reader
  helpers stay - public API and SecureStateDriver depend on them.

* fix(frame): restore the Lua stack after the multi-template graft

The comma-template graft can run a grafted template's child-frame OnLoad,
and in this hook-saturated environment a nested Lua op can shift the stack
slot the caller reads its return value from. Snapshot lua_gettop after the
engine build and restore it once the graft finishes, so the caller always
reads back the frame the engine left. Mirrors the Tooltip::SetEvents
save/restore discipline.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-08-23 18:15:27 -05:00
Brues f97379795d Localize UnitAura call for performance 2026-08-23 13:45:02 -05:00
Brues 3c6b449df5 feat(aura): zero-alloc positional accessors + AuraUtil
C_UnitAuras.GetAuraDataByIndex builds a fresh Lua table per call, churning
Lua 5.0's mark-sweep GC when addons scan many units' auras in an OnUpdate
loop. Add Blizzard's own answer: positional (multi-return) accessors plus the
AuraUtil helper library, so hot loops allocate nothing.

DLL: C_UnitAuras.UnitAura / UnitBuff / UnitDebuff return the 15-value
Classic-Era UnitAura tuple with no table allocation. Built on a shared
Emit { Table, Positional } seam through PushEnriched -- selection, cache
fallback, out-of-range group, and timing resolution are the same code as the
table path; only the terminal leaf differs (BuildTable vs the new
PushPositional, both fed by an extracted ResolveDisplay). Namespaced under
C_UnitAuras so the native UnitBuff/UnitDebuff globals keep their vanilla
short return.

Addon: Util/AuraUtil.lua backports ForEachAura / FindAura / FindAuraByName /
UnpackAuraData over those accessors. ForEachAura (non-packed) and FindAura
both source from the positional accessor, so they allocate nothing per aura --
FindAura included, where retail itself allocates a table per aura scanned.

Verified in-game: positional values match GetAuraDataByIndex field-by-field
(#12 isBossDebuff / #13 castByPlayer differ by design, matching retail's
UnitAura-vs-AuraData divergence); ForEachAura counts match GetUnitAuras; and
the positional path allocates ~0 KB/scan where the table path allocates
~1.3 KB per aura.

Fixes #30
2026-08-23 12:16:53 -05:00
Brues f000fd6ed7 build(release): emit matched PDB and ship crash symbols as a zip
Add /Zi (compiler) + /DEBUG (linker) so the Release build produces a
line-level ClassicAPI.pdb alongside the existing linker map. /OPT:REF and
/OPT:ICF stay explicit so /DEBUG does not re-enable dead code and the
shipped DLL does not grow; the PDB is a separate file matched to the DLL
by signature.

CI release runs now bundle the pdb + map into one crash-symbols zip
attached to the release, replacing the loose renamed map. The zip name is
kept unappealing to normal players and carries the tag so a dev can match
symbols to the exact build.
2026-08-23 10:40:03 -05:00
Brues 7960266969 feat(addon): add [known] macro conditional to SecureCmdOptionParse
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 via GetSpellName, since
1.12 has no name-to-id resolver -- so a name matches castable spellbook
entries only.

Verified in-game: [known:133] false on a non-Mage, [known:45651] and
[known:Aspect of the Viper] both true by id and by name.
2026-08-23 02:42:48 -05:00
Brues 85ed4eb378 feat(addon): backport SecureStateDriver + SecureCmdOptionParse
RegisterStateDriver / RegisterUnitWatch / attribute drivers, driven by a
Lua SecureCmdOptionParse whose macro-conditional grammar is reverse-
engineered from the 3.3.5 client. Builds entirely on the shipped frame
attribute system -- no DLL change.

- MacroOptions.lua: parse-once cached SecureCmdOptionParse. 33 conditions
  mapped to 1.12 / ClassicAPI queries; states 1.12 lacks (flying, vehicle)
  are constant-false, an unknown keyword warns once and never matches.
- SecureStateDriver.lua: 0.2s-poll manager with instant event rescans,
  state-visibility Show/Hide, state-* attribute drivers, unit-existence
  watch, SecureButton_GetAttribute/GetUnit. Registers via plain tables +
  an immediate top-level resolve instead of the upstream attribute
  bootstrap, so the synchronous OnAttributeChanged recursion guard never
  swallows a target frame's handler.

Verified in-game: parser grammar, both drivers, and druid [stance:N] bar
numbering.
2026-08-23 02:05:35 -05:00
Brues 7f1e632b49 feat(texture): add MaskTexture object API with multi-mask and rotation
Backport frame:CreateMaskTexture plus texture:AddMaskTexture /
RemoveMaskTexture / GetNumMaskTextures / GetMaskTexture -- positioned,
rect-based masks, the object form later clients use (SetMask(path) stays
the simple single-shape form).

Positioned masks sample via texgen (object-space vertex position through
a per-unit texture matrix), the mechanism of the engine's own minimap
disc mask, so no vertex UV stream is consumed per mask. That lifts both
v1 limits at once:
- Up to 7 masks apply at once (device stage cap); a texture shows only
  the intersection of all of them.
- mask:SetRotation rotates the clip shape (folded into the matrix), and a
  mask can move/resize each frame, so the clip animates.

State is restored in a co-hook on the indexed submit (FUN_GX_PRIM_INDEXED)
the engine issues after the stream call, not inside the stream hook --
restoring earlier reverts the state the queued draw is flushed with.

Two incidental fixes: a SetTexCoord-cropped base now masks against the
same rect the engine's corner store uses, and a recycled mask region is
detached from every base still holding it.

Offsets.h: add the texgen / coord-transform / texture-matrix / state-journal
/ indexed-submit constants (decoded from the D3D9 backend state flush
FUN_005a2f00), and fix the FUN_GX_TEXUNIT_ENABLE/_DISABLE mislabel (they
are texture-matrix push/pop, not stage enable/disable).
2026-08-22 23:19:33 -05:00
Brues 71d193073a docs(readme): merge modern-behavior highlights; add nameplate and focus rows
Fold the thin "Modern text rendering" and "Modern addon loading" sections into
one "Modern client behaviors" table, and showcase two more event-driven
features:
- Event-driven nameplates: the C_NamePlate API + NAME_PLATE_UNIT_ADDED/REMOVED
  events + nameplateN unit tokens.
- Focus target: FocusUnit/ClearFocus, PLAYER_FOCUS_CHANGED, the focus /
  focustarget tokens, and the predefined FOCUSTARGET / TARGETFOCUS keybinds.

Highlights is now two groups: Lua 5.1 compatibility and Modern client behaviors.
2026-08-22 19:33:54 -05:00
Brues eda44e6d2d docs(readme): drop the SavedVariables highlight; move Installation below the reference
Two README tweaks:
- Remove the "SavedVariables loaded first" row from the Modern addon loading
  highlights -- compatibility plumbing, not a showcase feature. Still fully
  documented in docs/API.md.
- Move Installation below Full API reference, so the page leads with the whole
  showcase (features + the complete API surface) before install steps.
2026-08-22 19:22:00 -05:00
Brues 81f04c82ad docs(readme): trim Lua 5.1 highlights to the features worth showcasing
Drop the Table lengths and Environment protection rows from the Lua 5.1
compatibility table -- both are compatibility plumbing, not features anyone
installs for. They stay fully documented in docs/API.md. The showcase now
lists Syntax, String methods, and Script-handler arguments.
2026-08-22 19:15:21 -05:00
Brues 54516ad22b docs(readme): note the function/event count in the intro
Mention the scale up front (550+ functions and 50+ events) and bump the Calls
catalog summary from the stale "400+" to match. Counts are approximate on
purpose so they don't go stale on every addition.
2026-08-22 19:10:25 -05:00
Brues 7e784005b9 docs(readme): de-stiffen the intro and section blurbs
Rewrite the prose the restructure introduced so it reads naturally instead of
like a controlled-language manual: merge the intro's clipped two-sentence
opener, drop the "transparent / no API to call / addons use it automatically"
clunk from the Highlights blurb, and replace "The complete surface." in the API
reference intro. No structural or content changes.
2026-08-22 19:05:26 -05:00
Brues d12ff75c6c docs(readme): lead with Lua 5.1 highlights, collapse the full API catalog
Restructure so the flagship features surface first. The page used to open
with a ~60-row function catalog and buried the headline behaviors (Lua 5.1,
inline textures, modern addon loading) in a table at the very bottom.

- Promote the former "Behaviors" table to a top "Highlights" section,
  reordered Lua-5.1-first (syntax, string methods, table lengths, environment
  protection, script-handler arguments), then modern text rendering, then
  modern addon loading. No rows dropped.
- Move Installation up, ahead of the reference.
- Group the full catalog under "Full API reference" with each table (Calls,
  GlueXML, Macros, Console, Events, Globals, Unit tokens, Bindings) behind a
  <details> block. Content is byte-for-byte unchanged -- every function,
  event, global, and token stays in the file and all docs/API.md links are
  intact.
2026-08-22 18:56:35 -05:00
Brues 76043fcdef fix(compat): stub WorldMapDeathRelease for Cartographer under the 5.1 probe
Cartographer_Notes gates a WorldMap release-spirit hook behind the Ace2-era
`lua51` probe (loadstring("return function(...) return ... end")), treating a
5.1-capable Lua as a modern client:

    if lua51 then
        WorldMapDeathRelease:SetScript("OnMouseUp", func)
    end

Our 5.1 syntax backport makes that probe pass, so Cartographer takes the modern
branch and hooks WorldMapDeathRelease -- a WorldMap frame 1.12 never had. The
nil index crashes Cartographer_Notes:OnEnable. On real vanilla the probe was
false and the line never ran.

Same 5.1-probe fallout as the Dewdrop table-length heal: making the client pass
5.1 probes makes addons assume modern-client frames exist. Provide an inert,
hidden WorldMapDeathRelease stub (gated on Cartographer being installed) so the
hook succeeds and does nothing -- the vanilla outcome. The embedded
!!!ClassicAPI addon loads first, so the frame exists before Cartographer's
OnEnable.
Fixes #29
2026-08-22 18:30:48 -05:00
Brues b9d23fbc8c feat(texture): add Texture:SetMask(path) alpha-mask backport
Backports the modern Texture:SetMask, clipping a texture to a mask's alpha
shape - round portraits, round minimap buttons, and other shaped art. Vanilla
1.12 has no general masking (only the special-cased minimap disc).

Mirrors the engine's own masked-quad draw (the minimap blip mask FUN_004eae10):
a co-hook on the vertex-stream primitive FUN_0058a2a0 recovers the owning
CSimpleTexture from the batch positions pointer (region = positions -
OFF_SIMPLETEXTURE_CORNERS), and for a region with a mask set binds the mask on
texture unit 1 with a MODULATE combiner, then re-runs the engine's own submit
with uv1 = uv0 so unit 1 samples the mask at the texture's own coordinates. The
unit is enabled/disabled around the draw so the mask never leaks onto other
textures. A cold co-hook on the region ctor clears a region's mask on pooled
reuse; a kill switch and SEH latch guard the draw path.

The recipe was pinned by an in-game probe on the live GL backend and matched
against FUN_004eae10; the full RE is in the "Texture masking" block in Offsets.h
and the Mask.cpp header. Masks must be BLP or uncompressed 32-bit TGA with the
shape in the alpha channel; two round masks ship in the client, other shapes are
addon-supplied content.
2026-08-22 17:14:36 -05:00
Brues a2c51a1ae6 fix(inline-texture): place chat icons pre-render to remove the one-frame lag
A ScrollingMessageFrame re-SetTexts every visible line on each new message, and
the engine builds each line lazily at paint. The icon regions are separate
objects whose placement is only known once a line builds mid-paint - after the
frame's texture pass already drew - so on a content change the new glyphs
appeared one frame before their icons caught up.

Co-hook the SMF display refresh (FUN_00788750), which runs pre-render from the
frame's own methods. After it, force each icon-bearing line to resolve+build via
the engine's own Realize(0) -> RebuildString -> ensure-built path and apply its
icon placement synchronously, before the frame draws. The placement math
(ComputePlacementsForNode) and the per-fs apply (ApplyDirtyBranch) are shared
with the existing paint-flush / FrameTick pipeline, so the later flush dedups to
a no-op.

Additive and best-effort: any line whose rect is not resolvable in time falls
back to the unchanged paint + tick pipeline (correct, one-frame lagged). Gated by
a new _classicapi_InlineTexEarly toggle (default on) with an SEH latch; the whole
feature keeps its _classicapi_InlineTexEnable kill switch.
2026-08-22 13:40:36 -05:00
Brues d5c8db9c2e docs+ci: contract-only table-length docs; rename release map asset
* README behaviors row + API.md section for the 5.1 table-length
  healing now describe the behavior (cleared tables count as empty
  without table.setn; arg tables and setn callers unaffected) instead
  of the implementation and the Dewdrop/FuBar bug story -- that history
  lives in the commit message and the Length.cpp comment.

* Release workflow ships the linker map as
  USE_FOR_DEBUGGING_CAPI_CRASHES.map (copy before upload), so users
  know to attach it to crash reports -- it resolves the logical
  addresses in an ERROR #132 stack trace to function names.
2026-08-22 12:13:25 -05:00
Brues 9c7c8dd8e5 fix(lua): clear the token vector between transpile passes + guard the hook
Tokenize never cleared its output vector, and RunPasses re-lexes into
the SAME vector after each pass that rewrites the buffer -- so every
chunk that triggered two rewriting passes walked a DOUBLED token
stream: stale tokens at old-buffer positions, then fresh tokens
starting back at 0. Two field-real failure modes:

* CRASH (hex pass -> vararg pass): a fresh token's t.start lands
  behind the splice cursor, `t.start - p` underflows as size_t, and
  append() throws std::length_error("string too long") straight
  through the loadbuffer hook into the engine -- fatal ERROR #132 at
  RaiseException. Reported from the field on WeakAuras' bundled
  Chomp\Internal.lua (hex BITS table + varargs); reproduced offline
  byte-for-byte with a standalone harness compiled from this file
  against the actual upstream Internal.lua.

* SILENT CORRUPTION (vararg pass -> ops pass): the same doubled stream
  made the operator pass read stale in-string positions as code -- a
  `%` INSIDE a string literal became __mod, emitting syntactically
  invalid output (diff-proven: Chomp's `channel:gsub("^[^%:]+", ...)`
  shipped as `channel:__mod(__mod(gsub("^[^,:]+",)) ...)`). Such files
  failed to compile behind WoW's soft error() -- dead file, half-loaded
  addon, no visible error. Any chunk combining `...` with `#`/`%` was
  at risk.

Fixes:
1. Tokenize clears at entry (the root fix). Verified on the real
   Chomp file: full pipeline transpiles cleanly -- hex -> exact
   decimals, (...) -> (unpack(arg)), #q -> __len(q), string-literal
   `%` untouched, the previously-mangled gsub line correct.
2. Catch-all guards at both hook boundaries (LoadBuffer_h and the
   _classicapi_Transpile diagnostic): any future C++ exception from
   the rewrite machinery falls back to compiling the ORIGINAL buffer
   untouched -- worst case the chunk behaves as stock 1.12, never a
   process kill.
2026-08-22 11:42:55 -05:00
Brues 813a10f696 fix(lua): heal stale 5.0 table lengths for 5.1-detecting libraries
The 5.1 syntax backport made the Ace2-era Lua-version probe pass:

    local lua51 = loadstring("return function(...) return ... end")
                      and true or false

Every Ace2 library gates on it, and Dewdrop-2.0 (the menu library
behind FuBarPlugin minimap buttons -- SuperAPI, NampowerSettings) uses
it to no-op table.setn: correct on real 5.1, where lengths are computed
borders, but wrong on this VM, whose table functions still read 5.0's
out-of-band length. Cleared-and-reused menu tables kept stale lengths,
table.insert appended past the nil'd slots, and the menus silently
broke ("clicking the minimap button does nothing"). Bisect confirmed:
forcing the probe back to 5.0 (VarargExpansion off) restores them.

Since we made the client PASS 5.1 probes, 5.1 length semantics must
hold. The engine funnels every length read through luaL_getn
(LUAL_GETN = 0x6F5050 -- table.insert/getn/remove/concat/sort/foreachi,
plus our unpack), so one co-hook closes the gap for every consumer:

  1. reported slot populated -> unchanged (one rawgeti of cost);
  2. t[n] nil but t.n is a number -> unchanged: an explicit n field is
     the 5.0 vararg-arg contract, where trailing nils are intentional
     (unpack({10,nil,30,n=3}) keeps all three slots);
  3. otherwise the out-of-band length is stale (keys cleared without
     setn) -> return the true border, the 5.1 answer. Read-only, no
     write-back: reads never mutate state, and the engine's own
     table.insert re-syncs the stored length on the next append.

table.setn keeps working; the heal only changes the answer when the
stored length points past the last populated slot -- exactly the case
where 5.0 and 5.1 disagree.

Docs: API.md "Stale table lengths (5.1 healing)" section + README
behaviors row.

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