Compare commits

...
34 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
Brues 7225eeb32c feat(lua): close the remaining 5.0->5.1 stdlib gaps
Systematic sweep of the Lua 5.1 stdlib delta against what ClassicAPI
already backports found five gaps, all confirmed by in-game probes:

* unpack(list [, i [, j]]) -- 5.0's unpack silently ignored the range
  args, so unpack(t, 2) returned the WHOLE table (wrong values, no
  error; the only silent gap). Replaced with a port of 5.1's
  luaB_unpack: ranges via luaL_getn + lua_rawgeti, so a vararg arg
  table's n field keeps its embedded-nil count. Both states.

* string.gsub table replacement -- 5.0 accepts only string/function
  and errors on a table. Shim on string.gsub AND the bare gsub global:
  a table becomes an adapter closure over the engine's own gsub
  (FUN_LUA_STR_GSUB = 0x007FD0E0, pinned by registration-table
  position, name string, and decode). Because this engine's add_value
  drops a non-string function result (match -> empty) where 5.1 keeps
  the match on nil/false, the shim wraps the pattern in one outer
  capture (^/$ anchors kept outside) so the closure receives the whole
  match and can hand it back for missing keys -- exact 5.1 keep-match.
  Patterns with a %1..%9 backref skip the wrap (renumbering hazard);
  their missing-key behavior stays the engine's (documented residual).

* collectgarbage 5.1 options -- "count" -> gcinfo, "collect" ->
  original collectgarbage(0), "step" -> loop-safe true, stop/restart/
  setpause/setstepmul -> no-op 0, numbers forwarded untouched. The
  originals are function objects stashed in the state's registry
  before the overwrite, so no new engine offsets and each /reload
  re-stashes the fresh engine original. Both states.

* table.maxn -- 5.1 luaB_maxn port (largest positive numeric key,
  full next-walk so nil holes don't stop it). Both states.

* coroutine.running -- returns the running coroutine, nil on the main
  thread. 5.0 has no lua_pushthread, so the thread TValue (tag 8 +
  the running L, this build's {tt@0, value@+8} layout) is written to
  the stack raw; main-thread detection via the registration-time L.

Verified in-game: ranged/holed/n-field unpack shapes, all four gsub
shapes (populated table, keep-match on missing key, whole-match key,
anchored pattern), maxn past a nil hole, collectgarbage("count"/
"collect"), and coroutine.running from main + inside a wrap.

Docs: five new API.md sections + TOC entries; README Lua list updated.
2026-08-22 01:46:24 -05:00
Brues 5a1d94c001 feat(lua): harden string methods -- kill switch + fail-closed captures
Follow-up hardening for the 5.1 string-method backport (9b9d80d):

* _classicapi_SetStringMethods(false) -- diagnostic kill switch on both
  states, mirroring _classicapi_SetTranspileOption. While off the hook
  forwards everything to the original resolver: exact stock behavior.

* Close both stale-capture windows by clearing each slot BEFORE its
  state can die, instead of relying on "no Lua runs in the window":
  - game slot: PrepareForReload (new StringMethods.h), called from
    FrameScript_Initialize_h before the world Lua state is reset --
    covers /reload AND /logout;
  - glue slot: cleared at the top of the in-game registration -- once
    world scripts are loading the glue state is gone, and the next glue
    boot re-captures before any glue Lua runs, so a recreated glue
    global_State at the same address can never match a stale capture.
  A cleared slot fails closed (stock "attempt to index" error) until
  its re-capture.

* StringLib.cpp: replace the obsolete "method sugar can't work" header
  NOTE -- both call forms resolve through the same string table now.

* docs: kill-switch bullet in the API.md String methods section.
2026-08-22 00:57:26 -05:00
Brues 1536741594 docs(readme): list Lua 5.1 string methods in the behaviors table
Follow-up to 9b9d80d, which added the API.md section but missed the
README's behaviors table where the other Lua 5.1 backports are listed.
2026-08-22 00:14:40 -05:00
Brues 9b9d80d807 feat(lua): backport Lua 5.1 string methods (("s"):upper(), s:format(...))
Lua 5.0 has no string methods at all -- the shared string metatable with
__index = string is a 5.1 addition, and both halves of the would-be
mechanism in this build match stock 5.0 exactly: lua_setmetatable
(0x006F4020) accepts only tables/userdata, and luaT_gettmbyobj
(0x006F7BD0) hardcodes &nilobject for every other type. So nothing can
be re-enabled; the 5.1 read path has to be added.

Transplant 5.1's mechanism at the one choke every string index reaches:
co-hook FUN_LUA_INDEX_NONTABLE (0x006F7EE0, luaV_gettable's non-table
continuation; 3 call sites -- the gettable else-branch plus
luaV_execute's OP_GETTABLE/OP_SELF fallbacks). When the indexed object
is a string, answer with luaV_gettable(L, <string library table>, key,
loop) -- byte-for-byte what the original's own TM-table branch does had
gettmbyobj returned that table, loop passed through unchanged. The
engine's unmodified lookup then yields exact 5.1 semantics, including
nil (not an error) for unknown keys. Non-strings fall through to the
original; settable on a string is a different continuation, untouched.

The site is cold: table __index dispatch (all frame/OO traffic) reads
the table's own metatable via fasttm (FUN_006F7D60) and never lands
here -- today's only callers are about-to-error non-table indexes.

One captured slot per Lua state (in-game + glue), keyed by global_State
so coroutine threads resolve too; each capture anchors the string table
in that state's registry against GC and re-runs per /reload and per
glue boot. Since baselib/StringLib already registers string.match /
gmatch / reverse into the same table, modern idioms like
msg:match("^!(%w+)") work with no further top-up.

Docs: new API.md "String methods" section; replaced the now-obsolete
"only string.foo(s), never s:foo()" caveat under string.match with a
pointer to it.

Verified in-game: literal and variable receivers, :format, 5.1 nil-miss,
and resolution inside a coroutine thread.
2026-08-22 00:09:59 -05:00
Brues bac27b9b2b fix(chat): auto-size raid-target markers to the chat font
{rtN}/{skull}/etc. emitted a hardcoded :14:14 size, so a marker never
tracked the chat font-size setting. Emit :0:0 instead — the inline
emitter resolves height/width to the target line's font pixel height at
draw time, so markers grow and shrink with the chat font and scale up in
speech bubbles, matching retail's ICON_LIST `...Icon_%d:0`. The sheet
crop is size-independent, so cell selection is unchanged.
2026-08-21 23:06:02 -05:00
Brues 31e85731b6 Rename transpile helper API
Rename the Lua transpile entrypoint from `_classicapi_TranspileLength` to `_classicapi_Transpile` and update the backing C++ function symbol to match.
2026-08-21 22:47:47 -05:00
Brues 58ddcace32 perf(item): settle the item-cache gate on a wall-clock delay, not frames
The startup gate that defers item queries until the engine's dispatch is
ready used a fixed 60-frame count, which is FPS-dependent: on a fast
machine it could fire before dispatch was ready (orphan risk) and on a
slow login it stretched well past its intended ~1s, leaving bag icons as
"?" longer than needed.

Anchor the delay to VAR_IN_WORLD (set by the enter-world initializer,
cleared on every transition) and measure a fixed 1000 ms past world-live,
so it is identical on any machine and only starts once the loading screen
is gone. The engine-response fast-latch is untouched: a real item
response still settles the gate instantly, so a warm WDB has no delay and
only a cold WDB waits the floor. Drops the now-unused frame counter.
2026-08-21 22:25:18 -05:00
Brues 58ee86447b feat(item): proactively cache carried items on enter-world
A bag item's icon comes from its ItemStats cache entry (m_displayInfoID);
on a cold WDB an uncached bag item resolves displayID 0, and the icon
path draws INV_Misc_QuestionMark ("?"). Nothing in the bag-draw path
queries stats (GetContainerItemInfo and the icon getter both do
null-callback lookups), and the server pushes no item stats on login, so
a carried item stayed "?" until a hover, a loot, or an addon GetItemInfo
happened to query it. Equipped items are exempt (icon from the
server-pushed visible-items table).

Seed the request queue with every carried item once the startup gate
settles (one-shot per enter-world, re-armed on login and /reload) on the
existing owned-priority lane. This gives complete carried-item coverage
ahead of any addon's background flood, stays bounded by the same owned
budget so it adds no burst (issue #25), and is still deferred past
g_settled so it cannot orphan a cache entry before the engine's dispatch
is ready.
2026-08-21 22:12:48 -05:00
brues-code a4d6290b80 Lua 5.1 syntax transpile + modern addon-loading compatibility (#28)
* feat(lua): backport 5.1 `#` and `%` operators via source transpile

Vanilla's Lua is 5.0, which lacks the `#` (length) and `%` (modulo)
operators, so modern addon ports that use them fail to COMPILE. Rather than
patch the 5.0 parser/VM (no OP_LEN/OP_MOD, no free opcode), rewrite the
SOURCE before it reaches the parser.

src/luasyntax/Transpile.cpp co-hooks luaL_loadbuffer (0x006F5690) — the one
function every compile funnels through (file scripts, loadstring, XML
handlers) — tokenizes each chunk with a small Lua-aware lexer, and runs a
precedence-climbing parser that rewrites, in one pass:

    #operand  ->  __len(operand)
    a % b     ->  __mod(a, b)

`#` is prefix but `%` is binary infix, so its operands are delimited by
PRECEDENCE: a*b%c -> __mod(a*b,c), a+b%c -> a+__mod(b,c), a%b%c chains
left-assoc as __mod(__mod(a,b),c), a%b^c -> __mod(a,b^c). The two operators
compose in the same pass (#t%2 -> __mod(__len(t),2)). No newlines are
inserted, so error line numbers are preserved.

Runtime helpers (registered on both the in-game and glue states):
 - __len: string length, or a table border via bisection (5.1 `#`
   semantics — ignores any table.setn count).
 - __mod: a - floor(a/b)*b. NOT the engine's math.mod, which is C fmod
   (truncated toward zero) and disagrees with `%` for negatives
   (-1 % 3 == 2, but fmod(-1,3) == -1).

Safety: the rewrite runs only when a chunk contains `#`/`%`, and either as an
operator already fails to compile on 5.0 — so it cannot regress working code
except by misreading one inside a string/comment ("%d", "%a+", --[[ # ]]).
The lexer's string/comment skipping is the one safety-critical part and is
exact. Gated behind default-on toggles (_classicapi_SetLengthOperator /
SetModuloOperator); diagnostic _classicapi_TranspileLength returns the
rewrite. `...` is a separate, larger phase.

Adds FUN_LUAL_LOADBUFFER to Offsets.h with its derivation.

* feat(lua): honor the 5.1 `__environment` metatable field in getfenv/setfenv

Lua 5.1 lets a sandbox hide an environment: put `__environment` in the env
table's metatable and `getfenv` returns that value instead of the real env,
while `setfenv` refuses to change it. 1.12's engine already has this
protection but reads a RAW `__fenv` field on the env table itself — the older
5.0 form. Modern addons ported from 5.1 use the metatable `__environment`
spelling, which 1.12 silently ignored, so their sandboxes leaked.

getfenv (0x00702AC0) and setfenv (0x00702BF8) share one predicate
(0x00702BA0) that reads the marker and, when present, substitutes it / errors
"cannot change a protected environment". src/luaenv/Fenv.cpp co-hooks and
fully replaces that predicate to read `rawget(getmetatable(env),
"__environment")` first, falling back to the raw `env.__fenv` field. One hook
updates both consumers. The reimplementation preserves the predicate's exact
stack contract (push env, push marker, net +2, boolean return) that getfenv
(settop -2) and setfenv (settop -3) depend on; both metatable and env lookups
are RAW, so an `__index` metatable on the env is not disturbed.

The 5.1 mechanism was verified against 3.3.5's `FUN_0084F2F0` (raw
`__environment` off the metatable; no `"__fenv"` string exists there — Blizzard
renamed it and moved it to the metatable at the 5.0->5.1 jump). The change is
additive and inert: nothing on disk sets either marker, so every existing
getfenv/setfenv caller keeps getting the real env — it only activates when
code deliberately marks an environment.

Adds LUA_GET_METATABLE, LUA_GET_FENV, and FUN_LUA_ENV_PROTECT_PREDICATE to
Offsets.h.

* Use Lua length operator for class count

* feat(lua): backport the 5.1 `...` vararg expression via source transpile

This build is pure Lua 5.0 varargs: `function(...)` parses and populates the
`arg` table (with `arg.n`), but `...` used as an EXPRESSION is a parse error
("unexpected symbol near '...'") — verified in-game. So modern 5.1 addon code
using `...` (`{...}`, `local a,b = ...`, `f(...)`, `return ...`,
`select('#', ...)`) fails to compile.

Add a vararg pass to the transpiler (RewriteChunk runs it before the # / %
precedence parser) that rewrites the `...` EXPRESSION to `unpack(arg)`. This is
a faithful, position-independent substitution: `unpack(arg)` is itself a
multi-value expression, so it spreads in tail position and truncates to one
value elsewhere exactly as `...` does, and this build's `unpack(arg)` preserves
embedded nils via `arg.n` (verified: `f(1,nil,3)` -> `1 nil 3`). No precedence
analysis needed — unlike `%`, it's a token substitution once one case is
excluded: the `...` in a function's PARAMETER LIST (the vararg declaration that
creates `arg`) is left intact, found by scanning each `function` keyword and
skipping its param parens. Nested vararg functions work for free — each
`function(...)` makes its own `arg` and `unpack(arg)` binds to the nearest one
(verified: inner arg count 2, outer 3).

Safety mirrors # / %: only chunks containing `...` are touched, and `...` as an
expression already fails to compile on 5.0, so it can't regress working code;
`...` inside strings/comments is skipped by the lexer, and `.` (field) / `..`
(concat) are distinguished by run length. Gated behind the default-on
`_classicapi_SetVarargExpansion` toggle; `_classicapi_TranspileLength` now shows
the full rewrite (vararg + # + %).

File-scope `...` (the `local name, addon = ...` idiom) transpiles fine but only
yields values if the client passes varargs to addon chunks — a loader question,
separate from this syntax rewrite.

* feat(lua): provide addon-args so file-scope `local name, ns = ...` works

Modern addons open files with `local name, ns = ...` — the addon name plus a
per-addon shared namespace table, passed by the loader as the chunk's varargs.
Vanilla 1.12 never passed those (its main chunk has no `arg`), so with the
`...` backport in place the idiom resolved to `unpack(nil)` and errored. This
supplies the args ourselves.

In the luaL_loadbuffer hook, for an addon-file chunk (chunkname
`@Interface\AddOns\<Name>\...`, parsed case-insensitively) that uses a real
`...`, prepend a chunk-local `arg = {"<Name>", __addonns("<Name>"), n=2}`. The
existing `... -> unpack(arg)` pass then makes `local name, ns = ...` yield the
name and the namespace table. The preamble is newline-free (line numbers
preserved), placed after any UTF-8 BOM, and only injected for AddOns\ chunks
(FrameXML and loadstring are untouched); the addon name is rejected if it
contains characters that would break the string literal.

`__addonns(name)` returns the per-addon table — created once and returned
identically for every file of the addon (backed by a name->table map in the
Lua registry), matching modern WoW's shared second vararg. Registered in-game.

Verified in-game: an addon file's `local name, ns = ...` yields
`("<AddonName>", <table>)` with `ns == __addonns(name)` (shared across the
addon's files). File-scope `...` was the last piece; the in-function case was
already handled by the `...` pass.

* feat(lua): add C_AddOns.GetAddOnLocalTable(name)

Public alias of the addon-args namespace: returns the same per-addon table the
`local name, ns = ...` idiom yields — create-once and shared across every file
of the addon, backed by the same registry map as the internal `__addonns`
(both call Script_AddonNS). Lets code fetch an addon's namespace by name.

Registered via RegisterTableFunction on C_AddOns (non-destructive; coexists
with the existing C_AddOns.* functions regardless of module init order).

Currently ungated — it returns a table for any name, create-on-demand. TODO #99
records the follow-up to match retail: return nil unless the addon is loaded
AND its TOC declares `## AllowAddOnTableAccess: 1`, gating only this public
entry point while leaving the internal preamble path ungated.

* feat(lua): gate C_AddOns.GetAddOnLocalTable behind loaded + TOC opt-in

GetAddOnLocalTable was a raw alias of the addon-args namespace accessor:
it returned any addon's private table by name, loaded or not, with no
access control. Retail (11.0) gates this -- one addon reads another's
local table only when the target opts in via the `## AllowAddOnTableAccess`
TOC directive. Mirror that.

The public accessor now lives in addons/LocalTable.cpp and returns the
table only when the named addon is loaded (FUN_ADDON_IS_LOADED, by-name)
AND its .toc declares `## AllowAddOnTableAccess: <nonzero>` (scanned via
FUN_FILE_READ, read as a TOC boolean flag). Either check failing returns
nil. Directive spelling and function name both verified present in the
1.15.8 Classic Era binary.

The internal preamble path (an addon receiving its OWN table via the
injected `local _, ns = ...`) stays ungated -- an addon never needs
permission for its own namespace. Both paths now resolve to the same
per-addon table through LuaSyntax::PushAddonNamespace (AddonNamespace.h),
the single owner of the registry-backed name->table map.

Also close the bypass in the `__addonns` global itself. The map lives in
the Lua registry (unreachable from Lua -- this build ships no
debug.getregistry / debug introspection), so `__addonns` was the sole
Lua-visible door, and it was ungated. It is now context-gated: it answers
only while an addon's own chunk is mid-load, for its own name, one-shot.
At runtime it denies every name. Verified safe against the loader: the
addon file loader is strictly per-file load-then-run
(FUN_006edb90 -> FUN_006ede10 -> FUN_00704bc0 -> FUN_00704ae0, where
luaL_loadbuffer is immediately followed by lua_pcall), so nothing runs
between the grant being set and the preamble consuming it.

Net result: the only runtime path to another addon's local table is the
TOC-gated GetAddOnLocalTable.

* fix(lua): close the __addonns loadstring-forgery vector

The addon-args grant (`g_loadingAddon`) that lets a chunk's injected
preamble fetch its own namespace via the `__addonns` global was armed
whenever luaL_loadbuffer saw an `Interface\AddOns\...` chunkname with a
`...`. That chunkname is forgeable: `loadstring(s, chunkname)` takes a
caller-controlled chunkname (arg 2) and compiles WITHOUT running, so

  loadstring("return ...", "@Interface\AddOns\Victim\x")
  __addonns("Victim")   -- returned Victim's private table

armed the grant and leaked another addon's local table, with no
`## AllowAddOnTableAccess` opt-in.

Fix: arm the grant only when the loadbuffer call comes from the engine's
immediate-run file funnel FUN_00704AE0 (it pcalls the chunk right after
compiling, so the preamble consumes the grant before anything else runs).
The hook checks `_ReturnAddress()` against RET_LUA_FILE_COMPILE
(0x00704B11, the instruction after that funnel's `call luaL_loadbuffer`).
`loadstring` (FUN_00703280) and `RunScript` (FUN_00704C70) call from
different return addresses, so neither can arm — even a loadstring nested
inside a RunScript body that itself runs through the file funnel. The two
callers that DO reach 0x00704B11 (FUN_00704bc0 DoFile, FUN_00704cd0
inline-XML-script) both supply engine-built chunknames, not caller data.
Verified in-game: forged loadstring compiles but yields nil.

Also fix two lexer/scan bugs found in review:
- SkipNumber treated e/E as an exponent marker in hex literals, so
  `0xE-1 % 2` lexed `0xE-1` as one token and rewrote to __mod(0xE-1,2)
  (=1) instead of 0xE-(1%2) (=13). Hex literals now use p/P only.
- The addon-path marker matched a backslash separator only while its
  own terminator scan accepted both; it now matches `addons\` or
  `addons/`, consistent with addons/Embedded.cpp.

* refactor(addons,lua): dedup CI helpers, share TOC scan, table-driven toggles

Review cleanups, behavior-preserving:

- Drop the hand-rolled LowerAscii/EqualsCI copies in luasyntax/Transpile.cpp
  and addons/LocalTable.cpp for `_stricmp`/`_strnicmp` (the codebase norm).
- Add addons/Toc.h `FindValue(buf, size, directive, ...)` — the `## Key:`
  line-start scan in one place. Embedded.cpp (Version) and LocalTable.cpp
  (AllowAddOnTableAccess) both consume it; the file-read stays per-caller
  since Embedded's is entangled with its disk-vs-embedded decision.
- Collapse the six per-switch transpile toggle functions to a {name, bool*}
  table plus one `_classicapi_SetTranspileOption(name, bool)` /
  `_classicapi_GetTranspileOption(name)` pair (name = Length / Modulo /
  VarargExpansion). Nothing outside Transpile.cpp referenced the old names.

* feat(addons): load multi-flavor addons via _Vanilla/_Classic TOC selection

Modern addons target several game flavors from one folder by shipping
`<Name>_Vanilla.toc` / `_Mainline.toc` / `_Cata.toc` / … and frequently no
plain `<Name>.toc`. Vanilla 1.12 only ever opens `<Name>\<Name>.toc`, so such
an addon never registers -- it is invisible, not merely mis-loaded.

Backport the retail client's flavor-file selection at the file-read layer.
The scan's TOC parser FUN_0051C9B0 reads Interface\AddOns\<Name>\<Name>.toc
via FUN_FILE_READ (0x00648620) and registers the addon only if that read
succeeds; the load pass and GetAddOnMetadata read the same path. We already
co-hook FUN_FILE_READ for the embedded addon, so FileRead_h now calls
AddOns::FlavorToc::TryHandle in its non-!!!ClassicAPI branch: for a base-TOC
read it probes <Name>_Vanilla.toc then <Name>_Classic.toc via the original
reader and serves the first that exists in place of the base (the parser
passes outSize=NULL, so it just forwards args with a different path).

Precedence mirrors retail: flavor wins over an existing base, and _Vanilla
beats _Classic. Does NOT fall back to _Mainline/_Cata/etc -- an addon shipping
only those doesn't support vanilla and stays unlisted. Folder enumeration
already lists flavor-only folders, so only the TOC read needed redirecting.

Verified in-game: a folder with only FlavorTest_Vanilla.toc registers and
loads; GetAddOnInfo returns the _Vanilla title/notes.

* feat(addons): prefer _Turtle.toc flavor on Turtle-lineage clients

Extend multi-flavor TOC selection with a _Turtle.toc suffix, a ClassicAPI
extension (not a retail flavor) for addons targeting Turtle's server content.
Probe order becomes _Turtle -> _Vanilla -> _Classic, with _Turtle offered
first and only when Turtle::Detected() is true, so stock 1.12 never prefers
Turtle-only code.

The redirect fires at both the registration scan (glue/char-select) and the
in-world load pass, which must select the same flavor -- so detection has to
read consistently at glue-scan time and in-world. The in-world signal
(TURTLE_WOW_VERSION, engine-set) is absent on the glue state, so Turtle::Detect
now also recognizes Turtle's glue-time TURTLE_* GlueXML string globals
(AUTH_TURTLE_WEBSITE / TURTLE_ARMORY / TURTLE_KNOWLEDGE_DATABASE, set in
GlueStrings.lua at glue boot, before the scan). Clones keep those names and
only rebrand the URL values, so name presence is clone-robust where realmList
is not. Detected() now latches on either signal and works in both states;
Version() still fills from TURTLE_WOW_VERSION once in-world.

Verified in-game: a folder shipping only FlavorTest2_Turtle.toc registers and
loads on Turtle (GetAddOnInfo returns the _Turtle title), while the earlier
_Vanilla-only test still works.

* feat(addons): narrow flavor TOC selection to _Turtle + _ClassicAPI

Drop `_Vanilla`/`_Classic` from the flavor probe list. Those are the 1.15
Classic Era client's suffixes -- a modern engine with the full modern API and
modern FrameXML templates. Adopting them told a multi-flavor addon "I'm 1.15",
so its `_Vanilla` build would assume APIs/templates this 1.12 + partial-backport
client doesn't fully have, load, and then break. A clean non-load is better.

Select only the two flavors that describe THIS environment, most specific first:
  _Turtle      -- Turtle client/content, only when Turtle::Detected();
  _ClassicAPI  -- 1.12 + ClassicAPI, always (ClassicAPI is present by
                  definition -- it is the code doing the redirect).
Both are ClassicAPI conventions the addon author opts into, not an attempt to
impersonate a retail flavor. If neither exists, TryHandle returns false and the
caller does the normal base read, which chains through any other FUN_FILE_READ
hooker before reaching the engine.

Verified in-game: a folder with only `<Name>_ClassicAPI.toc` registers/loads; a
`_Turtle`-only folder registers on Turtle; a `_Vanilla`-only folder no longer
registers (correctly, now that `_Vanilla` is not a selected flavor).

* feat(addons): backport per-line TOC directives (conditions + variables)

Single-TOC multi-flavor addons gate individual file-reference lines and
expand path variables:

    [Family]\Init.lua                              -> Classic\Init.lua
    Vanilla.lua        [AllowLoadGameType vanilla]  kept
    Mainline.lua       [AllowLoadGameType mainline] dropped
    Localization\[TextLocale].lua                  -> Localization\enUS.lua

Vanilla 1.12's TOC readers understand none of it: the scan parser
FUN_0051C9B0 only parses `##` metadata, and the load pass FUN_006edb90
hands each file line to FUN_006ede10 verbatim, where a trailing ` [...]`
breaks the extension check so the file silently never loads.

New module AddOns::TocRewrite rewrites the TOC buffer at the FUN_FILE_READ
chokepoint (alongside FlavorToc/Embedded), so the engine's own load loop
sees clean paths and both parsers are covered by one edit.

On this client the tokens resolve to: [AllowLoadGameType] keeps iff the
list has `vanilla`; [AllowLoadTextLocale] keeps iff it has the client
locale; [AllowLoad] keeps `game` and drops `glue`; [Family]->Classic,
[Game]->Vanilla, [TextLocale]->the client locale code. Locale comes from
VAR_LOCALE_NAME_TABLE (0x008558A4), the exact source Script_GetLocale
reads, so it matches GetLocale() byte-for-byte.

Gated to ...\AddOns\...\*.toc reads (a .lua `t[1]` would otherwise be
mangled); bracket-free TOCs stay zero-copy via a memchr bail; a rewritten
TOC goes into a fresh Storm buffer the engine frees like its own. Unknown
conditions fail safe (drop the line); gated-out lines become `#` comments.

Verified in-game on Turtle: 8 keep-cases loaded, 4 drop-cases (bare
mainline, glue, deDE, unknown keyword) did not.

* docs: document the branch's Lua 5.1 and TOC compatibility features

User-facing docs for the feat/lua-syntax-transpile branch.

docs/API.md:
- New "Lua 5.1 syntax: length, modulo, and vararg" section: the source
  rewrite for `#`, `%`, and `...`-as-expression; what each form becomes
  (`#` = border, ignores table.setn; `%` = 5.1 sign-of-b, contrasted with
  math.mod); the `local name, tbl = ...` addon-args idiom; limits
  (string/comment safety, first-close long-bracket matching, preserved
  line numbers) and the diagnostic kill-switch.
- New "getfenv / setfenv environment protection" section: the 5.1
  `__environment` metatable field, with the `__fenv` fallback noted.
- New "Conditional and multi-flavor TOC loading" section under AddOns:
  `_ClassicAPI` / `_Turtle` flavor file selection and per-line
  `[AllowLoadGameType]` / `[AllowLoadTextLocale]` / `[AllowLoad]`
  conditions and `[Family]` / `[Game]` / `[TextLocale]` path variables,
  with the token resolution table and limits.
- Table-of-contents entries for all three.

README.md:
- Add C_AddOns.GetAddOnLocalTable to the AddOns calls row (was missing).
- Add three rows to the Behaviors table (Lua 5.1 syntax, Lua 5.1
  environment protection, multi-flavor & conditional TOC loading), each
  linking to its API.md section.

* feat(addons): accept multi-flavor `## Interface:` version lists

A retail TOC can list several interface versions on one line
(`## Interface: 120100, 50504, 38002, 20506, 11200`). Vanilla's parse is a
plain atoi (FUN_0064ade0) that reads only the first number into entry+0x1C,
and the loadability resolver FUN_0051e780 compares that to the client
version FUN_0051d7d0() (0x2BC0 = 11200): a mismatch is the OUT_OF_DATE
reason that blocks loading while checkAddonVersion is on. So a list whose
first entry isn't 11200 never loads even when 11200 appears later in it.

TocRewrite now special-cases the `## Interface:` line: parse the whole
list, and if the client version (read live via
FUN_ADDON_CLIENT_INTERFACE_VERSION, not hardcoded) is present but not
first, rewrite the value to that single number so the engine stores 11200
and marks the addon compatible. A list without 11200 (or already leading
with it) is left verbatim, so it stays out of date exactly as before. The
fast-path gate widened to `[` or `,`; the content-compare bail keeps
comma-bearing TOCs (e.g. `## Dependencies: A, B`) zero-copy.

Docs (API.md, README.md) describe the behavior and the 11200-only
compatibility rule.

Verified in-game on Turtle: GetAddOnInfo on an addon listing
120100,50504,38002,20506,11200 returned no reason (loaded); the ...,11509
control returned INTERFACE_VERSION (out of date).

* feat(addons): honor ## LoadSavedVariablesFirst

Modern addons use `## LoadSavedVariablesFirst: 1` so their SavedVariables
are restored before their Lua runs, letting file-scope code read config.
Vanilla always runs an addon's files first, then loads SavedVariables,
then fires ADDON_LOADED (verified in FUN_0051f240), and never parses the
directive — so file-scope SV is always nil.

New module AddOns::SavedVarsFirst co-hooks the per-addon file loader
FUN_ADDON_LOAD_FILES (FUN_006edb90). For an addon whose TOC declares the
directive, it loads the SavedVariables first, mirroring the engine's own
path construction two steps later:

  account : WTF\Account\<account>\SavedVariables\<Name>.lua
  per-char: WTF\Account\<account>\<realm>\<character>\SavedVariables\<Name>.lua
            (falling back to the realm-less path)

account = *VAR_ACCOUNT_NAME_PTR, realm = FUN_GET_REALM_NAME, character =
FUN_GET_LOGIN_ACCOUNT_NAME (whose offset label is a misnomer — it is the
character name). Then it calls the original so the files run with SV
present. FUN_FILE_EXISTS picks the same per-char variant the engine would;
FUN_LUA_LOAD_FILE no-ops on a missing file.

v1 caveat (documented): the engine still runs its own SV load right after
the files, so a file-scope READ works but a file-scope WRITE is overwritten
before ADDON_LOADED. Writes belong in an ADDON_LOADED handler, as usual.

Docs: API.md "SavedVariables loaded first" + a README behaviors row.

Verified in-game on Turtle (login -> /reload): flagged SVFirst saw its DB
at file scope (sawAtFileScope=true), control SVNormal did not (false).

* feat(addons): suppress the redundant SavedVariables re-load (LoadSavedVariablesFirst v2)

v1 loaded a flagged addon's SavedVariables before its files, but the engine
still ran its OWN SV load right after the files — overwriting any value the
addon wrote to its SavedVariable at file scope before ADDON_LOADED.

v2 co-hooks FUN_LUA_LOAD_FILE and skips that one redundant re-load per
pre-loaded path: RunLuaFile loads each SV via the trampoline (bypassing the
hook) and records its full path; LuaLoad_h matches the engine's imminent
FUN_00704bc0(svPath, 0, 0) (case-insensitive, one-shot erase) and returns 0
without loading. The SV-load call sites ignore the return and the SV file
feeds nothing into the TOC checksum, so the skip is side-effect-free.
FUN_00704bc0 loads files only (RunScript runs strings through a different
path), so the extra hook is a cold addon-load-time path.

Now file-scope reads AND writes both survive — matching true
LoadSavedVariablesFirst. Docs (API.md) drop the write-clobber caveat.

Verified in-game on Turtle (fresh SavedVariables, repeated /reload): flagged
SVFirst's file-scope write persists (runs counter climbs 2, 3, 4), control
SVNormal stays 1.

* feat(addons): flavor-specific addon Bindings (Bindings_Turtle/_ClassicAPI.xml)

The Bindings.xml analog of FlavorToc. A modern addon may ship a
flavor-specific keybinding file instead of / alongside a plain
Bindings.xml. Vanilla only ever builds Interface\AddOns\<Name>\Bindings.xml,
gates it on an exists-check (FUN_00648a30), and — only then — loads it via
FUN_004b6f70, which reads through FUN_FILE_READ. So flavor selection needs
two pieces (a FileRead redirect alone can't help a flavor-only addon,
because the exists-check is on the base name):

- AddOns::FlavorBindings::TryHandle, called from the FUN_FILE_READ hook
  after FlavorToc: for a …\AddOns\<Name>\Bindings.xml read, serve a flavor
  variant if present (flavor wins over a plain Bindings.xml). Probe order
  Bindings_Turtle.xml (Turtle only) -> Bindings_ClassicAPI.xml.
- A co-hook on FUN_FILE_EXISTS: report Bindings.xml as existing when the
  base is absent but a flavor variant is present, so the loader runs for a
  flavor-only addon. Non-Bindings paths pass straight through.

Suffixes are _Turtle / _ClassicAPI, never _Vanilla / _Classic (a _Vanilla
bindings file pairs with the 1.15 build we don't load, so its handlers
wouldn't exist). Docs: API.md flavor section + README behaviors row.

Verified in-game: on Turtle all three cases load (flavor-only ClassicAPI,
flavor-only Turtle, flavor-over-base with the flavor winning); on stock the
Turtle-only one correctly drops while the ClassicAPI ones still load.

* feat(lua): backport 5.1 hexadecimal number literals via source transpile

Lua 5.0's lexer reads only decimal digits + one '.' + 'e' exponent, so a
`0x...` literal is a compile error (hence addons resort to
`tonumber("0x...", 16)`). Lua 5.1 added hex literals. As with `#` / `%` /
`...`, we rewrite the SOURCE at the luaL_loadbuffer chokepoint: each hex
INTEGER token becomes the exact decimal it denotes (unsigned), which 5.0's
lexer accepts and reads as the same double 5.1 would.

RewriteHex runs first in the transpile pipeline, gated on a "0x"/"0X"
presence scan; it reuses the tokenizer (so strings/comments are never
touched) and inserts no newlines (line numbers preserved). Hex floats
(0x1.8p3) and > 64-bit literals are left as-is — vanishingly rare, and they
fail exactly as today (no regression). Also fixed SkipNumber to stop a
number at a `..` so `0xFF.."x"` (hex then concat) tokenizes cleanly.
Toggle: _classicapi_SetTranspileOption("HexLiterals", false).

Docs: API.md "Lua 5.1 syntax" section + README behaviors row.

Verified in-game (19/19 harness asserts) and cross-checked against retail:
0xFF->255, 0xFFFFFFFF->4294967295, bit.band(0xF0,0x0F)->0, "0xFF" untouched
all match modern WoW exactly.

* feat(frame): enable modern positional script args by default

Modern handler signatures — `function(self, delta)` / OnEvent
`(self, event, ...)` — are core Lua 5.1 behavior, so flip Frame::ScriptArgs
on by default instead of opt-in. Ports that use them now work with no
`SetModernScriptArgs(true)` call.

It's purely additive (the `this`/`arg1`/`event` globals are still set, so
vanilla-style handlers keep working). `SetModernScriptArgs(false)` remains
the escape valve — it reimplements the tail of the engine's hottest Lua
path (fires for every OnUpdate frame), and disabling it makes both runners
a straight passthrough to exact vanilla dispatch.

Docs + code comments updated from "default OFF (opt-in)" to "default ON".

* docs: surface modern script-handler arguments as a behavior

Add a README Behaviors-table row for the now-default modern positional
script arguments (function(self, delta), OnEvent (self, event, ...)),
alongside the Lua 5.1 syntax row. The SetModernScriptArgs / GetModernScriptArgs
functions stay in the Frame calls table as the off-switch.

* fix(lua): correct transpiler lexer edge cases and operator env-independence

Review findings against the Lua 5.1 syntax transpiler:

- Tokenize `.`/`..`/`...` as multi-char tokens like Lua's own lexer, so a
  `...` vararg adjacent to a `..` concat (e.g. `x .. ...`) is recognized
  instead of forming an unsplittable five-dot run that left `...`
  un-rewritten and failed to compile. Guard the `#`/`%` field-access check
  to a single-char `.` so `a..b` isn't misread as `a.b`.

- Nest level-0 `[[ ]]` long brackets in SkipLongBracket to match the engine's
  own long-string reader (FUN_00700010 keeps a nesting counter). First-close
  matching mis-identified nested-string boundaries and could rewrite `#`/`%`
  bytes the parser treats as string content.

- Capture `__len`/`__mod`/`unpack` as chunk-locals in the load-time preamble
  so the lowered `#`/`%`/`...` operators resolve under a setfenv sandbox with
  no _G fall-through, mirroring real 5.1's environment-independent operators.

- Skip precompiled bytecode (0x1B chunk signature) instead of tokenizing it
  as source and corrupting the chunk.

- Cap parse recursion at 200 (Lua's LUAI_MAXCCALLS) so a pathologically
  nested chunk fails to compile cleanly instead of overflowing the C stack
  inside the hook.

- Case-fold the addon-namespace map key in PushAddonNamespace so a
  case-mismatched C_AddOns.GetAddOnLocalTable request lands on the addon's
  real table instead of minting a fresh empty one.

The `.. ...` vararg and nested-long-bracket fixes verified in-game via
_classicapi_TranspileLength.

* fix(addons): gate LoadSavedVariablesFirst pre-load on the declared scope

LoadSavedVarsEarly chose which SavedVariables files to pre-load from file
existence alone, but the engine loads the account file only when the addon
declares `## SavedVariables` and the per-char file only when it declares
`## SavedVariablesPerCharacter`. So a stale file for a scope the addon no
longer declares got executed early (Lua the engine never runs) and queued in
g_pendingSuppress, which the engine then never drained — a leak of one entry
per stale file per /reload.

Mirror the engine: read the TOC once (ReadSvFlags, replacing
TocWantsSavedVarsFirst) for the reorder flag plus whether each SV scope is
declared, and load each file only when its directive is declared and the file
exists. Every suppress entry we queue is now a file the engine re-loads and
drains.

* fix(turtle): only trust glue markers on the glue Lua state

Probe() checked the `TURTLE_*` glue markers whenever detection wasn't yet
latched, on whatever Lua state was current — including in-world. Those names
are set by Turtle's GlueXML and are only trustworthy on the glue state; in
world any loaded addon can define a global by one of them (a Turtle-UI port
shipping the strings, say), which permanently mis-latched Turtle detection on
a stock client and skewed flavor TOC/Bindings selection for the session.

Gate the marker probe on a `g_onGlue` phase flag driven by the engine's own
per-state script-registration hooks: the in-world ModuleAutoRegister (fires
before the addon load pass) clears it, each glue boot's GlueModuleAutoRegister
sets it. Neither reads a forgeable Lua global. Turtle still detects via the
markers at the glue scan and via TURTLE_WOW_VERSION in-world; a stock client
can no longer be latched by an in-world global.

Also make Version() return nullptr (not "") in the glue-detected-but-no-version
window, matching the header contract.

* revert MAX_CLASSES

* fix(frame): only pass positional script args to handlers that declare them

Default-ON modern script args changed behavior for every handler on the
engine's hottest Lua path, and the docs' "purely additive" claim was wrong:
a handler that DECLARES a parameter received a real value where vanilla always
passed nil.

Gate the reimplemented positional path on the handler's arity: a Lua closure
that declares a parameter or is vararg (function(self, delta) / function(self,
...)) takes it; a param-less handler (the vast majority of vanilla handlers,
which read this/arg1 globals) and a C-closure handler tail-call the original
engine runner unchanged. So param-less handlers are provably identical to
vanilla AND off the hot path (also the efficiency win: OnUpdate handlers that
gain nothing no longer pay the modern tail every frame). Only param-declaring
handlers observe the (self, [event,] arg1..N) shape.

Arity is read from the handler's Proto; offsets verified against the engine's
own luaD_precall (numparams @ +0x45, is_vararg @ +0x46), lua_dump, and
lua_iscfunction/lua_tocfunction (closure @ TValue+8, isC @ +6, Proto @ +0xC).

The param-declaring-vanilla-handler case (a function reused as both a direct
call and a handler) is inherently ambiguous and still gets the value; documented
honestly in ScriptArgs.cpp, docs/API.md, and README with SetModernScriptArgs
(false) as the opt-out. Verified in-game across both runners, both paths, the
toggle, and the caveat.

* refactor(addons): centralize engine-IO typedefs and CI string helpers

The `__stdcall` FUN_FILE_READ / FUN_FILE_EXISTS / Storm-allocator function
types were copied into ~8 places across the addon files, and Lower/EqCI into
three. These are ABI-critical: a copy declared __cdecl by mistake drifts ESP
per call and kills the process with no crash log. Hoist them to one home:

- new addons/EngineIO.h — FileReadFn / FileExistsFn / SMemAllocFn / SMemFreeFn,
  used by Embedded, FlavorToc.h, FlavorBindings.h/.cpp, SavedVarsFirst,
  LocalTable, TocRewrite.
- addons/Toc.h gains Lower / EqCI, used by FlavorBindings, SavedVarsFirst,
  TocRewrite.

Also drop EvalCondition's dead `known` out-parameter in TocRewrite (nothing
read it — an unknown keyword already returns false). Pure refactor, no behavior
change.

* perf(lua): tokenize each chunk once and gate the #/% parse on an operator token

The transpiler ran on every compiled chunk at login/reload, and:
- RewriteHex / RewriteVararg / RewriteAll each re-tokenized the whole chunk, so
  a file that trips more than one pre-check was lexed two or three times;
- RewriteAll's fast-bail was a raw memchr for '%', which is present in nearly
  every chunk via format strings ("%d"), so almost every chunk paid a full
  recursive parse only to discard an empty edit list.

New RunPasses tokenizes once and shares the stream across the passes, re-lexing
only after a pass actually rewrites the buffer (rare). RewriteAll now gates the
parse on an actual '#'/'%' OPERATOR token — a '%' inside a string is a TK_STRING
token, not a punct, so the format-string case skips the parse entirely.
RunPasses also unifies the previously duplicated RewriteChunk / LoadBuffer_h
pipelines. Output is identical (verified in-game via _classicapi_TranspileLength:
vararg-next-to-concat, nested long brackets, and a format-string vs operator '%'
all rewrite exactly as before).

* feat(lua): backport 5.1 leveled long brackets; fix top-level vararg fallback

Adversarial testing of the transpiler surfaced two gaps beyond PR 28:

- Leveled long brackets ([=[ ]=], [==[ ]==]) are the one 5.1 syntax
  construct still missing. The 5.0 engine lexer has no =-level handling
  (verified in FUN_006ff610), so they fail to compile. A new source-walking
  pre-pass rewrites a leveled long string to [[body]] when the body is safe
  (byte-exact, keeps the leading-newline strip) or to a quoted literal with
  \-newline continuations otherwise, and blanks leveled long comments to
  spaces. Line numbers preserved. Gated by the new LongBrackets toggle.

- Top-level ... outside the addon file funnel (RunScript, XML <OnLoad>,
  loadstring) expanded to unpack(arg) and threw on nil arg, where 5.1
  yields nothing. Non-addon vararg chunks now get a local arg={n=0}
  fallback.

Adds a /capitranspile self-test suite (19 cases, all passing in-game) and
updates docs/API.md (also fixing a stale limits note that described plain
long strings as non-nesting: they nest by depth, matching the engine) and
README.
2026-08-21 20:16:54 -05:00
Brues 998deeac4a Fix pfUI HookScript compatibility shim
Tightens the pfUI compatibility shim in AddOnCompat.lua. The temporary CreateFrame wrapper now leaves the maintained brues pfUI fork alone when either its X-Website tag or the pfUI.handlesHookScript capability flag is present, and only applies the HookScript workaround when neither signal exists.
2026-08-21 13:27:00 -05:00
Brues af02ddadb1 fix(loader): stand down the init worker under VanillaFixes
VanillaFixes creates the process suspended and injects every dlls.txt
DLL sequentially via remote threads before resuming. The fallback
worker spawned from DllMain starts the moment our own LoadLibrary
releases the loader lock -- i.e. while VF is still injecting the NEXT
dlls.txt DLL -- so its ~90-prologue install (plus the MH_ApplyQueued
thread-freeze) raced that DLL's DllMain patching the same engine
functions. SuperWoWhook.dll failed to load whenever ClassicAPI
preceded it in dlls.txt.

DllMain now skips the worker when VfPatcher.dll is present in the
process: under VF the `Load` export is the only install trigger, and
it is inherently race-free -- VfPatcher calls it on the game's main
thread after the process resumes, when every DllMain in the injection
chain has already completed, serialized in dlls.txt order. VfPatcher
is always injected before any dlls.txt DLL, and the Load mechanism
shipped in the same VF release (v1.4) as dlls.txt support itself, so
VF present implies Load will fire. Non-VF injectors keep the worker
unchanged.
2026-08-20 10:26:54 -05:00
Brues 93f3131307 fix(text): drop obsolete 1.5-glyph auto-width wrap bail
The wrap-stepper co-hook special-cased auto-width fontstrings (single-anchor,
no explicit width — pfUI addon-list labels, TwitchEmotes labels): when the whole
line fit within 1.5 glyphs of exactly filling its budget it applied zero shrink,
to avoid a spurious wrap from ~1-glyph drift between the width and wrap hooks'
icon-sum computations. The cost was that real-width frames landing within 1.5
glyphs of their edge also gave up their shrink and let icons overflow instead of
wrapping.

The trailing-icon trim, outline-ink threading, and exact-K measure work have
since tightened that drift under half a glyph, so the existing 0.5-glyph
feasibility tolerance now carries the auto-width case on its own. Verified
in-game: pfUI/TwitchEmotes labels no longer spuriously wrap, and near-full
real-frame lines wrap correctly instead of overflowing.
2026-08-19 22:21:24 -05:00
Brues 653a2c6e3a Update README.md 2026-08-19 19:09:37 -05:00
59 changed files with 7896 additions and 411 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>
+21 -5
View File
@@ -15,12 +15,15 @@ if C_AddOns.DoesAddOnExist('pfUI') then
-- written for on their pfActionBar buttons, which breaks them. While
-- the actionbar module builds those buttons, temporarily wrap
-- CreateFrame to shadow HookScript with a falsy field so those forks
-- fall back to their vanilla SetScript path. The maintained "brues"
-- fork handles HookScript itself, so it's detected via the TOC
-- X-Website tag and left alone. The wrapper is removed as soon as the
-- actionbar module finishes loading so it affects nothing else.
-- fall back to their vanilla SetScript path. The wrapper is removed as
-- soon as the actionbar module finishes loading so it affects nothing
-- else. A pfUI is trusted to handle HookScript itself if EITHER signal
-- is present: the maintained "brues" X-Website tag OR the
-- `pfUI.handlesHookScript` capability flag. Without the website the
-- flag is required; if neither is present we apply the shim.
local website = GetAddOnMetadata("pfUI", "X-Website")
if not website or not strfind(website, 'brues') then
local hasBruesWebsite = website and strfind(website, 'brues')
if not hasBruesWebsite and not pfUI.handlesHookScript then
local _createFrame = CreateFrame
local HookedCreateFrame = function(frameType, name, parent, template)
local frame = _createFrame(frameType, name, parent, template)
@@ -81,3 +84,16 @@ if C_AddOns.DoesAddOnExist("Puppeteer") then
CAPI_MouseoverClearedCompat(PTEnemyUpdater)
end)
end
-- Cartographer_Notes gates a WorldMap release-spirit hook behind an
-- `if lua51` probe, treating a 5.1-capable Lua as a modern client that has
-- the WorldMapDeathRelease frame. Our 5.1 syntax backport makes the probe
-- pass, so Cartographer takes the modern branch and hooks a frame 1.12's
-- WorldMap never had (WorldMapDeathRelease:SetScript in OnEnable) -> nil
-- index. Provide an inert, hidden stub so the hook succeeds and does nothing
-- -- the same outcome vanilla had, where the branch never ran. The embedded
-- !!!ClassicAPI addon loads first, so the frame exists before Cartographer's
-- OnEnable.
if C_AddOns.DoesAddOnExist("Cartographer") and not WorldMapDeathRelease then
CreateFrame("Button", "WorldMapDeathRelease", WorldMapButton or UIParent):Hide()
end
+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"
)
+77 -34
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.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnLocalTable`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem` |
| [AuraUtil](docs/API.md#aurautil) | `AuraUtil.FindAura`, `AuraUtil.FindAuraByName`, `AuraUtil.ForEachAura`, `AuraUtil.UnpackAuraData` |
| [Bindings](docs/API.md#bindings) | `SetBindingSpell`, `SetBindingItem`, `SetBindingMacro`, `SetBindingClick`, `SetOverrideBinding`, `SetOverrideBindingSpell`, `SetOverrideBindingItem`, `SetOverrideBindingMacro`, `SetOverrideBindingClick`, `ClearOverrideBindings` |
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
| [ChatBubbles](docs/API.md#chatbubbles) | `C_ChatBubbles.GetAllChatBubbles` |
@@ -37,7 +67,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `GetMouseFoci`, `frame:SetShown`, `fontstring:GetStringHeight`, `texture:SetRotation`, `texture:GetRotation`, `texture:SetVertexOffset`, `texture:GetVertexOffset`, `texture:SetColorTexture`, `fontstring:SetRotation`, `fontstring:GetRotation`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs` |
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `GetMouseFoci`, `frame:SetShown`, `fontstring:GetStringHeight`, `fontstring:GetUnboundedStringWidth`, `fontstring:GetWrappedWidth`, `fontstring:GetNumLines`, `fontstring:GetLineHeight`, `fontstring:IsTruncated`, `fontstring:SetMaxLines`, `fontstring:GetMaxLines`, `fontstring:SetFormattedText`, `texture:SetRotation`, `texture:GetRotation`, `texture:SetVertexOffset`, `texture:GetVertexOffset`, `texture:SetColorTexture`, `texture:SetMask`, `frame:CreateMaskTexture`, `texture:AddMaskTexture`, `texture:RemoveMaskTexture`, `texture:GetNumMaskTextures`, `texture:GetMaskTexture`, `fontstring:SetRotation`, `fontstring:GetRotation`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs`, `SecureCmdOptionParse`, `RegisterStateDriver`, `UnregisterStateDriver`, `RegisterAttributeDriver`, `UnregisterAttributeDriver`, `RegisterUnitWatch`, `UnregisterUnitWatch`, `UnitWatchRegistered`, `SecureButton_GetAttribute`, `SecureButton_GetUnit` |
| [FriendList](docs/API.md#friendlist) | `C_FriendList.GetFriendInfo`, `C_FriendList.GetFriendInfoByIndex`, `C_FriendList.GetNumFriends`, `C_FriendList.GetNumOnlineFriends`, `C_FriendList.GetNumWhoResults`, `C_FriendList.GetWhoInfo`, `C_FriendList.IsFriend`, `C_FriendList.IsIgnored`, `C_FriendList.IsIgnoredByGuid`, `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName`, `C_FriendList.SetFriendNotes`, `C_FriendList.SetFriendNotesByIndex` |
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID`, `ClosestGameObjectPosition` |
| [Glue](docs/API.md#glue) | `C_Glue.IsOnGlueScreen` |
@@ -50,7 +80,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootAllCorpses`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
| [LootHistory](docs/API.md#loothistory) | `C_LootHistory.GetNumItems`, `C_LootHistory.GetItem`, `C_LootHistory.GetPlayerInfo`, `C_LootHistory.Clear` |
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount` |
| [Lua](docs/API.md#lua) | `coroutine.create`, `coroutine.resume`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `string.gmatch`, `string.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `table.wipe` |
| [Lua](docs/API.md#lua) | `collectgarbage` (5.1 options), `coroutine.create`, `coroutine.resume`, `coroutine.running`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `string.gmatch`, `string.gsub` (table replacement), `string.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `table.maxn`, `table.wipe`, `unpack` (range args) |
| [Macros](docs/API.md#macros) | `C_Macro.CreateMacro`, `C_Macro.EditMacro`, `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
| [Mail](docs/API.md#mail) | `GetInboxItemLink`, `GetSendMailItemLink` |
| [Map](docs/API.md#map) | `C_Map.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapOverlays`, `C_Map.GetMapWorldSize` |
@@ -73,11 +103,14 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `UnitSpellTargetName`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration`, `C_UnitAuras.UnitAura`, `C_UnitAuras.UnitBuff`, `C_UnitAuras.UnitDebuff` |
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
| [XMLUtil](docs/API.md#xmlutil) | `C_XMLUtil.DoesTemplateExist`, `C_XMLUtil.GetTemplateInfo`, `C_XMLUtil.GetTemplates` |
### GlueXML calls
</details>
<details>
<summary><b>GlueXML calls</b> — login / realm-select / character-select screens</summary>
Registered on the **glue** Lua state (the engine that runs the login,
realm-select, and character-select screens). The persistence entries
@@ -97,7 +130,10 @@ the glue state because GlueXML had no way to reach them otherwise.
| Script | `RunScript` (compile and run a Lua chunk in the glue state's globals — useful for slash-command-style helpers in GlueXML) |
| State | `IsLoggedIn` |
### Macros
</details>
<details>
<summary><b>Macros</b> — engine-level parsing extensions</summary>
Engine-level extensions to macro parsing and dispatch — no new Lua
functions, just behavior the stock 1.12 engine didn't have. See the
@@ -109,7 +145,10 @@ functions, just behavior the stock 1.12 engine didn't have. See the
| `CastSpellByName("<spellID>")` | Same — numeric strings resolve through the engine's name resolver |
| `CastSpellNoToggle("<name>")` in a macro | Engine's macro parser now recognizes it as a primary-spell line, so the macro slot in an addon like pfUI highlights when its spell is auto-repeating or its self-aura is active |
### Console commands
</details>
<details>
<summary><b>Console commands</b> — developer console (<code>-console</code>)</summary>
Registered on the engine's developer console (the `~` console available
when launching with `-console`), not as Lua functions. See the
@@ -121,7 +160,10 @@ when launching with `-console`), not as Lua functions. See the
| `ExportInterfaceFiles art` | Extracts Blizzard's UI art (`.blp`/`.tga`) from the MPQs to `BlizzardInterfaceArt\` |
| `ExportDBCFiles` | Extracts the client's `.dbc` tables from the MPQs to `DBFilesClient\` |
### Events
</details>
<details>
<summary><b>Events</b> — new events fired to addons</summary>
| Event | Payload |
|-------|---------|
@@ -177,7 +219,10 @@ when launching with `-console`), not as Lua functions. See the
| `VOICE_CHAT_TTS_PLAYBACK_FAILED` | `status, utteranceID, destination` |
| `VOICE_CHAT_TTS_VOICES_UPDATE` | *(none)* |
### Globals
</details>
<details>
<summary><b>Globals & enums</b></summary>
| Group | Constants |
|-------|-----------|
@@ -193,7 +238,10 @@ when launching with `-console`), not as Lua functions. See the
| Spellbook bank | `Enum.SpellBookSpellBank.{Player,Pet}` (0–1) |
| Spellbook item type | `Enum.SpellBookItemType.{None,Spell,FutureSpell,PetAction,Flyout}` (0–4; 1.12 only yields `Spell`/`PetAction`) |
### Unit tokens
</details>
<details>
<summary><b>Unit tokens</b> — <code>nameplateN</code>, <code>focus</code>, <code>markN</code></summary>
| Token | Resolves to |
|-------|-------------|
@@ -201,7 +249,10 @@ when launching with `-console`), not as Lua functions. See the
| `focus` / `focustarget` | Sticky target set via [`FocusUnit`](docs/API.md#focusunitunit), cleared via [`ClearFocus`](docs/API.md#clearfocus). Same `UnitX` coverage as `nameplateN`. Fires [`PLAYER_FOCUS_CHANGED`](docs/API.md#player_focus_changed-event) on transition. See [Focus](docs/API.md#focus). |
| `mark1`..`mark8` | Unit currently wearing the Nth raid-target marker (`mark1` = star … `mark8` = skull). Same `UnitX` coverage as `nameplateN`, and fires unit events (`UNIT_HEALTH`, `UNIT_AURA`, …) with `arg1 == "markN"`. `UnitExists("markN")` is `false` when the marker is unset, out of range, or on a non-unit. Suffix chains (`mark1target`) compose. See [Unit tokens (`markN`)](docs/API.md#unit-tokens-markn). |
### Bindings
</details>
<details>
<summary><b>Bindings</b> — direct-action and override binding families</summary>
ClassicAPI backports the later-client direct-action and temporary override
binding families. Permanent bindings use the standard `SPELL`, `ITEM`,
@@ -212,11 +263,10 @@ See the [Bindings API reference](docs/API.md#bindings) for signatures,
precedence, execution behavior, macro-text usage, and 1.12 compatibility
notes.
#### Predefined focus bindings
Injected into the engine's **Targeting Functions** group at FrameXML
Bindings.xml load time, so they appear in the keybind UI alongside
native targeting bindings instead of orphaned at the bottom.
**Predefined focus bindings.** Injected into the engine's **Targeting
Functions** group at FrameXML Bindings.xml load time, so they appear in the
keybind UI alongside native targeting bindings instead of orphaned at the
bottom.
| Binding | Action |
|---------|--------|
@@ -226,14 +276,7 @@ native targeting bindings instead of orphaned at the bottom.
See [Predefined focus bindings](docs/API.md#predefined-focus-bindings-focustarget--targetfocus)
for the implementation note.
### Behaviors
Transparent engine tweaks — no API to call, they just fix a vanilla limitation:
| Tweak | Effect |
|-------|--------|
| Tooltip line cap | Lifts `GameTooltip`'s hard 30-line limit to 60 for every `GameTooltipTemplate` frame (`GameTooltip`, `ShoppingTooltip1/2`, `ItemRefTooltip`, AtlasLoot, …). Stat-heavy tooltips and comparison blocks (e.g. pfUI's eqcompare) no longer have their extra lines silently dropped. Done in pure C++ by growing the engine's FontString pool at tooltip-creation time. |
| Inline textures | Draws inline texture markup (`\|T…\|t`) as icons in FontStrings, chat, and tooltips, the way 4.3.4+ clients do. Vanilla 1.12 shows the raw escape as literal text instead. This covers item and spell icons, raid-target markers, and the coin icons in money strings. `GetStringWidth` and `GetStringHeight` count the icons, so measured width and text wrapping stay correct. Done in pure C++ by hooking the engine's text pipeline — no addon. |
</details>
## Installation
+39
View File
@@ -3815,3 +3815,42 @@ estimate. Anyone who wants vanilla threat should run an existing threat
addon. ClassicAPI's only worthwhile contribution in this space is an
unrelated precise-combat-log event feed — filed as its own thing, not as
"threat".
## 99. `C_AddOns.GetAddOnLocalTable(name)` — gate on loaded + opt-in TOC flag — DONE
Shipped gated (branch `feat/lua-syntax-transpile`,
[src/addons/LocalTable.cpp](src/addons/LocalTable.cpp)). Returns the addon's
private namespace table **only** when BOTH hold, else `nil`:
1. the addon `name` is **loaded** (`FUN_ADDON_IS_LOADED`, by-name), and
2. its `.toc` declares `## AllowAddOnTableAccess: <nonzero>` (scanned via
`FUN_FILE_READ` on `Interface\AddOns\<name>\<name>.toc`, read as a TOC
boolean flag; approach (a) from the notes below — no parser hook).
The two paths stay separate, as required:
- **Internal** `__addonns` (the `local name, ns = ...` preamble) stays
**ungated** — an addon always gets its own namespace. Still in
`luasyntax/Transpile.cpp`.
- **Public** `C_AddOns.GetAddOnLocalTable` carries the gate, in
`addons/LocalTable.cpp`.
Both resolve to the SAME table per addon via `LuaSyntax::PushAddonNamespace`
(the one owner of the registry-backed name→table map, exposed by
[src/luasyntax/AddonNamespace.h](src/luasyntax/AddonNamespace.h)) — so an addon
reads back exactly what it (or its files' `...`) stored.
Directive spelling + function name verified present in the 1.15.8 Classic Era
binary (`AllowAddOnTableAccess:` TOC key, `GetAddOnLocalTable`).
Deliberate scope choices (revisit only if an addon needs otherwise):
- **Name (string) input only.** A numeric index returns `nil` — cross-addon
table access is by name, and the map is name-keyed. Every other `C_AddOns.*`
wrapper accepts name-or-index; this one does not.
- **`nil` on denial, not a Lua error.** Matches the project's modern-API
convention (nil over `lua_error`). Retail may raise instead; switch the
denial branches to `Game::Lua::Error` if strict parity is wanted.
- **No per-name cache** on the TOC scan — GetAddOnLocalTable is not a hot path.
Not shipped by default: no addon opts in yet. The embedded `!!!ClassicAPI`
addon could add `## AllowAddOnTableAccess: 1` to its `.toc` to expose its own
namespace as a worked example, but that's a product/security decision left to
the maintainer.
+744 -20
View File
@@ -21,10 +21,19 @@ build instructions.
- [`C_AddOns.GetAddOnSecurity(indexOrName)`](#c_addonsgetaddonsecurityindexorname)
- [`C_AddOns.DoesAddOnExist(indexOrName)`](#c_addonsdoesaddonexistindexorname)
- [`C_AddOns.GetAddOnOptionalDependencies(indexOrName)`](#c_addonsgetaddonoptionaldependenciesindexorname)
- [`C_AddOns.GetAddOnLocalTable(name)`](#c_addonsgetaddonlocaltablename)
- [Conditional and multi-flavor TOC loading](#conditional-and-multi-flavor-toc-loading)
- [SavedVariables loaded first](#savedvariables-loaded-first)
- [AuctionHouse](#auctionhouse)
- [`C_AuctionHouse.PostItem(itemLocation, duration, quantity, numStacks, bid, buyout)`](#c_auctionhousepostitemitemlocation-duration-quantity-numstacks-bid-buyout)
- [AuraUtil](#aurautil)
- [`AuraUtil.ForEachAura`](#aurautilforeachaura)
- [`AuraUtil.FindAura`](#aurautilfindaura)
- [`AuraUtil.FindAuraByName`](#aurautilfindaurabyname)
- [`AuraUtil.UnpackAuraData`](#aurautilunpackauradata)
- [CharacterList](#characterlist)
- [`GetSavedCharacterOrder(realm)` / `SetSavedCharacterOrder(realm, order)` — GlueXML only](#getsavedcharacterorderrealm--setsavedcharacterorderrealm-order--gluexml-only)
@@ -175,6 +184,12 @@ build instructions.
- [`fontstring:SetFormattedText(format [, ...])`](#fontstringsetformattedtextformat--)
- [`texture:SetRotation(angle [, cx, cy])`](#texturesetrotationangle--cx-cy)
- [`texture:SetVertexOffset(vertexIndex, offsetX, offsetY)`](#texturesetvertexoffsetvertexindex-offsetx-offsety)
- [`texture:SetMask(path)`](#texturesetmaskpath)
- [`frame:CreateMaskTexture([name, layer, ...])`](#framecreatemasktexturename-layer-)
- [`texture:AddMaskTexture(mask)`](#textureaddmasktexturemask)
- [`texture:RemoveMaskTexture([mask])`](#textureremovemasktexturemask)
- [`texture:GetNumMaskTextures()`](#texturegetnummasktextures)
- [`texture:GetMaskTexture(index)`](#texturegetmasktextureindex)
- [`texture:SetColorTexture(colorR, colorG, colorB [, a])`](#texturesetcolortexturecolorr-colorg-colorb--a)
- [`fontstring:SetRotation(angle [, cx, cy])`](#fontstringsetrotationangle--cx-cy)
- [`frame:SetResizeBounds(minWidth, minHeight [, maxWidth, maxHeight])`](#framesetresizeboundsminwidth-minheight--maxwidth-maxheight)
@@ -183,6 +198,11 @@ build instructions.
- [`frame:GetEffectiveAlpha()`](#framegeteffectivealpha)
- [`frame:SetAttribute` / `SetAttributeNoHandler` / `ClearAttribute` / `GetAttribute` (+ unit-frame mouseover)](#framesetattributename-value--framesetattributenohandlername-value--frameclearattributename--framegetattribute)
- [`SetModernScriptArgs(enable)` / `GetModernScriptArgs()`](#setmodernscriptargsenable--getmodernscriptargs)
- [`SecureCmdOptionParse(options)`](#securecmdoptionparseoptions)
- [`RegisterStateDriver` / `UnregisterStateDriver`](#registerstatedriver--unregisterstatedriver)
- [`RegisterAttributeDriver` / `UnregisterAttributeDriver`](#registerattributedriver--unregisterattributedriver)
- [`RegisterUnitWatch` / `UnregisterUnitWatch` / `UnitWatchRegistered`](#registerunitwatch--unregisterunitwatch--unitwatchregistered)
- [`SecureButton_GetAttribute` / `SecureButton_GetUnit`](#securebutton_getattribute--securebutton_getunit)
- [FriendList](#friendlist)
- [`C_FriendList.SendWhoQueryByName(name)`](#c_friendlistsendwhoquerybynamename)
@@ -340,11 +360,19 @@ build instructions.
- [`C_LossOfControl.GetActiveLossOfControlData(index)`](#c_lossofcontrolgetactivelossofcontroldataindex)
- [Lua](#lua)
- [Lua 5.1 syntax](#lua-51-syntax)
- [String methods (`s:upper()`, `s:format(...)`)](#string-methods-supper-sformat)
- [`getfenv` / `setfenv` environment protection](#getfenv--setfenv-environment-protection)
- [`select(index, ...)`](#selectindex-)
- [`unpack(list [, i [, j]])`](#unpacklist--i--j)
- [`collectgarbage(opt [, arg])`](#collectgarbageopt--arg)
- [`table.wipe(t)`](#tablewipet)
- [`table.count(tbl)`](#tablecounttbl)
- [`table.maxn(t)`](#tablemaxnt)
- [Stale table lengths (5.1 healing)](#stale-table-lengths-51-healing)
- [`Mixin(object, ...)` / `CreateFromMixins(...)`](#mixinobject--createfrommixins)
- [`string.match` / `string.gmatch`](#stringmatch--stringgmatch)
- [`string.gsub` table replacement](#stringgsub-table-replacement)
- [`strsplit(sep, str [, pieces])`](#strsplitsep-str--pieces)
- [`strjoin(delimiter, ...)`](#strjoindelimiter-)
- [`strtrim(str [, chars])`](#strtrimstr--chars)
@@ -358,6 +386,7 @@ build instructions.
- [`coroutine.yield(...)`](#coroutineyield)
- [`coroutine.status(co)`](#coroutinestatusco)
- [`coroutine.wrap(fn)`](#coroutinewrapfn)
- [`coroutine.running()`](#coroutinerunning)
- [Async pattern (RunAsync + C_Timer.After)](#async-pattern-runasync--c_timerafter)
- [Macros](#macros)
@@ -598,6 +627,8 @@ build instructions.
- [UnitAuras](#unitauras)
- [`C_UnitAuras.GetAuraDataByIndex(unit, index [, filter])`](#c_unitaurasgetauradatabyindexunit-index--filter)
- [`C_UnitAuras.GetBuffDataByIndex(unit, index)` / `GetDebuffDataByIndex(unit, index)`](#c_unitaurasgetbuffdatabyindexunit-index--getdebuffdatabyindexunit-index)
- [`C_UnitAuras.UnitAura(unit, index [, filter])`](#c_unitaurasunitauraunit-index--filter)
- [`C_UnitAuras.UnitBuff(unit, index [, filter])` / `UnitDebuff(unit, index [, filter])`](#c_unitaurasunitbuffunit-index--filter--unitdebuffunit-index--filter)
- [`C_UnitAuras.GetUnitAuraBySpellID(unit, spellID [, filter])`](#c_unitaurasgetunitaurabyspellidunit-spellid--filter)
- [`C_UnitAuras.GetPlayerAuraBySpellID(spellID)`](#c_unitaurasgetplayeraurabyspellidspellid)
- [`C_UnitAuras.GetAuraDataBySpellName(unit, spellName [, filter])`](#c_unitaurasgetauradatabyspellnameunit-spellname--filter)
@@ -926,6 +957,141 @@ C_AddOns.GetAddOnOptionalDependencies("MyAddon") -- "Atlas", "pfQuest"
C_AddOns.GetAddOnOptionalDependencies("Atlas") -- (nothing — no OptionalDeps)
```
### `C_AddOns.GetAddOnLocalTable(name)`
Returns the private table of the addon named `name`. This is the same
table the addon's own files receive as the second value of `...`:
```lua
-- inside MyAddon's files
local addonName, addonTable = ...
```
Another addon reads that table only when both conditions are true:
1. `MyAddon` is loaded, and
2. `MyAddon`'s `.toc` file declares `## AllowAddOnTableAccess: 1`.
If either condition is false, the function returns `nil`. An addon
that does not opt in keeps its table private. The check protects each
addon's table from access it did not permit.
```lua
-- MyAddon.toc contains: ## AllowAddOnTableAccess: 1
local t = C_AddOns.GetAddOnLocalTable("MyAddon") -- MyAddon's table
t.sharedValue = 5 -- MyAddon sees this too
C_AddOns.GetAddOnLocalTable("DebugTools") -- nil (no opt-in directive)
C_AddOns.GetAddOnLocalTable("DoesNotExist") -- nil (not loaded)
```
Pass the addon name as a string. A numeric index returns `nil` — one
addon refers to another by name, not by load order.
### Conditional and multi-flavor TOC loading
Modern addons often support several game versions from one folder.
ClassicAPI backports two TOC mechanisms so these addons load on 1.12.
This client is the **Vanilla** game type of the **Classic** family.
**Flavor TOC files.** Some addons ship no plain `<Name>.toc`. They ship
one TOC per version instead. ClassicAPI loads such an addon from a
version-specific TOC, and prefers it even when a plain `<Name>.toc` also
exists:
- `<Name>_ClassicAPI.toc` — used on any client, because ClassicAPI is
always present.
- `<Name>_Turtle.toc` — used only on a Turtle client. It wins over the
`_ClassicAPI` file.
Both suffixes are ClassicAPI conventions. Name a TOC this way to target
this client on purpose. ClassicAPI does not use `_Vanilla` or `_Classic`
files — those target the 1.15 Classic Era client, which runs a modern
engine this build does not match.
**Flavor Bindings files.** The same selection applies to an addon's
keybinding file. An addon can ship `Bindings_ClassicAPI.xml` (used on any
client) or `Bindings_Turtle.xml` (used only on a Turtle client, and it
wins), in place of or alongside a plain `Bindings.xml`:
- A flavor file wins over a plain `Bindings.xml`.
- A flavor-only addon (no plain `Bindings.xml`) still loads its bindings.
The `_Vanilla` / `_Classic` names are not used here either, for the same
reason as the TOC files.
**Multi-flavor `## Interface:` version.** A retail TOC can list several
interface versions on one line:
```
## Interface: 120100, 50504, 38002, 20506, 11200
```
**Per-line directives.** Inside a TOC, gate individual file lines with a
condition, or expand a path variable:
```
# Loads on this client (game type is vanilla):
Vanilla.lua [AllowLoadGameType vanilla]
# Dropped on this client:
Mainline.lua [AllowLoadGameType mainline]
# Loads only under a matching client locale:
Localization\deDE.lua [AllowLoadTextLocale deDE]
# Path variables expand before the file loads:
[Family]\Init.lua # -> Classic\Init.lua
[Game]\Init.lua # -> Vanilla\Init.lua
Localization\[TextLocale].lua # -> Localization\enUS.lua (your locale)
```
How each token resolves on this client:
| Directive | Result |
|---|---|
| `[AllowLoadGameType ...]` | Loads the line when the list contains `vanilla`. |
| `[AllowLoadTextLocale ...]` | Loads the line when the list contains your client locale (the `GetLocale()` code). |
| `[AllowLoad ...]` | Loads on `game`, drops on `glue`. |
| `[Family]` | `Classic` |
| `[Game]` | `Vanilla` |
| `[TextLocale]` | Your client locale code, for example `enUS`. |
A line loads only when every condition on it passes. An unknown condition
drops the line. A file gated by a rule this client does not know stays
unloaded.
**Limits.**
- Conditions on `## metadata` lines are not supported. Only file-reference
lines are gated. Retail added metadata-line conditions in a much later
version.
- `[AllowLoad glue]` never loads. Addon files load in-game only.
### SavedVariables loaded first
Normally 1.12 runs an addon's files first, then loads its SavedVariables,
then fires `ADDON_LOADED`. So file-scope code sees its SavedVariables as
`nil`. A modern addon avoids this with one TOC line:
```
## SavedVariables: MyAddonDB
## LoadSavedVariablesFirst: 1
```
ClassicAPI honors `## LoadSavedVariablesFirst`. For a flagged addon, its
SavedVariables load **before** its files run, so file-scope code sees them:
```lua
-- With the directive, in MyAddon's file:
local db = MyAddonDB -- the restored table, not nil
```
This covers both `## SavedVariables` and `## SavedVariablesPerCharacter`.
File-scope reads and writes both behave as they do on modern clients. A
SavedVariables file exists only after the first save, so the first-ever
login still sees `nil` — there is nothing on disk to load yet.
## AuctionHouse
### `C_AuctionHouse.PostItem(itemLocation, duration, quantity, numStacks, bid, buyout)`
@@ -986,6 +1152,60 @@ stack); temp splits use general-purpose bags only, so family-restricted items
(arrows/shards that live in specialty bags) aren't supported; one job runs at
a time.
## AuraUtil
Helper library over [`C_UnitAuras`](#unitauras), backported from FrameXML. Its
purpose on 1.12 is the allocation-free aura scan: the non-packed path builds no
table per aura, which matters under Lua's garbage collector when you scan many
units each frame.
### AuraUtil.ForEachAura
`AuraUtil.ForEachAura(unit, filter, batchSize, func [, usePackedAura])` calls
`func` for each aura on `unit` matching `filter`, in order, until `func` returns a
truthy value or the auras run out.
- Non-packed (default): `func` receives the 15 positional values of
[`C_UnitAuras.UnitAura`](#c_unitaurasunitauraunit-index--filter) — no table is
built per aura.
- `usePackedAura` true: `func` receives the `AuraData` table from
`GetAuraDataByIndex`.
`batchSize` of `0` or less does nothing; any other value (or `nil`) visits every
matching aura — `batchSize` is retail's slot-batch hint, not a cap on the count.
```lua
AuraUtil.ForEachAura("target", "HARMFUL", nil, function(name, icon, count)
print(name, count)
end)
```
### AuraUtil.FindAura
`AuraUtil.FindAura(predicate, unit, filter [, arg1, arg2, arg3])` returns the
positional values of the first aura for which `predicate` returns truthy, or
`nil`. `predicate` receives the (up to three) caller arguments, then the aura's
positional values:
```lua
local function castByMe(_, _, _, name, icon, count, dispelType, duration, exp, source)
return source == "player"
end
local name = AuraUtil.FindAura(castByMe, "target", "HARMFUL")
```
### AuraUtil.FindAuraByName
`AuraUtil.FindAuraByName(auraName, unit, filter)` returns the positional values of
the first aura whose name matches `auraName`, or `nil`. Names are localized and
not unique, so this returns the first match.
### AuraUtil.UnpackAuraData
`AuraUtil.UnpackAuraData(auraData)` returns an `AuraData` table's fields as 15
positional values in the classic `UnitAura` order, or `nil` when `auraData` is
`nil`. (Position 13 is the table's `isFromPlayerOrPlayerPet`.)
## CharacterList
### `GetSavedCharacterOrder(realm)` / `SetSavedCharacterOrder(realm, order)` — GlueXML only
@@ -4232,6 +4452,96 @@ t:SetAllPoints(frame)
t:SetColorTexture(0.1, 0.6, 1.0, 0.8) -- semi-transparent blue fill
```
### `texture:SetMask(path)`
Clips a texture to the shape of a mask. Later clients added this for round
portraits, round minimap buttons, and other shaped art. Vanilla has no general
masking — only the special-cased minimap.
`path` is a mask texture. The mask's ALPHA channel is the shape: where the mask
is opaque the texture shows, where the mask is transparent the texture is
hidden. The mask is sampled across the texture's own texture coordinates, so it
lines up with the texture. Pass `nil` or `""` to remove the mask; the texture
returns to its full rectangle.
A mask file must be one the client can load and must hold its shape in the alpha
channel:
- A BLP, or an UNCOMPRESSED 32-bit TGA. The client cannot decode an
RLE-compressed TGA.
- The shape must be in the alpha channel, with white color. A mask that is fully
opaque does nothing; a mask whose shape is only in its color does not clip.
- Bottom-origin. A top-origin TGA loads upside down.
Two masks ship in the client and need no file of your own: `Textures\MinimapMask`
(round) and `Interface\CharacterFrame\TempPortraitAlphaMask` (round portrait).
For any other shape, put your own mask texture in your addon folder and pass its
path, the same way you ship any custom texture.
```lua
local t = frame:CreateTexture(nil, "ARTWORK")
t:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark")
t:SetMask("Textures\\MinimapMask") -- clip the icon to a circle
t:SetMask("Interface\\AddOns\\MyAddon\\round") -- or your own mask texture
t:SetMask(nil) -- remove the mask
```
> One difference from later clients: the mask follows the texture's own
> coordinates. If you crop the texture with `SetTexCoord`, the mask follows the
> cropped region rather than the full display area. For a normal, uncropped
> texture the two are the same.
### `frame:CreateMaskTexture([name, layer, ...])`
Creates a MaskTexture and returns it. A MaskTexture is a texture that never draws
itself. Instead it clips other textures to its shape. Later clients added this
object; vanilla has only the special-cased minimap mask.
Give the mask a shape with `SetTexture`, place and size it like any texture
(`SetPoint`, `SetSize`, `SetAllPoints`), then attach it with
`texture:AddMaskTexture`. Because the mask has its own position and size, it can
cover only part of the target, sit to one side, or move each frame.
The arguments match `frame:CreateTexture`. The mask shape lives in the texture's
alpha channel and must load from a BLP or an uncompressed 32-bit TGA — the same
rule as [`texture:SetMask`](#texturesetmaskpath).
```lua
local mask = frame:CreateMaskTexture()
mask:SetTexture("Interface\\AddOns\\MyAddon\\circle")
mask:SetAllPoints(icon) -- cover the whole icon → a round icon
icon:AddMaskTexture(mask)
```
### `texture:AddMaskTexture(mask)`
Clips this texture to `mask`'s shape and position. Where the mask is opaque the
texture shows; where the mask is transparent the texture is hidden.
Call it again with a different mask to add another. A texture with several masks
shows only where ALL of them are opaque — the masks intersect. So two circles
show their overlapping lens, and a square plus a circle show a square with
rounded corners. Up to 7 masks apply at once. Masks cannot subtract, so you
cannot cut a hole with this.
A mask honors [`texture:SetRotation`](#texturesetrotationangle--cx-cy): rotate
the mask and its clip shape turns with it. The mask can also move or resize each
frame (drive it from an `OnUpdate`), so the clip animates.
### `texture:RemoveMaskTexture([mask])`
Removes `mask` from this texture. With no argument, removes every mask, so the
texture returns to its full rectangle.
### `texture:GetNumMaskTextures()`
Returns the number of masks attached to this texture.
### `texture:GetMaskTexture(index)`
Returns the mask at `index` (1 is the first one added), or `nil` when there is
no mask at that index.
### `fontstring:SetRotation(angle [, cx, cy])`
Rotates a FontString's text by `angle` radians. A positive angle turns the text
@@ -4474,28 +4784,177 @@ addon ports written as `function(self, delta) … end` work unmodified:
`OnClick(self, button)`, `OnValueChanged(self, value)`, `OnUpdate(self, elapsed)`.
- `OnEvent`: `(self, event, arg1..argN)`.
It's purely **additive** — the `this` / `arg1` / `event` globals are still set, so
vanilla-style handlers keep working; a handler declaring no parameters just ignores
the extras. `SetModernScriptArgs(enable)` sets the state and returns it;
The `this` / `arg1` / `event` globals stay set, so a handler that reads them still
works. A handler that declares no parameters is unaffected — it cannot see the
positional arguments. `SetModernScriptArgs(enable)` sets the state and returns it.
`GetModernScriptArgs()` returns the current state.
**Default OFF (opt-in).** It reimplements the tail of the engine's hottest Lua
path (the runner that fires for every `OnUpdate`, every frame), so it stays off
unless you ask for it; while off the dispatch path is exactly vanilla. Enable it
once at load if your addon relies on the modern handler signature.
**Caveat — a parameter that was always nil now gets a value.** Vanilla passed
every handler zero arguments, so any parameter a handler declared was always nil.
When this feature is on, a declared parameter gets its real value. A modern
`function(self, delta)` handler needs this behavior. But it also changes a vanilla
handler that declared a parameter and expected it to be nil. One example is a
function used both as a direct call (with a real argument) and as a script
handler. If such a handler misbehaves, disable the feature with
`SetModernScriptArgs(false)`.
**Default ON.** Modern handler signatures are a core Lua 5.1 feature, so ports
that use them work with no setup. It reimplements the tail of the engine's
hottest Lua path (the runner that fires for every `OnUpdate`, every frame); if
you ever need exact-vanilla dispatch, `SetModernScriptArgs(false)` turns it off
and both runners become a straight passthrough.
```lua
SetModernScriptArgs(true) -- returns true
-- On by default; SetModernScriptArgs(false) would turn it off.
local f = CreateFrame("Frame")
f:EnableMouseWheel(true)
f:SetScript("OnMouseWheel", function(self, delta)
-- `self` and `delta` are bound; `this` / `arg1` still work too
end)
GetModernScriptArgs() -- true
GetModernScriptArgs() -- true (default)
```
### `SecureCmdOptionParse(options)`
Parses a macro conditional string. Returns the value of the first clause that
matches. This is the parser behind `/cast [combat] Spell`-style options.
The `options` string is a list of clauses, separated by `;`. A clause is zero
or more `[...]` condition groups, then a value:
```lua
SecureCmdOptionParse("[combat] Attack; [nostealth] Prowl; Rest")
```
Rules:
- A clause with no group always matches.
- Groups in one clause are OR'd. The first group that passes wins.
- Conditions in a group are separated by `,` and are AND'd.
- A `no` prefix negates a condition. A `:a/b/c` suffix adds arguments, which
are OR'd.
- A `@unit` or `target=unit` piece sets the group's target. Conditions that
need a unit use it, and default to `"target"`.
Returns the matched value, plus the passing group's target token as a second
value (nil when the group set no target). Returns nil when no clause matches.
```lua
SecureCmdOptionParse("hello") -- "hello"
SecureCmdOptionParse("[combat] a; b") -- "a" in combat, else "b"
SecureCmdOptionParse("[@focus,harm] a; b") -- "a", "focus" when focus is hostile
SecureCmdOptionParse("[nocombat] rest") -- "rest" out of combat, else nil
```
**Supported conditions.** Each maps to real 1.12 state:
| Condition | Meaning |
|---|---|
| `combat` | the player is in combat |
| `exists` / `dead` | the target exists / is dead or a ghost |
| `help` / `harm` | the target is friendly / hostile |
| `party` / `raid` | the target is in your party / raid |
| `group` / `group:party` / `group:raid` | you are in a group / party or raid / raid |
| `stance` / `stance:N` | you are shapeshifted / in bar form N (`form` is the same) |
| `stealth` / `mounted` / `swimming` / `indoors` / `outdoors` | player state |
| `mod` / `mod:shift` / `mod:ctrl` / `mod:alt` | a modifier key is held (`modifier` is the same) |
| `button:N` | the current button is N (defaults to the left button; `btn` is the same) |
| `bar:N` / `actionbar:N` | the current action bar page is N |
| `bonusbar` / `bonusbar:N` | a bonus bar is active / bonus bar N is active |
| `pet` / `pet:name` | you have a pet / a pet with that name or family |
| `channeling` / `channeling:spell` | you are channeling / channeling that spell |
| `equipped:type` | an item of that type, subtype, or slot is equipped (`worn` is the same) |
| `cursor` | the cursor holds an item, a spell, or money |
| `spec` / `spec:1` | always true (vanilla has one spec) |
| `known:spellID` / `known:name` | you know that spell |
**Always false.** `flying`, `flyable`, `vehicleui`, and `unithasvehicleui`
describe state that 1.12 does not have, so they never match.
**Unknown condition.** An unrecognized keyword never matches, and prints a
warning once. Keywords are case-sensitive: `[Combat]` is unknown, `[combat]` is
not.
The `[stance:N]` number is the shapeshift bar slot, not the form ID that
[`GetShapeshiftFormID()`](#getshapeshiftformid) returns. On a druid, bar slot 1
is Bear and slot 3 is Cat.
`known` is a ClassicAPI extension, not a Blizzard conditional. A numeric
argument uses [`IsPlayerSpell`](#isplayerspellspellid), so it matches any known
spell — a spellbook spell, a talent, a profession recipe, or a racial. A name
argument matches only a spellbook spell, because there is no name-to-ID
resolver. Use the spell ID for a talent or a recipe.
### `RegisterStateDriver` / `UnregisterStateDriver`
`RegisterStateDriver(frame, state, values)` drives a frame's state from a macro
conditional string. The driver re-runs `SecureCmdOptionParse(values)` on a
0.2 second poll and applies the result.
For the state `"visibility"`, the value `"show"` or `"hide"` shows or hides the
frame:
```lua
RegisterStateDriver(myFrame, "visibility", "[combat] hide; show")
```
For any other state, the driver sets the attribute `"state-"..state` to the
value. An `OnAttributeChanged` handler then reacts to it.
`UnregisterStateDriver(frame, state)` removes one driver.
`UnregisterStateDriver(frame)` with no state removes every driver on the frame.
The driver also rescans at once on combat, target, focus, form, pet, group,
action-bar, and modifier-key changes, so state that a poll would lag catches up
without delay.
### `RegisterAttributeDriver` / `UnregisterAttributeDriver`
`RegisterAttributeDriver(frame, attribute, values)` is the same as the
state-driver pair, but it sets `attribute` directly instead of adding the
`"state-"` prefix. An attribute name that starts with `_` is ignored.
The driver coerces a numeric value to a number, and the literal string `"nil"`
to nil, before it sets the attribute.
`UnregisterAttributeDriver(frame, attribute)` removes one driver.
`UnregisterAttributeDriver(frame)` with no attribute removes every driver on the
frame.
### `RegisterUnitWatch` / `UnregisterUnitWatch` / `UnitWatchRegistered`
`RegisterUnitWatch(frame [, asState])` shows or hides a frame as its `unit`
attribute comes into and out of existence. Set the `unit` attribute first:
```lua
myUnitFrame:SetAttribute("unit", "target")
RegisterUnitWatch(myUnitFrame) -- shown only while you have a target
```
With `asState` true, the watch sets the boolean attribute `"state-unitexists"`
instead of calling Show/Hide, so an `OnAttributeChanged` handler drives the
visibility.
The Show/Hide form also sets a `"statehidden"` attribute (true when hidden), so
unit-frame code can tell a driver-hidden frame from one the user hid.
`UnregisterUnitWatch(frame)` stops the watch. The frame stays in its last shown
or hidden state. `UnitWatchRegistered(frame)` returns whether a watch is active.
### `SecureButton_GetAttribute` / `SecureButton_GetUnit`
`SecureButton_GetAttribute(frame, name)` reads an attribute. When the frame opts
in with a `"useparent-"..name` or `"useparent*"` attribute, it walks up to the
parent frame to find the value.
`SecureButton_GetUnit(frame)` returns the frame's `unit` attribute, with an
optional `unitsuffix` attribute appended. The unit-watch and click systems use
it to find a frame's unit.
**No taint.** These are functional copies of the modern secure templates. 1.12
has no combat lockdown or taint, so nothing here is protected — the names exist
for addon compatibility.
## FriendList
### `C_FriendList.SendWhoQueryByName(name)`
@@ -8346,6 +8805,136 @@ backported addons find them. Most are single-function additions (several are
just the 5.0→5.1 renames — `string.gmatch`←`gfind`, `math.fmod`←`math.mod`);
`coroutine.*` restores the whole stripped coroutine library.
### Lua 5.1 syntax
1.12 runs Lua 5.0. It cannot compile five pieces of Lua 5.1 syntax that
modern addons use. These are the length operator `#`, the modulo operator
`%`, `...` used as an expression, `0x` hexadecimal number literals, and
leveled long brackets (`[=[ ... ]=]`). ClassicAPI rewrites addon source to
the 5.0 equivalent before it compiles, so all five work:
```lua
local n = #myTable -- length operator
local r = a % b -- modulo operator
local args = { ... } -- ... as an expression, not only in a parameter list
local mask = 0xFF00 -- hex number literal
local doc = [=[ has ]] in it ]=] -- leveled long bracket
```
The rewrite is transparent. You do not call anything. It runs on every
chunk the client compiles — addon files, `loadstring`, and XML `<OnLoad>`
handlers.
What each form does:
- `#x` gives the length of a string, or a border of a table (the value
Lua 5.1 `#` returns). It ignores `table.setn`.
- `a % b` uses the Lua 5.1 result `a - floor(a/b)*b`, which takes the sign
of `b`. This differs from `math.mod` (C `fmod`, truncated toward zero):
`-1 % 3` is `2`, while `math.mod(-1, 3)` is `-1`.
- `...` as an expression yields all the varargs. The `...` in a function
parameter list stays as the vararg declaration.
- `0xFF00` becomes its decimal value (`65280`) — the same number Lua 5.1
produces. Vanilla's lexer rejects `0x` literals, so without this an addon
needs `tonumber("0xFF00", 16)`.
- `[=[ ... ]=]` (a leveled long bracket, with any number of `=`) holds text
a plain `[[ ... ]]` cannot, such as text that contains `]]`. ClassicAPI
rewrites a leveled long string to a plain long string, or to a quoted
string when its body needs one. It removes a leveled long comment
(`--[=[ ... ]=]`).
**Addon file arguments.** A modern addon reads its name and its private
table from the file arguments:
```lua
local addonName, addonTable = ...
```
Vanilla never passed these to an addon file. ClassicAPI supplies them to
any file under `Interface\AddOns\` that reads `...`. `addonName` is the
folder name. `addonTable` is one table shared by every file of that addon.
To read another addon's table, use
[`C_AddOns.GetAddOnLocalTable`](#c_addonsgetaddonlocaltablename), which
needs that addon's opt-in.
**Limits.**
- The rewrite reads strings and comments correctly. A `%` in `"%d"` or a
`#` in `--[[ # ]]` is left alone.
- A plain long string that contains `[[` nests and closes by depth, the
same as the 5.0 engine. Lua 5.1 does not nest. For text that contains
`[[` or `]]`, use a leveled bracket (`[=[ ... ]=]`).
- ClassicAPI rewrites a leveled long string to a plain `[[ ... ]]` when it
can, which keeps the exact value. A body that contains `[[` or `]]`, or
ends with `]`, becomes a quoted string instead. A quoted string keeps a
leading newline in the value. Lua 5.1 drops that newline.
- Only integer hex is converted. Hex *floats* (`0x1.8p3`) and literals
wider than 64 bits are left as-is. Both are almost nonexistent in addon
code.
- Error line numbers stay correct. The rewrite adds no new lines.
- The globals `__len` and `__mod` are the rewrite's helper functions.
Treat them as internal. Do not call them directly.
- To turn a rewrite off for diagnosis, call
`_classicapi_SetTranspileOption(name, false)`, where `name` is
`"Length"`, `"Modulo"`, `"VarargExpansion"`, `"HexLiterals"`, or
`"LongBrackets"`. This
reverts affected chunks to the state that fails to compile, so use it
only to answer "is the rewrite breaking this addon?".
### String methods (`s:upper()`, `s:format(...)`)
In Lua 5.1, every string value accepts method calls: `s:method(...)`
resolves through the `string` table. Lua 5.0 has no string methods — on
stock 1.12, any `s:upper()` fails with `attempt to index a string value`.
ClassicAPI restores the 5.1 behavior:
```lua
("asd"):upper() -- "ASD"
("%d gold"):format(price) -- method call on a literal
msg:match("^!(%w+)") -- method call on a variable
link:sub(1, 5)
```
- Both call forms work and give the same result: `s:upper()` and
`string.upper(s)`.
- Every function in the `string` table is reachable as a method. That
includes the ClassicAPI additions ([`match` /
`gmatch`](#stringmatch--stringgmatch),
[`reverse`](#stringreverses--strrevs)) and any function an addon adds to
`string`.
- Reading an unknown key gives `nil`, the Lua 5.1 result: `("x").nope` is
`nil`, and `("x"):nope()` fails with `attempt to call`, not `attempt to
index`.
- Writing to a string (`("x").y = 1`) still fails, the same as Lua 5.1.
- Works everywhere strings do: in the world, on the login screen, and
inside coroutines.
- To turn method resolution off for diagnosis, call
`_classicapi_SetStringMethods(false)`. Strings then error on index, the
stock 1.12 behavior. Use it only to answer "is this backport breaking
this addon?".
### `getfenv` / `setfenv` environment protection
A sandbox can protect the environment table it gives to restricted code.
Put a metatable on that table with an `__environment` field:
```lua
local view = setmetatable({}, { __environment = "protected" })
setfenv(restrictedFn, view)
```
From then on, code inside the sandbox sees the protection:
- `getfenv()` returns the `__environment` value, not the real table. The
restricted code cannot reach or change the true environment.
- `setfenv()` on that environment raises `cannot change a protected
environment`.
This is the Lua 5.1 form. 1.12 already protected environments, but it read
a raw `__fenv` field on the table itself. ClassicAPI adds the 5.1
metatable form and keeps `__fenv` as a fallback. The change is additive:
an environment with neither marker behaves as before.
### `select(index, ...)`
The Lua 5.1 vararg helper, backported to 1.12's Lua 5.0. Two forms:
@@ -8377,6 +8966,40 @@ Ported from Lua 5.1's `luaB_select` (lbaselib.c). The numeric form pushes
nothing — the varargs are already on the stack, so it just returns the
count of the slice from `n+1` onward.
### `unpack(list [, i [, j]])`
The Lua 5.1 range form. Vanilla's 5.0 `unpack(list)` takes no range
arguments and ignored extras **silently** — `unpack(t, 2)` returned the
whole table: wrong values, no error. ClassicAPI replaces `unpack` with
the 5.1 version (a port of `luaB_unpack`):
```lua
unpack({10, 20, 30}) -- 10, 20, 30
unpack({10, 20, 30}, 2) -- 20, 30
unpack({10, 20, 30}, 2, 2) -- 20
```
`i` defaults to `1` and `j` to the table length — the `table.getn`
length, so a vararg `arg` table's `n` field is honored and embedded nils
keep their slots. Errors on a non-table first argument, exactly like 5.1.
### `collectgarbage(opt [, arg])`
Accepts Lua 5.1's string options. Vanilla's 5.0 collector takes only an
optional numeric threshold and errored on every string option
(`bad argument #1 (number expected, got string)`).
- `"count"` — returns the KB of Lua memory in use (the `gcinfo()` value).
- `"collect"` — runs a full garbage-collection cycle. Returns `0`.
- `"step"` — accepted no-op returning `true`. The 5.0 collector cannot
step incrementally; `true` means "cycle finished", so
`repeat until collectgarbage("step")` loops exit immediately.
- `"stop"` / `"restart"` / `"setpause"` / `"setstepmul"` — accepted
no-ops returning `0`. The 5.0 collector has no toggle or tuning;
callers lose the optimization, nothing breaks.
- A number (or no argument) keeps vanilla's threshold behavior
unchanged.
### `table.wipe(t)`
Removes every key from `t`, leaving it empty but preserving its
@@ -8421,6 +9044,43 @@ table.count({ [-3] = "x" }) -- 1, 0, 0
Pure iteration over the table (`lua_next`), so no dependency on Lua's internal
storage layout. Errors on non-table input.
### `table.maxn(t)`
Returns the largest positive numeric key of `t`, or `0` when it has none
(Lua 5.1, `luaB_maxn`). Unlike `table.getn` it scans every key, so it
sees past nil holes in sparse arrays:
```lua
table.maxn({ 10, 20, 30 }) -- 3
table.maxn({ [1] = "x", [9] = "y" }) -- 9 (getn would say 1)
table.maxn({ a = 1 }) -- 0
```
Errors on non-table input.
### Stale table lengths (5.1 healing)
Lua 5.0 stores a table's `table.insert` / `table.getn` length **out of
band**. Clearing a table's keys (`for k in pairs(t) do t[k] = nil end`)
does not reset it — 5.0 code must also call `table.setn(t, 0)`. Lua 5.1
removed `setn` entirely; lengths are computed from the table itself.
ClassicAPI gives you the 5.1 behavior — code written for Lua 5.1 (or
that detects it) can skip `setn` and still get correct lengths.
When the stored length points at a nil slot
and the table has no explicit `n` field, `table.getn` — and everything
built on it: `table.insert`, `table.remove`, `table.concat`,
`table.sort`, `table.foreachi`, `unpack` — returns the true border, the
same answer Lua 5.1 gives. Two things deliberately keep their old
behavior:
- A table with an explicit numeric `n` field (the vararg `arg` contract)
keeps its stored count, so trailing nils survive
(`unpack({10, nil, 30, n = 3})` still returns all three slots).
- `table.setn` still works; the heal only changes the answer when the
stored length points past the last populated slot.
### `Mixin(object, ...)` / `CreateFromMixins(...)`
The FrameXML table-mixin primitives, provided as engine C functions.
@@ -8468,13 +9128,30 @@ string.match("no digits", "%d+") -- nil
for word in string.gmatch("a,bb,ccc", "[^,]+") do print(word) end -- a / bb / ccc
```
> **Only the `string.foo(s, ...)` call form works — not the `("x"):foo(...)`
> method sugar.** WoW's Lua VM strips type-metatables for every value except
> tables and userdata (both `lua_setmetatable` and the VM's own
> `luaT_gettmbyobj` hard-return "no metamethod" for strings), so string values
> have no `__index` and there's no client-side way to add one short of hooking
> a hot VM-core function. This is the long-standing vanilla 1.12 constraint —
> always write `string.match(s, p)`, never `s:match(p)`.
Both call forms work: `string.match(s, p)` and `s:match(p)`. See
[String methods](#string-methods-supper-sformat).
### `string.gsub` table replacement
Lua 5.1 allows a **table** as `gsub`'s replacement: every match looks up
the first capture (the whole match when the pattern declares no
captures) as a key and substitutes the value. A `nil` or `false` value
keeps the original match. Vanilla's 5.0 `gsub` accepts only a string or
function replacement and errors on a table
(`string or function expected`).
```lua
("$name the $class"):gsub("%$(%w+)", { name = "Bob", class = "Druid" })
-- "Bob the Druid", 2
("$name and $unknown"):gsub("%$(%w+)", { name = "Bob" })
-- "Bob and $unknown", 2 (a missing key keeps the match, as in 5.1)
```
Both `string.gsub` and the bare `gsub` global have the upgrade; string
and function replacements behave exactly as before. One residual: when
the pattern itself contains a `%1`..`%9` back-reference, a missing table
key removes the match instead of keeping it.
### `strsplit(sep, str [, pieces])`
@@ -8595,9 +9272,9 @@ Restores Lua 5.0's stripped `coroutine.*` library. The C-level
coroutine machinery (`lua_resume`, `lua_yield`, `lua_newthread`,
and the `thread` type at tag 8) is linked into the engine, but
the script-facing library was never registered as a global table.
ClassicAPI rewires the standard five entries on `coroutine`,
matching Lua 5.0 semantics with two WoW-specific quirks called
out below.
ClassicAPI rewires the standard five entries on `coroutine`, plus
Lua 5.1's `coroutine.running`, matching Lua 5.0 semantics with two
WoW-specific quirks called out below.
#### `coroutine.create(fn)`
@@ -8695,6 +9372,19 @@ end
for n in range(1, 5) do print(n) end
```
#### `coroutine.running()`
Returns the coroutine that is currently running, or `nil` when called
from the main thread (Lua 5.1's contract — the 5.2 second return,
`ismain`, is not provided).
```lua
coroutine.running() -- nil (main thread)
coroutine.wrap(function()
print(coroutine.status(coroutine.running())) -- "running"
end)()
```
#### Async pattern (`RunAsync` + `C_Timer.After`)
Coroutines pair naturally with `C_Timer.After` for chunked
@@ -14633,6 +15323,40 @@ Convenience wrappers locking the filter to `HELPFUL` or `HARMFUL`
respectively. Equivalent to `GetAuraDataByIndex(unit, index,
"HELPFUL")` / `"HARMFUL"`.
### `C_UnitAuras.UnitAura(unit, index [, filter])`
Returns the same aura as `GetAuraDataByIndex`, but as 15 positional values
instead of a table — so it allocates nothing. Use it in an `OnUpdate` loop that
scans many auras, where one table per aura strains Lua's garbage collector.
Prefer [`AuraUtil.ForEachAura`](#aurautilforeachaura) for iteration.
The values, in order:
```
name, icon, count, dispelType, duration, expirationTime, source, isStealable,
nameplateShowPersonal, spellId, canApplyAura, isBossDebuff, castByPlayer,
nameplateShowAll, timeMod
```
Returns a single `nil` when the unit has fewer than `index` matching auras.
`dispelType` is the dispel-type string (`"Magic"`, `"Curse"`, `"Disease"`,
`"Poison"`, or `nil`). `source` is the caster's unit token, or `nil` when the cast
was not seen this session. `castByPlayer` is true when the local player cast the
aura. `filter` takes the same tokens as `GetAuraDataByIndex`.
The values are the `GetAuraDataByIndex` fields in the classic `UnitAura` order,
with two field-name differences from the table: position 12 is `isBossDebuff`
(the table field is `isBossAura`) and position 13 is `castByPlayer` (the table
field is `isFromPlayerOrPlayerPet`). On 1.12 the boss field is always false;
`castByPlayer` is reported truthfully.
### `C_UnitAuras.UnitBuff(unit, index [, filter])` / `UnitDebuff(unit, index [, filter])`
The `UnitAura` positional form with the range locked to `HELPFUL` or `HARMFUL`.
The `filter` still honors the `PLAYER`, `DISPELLABLE`, and `CROWD_CONTROL`
predicates. Namespaced under `C_UnitAuras` so they never clash with the native
global `UnitBuff` / `UnitDebuff` (which keep their vanilla texture-first return).
### `C_UnitAuras.GetUnitAuraBySpellID(unit, spellID [, filter])`
Linear-searches `unit`'s aura array for the first populated slot
+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
+49 -11
View File
@@ -16,12 +16,15 @@
#include "MinHook.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "luasyntax/StringMethods.h"
#include "model/DisplayInfo.h"
#include "nameplate/Walk.h"
#include "player/NameCache.h"
#include "text/InlineTexture.h"
#include "text/InlineTexturePool.h"
#include "texture/Mask.h"
#include "texture/Transform.h"
#include "tooltip/SetEvents.h"
static Game::FrameScript_Initialize_t FrameScript_Initialize_o = nullptr;
static Game::LoadScriptFunctions_t LoadScriptFunctions_o = nullptr;
@@ -61,10 +64,25 @@ static bool __fastcall FrameScript_Initialize_h() {
// reload destroys every addon texture and the region pool reuses their pointers.
Texture::Transform::PrepareForReload();
// Drop per-region texture masks for the same reason (SetMask).
Texture::Mask::PrepareForReload();
// Drop pending character-dress jobs and their engine compositors — the
// reload destroys every Model frame the jobs point at.
Model::DisplayInfo::PrepareForReload();
// Drop the per-tooltip OnTooltipSet* handler cells — the reload destroys
// and recreates every tooltip object, and the handler refs die with the
// Lua reset (issue #33: the cell table filled after ~18 reloads, then
// recycled tooltip addresses fired dead refs).
Tooltip::SetEvents::PrepareForReload();
// Forget the captured `string` table before the world Lua state resets
// (this hook also fires on /logout, where the state's tables die) —
// string-method resolution fails closed (stock index error) until
// LoadScriptFunctions re-captures.
LuaSyntax::StringMethods::PrepareForReload();
// Persist the name cache before the engine starts tearing down.
// This hook fires on both `/reload` and `/logout` (the engine
// re-initializes Lua state in both cases), giving us a clean
@@ -96,6 +114,7 @@ static void __stdcall LoadGlueScriptFunctions_h() {
Text::InlineTexture::PrepareForReload();
Text::InlineTexturePool::PrepareForReload();
Texture::Transform::PrepareForReload();
Texture::Mask::PrepareForReload();
}
// Every Lua-side `frame:RegisterEvent(...)` is a chance to claim a slot
@@ -127,9 +146,20 @@ static void __fastcall FrameRegisterEvent_h(void *frame, void *edx,
// the latched EnsureInitialized():
// * VanillaFixes' `Load` export, called on the game's main thread after
// injection (the sanctioned VF extension point), or
// * a fallback worker thread we spawn from DllMain, for any other injector
// that never calls `Load`.
// Whichever arrives first installs; the other observes the cached result.
// * a fallback worker thread we spawn from DllMain, ONLY when VanillaFixes
// is not the loader (no VfPatcher.dll in the process), for injectors that
// never call `Load`.
//
// The worker MUST stand down under VanillaFixes. VF creates the process
// suspended and injects every dlls.txt DLL sequentially via remote threads;
// a worker spawned from our DllMain starts the moment our own LoadLibrary
// releases the loader lock — i.e. while VF is still injecting the NEXT
// dlls.txt DLL. Its ~90-prologue install (plus the MH_ApplyQueued
// thread-freeze) then races that DLL's DllMain patching the same engine
// functions, which made SuperWoWhook.dll fail to load whenever ClassicAPI
// preceded it in dlls.txt. `Load` has no such race: VfPatcher calls it on the
// game's main thread after the process resumes, when every DllMain in the
// injection chain has already completed, serialized in dlls.txt order.
// ---------------------------------------------------------------------------
static volatile LONG g_initClaimed = 0; // 0 until a thread takes the installer role
@@ -218,14 +248,22 @@ BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved) {
DisableThreadLibraryCalls(hModule);
// Install nothing here (loader lock — see the block comment above).
// Spawn a worker that installs off the lock; the new thread cannot
// run its body until DllMain returns and the loader lock releases,
// and we never wait on it. Under VanillaFixes the `Load` export
// usually wins the latch first; this worker covers every other
// injector. DisableThreadLibraryCalls suppressed its THREAD_ATTACH.
HANDLE worker = CreateThread(nullptr, 0, InitWorker, nullptr, 0, nullptr);
if (worker != nullptr)
CloseHandle(worker);
//
// Under VanillaFixes, `Load` is the ONLY install trigger. VfPatcher.dll
// is injected before any dlls.txt DLL, so its presence identifies VF as
// the loader; and the `Load` mechanism shipped in the same VF release
// (v1.4) as dlls.txt support itself, so any VF that loaded us will call
// it. Spawning the worker here would race the DllMains of the dlls.txt
// DLLs VF injects after us (see the block comment above).
if (GetModuleHandleW(L"VfPatcher.dll") == nullptr) {
// Any other injector: no `Load` caller exists, so install from a
// worker off the lock. The new thread cannot run its body until
// DllMain returns and the loader lock releases, and we never wait
// on it. DisableThreadLibraryCalls suppressed its THREAD_ATTACH.
HANDLE worker = CreateThread(nullptr, 0, InitWorker, nullptr, 0, nullptr);
if (worker != nullptr)
CloseHandle(worker);
}
} else if (reason == DLL_PROCESS_DETACH) {
// Clean /quit path — only tear down if we actually initialized.
// Flush before MH_Uninitialize — the cache's file I/O uses Win32
+332
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,
@@ -4679,6 +4719,17 @@ enum Offsets {
VAR_LOCALE_INDEX = 0x00C0E080, // 0..8, picks one of the 9 localized strings
// Locale-name string table — a parallel `const char *[8]` indexed by
// VAR_LOCALE_INDEX, holding the exact codes `GetLocale()` returns:
// enUS / koKR / frFR / deDE / zhCN / zhTW / esES / xxYY. This is the
// source `Script_GetLocale` (FUN_0048d8b0) reads:
// lua_pushstring(L, (&PTR_DAT_008558a4)[DAT_00c0e080]);
// Verified by resolving all 8 entries from the binary. Index range is
// 0..7 (VAR_LOCALE_INDEX is documented 0..8, but slot 8 overreads into
// the string pool — clamp to 0..7 before reading). Used by the TOC
// `[TextLocale]` / `[AllowLoadTextLocale]` directives (AddOns::TocRewrite).
VAR_LOCALE_NAME_TABLE = 0x008558A4,
LUA_IS_NUMBER = 0x6F34D0,
LUA_IS_STRING = 0x6F3510,
LUA_TO_NUMBER = 0x6F3620,
@@ -4734,6 +4785,15 @@ enum Offsets {
// CObjects — which is exactly why the engine pushes handler/frame
// through it rather than a plain rawget.
LUA_RAWGETI = 0x6F3BC0,
// `lua_getmetatable(L, objindex)` — pushes the metatable of the value at
// objindex and returns 1, or pushes nothing and returns 0 when it has none.
// Verified in docs/LuaCAPI.md and by the getfenv/getmetatable base-lib
// functions that call it. Used by the `__environment` env-protection hook.
LUA_GET_METATABLE = 0x6F3CF0,
// `lua_getfenv(L, idx)` — pushes the environment table of the function
// (or userdata/thread) at `idx`. The getfenv/setfenv base-lib code calls
// it to read a function's environment before the protection check.
LUA_GET_FENV = 0x6F3D50,
LUA_SET_TABLE = 0x6F3E20,
LUA_RAW_SET = 0x6F3EA0,
LUA_INSERT = 0x6F31A0,
@@ -4800,6 +4860,85 @@ enum Offsets {
// `rawset(nil)` clear leaves it stale and subsequent `table.insert`
// appends past the wiped slots.
LUAL_SETN = 0x6F4EA0,
// `luaL_getn(L, tableIndex)` — Lua 5.0 auxlib table-length getter,
// `int __fastcall(L /*ecx*/, idx /*edx*/)`, plain RET (no stack
// args), length in eax. Verified by disassembly: a negative idx is
// normalized via lua_gettop; tries the `t.n` field (rawget "n"),
// then the registry LUA_SIZES path, then falls back to a linear
// rawgeti scan for the first nil. Counterpart of LUAL_SETN above
// (named there since the table.insert trace). Used by the 5.1
// `unpack(list, i, j)` upgrade for the default range end.
LUAL_GETN = 0x6F5050,
// `luaV_gettable(L, t, key, loop)` — the VM's generic index resolver.
// __fastcall(L /*ecx*/, t /*edx*/, key /*stack*/, loop /*stack*/),
// RET 8. Returns the result TValue* in eax (Ghidra types it void; the
// table-hit path demonstrably reaches the epilogue with eax =
// luaH_get's return — `CALL 0x006fa7a0; CMP [EAX],0; JNZ epilogue` at
// 0x006F7D1D..25). Table case: luaH_get (0x006FA7A0); nil result →
// the metatable continuation FUN_006F7D60, which reads the TABLE'S
// OWN metatable (hvalue+0x08, fast-TM flag byte hvalue+0x06 — 5.0
// fasttm) and returns &nilobject (0x00811BC0) when there is none
// (miss = nil, never an error). Non-table case → the continuation
// below, loop+1. Depth-guarded at 100 ("loop in gettable").
FUN_LUA_V_GETTABLE = 0x006F7CF0,
// The non-table branch of luaV_gettable — stock Lua 5.0's "index a
// non-table value" continuation. Same convention/return as
// FUN_LUA_V_GETTABLE (result TValue* in eax, RET 8). Body: __index
// tag method via luaT_gettmbyobj (0x006F7BD0 — which HARDCODES
// &nilobject for anything but table/userdata; stock 5.0, nothing
// stripped); nil → luaG_typeerror "attempt to index" (0x006FC620);
// function TM → call it; table TM → recurse FUN_LUA_V_GETTABLE with
// `loop` passed through UNCHANGED (0x006F7F23). Exactly 3 call
// sites: luaV_gettable's else-branch + luaV_execute's OP_GETTABLE /
// OP_SELF non-table fallbacks (0x006F8A14 / 0x006F8C17 in
// FUN_006F8720) — so it fires ONLY when code indexes a non-table,
// which in stock 5.0 is always about to raise "attempt to index"
// (table `__index` dispatch goes through FUN_006F7D60's fasttm and
// never lands here). A cold, uncontested hook site. Prologue is
// plain PUSH/MOV — MinHook-relocatable. Co-hooked by
// LuaSyntax::StringMethods to backport Lua 5.1 string methods
// (`("x"):upper()`): strings get the `string` library table as their
// __index, exactly 5.1's shared-string-metatable mechanism.
FUN_LUA_INDEX_NONTABLE = 0x006F7EE0,
// `luaL_loadbuffer(L, buff, size, chunkname)` — compiles a source
// buffer into a Lua function on the stack (does NOT run it). Returns
// an int status (0 = ok) in eax; Ghidra types it void because the
// status flows through `lua_load`. __fastcall(L /*ecx*/, buff /*edx*/,
// size /*stack*/, chunkname /*stack*/); strips a leading UTF-8 BOM.
// This is the UNIVERSAL Lua compile chokepoint — every path that turns
// source text into bytecode funnels through it: `luaL_loadstring`
// (0x006F57C0, backs the `loadstring` global) and the three FrameScript
// execute/load funnels (0x00704AE0 / 0x00704C70 / 0x00703280, backing
// `RunScript`, XML `<OnLoad>`/`<OnClick>` handlers, and file-loaded
// addon chunks). Verified via xrefs to `lua_load` (0x006F4320) and its
// two callers (this = loadbuffer, 0x006F5490 = loadfile). Hooking here
// lets a source-level transform see every chunk before the 5.0 parser.
FUN_LUAL_LOADBUFFER = 0x006F5690,
// Return address of the `call luaL_loadbuffer` inside the compile-AND-run
// funnel FUN_00704AE0 (the CALL is at 0x00704B0C; next instruction, and so
// the pushed return address, is 0x00704B11). FUN_00704AE0 compiles a buffer
// then IMMEDIATELY `lua_pcall`s it (0x00704B42) — it is the funnel every
// addon/FrameXML FILE load reaches (FUN_006ede10 -> FUN_00704bc0 -> here)
// AND what `RunScript` runs through. LuaSyntax's `luaL_loadbuffer` hook
// reads `_ReturnAddress()` and arms the addon-args grant ONLY when the
// caller is THIS site — i.e. a chunk that will be run before anything else,
// so its preamble consumes the grant immediately. `loadstring` (0x00703280,
// returns to 0x007032B5) compiles WITHOUT running and takes a
// caller-forgeable chunkname, so it must NOT arm; the call-site check
// excludes it even when nested inside a RunScript body.
RET_LUA_FILE_COMPILE = 0x00704B11,
// Shared environment-protection predicate for `getfenv`/`setfenv`
// (`bool __fastcall(L /*ecx*/)`). Pushes the function-at-top's environment
// via `lua_getfenv(L, -1)`, then pushes the protection marker
// `env["__fenv"]` (a RAW field on the env table) and returns whether it is
// non-nil — net stack effect +2 (env, marker). `getfenv` (`0x00702AC0`)
// returns the marker in place of the real env when set; `setfenv`
// (`0x00702BF8`) raises "cannot change a protected environment" when set.
// We co-hook this ONE predicate to read `getmetatable(env).__environment`
// (raw) instead — the Lua 5.1 form (verified against 3.3.5's
// `FUN_0084F2F0`) — with the raw `__fenv` field kept as a fallback, so both
// consumers gain the 5.1 semantics with the 1.12 behavior preserved.
FUN_LUA_ENV_PROTECT_PREDICATE = 0x00702BA0,
// `lua_checkstack(L, n)` — `int __fastcall(L /*ecx*/, n /*edx*/)`.
// Ensures room for `n` more stack values, growing if needed; returns 0
// (without growing) when `(top-base)/16 + n` would exceed LUA_MAXCSTACK
@@ -4823,6 +4962,19 @@ enum Offsets {
// `BaseLib::StringLib` registers `string.gmatch` as a direct alias of
// this function pointer — no wrapper.
FUN_LUA_STR_GFIND = 0x007FCFA0,
// `str_gsub` — the Lua 5.0 `string.gsub` C function (strlib entry
// at `0x00822DE0`, name string "gsub" at `0x00882230` — the last
// entry before the registration table's NULL terminator, matching
// stock 5.0 order). Verified by decode: args (s, pat, repl, maxN),
// `^` anchor pre-strip, and the luaL_argerror(3) "string or
// function expected" gate. Its add_value helper (`FUN_007fd2a0`)
// matches stock 5.0: a FUNCTION replacement's non-string result is
// popped and NOTHING is appended (match → empty) — 5.1 instead
// keeps the original match for nil/false. That difference is why
// `BaseLib::StringLib`'s 5.1 table-replacement shim wraps the
// pattern in an outer capture (arg 1 = whole match) so its adapter
// closure can hand the match back for missing keys.
FUN_LUA_STR_GSUB = 0x007FD0E0,
// `str_format` — the Lua 5.0 `string.format` C function (`int
// __fastcall(void *L)`), strlib entry at `0x00822dc0`. Verified by decode:
// reads the format at arg 1, handles `%%`, `%d/i/c`, `%e/E/f/g/G`,
@@ -5139,6 +5291,43 @@ enum Offsets {
// call is a no-op when the user has the addon installed on disk.
FUN_TOC_PARSER = 0x0051C9B0,
// Client interface version — `__cdecl() -> uint32` (no args), a bare
// `return 0x2BC0` (11200). The addon loadability resolver `FUN_0051e780`
// compares an addon's parsed `## Interface:` number (entry+0x1C) against
// this; a mismatch is the OUT_OF_DATE reason (code 7) that blocks loading
// while the `checkAddonVersion` cvar is on. `AddOns::TocRewrite` calls it
// to recognize the client version inside a multi-flavor comma-list.
FUN_ADDON_CLIENT_INTERFACE_VERSION = 0x0051D7D0,
// Per-addon file loader — `__fastcall(char *tocPath, int *bindingsCtx,
// int *progress) -> uint32`. `FUN_0051f240` calls it once per addon with
// `Interface\AddOns\<Name>\<Name>.toc`; it reads the TOC and runs each
// referenced Lua/XML file. Vanilla runs it BEFORE the addon's
// SavedVariables are loaded (files execute with SV nil), so
// `Addons::SavedVarsFirst` co-hooks it to honor `## LoadSavedVariablesFirst`
// by loading the SV first for flagged addons.
FUN_ADDON_LOAD_FILES = 0x006EDB90,
// Read + run a Lua file — `__fastcall(const char *path, void *checksumOut,
// void *errCtx) -> uint32`. Reads via `FUN_FILE_READ`, then compiles +
// pcalls; returns 0 (and fires errCtx's callback only when errCtx != null)
// if the file is missing, so it is safe to call on an absent path. How the
// engine loads each SavedVariables `.lua` (and how addon files run).
FUN_LUA_LOAD_FILE = 0x00704BC0,
// File-exists probe — `__stdcall(const char *path, int mode)` (RET 8);
// nonzero when the file exists. `mode = 1` on the addon / SavedVariables
// paths. Used to mirror the engine's SV path fallback (prefer the
// realm-scoped per-character file, else the realm-less one).
FUN_FILE_EXISTS = 0x00648A30,
// `realmName` CVar value reader — no-arg, `-> const char *` (last-connected
// realm's display name; lazily registers the cvar). The realm segment of
// the per-character SavedVariables path. (The character segment is
// FUN_GET_LOGIN_ACCOUNT_NAME, whose label is a misnomer — it returns the
// 0x00C27D88 buffer, NULL until a character is logged in.)
FUN_GET_REALM_NAME = 0x005AB7D0,
// AddOn registry init — `__fastcall(accountName)`. Documented in
// CLAUDE.md ("AddOn registry & hot-reload"). Hooked post-call so
// we can splice the embedded addon entry into the freshly-populated
@@ -6711,6 +6900,31 @@ enum Offsets {
// (case-insensitive, hashed). See FUN_006ee6f0 in Templates.cpp notes.
FUN_XML_TEMPLATE_LOOKUP = 0x006EE6F0,
// `CreateFrame(type [, name, parent, template])` — the global FrameScript
// function at table 0x00872E74 slot 3. Standard `int __fastcall(void *L)`
// shape. Vanilla accepts only a SINGLE template name; the hook in
// `Frame::CreateFrame` splits comma-separated inherits strings so modern
// multi-template calls work (e.g. "UIPanelButtonTemplate,
// SecureActionButtonTemplate"). It builds the frame with the first
// resolved template through the engine, then applies each remaining
// template onto the created object with the engine's OWN inherit
// primitive: `frameObj->vtable[+8](templateDefNode, status)` for content
// (FUN_00769820) and `(frameObj+0x24)->vtable[+8](...)` for the template's
// `<Frames>` children (FUN_0076a060) — exactly the two `__thiscall`
// recursion calls the builder FUN_006ee280 emits when resolving an
// `inherits=`. Finalize/OnLoad (`vtable[+0x24]`) runs once during the
// engine build and is NOT re-run.
FUN_SCRIPT_CREATEFRAME = 0x007060B0,
// XML-build "status" object — a printf-style error/warning accumulator the
// appliers log through (`status->vtable[+0xc](status, level, fmt, ...)`,
// __cdecl varargs). Script_CreateFrame builds one on its stack as the
// 5-dword `{PTR_TEXLOAD_DESC_VTBL, 8, &self+8, (&self+8)|1, 0}` (same shape
// the texture loader reuses; see PTR_TEXLOAD_DESC_VTBL). Reset/emptied by
// `FUN_FRAMESCRIPT_STATUS_RESET` (`__thiscall(this)`), which frees any
// accumulated message nodes and re-stamps the vtable.
FUN_FRAMESCRIPT_STATUS_RESET = 0x00419E30,
// --- Inline texture escape (`|Tpath:h:w:...|t`) backport -------------------
// See src/text/InlineTexture.cpp and docs/InlineTextureEscapes.md. The
// 1.12 text pipeline is a shared `|`-tokenizer feeding per-purpose loops.
@@ -6732,6 +6946,124 @@ enum Offsets {
// Script_Texture_SetTexture's own call site does.
VAR_TEXTURE_BLEND_DEFAULT = 0x00878CF0,
// --- Texture masking (`Texture:SetMask` backport) ---------------------------
// Mirrors the engine's own canonical masked-quad draw: the minimap BLIP mask
// FUN_004eae10 (a sibling of the disc FUN_004ec440; both fed by the mask that
// Minimap:SetMaskTexture at 0x004EE4A0 loads into DAT_00bc7968). FUN_004eae10
// is the exact template — bind base on unit 0, mask on unit 1, force each
// unit's combiner to MODULATE, submit with SEPARATE uv0/uv1, then unbind
// unit 1:
// GxRs(0x17, base); GxRs(0x1F, 1); // unit 0: bind + MODULATE preset
// GxRs(0x18, mask); GxRs(0x20, 1); // unit 1: bind + MODULATE preset
// FUN_0058a2a0(4, corners, …, uv0, uv1); // explicit second UV stream
// GxRs(0x18, 0); // unbind unit 1
// Unit 1's MODULATE combiner multiplies the mask's alpha into the base's
// alpha, so a white-with-alpha mask clips the base to the mask's shape.
//
// The recipe was also pinned by an in-game probe (git history's
// MaskProbe.cpp) on the live backend — findings, load-bearing:
// * The COMBINER PRESET selectors are 0x1F+unit (GXRS_COMBINER0), value 1 =
// MODULATE/MODULATE (the .rdata preset tables at 0x0080a25c/0x0080a274,
// consumed by the D3D9 combiner apply FUN_005a2dd0: COLOROP table
// {4,4,13,7,5,12}, ALPHAOP {3,4,3,7,5,3} — value 1 is the only preset
// that MODULATEs the alpha channel too, which is what masking rides).
// Binding a texture on unit 1 alone happens to give MODULATE by default,
// but FUN_004eae10 sets it EXPLICITLY — we mirror that.
// * The SECOND vertex UV stream (uv1) DOES feed unit 1 (FUN_004eae10
// passes a distinct uv1 = this+0x50), so uv1 = uv0 makes unit 1 sample
// the mask at the base's own texcoords — exactly SetMask semantics.
// uv1 is the LAST explicit UV slot that exists: the vertex-format
// component table at 0x008097A8 (13 formats × 13 components) has no
// format with a third UV set, so masks past the first CANNOT ride an
// explicit stream — they use texgen (below).
GXRS_TEXTURE0 = 0x17, // texture bind, +unit 0..7 (via FUN_GX_RS_SET_PTR)
GXRS_COMBINER0 = 0x1F, // combiner preset, +unit 0..7 (via FUN_GX_RS_SET)
GXRS_COMBINE_MODULATE = 1, // preset value: COLOROP=MODULATE, ALPHAOP=MODULATE
// GxRs setters — `__fastcall(selector, value)`. The int form (0x00589E60)
// writes combiner presets and the like; the pointer form (0x00589E80) writes
// texture binds.
FUN_GX_RS_SET = 0x00589E60,
FUN_GX_RS_SET_PTR = 0x00589E80,
// --- Texgen + per-unit texture matrices (multi-mask / rotated masks) -------
// Decoded from the D3D9 backend's draw-time state flush FUN_005a2f00 (the
// selector → SetTextureStageState/SetTransform Rosetta stone) and the
// minimap DISC draw FUN_004ec440, which is the engine's own texgen-mask
// template (it submits positions+colors ONLY and feeds BOTH units via
// texgen + a per-unit texture matrix). Selector namespace is five per-unit
// families, each 8 units wide (device supports 8 fixed-function stages;
// live cap at [VAR_GX_DEVICE]+OFF_GXDEV_STAGE_CAP):
// 0x17+u bind 0x1F+u combiner 0x27+u mip-bias 0x2F+u texgen
// 0x37+u coord-transform mode
// Texgen values (FUN_005a2b40): 0 = passthrough (stage reads vertex UV set
// #u — the default; why stage 1 sees the explicit uv1 stream), 1 = OBJECT-
// space position (D3D TCI_CAMERASPACEPOSITION + an auto inv(view)·inv(model)
// basis matrix — UV becomes a pure function of the RAW vertex position,
// immune to whatever model/view the pass set), 2 = world-space position,
// 3 = camera-space position (identity basis; what the disc draw uses),
// 5/4/6 = normal/reflection.
// Coord-transform values (FUN_005a14e0): 0 = off, 1 = apply the unit's
// texture matrix (basis × user matrix, D3DTTFF_COUNT3), 2 = projected.
// Per-unit texture-matrix STACKS (4 levels, base ctx+0xFD0+unit*0x118;
// slot 8 = model, 10 = view) — the disc draw's push/translate/scale/pop:
GXRS_TEXGEN0 = 0x2F, // texgen mode, +unit (int form)
GXRS_TEXGEN_OBJECT_POS = 1,
GXRS_TEXXFORM0 = 0x37, // coord-transform mode, +unit (int form)
GXRS_TEXXFORM_MATRIX = 1,
// Texture-matrix stack ops — `__fastcall(int unit, ...)`. PUSH duplicates
// the top (max depth 4); PUSH_LOAD pushes then loads an arbitrary 4×4
// (row-vector D3D layout: out = pos·M, translation in row 3). POP restores.
// (PUSH/POP were previously mislabeled FUN_GX_TEXUNIT_ENABLE/_DISABLE —
// they never enabled anything; binding a texture is what enables a stage,
// binding null disables it, per FUN_005a29d0.)
FUN_GX_TEXMTX_PUSH = 0x0058B200, // (unit)
FUN_GX_TEXMTX_PUSH_LOAD = 0x0058B210, // (unit, const float m[16])
FUN_GX_TEXMTX_POP = 0x0058B220, // (unit)
// Gx render-state journal push/pop — brackets a state scope; pop replays
// every selector written since the push back to its prior value. The engine
// wraps each region-layer draw (FUN_0076fb00) and the disc draw in a pair.
FUN_GX_STATE_PUSH = 0x00589F40,
FUN_GX_STATE_POP = 0x00589F50,
// The indexed submit that actually queues the draw for the verts staged by
// FUN_GX_PRIM_STREAMS — `__fastcall(int primType, int indexCount, const
// void *indices)`. Every FUN_GX_PRIM_STREAMS caller invokes it immediately
// after (region layer: FUN_0076fb00; disc: FUN_004ec440). State/matrix
// RESTORES must run AFTER this call, not after the stream call — the disc
// draw pops its matrices/state only past this point, and restoring earlier
// reverts the state the queued draw will be flushed with.
FUN_GX_PRIM_INDEXED = 0x0058A2E0,
// The device/context singleton the Gx wrappers route through (set at
// backend creation, FUN_00589ac0). +0x23C holds the live texture-stage cap
// the per-stage applies clamp against (FUN_005a29d0/FUN_005a2b40).
VAR_GX_DEVICE = 0x00C0ED38,
OFF_GXDEV_STAGE_CAP = 0x23C,
// The vertex-stream primitive every textured region draws through
// (FUN_0076fb00 calls it per region). Decompiler signature (13 params):
// (count, positions, posStride, s3, s3Stride, colors, colorStride, drop8,
// drop9, uv0, uv0Stride, uv1, uv1Stride); RET 0x2C. The inner FUN_0058a3d0
// builds an interleaved vertex from whichever of {s3, colors, uv0, uv1} are
// non-null — so uv1 is a real, buildable second UV slot no engine caller
// populates. `positions` for a region draw is the region's corner array
// (region + OFF_SIMPLETEXTURE_CORNERS), which is how the mask co-hook
// recovers the owning region: region = positions - OFF_SIMPLETEXTURE_CORNERS
// (verified: FUN_00772fd0 appends the batch entry with positions =
// region+0xD4, and FUN_0076fb00 forwards that pointer here).
FUN_GX_PRIM_STREAMS = 0x0058A2A0,
// Path → HTEXTURE loader trio (re-adds of the quad-era offsets removed in
// 56c2670, with the same derivations — see docs/InlineTextureEscapes.md):
// FUN_TEXTURE_LOAD_BY_PATH(path, &desc, flags, 0, 1) __fastcall, desc =
// the 5-dword {PTR_TEXLOAD_DESC_VTBL, 8, &self8, (&self8)|1, 0} on-stack
// node FUN_00770200 builds; flags via FUN_GX_TEXFLAGS_INIT(&flags, blend,
// 0,0, 0,0,0, 1, 0) __thiscall. Never returns null (engine substitutes a
// fallback texture). FUN_TEXTURE_GET_RENDERABLE(hTex, 1, 0) __fastcall
// returns the bindable CGxTex at [hTex+0x140] and drives the streaming
// load — calling it each frame IS the residency reference (what the
// minimap's masked draw does for its mask at 0x004ec65c).
FUN_TEXTURE_LOAD_BY_PATH = 0x00449D90,
PTR_TEXLOAD_DESC_VTBL = 0x007FFA10,
FUN_GX_TEXFLAGS_INIT = 0x0058A980,
FUN_TEXTURE_GET_RENDERABLE = 0x0044ACF0,
// --- Inline-texture positioning (slice 1: measure/emit integration) --------
// The 1.12 text pipeline builds a layout as a list of render "nodes" (one
// per wrapped line); each node owns per-font-page glyph vertex batches and a
+55 -48
View File
@@ -47,6 +47,11 @@
#include "Game.h"
#include "Offsets.h"
#include "addons/EngineIO.h"
#include "addons/FlavorBindings.h"
#include "addons/FlavorToc.h"
#include "addons/Toc.h"
#include "addons/TocRewrite.h"
#include "bindings/Inject.h"
#include <cstdint>
@@ -111,30 +116,15 @@ const ClassicAPIFiles::File *LookupEmbedded(const char *suffix) {
return nullptr;
}
// Storm allocator — same one `FUN_FILE_READ` uses internally. Buffer
// allocated here is freed cleanly by the caller's standard `SMemFree`
// (`FUN_STORM_SMEM_FREE`). `__stdcall` per the function's `RET 0x10`
// (4 args × 4 bytes = 16) epilogue.
// __stdcall void *SMemAlloc(size_t size, const char *file, int line, int flags)
using SMemAlloc_t = void *(__stdcall *)(size_t size, const char *file, int line, int flags);
// Engine file I/O + Storm allocator — see addons/EngineIO.h for the shapes and
// the __stdcall / ESP-drift hazard. A buffer we SMemAlloc here is freed cleanly
// by the caller's standard SMemFree in turn.
using AddOns::EngineIO::FileReadFn;
using AddOns::EngineIO::SMemAllocFn;
using AddOns::EngineIO::SMemFreeFn;
// `FUN_FILE_READ` — same shape derived from the Octo decompile.
// Calling convention is `__stdcall` (callee cleans 28 bytes via
// `RET 0x1C`), not `__cdecl` — confirmed via the function epilogue.
// Getting this wrong silently corrupts the caller's stack frame.
// __stdcall int FileRead(int unused, const char *path, void **outBuf,
// size_t *outSize, size_t extraBytes,
// int flag1, int flag2)
using FileRead_t = int(__stdcall *)(int unused, const char *path, void **outBuf,
size_t *outSize, size_t extraBytes,
int flag1, int flag2);
FileRead_t FileRead_o = nullptr;
// Storm-allocator free. Used to release the disk-TOC buffer after we
// scrape its version line — same path the engine itself uses when
// `FileRead_o` succeeded and the caller's done with the result.
// __stdcall void SMemFree(void *buf, const char *file, int line, int flags)
using SMemFree_t = void(__stdcall *)(void *buf, const char *file, int line, int flags);
// The trampoline to the original FUN_FILE_READ (installed by the co-hook below).
FileReadFn FileRead_o = nullptr;
// Extracts the value of the `## Version: X` line from a TOC byte
// buffer. Writes into `out` (size `outSize`) and returns true on
@@ -142,25 +132,19 @@ using SMemFree_t = void(__stdcall *)(void *buf, const char *file, int line, int
// sensitive on the key. Returns false if no Version line is found.
bool ExtractTocVersion(const char *content, size_t size,
char *out, size_t outSize) {
static const char kKey[] = "## Version:";
constexpr size_t kKeyLen = sizeof(kKey) - 1;
for (size_t i = 0; i + kKeyLen <= size; ++i) {
const bool atLineStart = (i == 0) || content[i - 1] == '\n';
if (!atLineStart) continue;
if (std::memcmp(content + i, kKey, kKeyLen) != 0) continue;
const char *p = content + i + kKeyLen;
const char *end = content + size;
while (p < end && (*p == ' ' || *p == '\t')) ++p;
size_t j = 0;
while (p < end && *p != '\r' && *p != '\n' && j + 1 < outSize) {
out[j++] = *p++;
}
while (j > 0 && (out[j - 1] == ' ' || out[j - 1] == '\t')) --j;
out[j] = '\0';
return j > 0;
const char *v = nullptr;
size_t n = 0;
if (outSize == 0)
return false;
if (!AddOns::Toc::FindValue(content, size, "## Version:", &v, &n) || n == 0) {
out[0] = '\0';
return false;
}
if (outSize > 0) out[0] = '\0';
return false;
if (n >= outSize)
n = outSize - 1;
std::memcpy(out, v, n);
out[n] = '\0';
return true;
}
// Returns -1/0/+1 for `a < b` / `a == b` / `a > b`. "DEV" is the
@@ -229,7 +213,7 @@ bool DiskHasDevMarker() {
const int ok = FileRead_o(0, fullPath, &buf, &size, 1, 1, 0);
if (ok == 0 || buf == nullptr)
return false;
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
auto SMemFree = reinterpret_cast<SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE);
SMemFree(buf, __FILE__, __LINE__, 0);
return true;
}
@@ -274,7 +258,7 @@ void DecideSource() {
// Free the disk buffer we just borrowed — we only needed it for
// the version-line scrape. The actual content for the engine's
// TOC read comes through the regular hook path below.
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
auto SMemFree = reinterpret_cast<SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE);
SMemFree(diskBuf, __FILE__, __LINE__, 0);
// Missing or unparseable disk version → assume it's older than
@@ -301,10 +285,33 @@ int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
const char *suffix = StripAddonPrefix(path);
if (suffix == nullptr) {
// Path isn't under `Interface\AddOns\!!!ClassicAPI\` — straight
// pass-through, we don't care.
return FileRead_o(unused, path, outBuf, outSize, extraBytes,
flag1, flag2);
// Not our embedded addon. If this is a base-TOC read for a multi-flavor
// addon that ships only `<Name>_Turtle.toc` / `_ClassicAPI.toc`, serve
// that in place of the missing `<Name>.toc` so it registers and loads
// (see addons/FlavorToc.h).
int result;
if (AddOns::FlavorToc::TryHandle(unused, path, outBuf, outSize,
extraBytes, flag1, flag2, FileRead_o)) {
result = 1;
} else if (AddOns::FlavorBindings::TryHandle(unused, path, outBuf, outSize,
extraBytes, flag1, flag2,
FileRead_o)) {
// Flavor-specific addon keybindings: serve `Bindings_Turtle.xml` /
// `Bindings_ClassicAPI.xml` for a `…\AddOns\<Name>\Bindings.xml`
// read (see addons/FlavorBindings.h). The paired exists-check
// co-hook lives in that module.
result = 1;
} else {
result = FileRead_o(unused, path, outBuf, outSize, extraBytes,
flag1, flag2);
}
// Per-line TOC directives: evaluate `[Condition]` and expand
// `[Variable]` tokens on this addon TOC's file lines so the load
// pass sees clean paths (see addons/TocRewrite.h). No-op unless
// `path` is an addon `.toc` that actually contains directives.
if (result != 0)
AddOns::TocRewrite::Transform(path, outBuf, outSize);
return result;
}
DecideSource();
@@ -331,7 +338,7 @@ int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
flag1, flag2);
}
auto SMemAlloc = reinterpret_cast<SMemAlloc_t>(
auto SMemAlloc = reinterpret_cast<SMemAllocFn>(
Offsets::FUN_STORM_SMEM_ALLOC);
const size_t totalSize = entry->size + extraBytes;
void *buf = SMemAlloc(totalSize, __FILE__, __LINE__, 0);
+41
View File
@@ -0,0 +1,41 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstddef>
// One home for the engine file-I/O and Storm-allocator function-pointer types
// the addon modules call. These are ABI-critical: FUN_FILE_READ /
// FUN_FILE_EXISTS / the Storm allocator are all `__stdcall` (callee cleans the
// stack), and declaring one `__cdecl` by mistake drifts ESP a few bytes per
// call and kills the process with no crash log. The shape lived in ~6 copies
// across the addon files; centralize it so the convention cannot drift.
namespace AddOns::EngineIO {
// FUN_FILE_READ (0x00648620) — __stdcall, callee cleans 28 bytes (RET 0x1C).
// arg0 is an optional archive handle (0 = merged VFS); outSize may be null.
using FileReadFn = int(__stdcall *)(int unused, const char *path, void **outBuf,
size_t *outSize, size_t extraBytes,
int flag1, int flag2);
// FUN_FILE_EXISTS (0x00648A30) — __stdcall, RET 8.
using FileExistsFn = int(__stdcall *)(const char *path, int mode);
// FUN_STORM_SMEM_ALLOC / FUN_STORM_SMEM_FREE — __stdcall, RET 0x10. Buffers
// FUN_FILE_READ hands out are freed with SMemFreeFn; a buffer we allocate with
// SMemAllocFn is freed cleanly by the engine's own SMemFree in turn.
using SMemAllocFn = void *(__stdcall *)(size_t size, const char *file, int line, int flags);
using SMemFreeFn = void(__stdcall *)(void *buf, const char *file, int line, int flags);
} // namespace AddOns::EngineIO
+130
View File
@@ -0,0 +1,130 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "addons/EngineIO.h"
#include "addons/FlavorBindings.h"
#include "addons/Toc.h"
#include "Game.h"
#include "Offsets.h"
#include "turtle/Detect.h"
#include <cstddef>
#include <cstdint>
#include <cstring>
namespace AddOns::FlavorBindings {
namespace {
using AddOns::Toc::EqCI;
using AddOns::Toc::Lower;
constexpr size_t NPOS = static_cast<size_t>(-1);
// If `path` is an addon Bindings read (`…\AddOns\<Name>\Bindings.xml`, CI),
// return the length of the prefix up to the `Bindings.xml` filename (so a
// flavor filename can be appended in its place); else NPOS.
size_t AddonBindingsPrefix(const char *path) {
if (path == nullptr) return NPOS;
const size_t len = std::strlen(path);
const char *kFile = "bindings.xml"; // 12 chars, compared case-insensitively
const size_t flen = 12;
if (len < flen + 1) return NPOS;
for (size_t i = 0; i < flen; ++i)
if (Lower(path[len - flen + i]) != kFile[i]) return NPOS;
const size_t prefixLen = len - flen;
if (path[prefixLen - 1] != '\\' && path[prefixLen - 1] != '/')
return NPOS; // "Bindings.xml" must be a whole path segment
for (size_t i = 0; i + 8 <= len; ++i)
if (EqCI(path + i, 8, "\\AddOns\\")) return prefixLen;
return NPOS;
}
// Write `<prefix><flavorFile>` (e.g. `…\AddOns\Foo\Bindings_Turtle.xml`) into
// `out`. Returns false if it would not fit.
bool BuildFlavorPath(const char *path, size_t prefixLen, const char *flavorFile,
char *out, size_t outSize) {
const size_t flen = std::strlen(flavorFile);
if (prefixLen + flen + 1 > outSize) return false;
std::memcpy(out, path, prefixLen);
std::memcpy(out + prefixLen, flavorFile, flen + 1); // incl. NUL
return true;
}
// Fills `order` with the flavor filenames to probe, most specific first, and
// returns the count. `Bindings_Turtle.xml` only on a Turtle client.
int FlavorOrder(const char *(&order)[2]) {
int count = 0;
if (Turtle::Detected())
order[count++] = "Bindings_Turtle.xml";
order[count++] = "Bindings_ClassicAPI.xml";
return count;
}
// Engine file-exists probe FUN_00648a30 — __stdcall(path, mode) (RET 8).
using EngineIO::FileExistsFn;
FileExistsFn g_origExists = nullptr;
// Co-hook the exists-check so `Interface\AddOns\<Name>\Bindings.xml` reports as
// existing when the base file is absent but a flavor variant is present — so
// the loader (`FUN_004b6f70`, then our TryHandle redirect) runs for a
// flavor-only addon. Every non-Bindings path passes straight through.
int __stdcall FileExists_h(const char *path, int mode) {
const size_t prefixLen = AddonBindingsPrefix(path);
if (prefixLen == NPOS)
return g_origExists(path, mode);
if (g_origExists(path, mode) != 0)
return 1; // the plain Bindings.xml exists
const char *order[2];
const int count = FlavorOrder(order);
char cand[512];
for (int i = 0; i < count; ++i) {
if (BuildFlavorPath(path, prefixLen, order[i], cand, sizeof cand) &&
g_origExists(cand, mode) != 0)
return 1;
}
return 0;
}
const Game::HookAutoRegister _hookExists{
Offsets::FUN_FILE_EXISTS,
reinterpret_cast<void *>(&FileExists_h),
reinterpret_cast<void **>(&g_origExists)};
} // namespace
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
size_t extraBytes, int flag1, int flag2, FileReadFn orig) {
const size_t prefixLen = AddonBindingsPrefix(path);
if (prefixLen == NPOS)
return false;
const char *order[2];
const int count = FlavorOrder(order);
char cand[512];
for (int i = 0; i < count; ++i) {
if (!BuildFlavorPath(path, prefixLen, order[i], cand, sizeof cand))
continue;
// Serve the flavor file in place of Bindings.xml (a selected flavor
// wins over a plain Bindings.xml). No recursion: `orig` is the original
// reader passed by the FUN_FILE_READ hook.
if (orig(unused, cand, outBuf, outSize, extraBytes, flag1, flag2) != 0)
return true;
}
return false; // no flavor variant — caller reads the base Bindings.xml
}
} // namespace AddOns::FlavorBindings
+55
View File
@@ -0,0 +1,55 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstddef>
#include "addons/EngineIO.h"
namespace AddOns::FlavorBindings {
using EngineIO::FileReadFn;
// Flavor-specific addon Bindings selection — the Bindings.xml analog of
// AddOns::FlavorToc.
//
// A modern addon can ship a flavor-specific keybinding file
// (`Bindings_Turtle.xml` / `Bindings_ClassicAPI.xml`) instead of / alongside a
// plain `Bindings.xml`. The per-addon loader `FUN_0051f240` only ever builds
// `Interface\AddOns\<Name>\Bindings.xml`, gates it on an exists-check
// (`FUN_00648a30`), and — when that passes — loads it via `FUN_004b6f70`, which
// reads the file through `FUN_FILE_READ`. Two pieces make flavor files work:
//
// 1. This TryHandle, called from the FUN_FILE_READ hook: when `path` is an
// addon `…\AddOns\<Name>\Bindings.xml` read, serve a flavor variant in its
// place if one exists (a selected flavor wins over a plain `Bindings.xml`,
// matching FlavorToc). Probe order `Bindings_Turtle.xml` (only when
// Turtle::Detected) then `Bindings_ClassicAPI.xml`.
// 2. A co-hook on the exists-check `FUN_00648a30` (inside FlavorBindings.cpp):
// report `Bindings.xml` as existing when the base is absent but a flavor
// variant is present — otherwise the loader is never called for a
// flavor-only addon.
//
// Only `_Turtle` / `_ClassicAPI` are selected — never `_Vanilla` / `_Classic`
// (which target the 1.15 Classic Era build; its bindings reference handlers
// that build defines and this client does not load, so they would bind to
// missing functions). Both suffixes are ClassicAPI conventions, opt-in by the
// addon author, and mirror FlavorToc.
//
// Returns false when `path` is not an addon Bindings.xml read or no flavor
// variant exists — the caller then performs the normal base read.
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
size_t extraBytes, int flag1, int flag2, FileReadFn orig);
} // namespace AddOns::FlavorBindings
+108
View File
@@ -0,0 +1,108 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "addons/FlavorToc.h"
#include "turtle/Detect.h"
#include <cstring>
namespace AddOns::FlavorToc {
namespace {
// If `path` is `…\AddOns\<Name>\<Name>.toc` (case-insensitive), set `*prefixLen`
// to the byte length of the path up to and including the base `<Name>` filename
// (so a `_Vanilla.toc` suffix can be appended in place) and return true.
bool ParseBaseToc(const char *path, size_t *prefixLen) {
const size_t len = std::strlen(path);
if (len < 5 || _strnicmp(path + len - 4, ".toc", 4) != 0)
return false;
// Last separator -> "<Name>.toc" file part.
size_t lastSep = static_cast<size_t>(-1);
for (size_t i = len; i-- > 0;)
if (path[i] == '\\' || path[i] == '/') { lastSep = i; break; }
if (lastSep == static_cast<size_t>(-1))
return false;
const char *file = path + lastSep + 1; // "<Name>.toc"
const size_t nameLen = (len - (lastSep + 1)) - 4; // strip ".toc"
if (nameLen == 0)
return false;
// Previous separator -> "<Name>" folder segment; it must equal the base name.
size_t prevSep = static_cast<size_t>(-1);
for (size_t i = lastSep; i-- > 0;)
if (path[i] == '\\' || path[i] == '/') { prevSep = i; break; }
if (prevSep == static_cast<size_t>(-1))
return false;
const char *folder = path + prevSep + 1;
const size_t folderLen = lastSep - (prevSep + 1);
if (folderLen != nameLen || _strnicmp(folder, file, nameLen) != 0)
return false;
// Grandparent segment must be "AddOns" (so FrameXML/other .toc reads pass through).
size_t prev2Sep = static_cast<size_t>(-1);
for (size_t i = prevSep; i-- > 0;)
if (path[i] == '\\' || path[i] == '/') { prev2Sep = i; break; }
if (prev2Sep == static_cast<size_t>(-1))
return false;
const char *gp = path + prev2Sep + 1;
const size_t gpLen = prevSep - (prev2Sep + 1);
if (gpLen != 6 || _strnicmp(gp, "AddOns", 6) != 0)
return false;
*prefixLen = lastSep + 1 + nameLen; // "…\AddOns\<Name>\<Name>"
return true;
}
} // namespace
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
size_t extraBytes, int flag1, int flag2, FileReadFn orig) {
if (path == nullptr)
return false;
size_t prefixLen = 0;
if (!ParseBaseToc(path, &prefixLen))
return false;
char cand[512];
if (prefixLen + 16 >= sizeof cand) // "_ClassicAPI.toc" + NUL is 16 (longest)
return false; // pathological path length — let base read run
std::memcpy(cand, path, prefixLen);
// Only flavors that describe THIS environment, most specific first:
// `_Turtle` — Turtle client/content, only when Turtle is detected;
// `_ClassicAPI` — 1.12 + ClassicAPI, ALWAYS (ClassicAPI is present by
// definition — it is the code doing this redirect).
// Deliberately NOT `_Vanilla`/`_Classic` (those target the 1.15 Classic Era
// client — a modern engine + full modern API — which this 1.12 +
// partial-backport client is not), nor `_Mainline`/`_Cata`/etc.
const char *order[2];
int count = 0;
if (Turtle::Detected())
order[count++] = "_Turtle.toc";
order[count++] = "_ClassicAPI.toc";
for (int i = 0; i < count; ++i) {
std::memcpy(cand + prefixLen, order[i], std::strlen(order[i]) + 1); // incl. NUL
if (orig(unused, cand, outBuf, outSize, extraBytes, flag1, flag2) != 0)
return true; // served the flavor TOC in place of the base
}
// No matching flavor TOC — fall through: the caller reads the base path,
// which chains through any other FUN_FILE_READ hooker before the engine.
return false;
}
} // namespace AddOns::FlavorToc
+60
View File
@@ -0,0 +1,60 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstddef>
#include "addons/EngineIO.h"
namespace AddOns::FlavorToc {
using EngineIO::FileReadFn;
// Modern-client multi-flavor TOC selection, backported.
//
// A modern addon that targets several game flavors from one folder ships
// `<Name>_Vanilla.toc` / `_Mainline.toc` / `_Cata.toc` / … and often NO plain
// `<Name>.toc`. Vanilla 1.12 only ever opens `<Name>\<Name>.toc`, so such an
// addon never registers — it is invisible, not merely mis-loaded.
//
// Called from the FUN_FILE_READ hook. If `path` is an addon base-TOC read
// (`…\AddOns\<Name>\<Name>.toc`), probe the flavor TOCs that describe THIS
// environment and, if one exists, serve it via `orig` (as if it were the base
// TOC) and return true. Order, most specific first:
// `<Name>_Turtle.toc` — Turtle client/content, ONLY when Turtle is
// detected (Turtle::Detected); a ClassicAPI extension;
// `<Name>_ClassicAPI.toc` — 1.12 + ClassicAPI, ALWAYS (ClassicAPI is present
// by definition — it is the code doing this redirect).
// Both are ClassicAPI conventions, not retail flavors. We deliberately do NOT
// map to `_Vanilla`/`_Classic` (those target the 1.15 Classic Era client — a
// modern engine + full modern API — which a 1.12 + partial-backport client is
// not), nor `_Mainline`/`_Cata`/etc. A selected flavor wins over an existing
// base `<Name>.toc`. If neither flavor exists, TryHandle returns false and the
// caller does the normal base read — which chains through any other DLL that
// hooked FUN_FILE_READ before reaching the engine.
//
// The redirect fires at BOTH the registration scan (glue/char-select) and the
// in-world load pass, which must agree on the flavor. `Turtle::Detected()` is
// therefore consistent across both: it reads the in-world `TURTLE_WOW_VERSION`
// global AND Turtle's glue-time `TURTLE_*` GlueXML globals, so it returns true
// at the glue scan too (see turtle/Detect.h).
//
// Returns false when `path` is not a base-TOC read, or no vanilla flavor TOC
// exists — the caller then performs the normal base read. Because the redirect
// is at the read layer, the addon scan (FUN_0051C9B0), the load pass
// (FUN_006edb90) and GetAddOnMetadata all see the same selected TOC.
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
size_t extraBytes, int flag1, int flag2, FileReadFn orig);
} // namespace AddOns::FlavorToc
+132
View File
@@ -0,0 +1,132 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_AddOns.GetAddOnLocalTable(name)` — the public, gated view of an addon's
// private namespace table (the `local _, addonTable = ...` second vararg the
// transpiler injects; see luasyntax/AddonNamespace.h).
//
// Retail added this in 11.0 alongside the `## AllowAddOnTableAccess` TOC
// directive: one addon can read another's local table ONLY when the target
// opts in. We mirror that gate:
// 1. the named addon is currently LOADED, and
// 2. its `.toc` declares `## AllowAddOnTableAccess: 1`.
// Either check failing returns nil. The internal preamble path (an addon
// receiving its OWN table) is a different, ungated function (`__addonns`) —
// an addon never needs permission for its own namespace.
//
// Directive spelling verified against the 1.15.8 Classic Era binary (both the
// `AllowAddOnTableAccess:` TOC key and the `GetAddOnLocalTable` name are
// present there). The vanilla 1.12 TOC parser drops unknown `##` directives,
// so we scan the `.toc` ourselves via `FUN_FILE_READ`.
#include "Game.h"
#include "Offsets.h"
#include "addons/EngineIO.h"
#include "addons/Toc.h"
#include "luasyntax/AddonNamespace.h"
#include <cstddef>
#include <cstdint>
#include <cstdio>
namespace AddOns::LocalTable {
namespace {
// IS_LOADED is a by-name hash lookup returning the loaded byte (`entry+0x18`);
// passing the name string works because the lookup hashes it. See Offsets.h.
using IsLoadedFn_t = uint8_t(__fastcall *)(const char *name);
// FUN_FILE_READ / the Storm free (see addons/EngineIO.h). Calling the raw
// FUN_FILE_READ address routes through Embedded's file-read hook, which forwards
// every non-`!!!ClassicAPI` path to the original untouched — what we want here.
using AddOns::EngineIO::FileReadFn;
using AddOns::EngineIO::SMemFreeFn;
// True iff the addon's `.toc` has a line `## AllowAddOnTableAccess: <nonzero>`.
// The value is read as a TOC boolean flag: parse the leading integer, true iff
// non-zero (so `1` opts in, `0`/absent do not) — matching WoW's numeric TOC
// flag semantics. Not cached: GetAddOnLocalTable is not a hot path.
bool TocAllowsTableAccess(const char *name) {
// A registered, loaded addon name never carries a path separator; reject
// any that does so the read stays bounded to the addon's own folder.
for (const char *p = name; *p; ++p)
if (*p == '\\' || *p == '/')
return false;
char path[300];
std::snprintf(path, sizeof path, "Interface\\AddOns\\%s\\%s.toc", name, name);
void *buf = nullptr;
size_t size = 0;
auto fileRead = reinterpret_cast<FileReadFn>(Offsets::FUN_FILE_READ);
if (fileRead(0, path, &buf, &size, 1, 1, 0) == 0 || buf == nullptr)
return false;
const char *v = nullptr;
size_t n = 0;
bool allowed = false;
if (AddOns::Toc::FindValue(static_cast<const char *>(buf), size,
"## AllowAddOnTableAccess:", &v, &n)) {
int value = 0;
bool anyDigit = false;
for (size_t k = 0; k < n && v[k] >= '0' && v[k] <= '9'; ++k) {
value = value * 10 + (v[k] - '0');
anyDigit = true;
}
allowed = anyDigit && value != 0;
}
auto smemFree = reinterpret_cast<SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE);
smemFree(buf, __FILE__, __LINE__, 0);
return allowed;
}
// `C_AddOns.GetAddOnLocalTable(name)` -> table, or nil.
//
// Name (string) input only — the local table is a by-name concept (one addon
// asking for another by name), and the namespace map is name-keyed. Numeric
// index is not accepted (returns nil); no addon references another's table by
// load order.
int __fastcall Script_GetAddOnLocalTable(void *L) {
const char *name = Game::Lua::ToString(L, 1);
if (name == nullptr || *name == '\0') {
Game::Lua::PushNil(L);
return 1;
}
auto isLoaded = reinterpret_cast<IsLoadedFn_t>(Offsets::FUN_ADDON_IS_LOADED);
if (isLoaded(name) == 0) { // not loaded (covers "does not exist")
Game::Lua::PushNil(L);
return 1;
}
if (!TocAllowsTableAccess(name)) { // opted out / no directive
Game::Lua::PushNil(L);
return 1;
}
LuaSyntax::PushAddonNamespace(L, name);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_AddOns", "GetAddOnLocalTable",
&Script_GetAddOnLocalTable);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace AddOns::LocalTable
+270
View File
@@ -0,0 +1,270 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `## LoadSavedVariablesFirst: 1` — modern TOC directive, backported.
//
// Retail loads a flagged addon's SavedVariables BEFORE its Lua runs, so
// file-scope code (`local db = MyAddonDB`) sees restored config immediately.
// Vanilla 1.12 always does the opposite: the per-addon loader `FUN_0051f240`
// runs the addon's files (`FUN_ADDON_LOAD_FILES`), THEN loads SavedVariables,
// THEN fires `ADDON_LOADED`. So file-scope SV is always nil, and vanilla never
// parses the directive at all.
//
// We co-hook `FUN_ADDON_LOAD_FILES` (the per-addon file load, once per addon,
// in-world). For an addon whose TOC declares `## LoadSavedVariablesFirst`
// (nonzero), load its SavedVariables first — mirroring the engine's OWN path
// construction two steps later in `FUN_0051f240` (verified by disassembly):
// account : WTF\Account\<account>\SavedVariables\<Name>.lua
// per-char: WTF\Account\<account>\<realm>\<character>\SavedVariables\<Name>.lua,
// falling back to WTF\Account\<account>\<character>\SavedVariables\<Name>.lua
// (account = *VAR_ACCOUNT_NAME_PTR, realm = FUN_GET_REALM_NAME,
// character = FUN_GET_LOGIN_ACCOUNT_NAME) — then call the original so the
// files run with SV present. `FUN_LUA_LOAD_FILE` no-ops on a missing file, and
// `FUN_FILE_EXISTS` picks the same per-char variant the engine would.
//
// The engine STILL runs its own SavedVariables load at the normal step, right
// after the files. Left alone, that re-load would overwrite any value the
// addon wrote to its SavedVariable at file scope before ADDON_LOADED. So we
// also co-hook FUN_LUA_LOAD_FILE and suppress that one redundant re-load: each
// path we pre-load is remembered, and the engine's imminent load of that exact
// path is skipped once. This makes the behavior match true
// LoadSavedVariablesFirst — file-scope reads AND writes both survive. Our own
// early load calls the trampoline directly, so it is never self-suppressed.
// FUN_LUA_LOAD_FILE loads FILES only (not RunScript string execution), so the
// extra hook is a cold, addon-load-time path.
#include "Game.h"
#include "Offsets.h"
#include "addons/EngineIO.h"
#include "addons/Toc.h"
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
namespace AddOns::SavedVarsFirst {
namespace {
constexpr size_t NPOS = static_cast<size_t>(-1);
// Engine file I/O via the hooked FUN_FILE_READ (so a flavor TOC's flag is read —
// FlavorToc applies), the exists-check, and the Storm free. See addons/EngineIO.h.
using AddOns::EngineIO::FileExistsFn;
using AddOns::EngineIO::FileReadFn;
using AddOns::EngineIO::SMemFreeFn;
using LoadTocFilesFn = uint32_t(__fastcall *)(char *tocPath, int *a2, int *a3);
using LuaLoadFileFn = uint32_t(__fastcall *)(const char *path, void *a2, void *a3);
using NameFn = const char *(*)(); // no-arg readers (return in EAX)
LoadTocFilesFn g_orig = nullptr;
LuaLoadFileFn g_origLuaLoad = nullptr;
// Full SV paths we pre-loaded whose imminent engine re-load must be skipped
// once. Populated in LoadSavedVarsEarly, drained by LuaLoad_h. Only ever holds
// a handful of entries (one addon's SV files, briefly). Main-thread only.
std::vector<std::string> g_pendingSuppress;
using AddOns::Toc::EqCI;
using AddOns::Toc::Lower;
bool SamePathCI(const char *a, const char *b) {
for (; *a != '\0' && *b != '\0'; ++a, ++b)
if (Lower(*a) != Lower(*b)) return false;
return *a == *b;
}
// Extract "<Name>" from an addon base-TOC path `…\AddOns\<Name>\<Name>.toc`.
// Returns false for anything else (FrameXML.toc, nested paths, …) so the
// SavedVariables reorder only touches real addon loads.
bool AddonNameFromToc(const char *path, char *out, size_t outSize) {
const size_t len = std::strlen(path);
if (len < 4 || !(path[len - 4] == '.' && Lower(path[len - 3]) == 't' &&
Lower(path[len - 2]) == 'o' && Lower(path[len - 1]) == 'c'))
return false;
size_t lastSep = NPOS;
for (size_t i = len; i-- > 0;)
if (path[i] == '\\' || path[i] == '/') { lastSep = i; break; }
if (lastSep == NPOS) return false;
size_t prevSep = NPOS;
for (size_t i = lastSep; i-- > 0;)
if (path[i] == '\\' || path[i] == '/') { prevSep = i; break; }
if (prevSep == NPOS) return false;
size_t gpSep = NPOS;
for (size_t i = prevSep; i-- > 0;)
if (path[i] == '\\' || path[i] == '/') { gpSep = i; break; }
if (gpSep == NPOS) return false;
if (prevSep - (gpSep + 1) != 6 || !EqCI(path + gpSep + 1, 6, "AddOns"))
return false;
const size_t nameLen = lastSep - (prevSep + 1);
if (nameLen == 0 || nameLen + 1 > outSize) return false;
std::memcpy(out, path + prevSep + 1, nameLen);
out[nameLen] = '\0';
return true;
}
// True iff the leading integer of a TOC value is nonzero (WoW numeric flag).
bool NonzeroFlag(const char *v, size_t n) {
long val = 0;
bool digit = false;
for (size_t i = 0; i < n && v[i] >= '0' && v[i] <= '9'; ++i) {
val = val * 10 + (v[i] - '0');
digit = true;
}
return digit && val != 0;
}
// The SavedVariables directives that decide what LoadSavedVarsEarly does.
struct SvFlags {
bool first = false; // `## LoadSavedVariablesFirst:` is nonzero
bool account = false; // `## SavedVariables:` declares at least one variable
bool perChar = false; // `## SavedVariablesPerCharacter:` declares at least one
};
// Read the addon's TOC once (through the hooked FUN_FILE_READ, so a flavor
// TOC's directives are honored) and extract the three flags above.
SvFlags ReadSvFlags(const char *tocPath) {
SvFlags f;
auto FileRead =
reinterpret_cast<FileReadFn>(static_cast<uintptr_t>(Offsets::FUN_FILE_READ));
void *buf = nullptr;
size_t size = 0;
if (FileRead(0, tocPath, &buf, &size, 1, 1, 0) == 0 || buf == nullptr)
return f;
const char *b = static_cast<const char *>(buf);
const char *v = nullptr;
size_t n = 0;
if (AddOns::Toc::FindValue(b, size, "## LoadSavedVariablesFirst:", &v, &n))
f.first = NonzeroFlag(v, n);
// Gate each SV file on the DECLARATION, not mere file existence — mirroring
// the engine, which loads the account file iff `## SavedVariables` is
// declared and the per-char file iff `## SavedVariablesPerCharacter` is.
// (The trailing `:` keeps "## SavedVariables:" from matching the
// "## SavedVariablesPerCharacter:" line.) Otherwise a stale SV file from a
// scope the addon no longer declares would be executed early — Lua the
// engine never runs — and queued for a suppression the engine never drains.
f.account = AddOns::Toc::FindValue(b, size, "## SavedVariables:", &v, &n) && n > 0;
f.perChar =
AddOns::Toc::FindValue(b, size, "## SavedVariablesPerCharacter:", &v, &n) && n > 0;
reinterpret_cast<SMemFreeFn>(static_cast<uintptr_t>(Offsets::FUN_STORM_SMEM_FREE))(
buf, __FILE__, __LINE__, 0);
return f;
}
bool FileExists(const char *path) {
return reinterpret_cast<FileExistsFn>(
static_cast<uintptr_t>(Offsets::FUN_FILE_EXISTS))(path, 1) != 0;
}
// Load a SavedVariables file we found, then mark its path so the engine's own
// re-load of it (right after the addon's files) is suppressed — preserving any
// file-scope write. Calls the trampoline directly so this load is never itself
// suppressed by our LuaLoad_h hook.
void RunLuaFile(const char *path) {
if (g_origLuaLoad == nullptr)
return;
g_origLuaLoad(path, nullptr, nullptr);
g_pendingSuppress.emplace_back(path);
}
// Load the addon's SavedVariables early, mirroring FUN_0051f240's own paths.
// Each scope loads only when its directive is declared (`hasAccount` /
// `hasPerChar`) AND the file exists — exactly what the engine's own SV step
// would re-load, so every suppress entry we queue is later drained.
void LoadSavedVarsEarly(const char *addonName, bool hasAccount, bool hasPerChar) {
const char *account = *reinterpret_cast<const char *const *>(
static_cast<uintptr_t>(Offsets::VAR_ACCOUNT_NAME_PTR));
if (account == nullptr || *account == '\0')
return;
char path[260];
// Account-wide (`## SavedVariables`).
if (hasAccount) {
std::snprintf(path, sizeof path, "WTF\\Account\\%s\\SavedVariables\\%s.lua",
account, addonName);
if (FileExists(path))
RunLuaFile(path);
}
// Per-character (`## SavedVariablesPerCharacter`): prefer the realm-scoped
// file, else the realm-less one — the engine's own fallback order.
if (!hasPerChar)
return;
const char *character = reinterpret_cast<NameFn>(
static_cast<uintptr_t>(Offsets::FUN_GET_LOGIN_ACCOUNT_NAME))();
if (character == nullptr || *character == '\0')
return;
const char *realm = reinterpret_cast<NameFn>(
static_cast<uintptr_t>(Offsets::FUN_GET_REALM_NAME))();
if (realm != nullptr && *realm != '\0') {
std::snprintf(path, sizeof path,
"WTF\\Account\\%s\\%s\\%s\\SavedVariables\\%s.lua", account,
realm, character, addonName);
if (FileExists(path)) {
RunLuaFile(path);
return;
}
}
std::snprintf(path, sizeof path, "WTF\\Account\\%s\\%s\\SavedVariables\\%s.lua",
account, character, addonName);
if (FileExists(path))
RunLuaFile(path);
}
uint32_t __fastcall LoadTocFiles_h(char *tocPath, int *a2, int *a3) {
char name[128];
if (tocPath != nullptr && AddonNameFromToc(tocPath, name, sizeof name)) {
const SvFlags f = ReadSvFlags(tocPath);
if (f.first)
LoadSavedVarsEarly(name, f.account, f.perChar);
}
return g_orig(tocPath, a2, a3);
}
// Skip the engine's one redundant SavedVariables re-load per pre-loaded path.
// Every other file load passes straight through. Match is by full path
// (case-insensitive); a path is suppressed exactly once, so nothing else is
// affected. Our own early load bypasses this via the trampoline.
uint32_t __fastcall LuaLoad_h(const char *path, void *a2, void *a3) {
if (path != nullptr) {
for (size_t i = 0; i < g_pendingSuppress.size(); ++i) {
if (SamePathCI(g_pendingSuppress[i].c_str(), path)) {
g_pendingSuppress.erase(g_pendingSuppress.begin() +
static_cast<std::ptrdiff_t>(i));
return 0; // suppress — the addon's file-scope SV writes survive
}
}
}
return g_origLuaLoad(path, a2, a3);
}
const Game::HookAutoRegister _hook{
Offsets::FUN_ADDON_LOAD_FILES,
reinterpret_cast<void *>(&LoadTocFiles_h),
reinterpret_cast<void **>(&g_orig)};
const Game::HookAutoRegister _hookLua{
Offsets::FUN_LUA_LOAD_FILE,
reinterpret_cast<void *>(&LuaLoad_h),
reinterpret_cast<void **>(&g_origLuaLoad)};
} // namespace
} // namespace AddOns::SavedVarsFirst
+69
View File
@@ -0,0 +1,69 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstddef>
#include <cstring>
namespace AddOns::Toc {
// Fold an ASCII letter to lower case (leaves every other byte unchanged).
inline char Lower(char c) {
return (c >= 'A' && c <= 'Z') ? static_cast<char>(c + 32) : c;
}
// Case-insensitive equality of the `n` bytes at `s` against the NUL-terminated
// `lit` (ASCII). True only when the lengths match too. Shared by the addon
// modules that scan `…\AddOns\<Name>\…` paths and TOC directive lines.
inline bool EqCI(const char *s, size_t n, const char *lit) {
for (size_t i = 0; i < n; ++i)
if (lit[i] == '\0' || Lower(s[i]) != Lower(lit[i]))
return false;
return lit[n] == '\0';
}
// Scans an in-memory TOC buffer for `directive` (the full line prefix, e.g.
// "## Version:") at a line start, case-insensitively, and returns the value
// that follows via out-params: [*valStart, *valStart + *valLen), trimmed of
// leading and trailing spaces/tabs and excluding the CR/LF/EOF terminator.
// Returns false (out-params untouched) when the directive is absent. Pure —
// reads no files; the caller owns the buffer (via FUN_FILE_READ, or an embedded
// copy). Shared by addons/LocalTable.cpp (AllowAddOnTableAccess) and
// addons/Embedded.cpp (Version) so the line-scan lives in one place.
inline bool FindValue(const char *buf, size_t size, const char *directive,
const char **valStart, size_t *valLen) {
const size_t dlen = std::strlen(directive);
if (dlen == 0)
return false;
for (size_t i = 0; i + dlen <= size; ++i) {
const bool atLineStart = (i == 0) || buf[i - 1] == '\n';
if (!atLineStart || _strnicmp(buf + i, directive, dlen) != 0)
continue;
const char *p = buf + i + dlen;
const char *end = buf + size;
while (p < end && (*p == ' ' || *p == '\t'))
++p;
const char *v = p;
while (p < end && *p != '\r' && *p != '\n')
++p;
while (p > v && (p[-1] == ' ' || p[-1] == '\t'))
--p;
*valStart = v;
*valLen = static_cast<size_t>(p - v);
return true;
}
return false;
}
} // namespace AddOns::Toc
+297
View File
@@ -0,0 +1,297 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "addons/EngineIO.h"
#include "addons/TocRewrite.h"
#include "addons/Toc.h"
#include "Offsets.h"
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <string>
namespace AddOns::TocRewrite {
namespace {
using AddOns::EngineIO::SMemAllocFn;
using AddOns::EngineIO::SMemFreeFn;
using AddOns::Toc::EqCI;
using AddOns::Toc::Lower;
bool IsSpace(char c) { return c == ' ' || c == '\t'; }
// The client locale code ("enUS", "frFR", …) — the exact string
// GetLocale() returns (Offsets.h VAR_LOCALE_NAME_TABLE derivation).
const char *ClientLocale() {
uint32_t idx = *reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
if (idx > 7) idx = 0; // slot 8 overreads the string pool — clamp
const char *const *table = reinterpret_cast<const char *const *>(
static_cast<uintptr_t>(Offsets::VAR_LOCALE_NAME_TABLE));
const char *s = table[idx];
return (s != nullptr) ? s : "enUS";
}
// True iff the comma-separated `list` (spaces allowed) contains `token` (CI).
bool ListContains(const char *list, size_t listLen, const char *token) {
size_t i = 0;
while (i < listLen) {
while (i < listLen && (IsSpace(list[i]) || list[i] == ',')) ++i;
const size_t start = i;
while (i < listLen && list[i] != ',') ++i;
size_t end = i; // [start, end) is one item, maybe with trailing space
while (end > start && IsSpace(list[end - 1])) --end;
if (end > start && EqCI(list + start, end - start, token)) return true;
}
return false;
}
// A single-word `[Token]` variable expansion, or nullptr if `head` is not
// a known variable. This 1.12 + ClassicAPI client is the Classic family,
// the Vanilla game type, and whatever the client text locale is.
const char *VariableValue(const char *head, size_t headLen) {
if (EqCI(head, headLen, "Family")) return "Classic";
if (EqCI(head, headLen, "Game")) return "Vanilla";
if (EqCI(head, headLen, "TextLocale")) return ClientLocale();
return nullptr;
}
// The client interface version the loadability check compares against
// (FUN_0051d7d0 -> 0x2BC0 = 11200). Read live so we mirror the engine's own
// value rather than hardcoding it.
using ClientIfaceVer_t = uint32_t(__cdecl *)();
uint32_t ClientInterfaceVersion() {
return reinterpret_cast<ClientIfaceVer_t>(
static_cast<uintptr_t>(Offsets::FUN_ADDON_CLIENT_INTERFACE_VERSION))();
}
// If `line` is a `## Interface:` directive whose value is a comma-list that
// contains the client version but does NOT lead with it, append a normalized
// `<prefix>: <clientVersion>` line to `out` and return true. Vanilla's parser
// reads only the first number, so without this a later 11200 is never seen and
// the addon is marked out of date. Returns false for any other line, and for
// an interface line that needs no change (single value, no client version, or
// client version already first) — the caller then copies the line verbatim.
bool TryRewriteInterfaceLine(const char *line, size_t len, std::string &out) {
size_t s = 0;
while (s < len && IsSpace(line[s])) ++s;
if (s + 1 >= len || line[s] != '#' || line[s + 1] != '#') return false;
size_t p = s + 2;
while (p < len && IsSpace(line[p])) ++p;
static const char kKey[] = "interface"; // matched case-insensitively
const size_t klen = 9;
if (p + klen > len) return false;
for (size_t i = 0; i < klen; ++i)
if (Lower(line[p + i]) != kKey[i]) return false;
p += klen;
while (p < len && IsSpace(line[p])) ++p;
if (p >= len || line[p] != ':') return false;
++p; // past ':'
const size_t prefixEnd = p; // "## Interface:" (author spacing preserved)
const uint32_t client = ClientInterfaceVersion();
long first = -1;
bool hasComma = false, hasClient = false;
size_t q = p;
while (q < len) {
while (q < len && (IsSpace(line[q]) || line[q] == ',')) {
if (line[q] == ',') hasComma = true;
++q;
}
long v = 0;
bool digit = false;
while (q < len && line[q] >= '0' && line[q] <= '9') {
v = v * 10 + (line[q] - '0');
++q;
digit = true;
}
if (digit) {
if (first < 0) first = v;
if (static_cast<uint32_t>(v) == client) hasClient = true;
} else if (q < len) {
++q; // skip a stray non-digit so the scan always advances
}
}
if (!hasComma || !hasClient || first == static_cast<long>(client))
return false; // single value / no client version / already first
out.append(line, prefixEnd);
out.push_back(' ');
out += std::to_string(client);
return true;
}
// Evaluate a `[keyword args]` condition — the return is whether the line should
// load. An unrecognized keyword returns false so the caller drops the line (a
// condition we cannot confirm must not load — fail safe).
bool EvalCondition(const char *head, size_t headLen, const char *args,
size_t argsLen) {
if (EqCI(head, headLen, "AllowLoadGameType"))
return ListContains(args, argsLen, "vanilla");
if (EqCI(head, headLen, "AllowLoadTextLocale"))
return ListContains(args, argsLen, ClientLocale());
if (EqCI(head, headLen, "AllowLoad"))
return ListContains(args, argsLen, "game"); // addon files: in-game only
return false;
}
// Rewrite one file-reference line into `out`: expand `[Variable]` tokens,
// evaluate and strip `[Condition]` directives. Returns true to KEEP the
// line (all conditions passed); on true `out` holds the cleaned,
// right-trimmed path.
bool ProcessFileLine(const char *line, size_t len, std::string &out) {
out.clear();
bool keep = true;
size_t i = 0;
while (i < len) {
const char c = line[i];
if (c != '[') {
out.push_back(c);
++i;
continue;
}
size_t close = i + 1;
while (close < len && line[close] != ']') ++close;
if (close >= len) { // unterminated '[' — copy the rest verbatim
out.append(line + i, len - i);
break;
}
const char *inner = line + i + 1;
const size_t innerLen = close - (i + 1);
size_t h = 0;
while (h < innerLen && !IsSpace(inner[h])) ++h; // [0,h) = head word
size_t a = h;
while (a < innerLen && IsSpace(inner[a])) ++a; // skip separating ws
const char *head = inner;
const size_t headLen = h;
const char *args = inner + a;
const size_t argsLen = innerLen - a;
if (argsLen == 0) {
// Single word: a variable to expand, else leave verbatim (an
// unknown token becomes a path the engine cannot open — the
// file just does not load).
const char *val = VariableValue(head, headLen);
if (val != nullptr) out.append(val);
else out.append(line + i, (close + 1) - i);
} else {
// Keyword + args: a condition. Strip it from the path (drop a
// space we already emitted before it so nothing dangles).
keep = keep && EvalCondition(head, headLen, args, argsLen);
if (!out.empty() && out.back() == ' ') out.pop_back();
}
i = close + 1;
}
while (!out.empty() && IsSpace(out.back())) out.pop_back();
return keep;
}
// Walk the whole TOC, preserving line terminators and any UTF-8 BOM.
// `#` comment and `##` metadata lines pass through verbatim; only file
// reference lines are rewritten. A gated-out line becomes a `#` comment
// so the load loop skips it (and a dumped TOC shows why it was dropped).
void RebuildToc(const char *src, size_t srcLen, std::string &out) {
out.clear();
out.reserve(srcLen + 32);
size_t i = 0;
if (srcLen >= 3 && static_cast<uint8_t>(src[0]) == 0xEF &&
static_cast<uint8_t>(src[1]) == 0xBB &&
static_cast<uint8_t>(src[2]) == 0xBF) {
out.append(src, 3);
i = 3;
}
std::string lineOut;
while (i < srcLen) {
const size_t lineStart = i;
while (i < srcLen && src[i] != '\r' && src[i] != '\n') ++i;
const char *line = src + lineStart;
const size_t len = i - lineStart;
const size_t termStart = i;
if (i < srcLen && src[i] == '\r') ++i;
if (i < srcLen && src[i] == '\n') ++i;
size_t s = 0;
while (s < len && IsSpace(line[s])) ++s;
if (s >= len) {
out.append(line, len); // blank
} else if (line[s] == '#') {
// Comment / `##` metadata — verbatim, except a multi-flavor
// `## Interface:` list that hides the client version.
if (!TryRewriteInterfaceLine(line, len, out))
out.append(line, len);
} else if (ProcessFileLine(line, len, lineOut)) {
out.append(lineOut);
} else {
out.push_back('#'); // gated out — comment it so the loader skips it
out.append(line, len);
}
out.append(src + termStart, i - termStart);
}
}
// True iff `path` is an addon `.toc` read (…\AddOns\…\*.toc). Gates the
// rewrite off every non-TOC read (a `.lua` with a `t[1]` subscript would
// otherwise be mangled) and off non-addon TOCs (FrameXML.toc, etc.).
bool IsAddonToc(const char *path) {
const size_t len = std::strlen(path);
if (len < 4) return false;
if (!(path[len - 4] == '.' && Lower(path[len - 3]) == 't' &&
Lower(path[len - 2]) == 'o' && Lower(path[len - 1]) == 'c'))
return false;
for (size_t i = 0; i + 8 <= len; ++i)
if (EqCI(path + i, 8, "\\addons\\")) return true;
return false;
}
} // namespace
void Transform(const char *path, void **outBuf, size_t *outSize) {
if (path == nullptr || outBuf == nullptr || *outBuf == nullptr) return;
if (!IsAddonToc(path)) return;
const char *content = static_cast<const char *>(*outBuf);
const size_t size = (outSize != nullptr && *outSize != 0)
? *outSize
: std::strlen(content);
if (size == 0) return;
// Fast path: no '[' (directives) and no ',' (a possible `## Interface:`
// list) means nothing to do — leave the engine buffer alone.
if (std::memchr(content, '[', size) == nullptr &&
std::memchr(content, ',', size) == nullptr)
return;
std::string rebuilt;
RebuildToc(content, size, rebuilt);
if (rebuilt.size() == size &&
std::memcmp(rebuilt.data(), content, size) == 0)
return; // brackets/commas were only in unaffected lines — nothing changed
auto SMemAlloc = reinterpret_cast<SMemAllocFn>(Offsets::FUN_STORM_SMEM_ALLOC);
auto SMemFree = reinterpret_cast<SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE);
const size_t newLen = rebuilt.size();
void *buf = SMemAlloc(newLen + 1, __FILE__, __LINE__, 0); // +1 NUL (extraBytes=1)
if (buf == nullptr) return; // OOM — keep the original
std::memcpy(buf, rebuilt.data(), newLen);
static_cast<char *>(buf)[newLen] = '\0';
SMemFree(*outBuf, __FILE__, __LINE__, 0);
*outBuf = buf;
if (outSize != nullptr) *outSize = newLen;
}
} // namespace AddOns::TocRewrite
+79
View File
@@ -0,0 +1,79 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstddef>
namespace AddOns::TocRewrite {
// Modern-client per-line TOC directives, backported.
//
// A single-TOC multi-flavor addon gates individual file lines with
// conditions and expands path variables:
//
// [Family]\Init.lua # -> Classic\Init.lua
// Vanilla.lua [AllowLoadGameType vanilla]
// Mainline.lua [AllowLoadGameType mainline] # dropped on this client
// Localization\[TextLocale].lua # -> Localization\enUS.lua
// deDE.lua [AllowLoadTextLocale deDE]
//
// Vanilla 1.12's TOC parsers never understood any of this. The scan
// parser (FUN_0051C9B0) reads only `##` metadata; the load pass
// (FUN_006edb90) hands each non-`#` line to the per-file loader verbatim,
// where the trailing ` [...]` breaks the extension check and the file
// silently never loads. `[Variable]` tokens likewise become part of a
// path that does not exist.
//
// This closes the gap at the FUN_FILE_READ layer (alongside FlavorToc /
// Embedded): after an addon TOC is read, rewrite its file-reference lines
// so the engine's own load loop sees clean paths. `##` metadata and `#`
// comment lines are copied verbatim, with ONE exception (`## Interface:`,
// below); metadata-line conditions, added in retail 12.0.7, are out of
// scope. What the tokens resolve to on this 1.12 client:
//
// [AllowLoadGameType ...] keep the line iff the list contains `vanilla`
// [AllowLoadTextLocale ...] keep the line iff the list contains the
// client locale (GetLocale() code, e.g. enUS)
// [AllowLoad ...] addon files only load in-game, so `game`
// keeps and `glue` drops
// [Family] -> Classic
// [Game] -> Vanilla
// [TextLocale] -> the client locale code
//
// All conditions on a line must pass for it to load (AND). An unknown
// `[keyword args]` condition fails safe (drops the line); an unknown
// single-word `[Token]` is left verbatim (the engine then fails to find
// that path, so the file does not load).
//
// Multi-flavor `## Interface:` list. Retail lets a TOC list several
// interface versions (`## Interface: 120100, 50504, 11200`). Vanilla's
// parser is a plain atoi that reads only the FIRST number, so it never
// sees a later 11200 and marks the addon out of date. When the client
// version (FUN_ADDON_CLIENT_INTERFACE_VERSION, 11200) appears anywhere in
// the list but is not first, the `## Interface:` line is rewritten to that
// single value so the engine marks the addon compatible. A list WITHOUT
// the client version is left unchanged — it still parses to its first
// value and stays out of date, exactly as before.
//
// Called from the FUN_FILE_READ hook after the read succeeds. Does
// nothing unless `path` is an addon `.toc` read AND its content contains a
// `[` (directives) or a `,` (a possible `## Interface:` list) — the
// fast-path bail keeps every unaffected TOC zero-copy, and a rebuild that
// changes nothing is discarded by a content compare. When it does rewrite,
// it allocates a fresh Storm buffer, frees the original, and updates
// `*outBuf` / `*outSize` — the caller frees the replacement exactly as it
// would the engine's own read buffer.
void Transform(const char *path, void **outBuf, size_t *outSize);
} // namespace AddOns::TocRewrite
+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
+145
View File
@@ -0,0 +1,145 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `collectgarbage(opt [, arg])` — accept Lua 5.1's string options on the
// 5.0 collector. 5.0's collectgarbage takes only an optional NUMBER (a KB
// threshold for the next cycle) and errors on every 5.1 option (verified
// in-game: `collectgarbage("count")` → "bad argument #1 (number expected,
// got string)"). Shim dispatch:
//
// "count" → KB of Lua memory in use — the engine's own gcinfo()
// value, the same number 5.1's "count" reports.
// "collect" → original collectgarbage(0): threshold 0 forces a full
// cycle, the canonical 5.0 "collect now". Returns 0.
// "step" → no-op returning true ("cycle finished"): the 5.0 GC
// cannot step incrementally, and true is the loop-safe
// sentinel (`repeat until collectgarbage("step")` exits
// immediately instead of spinning forever).
// "stop" / "restart" / "setpause" / "setstepmul"
// → accepted no-ops returning 0 — the 5.0 GC has no toggle
// or tuning; callers just lose the optimization.
// number / no argument
// → forwarded to the original untouched (5.0 threshold
// semantics preserved for vanilla-era callers).
//
// The originals (`collectgarbage`, `gcinfo`) are function objects stashed
// in the state's registry at registration, BEFORE the global is
// overwritten — no raw engine address needed, and each /reload re-stashes
// the freshly re-registered engine original (module callbacks run after
// the engine's own registration pass). Registered on BOTH states.
#include "Game.h"
#include <cstring>
namespace BaseLib::CollectGarbage {
namespace {
constexpr const char *kOrigCollect = "__classicapi_orig_collectgarbage";
constexpr const char *kOrigGcInfo = "__classicapi_orig_gcinfo";
// Pushes registry[key]; true when it holds a function (left on the
// stack), false otherwise (stack unchanged).
bool PushStashed(void *L, const char *key) {
Game::Lua::PushString(L, key);
Game::Lua::RawGet(L, Game::Lua::REGISTRY_INDEX);
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_FUNCTION)
return true;
Game::Lua::SetTop(L, -2);
return false;
}
int __fastcall Script_collectgarbage(void *L) {
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_STRING) {
const char *opt = Game::Lua::ToString(L, 1);
if (std::strcmp(opt, "count") == 0) {
if (PushStashed(L, kOrigGcInfo)) {
Game::Lua::Call(L, 0, 1); // gcinfo's first return = KB in use
return 1;
}
Game::Lua::PushNumber(L, 0.0); // no gcinfo on this state
return 1;
}
if (std::strcmp(opt, "collect") == 0) {
if (PushStashed(L, kOrigCollect)) {
Game::Lua::PushNumber(L, 0.0); // threshold 0 = collect now
Game::Lua::Call(L, 1, 0);
}
Game::Lua::PushNumber(L, 0.0); // 5.1 returns 0 for "collect"
return 1;
}
if (std::strcmp(opt, "step") == 0) {
Game::Lua::PushBool(L, true); // "cycle finished" — see header
return 1;
}
if (std::strcmp(opt, "stop") == 0 || std::strcmp(opt, "restart") == 0 ||
std::strcmp(opt, "setpause") == 0 ||
std::strcmp(opt, "setstepmul") == 0) {
Game::Lua::PushNumber(L, 0.0);
return 1;
}
Game::Lua::Error(L, "bad argument #1 to 'collectgarbage' (invalid option '%s')",
opt);
return 0; // unreachable
}
// Number or no argument: original 5.0 threshold behavior, forwarded.
const int n = Game::Lua::GetTop(L);
if (!PushStashed(L, kOrigCollect))
return 0; // no original captured — inert
for (int idx = 1; idx <= n; ++idx)
Game::Lua::PushValue(L, idx);
Game::Lua::Call(L, n, Game::Lua::MULTRET);
return Game::Lua::GetTop(L) - n;
}
// Stashes the function at `_G[name]` under registry[key]; true when a
// function was actually stashed.
bool Stash(void *L, const char *name, const char *key) {
Game::Lua::PushString(L, name);
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // [fn?]
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_FUNCTION) {
Game::Lua::SetTop(L, -2);
return false;
}
Game::Lua::PushString(L, key); // [fn, key]
Game::Lua::Insert(L, -2); // [key, fn]
Game::Lua::RawSet(L, Game::Lua::REGISTRY_INDEX);
return true;
}
void DoRegister(bool glue) {
void *L = Game::Lua::State();
if (L == nullptr)
return;
// Stash BEFORE overwriting; if the engine original is somehow absent,
// leave the global untouched rather than installing a dead shim.
if (!Stash(L, "collectgarbage", kOrigCollect))
return;
Stash(L, "gcinfo", kOrigGcInfo); // optional; "count" degrades to 0
if (glue)
Game::Lua::RegisterGlueFunction("collectgarbage", &Script_collectgarbage);
else
Game::Lua::RegisterGlobalFunction("collectgarbage", &Script_collectgarbage);
}
void RegisterInGame() { DoRegister(false); }
void RegisterGlue() { DoRegister(true); }
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace BaseLib::CollectGarbage
+133 -6
View File
@@ -15,6 +15,7 @@
//
// - `string.match(s, pattern [, init])` — first-match extraction (5.1).
// - `string.gmatch(s, pattern)` — match iterator (5.1).
// - `string.gsub` TABLE replacement — 5.1 form (see the shim below).
// - `strsplit(sep, str [, pieces])` — WoW global; split on any char.
// - `strjoin(delimiter, ...)` — WoW global; join varargs.
// - `strtrim(str [, chars])` — WoW global; trim a char set.
@@ -28,12 +29,11 @@
// it as a direct alias of the engine's `gfind` C function.
// `strsplit` is a hand-rolled port of 3.3.5's `strsplit` (see below).
//
// NOTE: only the `string.foo(s, ...)` call form works — NOT the
// `("x"):foo(...)` method sugar. WoW's Lua VM strips type-metatables for
// every value except tables/userdata (both `lua_setmetatable` and the VM's
// `luaT_gettmbyobj` hard-return "no metamethod" for strings), so strings have
// no `__index`, and there's no client-side way to add one short of hooking a
// hot VM-core function. This is the long-standing vanilla constraint.
// NOTE: both call forms work — `string.foo(s, ...)` AND the `("x"):foo(...)`
// method sugar. Method calls resolve through this same `string` table via
// LuaSyntax::StringMethods (the Lua 5.1 string-metatable backport), so
// everything registered here is automatically reachable as a method, on
// both states.
#include "Game.h"
#include "Offsets.h"
@@ -261,11 +261,138 @@ int __fastcall Script_string_reverse(void *L) {
return 1;
}
// --- string.gsub table-replacement upgrade (Lua 5.1) ------------------------
//
// 5.1 allows a TABLE as gsub's replacement: each match looks up the first
// capture (the whole match when the pattern declares no captures) in the
// table; a nil/false value keeps the original match; string/number values
// substitute. This engine's gsub (FUN_LUA_STR_GSUB) is stock 5.0 —
// string/function only ("string or function expected" otherwise), and its
// add_value appends NOTHING when a replacement function returns a
// non-string (match → empty; verified by decode, see Offsets.h). So a
// plain table→closure adapter would EAT unmatched tokens where 5.1 keeps
// them, and the closure can't reconstruct the whole match from capture
// args alone.
//
// The shim therefore rewrites the pattern with one OUTER capture —
// `pat` → `(pat)`, keeping a leading `^` and an unescaped trailing `$`
// outside (both are anchors only at their positional ends; inside the
// parens they'd turn literal) — so the adapter closure always receives
// the whole match as arg 1, the pattern's own first capture as arg 2, and
// can hand arg 1 back to reproduce 5.1's keep-match for missing keys.
// Patterns containing a `%1`..`%9` back-reference can't be wrapped (the
// outer capture would renumber what `%N` refers to); those fall back to
// an unwrapped adapter whose missing-key behavior is the engine's own
// (match → empty) — the one documented divergence, and the combination
// (table replacement + pattern back-references) is essentially absent
// from real code. String/function replacements tail-call the engine gsub
// untouched.
// True when the pattern contains a `%1`..`%9` back-reference. Escape-aware:
// the char after any `%` is consumed, so `%%1` (literal percent, then '1')
// doesn't count.
bool PatternHasBackref(const char *p, unsigned len) {
for (unsigned i = 0; i + 1 < len; ++i) {
if (p[i] != '%')
continue;
const char c = p[i + 1];
if (c >= '1' && c <= '9')
return true;
++i; // skip the escaped char
}
return false;
}
// Adapter for the wrapped-pattern path: arg 1 = whole match, arg 2 = the
// pattern's own first capture (when it declared any). Upvalue 1 = the
// replacement table. Missing/false value → return the whole match (5.1's
// keep-match).
int __fastcall GsubTableLookupWrapped(void *L) {
const int nargs = Game::Lua::GetTop(L);
Game::Lua::PushValue(L, nargs >= 2 ? 2 : 1); // the 5.1 lookup key
Game::Lua::GetTable(L, Game::Lua::UpvalueIndex(1)); // tbl[key]
const int t = Game::Lua::Type(L, -1);
if (t == Game::Lua::TYPE_NIL ||
(t == Game::Lua::TYPE_BOOLEAN && Game::Lua::ToBoolean(L, -1) == 0))
Game::Lua::PushValue(L, 1); // keep the original match
return 1; // top value is the result
}
// Adapter for back-reference patterns (unwrapped): arg 1 is already the
// 5.1 key. A missing key returns nil → the engine appends nothing.
int __fastcall GsubTableLookupPlain(void *L) {
Game::Lua::PushValue(L, 1);
Game::Lua::GetTable(L, Game::Lua::UpvalueIndex(1));
return 1;
}
int __fastcall Script_string_gsub(void *L) {
const auto engineGsub =
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_STR_GSUB);
if (Game::Lua::Type(L, 3) != Game::Lua::TYPE_TABLE)
return engineGsub(L); // string/function/error paths untouched
const char *pat = Game::Lua::ToString(L, 2);
if (pat == nullptr)
return engineGsub(L); // let the engine raise its own arg error
const unsigned patLen = Game::Lua::StrLen(L, 2);
Game::Lua::SetTop(L, 4); // normalize to (s, pat, tbl, n) — gsub reads ≤4
if (!PatternHasBackref(pat, patLen)) {
// Build `(body)` with the anchors outside the capture.
unsigned b = 0, e = patLen;
std::string wrapped;
wrapped.reserve(patLen + 3);
if (e > b && pat[0] == '^') {
wrapped += '^';
b = 1;
}
bool anchoredBack = false;
if (e > b && pat[e - 1] == '$') {
// `$` is an anchor only when not %-escaped: an even number of
// `%` immediately before it means the `$` itself is unescaped.
unsigned pc = 0;
for (int idx = static_cast<int>(e) - 2;
idx >= static_cast<int>(b) && pat[idx] == '%'; --idx)
++pc;
anchoredBack = (pc % 2) == 0;
if (anchoredBack)
--e;
}
wrapped += '(';
wrapped.append(pat + b, e - b);
wrapped += ')';
if (anchoredBack)
wrapped += '$';
Game::Lua::PushLString(L, wrapped.data(),
static_cast<unsigned int>(wrapped.size())); // 5
Game::Lua::PushValue(L, 3); // 6: tbl
Game::Lua::PushCClosure(L, &GsubTableLookupWrapped, 1); // 6: closure
Game::Lua::Insert(L, 2); // closure→2: s, closure, pat, tbl, n, wrapped
Game::Lua::Insert(L, 2); // wrapped→2: s, wrapped, closure, pat, tbl, n
Game::Lua::Remove(L, 4); // drop the original pattern
Game::Lua::Remove(L, 4); // drop the table → (s, wrapped, closure, n)
} else {
Game::Lua::PushValue(L, 3); // 5: tbl
Game::Lua::PushCClosure(L, &GsubTableLookupPlain, 1); // 5: closure
Game::Lua::Insert(L, 3); // → s, pat, closure, tbl, n
Game::Lua::Remove(L, 4); // → s, pat, closure, n
}
return engineGsub(L);
}
void RegisterFns() {
Game::Lua::RegisterTableFunction("string", "match", &Script_string_match);
Game::Lua::RegisterTableFunction("string", "gmatch", Script_string_gmatch);
Game::Lua::RegisterTableFunction("string", "gsub", &Script_string_gsub);
Game::Lua::RegisterTableFunction("string", "reverse", &Script_string_reverse);
Game::Lua::RegisterTableFunction("string", "split", &Script_strsplit);
// The bare `gsub` global (1.12 exposes the string functions as globals
// too) must match `string.gsub`, so addons calling either form get the
// table-replacement upgrade.
Game::Lua::RegisterGlobalFunction("gsub", &Script_string_gsub);
Game::Lua::RegisterGlobalFunction("strsplit", &Script_strsplit);
Game::Lua::RegisterGlobalFunction("strjoin", &Script_strjoin);
Game::Lua::RegisterGlobalFunction("strtrim", &Script_strtrim);
+86
View File
@@ -0,0 +1,86 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `unpack(list [, i [, j]])` — upgrade 1.12's Lua 5.0 `unpack` to the 5.1
// signature. 5.0's unpack takes NO range arguments and silently ignores
// extras, so 5.1 code calling `unpack(t, 2)` got the WHOLE table back —
// wrong values with no error, the worst failure mode (verified in-game:
// `unpack({10,20,30}, 2)` returned 10, 20, 30). This replacement is a
// port of 5.1's `luaB_unpack` (lbaselib.c): `i` defaults to 1 and `j` to
// the table length via luaL_getn — which reads a `t.n` field first, so
// the transpiler's `unpack(arg)` vararg expansion keeps its
// embedded-nil-aware count exactly as before.
//
// Registered on BOTH states (language-level helper, both are 5.0).
#include "Game.h"
#include "Offsets.h"
namespace BaseLib::Unpack {
namespace {
// Engine auxlib/API entries (see Offsets.h for derivations).
using LuaLGetN_t = int(__fastcall *)(void *L, int idx);
using RawGetI_t = void(__fastcall *)(void *L, int idx, int n);
// Range argument at `idx`: absent/nil → `fallback`; a number (or numeric
// string, matching luaL_optint's coercion) → its value; anything else
// errors like 5.1's luaL_optint.
int OptRange(void *L, int idx, int fallback) {
if (Game::Lua::Type(L, idx) <= Game::Lua::TYPE_NIL) // none or nil
return fallback;
if (!Game::Lua::IsNumber(L, idx)) {
Game::Lua::Error(L, "bad argument #%d to 'unpack' (number expected)", idx);
return 0; // unreachable
}
return static_cast<int>(Game::Lua::ToNumber(L, idx));
}
int __fastcall Script_unpack(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, "bad argument #1 to 'unpack' (table expected)");
return 0; // unreachable
}
const int i = OptRange(L, 2, 1);
const int e = OptRange(
L, 3, reinterpret_cast<LuaLGetN_t>(Offsets::LUAL_GETN)(L, 1));
if (i > e)
return 0;
// `n <= 0` guards the int overflow of `e - i + 1` (i <= e holds here),
// mirroring 5.1's luaB_unpack.
const int n = e - i + 1;
if (n <= 0 || Game::Lua::CheckStack(L, n) == 0) {
Game::Lua::Error(L, "too many results to unpack");
return 0; // unreachable
}
const auto rawGetI = reinterpret_cast<RawGetI_t>(Offsets::LUA_RAWGETI);
for (int k = i; k <= e; ++k)
rawGetI(L, 1, k);
return n;
}
void RegisterInGame() {
Game::Lua::RegisterGlobalFunction("unpack", &Script_unpack);
}
void RegisterGlue() {
Game::Lua::RegisterGlueFunction("unpack", &Script_unpack);
}
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace BaseLib::Unpack
+7 -7
View File
@@ -40,11 +40,6 @@ namespace Chat::RaidMarkers {
namespace {
// On-screen pixel size of an inline marker. The default chat font is ~14px, so 14
// keeps a marker about one line tall. Fixed (the substitution is a static string
// rewrite and can't see the target font); tunable here if it reads too small/large.
constexpr int kMarkerSizePx = 14;
struct Token {
const char *name; // lowercase alias, no braces
int marker; // raid-target index 1..8
@@ -126,11 +121,16 @@ const char *Substitute(const char *msg, char *buf, size_t bufSize) {
// and renders). Anti-spoof Sanitize runs before this, so these
// are the only `||T` openers a player can produce.
char markup[128];
// `:0:0` = auto-size. The inline emitter resolves height/width
// to the target line's font pixel height at draw time, so a
// marker tracks the chat font size (and grows in speech bubbles)
// exactly like retail's ICON_LIST `…Icon_%d:0`. The rest of the
// payload crops the 256x256 sheet to the cell (l,r,t,b).
const int m = std::snprintf(
markup, sizeof markup,
"||TInterface\\TargetingFrame\\UI-RaidTargetingIcons:%d:%d:0:0:256:256:"
"||TInterface\\TargetingFrame\\UI-RaidTargetingIcons:0:0:0:0:256:256:"
"%d:%d:%d:%d||t",
kMarkerSizePx, kMarkerSizePx, l, r, t, b);
l, r, t, b);
// Reserve room for the literal tail after this token
// (msg[k+1..]) so the rest of the message always fits.
const size_t tail = msgLen - (k + 1);
+30
View File
@@ -211,12 +211,42 @@ static int __fastcall Script_Coroutine_Wrap(void *L) {
return 1;
}
// `coroutine.running()` — Lua 5.1 addition: the running coroutine, or
// nil when called from the main thread. The 5.0 C API has no
// `lua_pushthread` (also a 5.1 addition), so the thread TValue is
// written to the stack raw: this build's TValue is 16 bytes with
// {tt @ +0, value @ +8} (see LuaSyntax::StringMethods for the layout
// derivation), tag 8 = thread, and the value is the running `lua_State`
// itself — exactly what `lua_newthread` stores and what LUA_TO_POINTER
// reads back at tvalue[2]. No stack-overflow check needed: every C
// function is guaranteed LUA_MINSTACK free slots and this pushes one.
// The running thread is trivially GC-alive, so no anchoring either.
// Main-thread detection: module callbacks run on the state's main
// thread, so the L captured at registration IS the main thread;
// coroutine bodies run on their own lua_State.
static void *g_mainL = nullptr;
static constexpr uintptr_t OFF_L_TOP = 0x8; // L->top (push helpers' [ecx+8])
static int __fastcall Script_Coroutine_Running(void *L) {
if (L == g_mainL) {
Game::Lua::PushNil(L);
return 1;
}
uint8_t *top = Game::Read<uint8_t *>(L, OFF_L_TOP);
*reinterpret_cast<int *>(top) = Game::Lua::TYPE_THREAD;
*reinterpret_cast<void **>(top + 8) = L;
Game::Ref<uint8_t *>(L, OFF_L_TOP) = top + 16;
return 1;
}
static void RegisterLuaFunctions() {
g_mainL = Game::Lua::State(); // the state's main thread (see Running)
Game::Lua::RegisterTableFunction("coroutine", "create", &Script_Coroutine_Create);
Game::Lua::RegisterTableFunction("coroutine", "resume", &Script_Coroutine_Resume);
Game::Lua::RegisterTableFunction("coroutine", "yield", &Script_Coroutine_Yield);
Game::Lua::RegisterTableFunction("coroutine", "status", &Script_Coroutine_Status);
Game::Lua::RegisterTableFunction("coroutine", "wrap", &Script_Coroutine_Wrap);
Game::Lua::RegisterTableFunction("coroutine", "running", &Script_Coroutine_Running);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+130 -12
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
+93 -20
View File
@@ -25,13 +25,28 @@
// So `function(self, delta)` gets nothing today.
//
// We co-hook both runners and REPLACE their pcall tail: keep setting the same
// globals (so vanilla handlers reading `this`/`arg1` keep working — this is
// purely additive; Lua silently drops the extra positional args a zero-param
// handler doesn't declare), but push `self` + `arg1..argN` as real arguments
// and `pcall` with `nargs = 1 + N`. The reimplementation mirrors the engine's
// message-handler errfunc, but saves the clobbered globals on the Lua stack
// (not registry refs like the engine) so an out-of-memory longjmp mid-push
// unwinds them for free — no leaked refs, no per-call ref churn. See RunModern.
// globals (so handlers reading `this`/`arg1` keep working), but push `self` +
// `arg1..argN` as real arguments and `pcall` with `nargs = 1 + N`. The
// reimplementation mirrors the engine's message-handler errfunc, but saves the
// clobbered globals on the Lua stack (not registry refs like the engine) so an
// out-of-memory longjmp mid-push unwinds them for free — no leaked refs, no
// per-call ref churn. See RunModern.
//
// The reimplemented path is taken ONLY for a handler that actually declares a
// parameter or is vararg (checked via its Proto — see HandlerWantsArgs). A
// handler that declares NO parameters can't observe positional args, so pushing
// them is a no-op — those route through the original engine runner unchanged:
// provably identical to vanilla, and OFF the engine's hottest Lua path (the vast
// majority of vanilla handlers, which read `this`/`arg1`, are param-less). So a
// param-less vanilla handler is untouched; only a param-declaring handler sees
// the `(self, [event,] arg1..argN)` shape.
//
// Irreducible caveat: a param-declaring handler and a modern handler are
// indistinguishable to the dispatcher — both are one-param Lua functions. So a
// vanilla handler that declares a parameter expecting it to be nil (e.g. a
// function reused as both a direct call and a handler) WILL now receive a real
// value. There is no signal to tell the two apart; such a handler must opt out
// per-session via `SetModernScriptArgs(false)`.
//
// Convention: `(self, arg1..argN)` for every script, plus `event` inserted as
// the first positional for OnEvent — `(self, event, arg1..argN)` — matching
@@ -41,10 +56,12 @@
// SetScript creates a distinct ref, so a function bound to two scripts still
// differs per slot — so no fragile "are we mid event-dispatch" flag is needed.
//
// Runtime switch, default OFF (opt-in): `SetModernScriptArgs(true)` enables it,
// `GetModernScriptArgs()` reads the state. While disabled both detours are a
// straight passthrough to the original runner (zero reimplementation risk).
// Opt-in because this lands on the hottest Lua path in the engine
// Runtime switch, default ON — modern handler signatures are a core 5.1
// feature, so ports that write `function(self, delta)` work out of the box.
// `SetModernScriptArgs(false)` disables it, `GetModernScriptArgs()` reads the
// state. While disabled both detours are a straight passthrough to the original
// runner (exact vanilla behavior, zero reimplementation risk) — the OFF escape
// valve, since this lands on the hottest Lua path in the engine
// (FUN_FRAME_RUN_SCRIPT_ARGS fires for every OnUpdate frame).
#include "Game.h"
@@ -56,9 +73,10 @@ namespace Frame::ScriptArgs {
namespace {
// Runtime switch. Default OFF (opt-in). When false, both detours tail-call the
// original runner unchanged (exact vanilla behavior, no reimplementation risk).
bool g_enabled = false;
// Runtime switch. Default ON (core 5.1 behavior). When false, both detours
// tail-call the original runner unchanged (exact vanilla behavior, no
// reimplementation risk) — the OFF escape valve for this hot path.
bool g_enabled = true;
// Engine cap: the runner stops setting arg globals at index 19 (arg1..arg19).
constexpr int kMaxArgs = 19;
@@ -263,6 +281,55 @@ bool RunModern(int handlerRef, void *frame, const char *fmt, const void *vaPtr)
return true;
}
// Lua 5.0 internal offsets for reading a handler's arity — verified against the
// engine's own luaD_precall (0x006F6050) / lua_dump (0x006F4370) /
// lua_iscfunction (0x006F34A0):
// lua_State: top @ +0x08 (TValue *, next free slot)
// TValue: tag @ +0x00 (int); GC/closure pointer @ +0x08 (16-byte TValue)
// Closure: isC byte @ +0x06; union member (C func / Proto) @ +0x0C
// Proto: numparams (u8) @ +0x45, is_vararg (u8) @ +0x46
// Single-use here, so kept local (like the lua_State field offsets elsewhere).
constexpr uintptr_t OFF_LUASTATE_TOP = 0x08;
constexpr uintptr_t OFF_TVALUE_GC = 0x08;
constexpr uintptr_t OFF_CLOSURE_ISC = 0x06;
constexpr uintptr_t OFF_LCLOSURE_PROTO = 0x0C;
constexpr uintptr_t OFF_PROTO_NUMPARAMS = 0x45;
constexpr uintptr_t OFF_PROTO_IS_VARARG = 0x46;
constexpr int kLuaTFunction = 6;
constexpr uintptr_t kTValueSize = 0x10;
// Does this handler actually observe positional args? A vanilla handler reads
// this/arg1 globals and declares NO parameters, so pushing positional args to
// it is observably identical to vanilla — those route through the original
// engine runner instead (exact vanilla, and off the hottest Lua path). Only a
// Lua closure that declares a parameter or is vararg — a modern
// `function(self, delta)` / `function(self, ...)` — takes the reimplemented
// positional path. A C-closure handler (HookScript's thunk, say) reads
// globals/upvalues, never positional args, so it counts as "no" too.
//
// Reads the handler's Proto directly (the engine exposes no arity via the C
// API); every read is bounded — a non-function ref, a C closure, or a null
// Proto all fall through to "no".
bool HandlerWantsArgs(void *L, int handlerRef) {
RawGetI(L, Game::Lua::REGISTRY_INDEX, handlerRef); // push registry[handlerRef]
bool wants = false;
const void *tv = reinterpret_cast<const void *>(
Game::Read<uintptr_t>(L, OFF_LUASTATE_TOP) - kTValueSize);
if (Game::Read<int>(tv, 0) == kLuaTFunction) {
const void *cl =
reinterpret_cast<const void *>(Game::Read<uintptr_t>(tv, OFF_TVALUE_GC));
if (cl != nullptr && Game::Read<uint8_t>(cl, OFF_CLOSURE_ISC) == 0) { // Lua closure
const void *proto = reinterpret_cast<const void *>(
Game::Read<uintptr_t>(cl, OFF_LCLOSURE_PROTO));
if (proto != nullptr)
wants = Game::Read<uint8_t>(proto, OFF_PROTO_NUMPARAMS) > 0 ||
Game::Read<uint8_t>(proto, OFF_PROTO_IS_VARARG) != 0;
}
}
Game::Lua::SetTop(L, -2); // pop the handler
return wants;
}
// --- FUN_FRAME_RUN_SCRIPT_ARGS co-hook (scripts with values) ----------------
using RunArgs_t = void(__cdecl *)(int handlerRef, void *frame, const char *fmt,
const void *vaPtr);
@@ -270,9 +337,12 @@ RunArgs_t g_origRunArgs = nullptr;
void __cdecl RunArgs_h(int handlerRef, void *frame, const char *fmt,
const void *vaPtr) {
if (g_enabled && handlerRef != 0 && fmt != nullptr &&
RunModern(handlerRef, frame, fmt, vaPtr))
return;
if (g_enabled && handlerRef != 0 && fmt != nullptr) {
void *L = Game::Lua::State();
if (L != nullptr && HandlerWantsArgs(L, handlerRef) &&
RunModern(handlerRef, frame, fmt, vaPtr))
return;
}
g_origRunArgs(handlerRef, frame, fmt, vaPtr);
}
@@ -283,9 +353,12 @@ using Invoke_t = void(__fastcall *)(int handlerRef, void *frame);
Invoke_t g_origInvoke = nullptr;
void __fastcall Invoke_h(int handlerRef, void *frame) {
if (g_enabled && handlerRef != 0 && frame != nullptr &&
RunModern(handlerRef, frame, /*fmt*/ nullptr, /*vaPtr*/ nullptr))
return;
if (g_enabled && handlerRef != 0 && frame != nullptr) {
void *L = Game::Lua::State();
if (L != nullptr && HandlerWantsArgs(L, handlerRef) &&
RunModern(handlerRef, frame, /*fmt*/ nullptr, /*vaPtr*/ nullptr))
return;
}
g_origInvoke(handlerRef, frame);
}
+69 -12
View File
@@ -176,12 +176,20 @@ struct Pending {
constexpr int kMaxPending = 512;
constexpr int kMaxWaitTicks = 1200;
// World ticks after which we assume the engine's startup
// Wall-clock delay past world-live before we assume the engine's startup
// network/opcode-dispatch state has settled and eager queries are safe.
// Also latched the moment the engine's own response handler fires (a
// response proves dispatch is live) — see `ItemStatsCacheResponse_h`.
// Anchored to VAR_IN_WORLD (set to 1 by the enter-world initializer
// FUN_004908C0, cleared on every transition — the same flag
// bag/UpdateDelayed trusts), NOT a raw frame count: a fixed tick count is
// FPS-dependent, so it could fire before dispatch was ready on a fast
// machine (orphan risk — see the block comment above) yet leave bag icons
// as "?" far longer than intended on a slow login. World-live alone is
// still PLAYER_ENTERING_WORLD's first frame — inside the orphan window —
// so we wait this long past it. Also latched the instant the engine's own
// item-response handler fires (a response proves dispatch is live, so a
// warm WDB settles with no delay) — see `ItemStatsCacheResponse_h`.
// One-way latch; see the relog note in `WarmCache`.
constexpr int kSettleTicks = 60;
constexpr uint32_t kSettleMs = 1000;
// Minimum spacing between outgoing *background* SMSG_ITEM_QUERY_SINGLE
// requests. A single /reload can make addons (pfQuest / Questie / Bagnon)
@@ -198,10 +206,10 @@ constexpr uint32_t kMinRequestSpacingMs = 50;
// login; behind the background floor they showed as "unknown" for a
// second or two while a bulk addon scan drained ahead of them. These
// are issued on a separate, more generous budget so they resolve fast.
// The set is bounded (~120 items) and the engine prefetches inventory in
// parallel, so a small per-tick cap keeps them near-instant without
// re-introducing a burst: worst case is kOwnedPerTick + 1 packets per
// frame (~7), far below the flood that broke connectivity.
// The set is bounded (~120 items), so a small per-tick cap keeps them
// near-instant without re-introducing a burst: worst case is
// kOwnedPerTick + 1 packets per frame (~7), far below the flood that
// broke connectivity.
constexpr int kOwnedPerTick = 6;
constexpr int kMaxOwned = 256;
constexpr uint32_t kOwnedRefreshMs = 1000; // carried set changes slowly
@@ -209,7 +217,10 @@ constexpr uint32_t kOwnedRefreshMs = 1000; // carried set changes slowly
static Pending g_pending[kMaxPending];
static int g_pendingCount = 0;
static bool g_settled = false;
static int g_worldTicks = 0;
// Tick timestamp when we first saw the world go live (VAR_IN_WORLD == 1);
// 0 = not yet. Anchors the kSettleMs delay. Only meaningful until the
// one-way g_settled latch flips, so it is never reset.
static uint32_t g_inWorldSinceMs = 0;
static uint32_t g_lastRequestMs = 0;
// Snapshot of the itemIDs the player currently carries. Rebuilt by a
@@ -219,6 +230,10 @@ static uint32_t g_owned[kMaxOwned];
static int g_ownedCount = 0;
static uint32_t g_ownedBuiltMs = 0;
static bool g_ownedEverBuilt = false;
// One-shot latch: have we proactively seeded the queue with the player's
// carried items this enter-world session? Re-armed per in-game init (login
// or /reload) in RegisterLuaFunctions.
static bool g_ownedPrefetched = false;
static int FindPending(uint32_t itemID) {
for (int i = 0; i < g_pendingCount; ++i) {
@@ -441,11 +456,47 @@ static bool IsOwned(uint32_t itemID) {
// giving up after `kMaxWaitTicks` for items whose query was already
// sent but never resolved (lost query, server silent).
static void OnWorldTick() {
if (!g_settled && ++g_worldTicks >= kSettleTicks)
g_settled = true;
const uint32_t now = Time::Clock::NowMs();
// Settle the startup gate a fixed wall-clock delay past world-live —
// or leave it to ItemStatsCacheResponse_h to latch the instant a real
// item response arrives, whichever comes first. See kSettleMs.
if (!g_settled) {
const bool inWorld = *reinterpret_cast<const volatile uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_IN_WORLD)) != 0;
if (inWorld && g_inWorldSinceMs == 0)
g_inWorldSinceMs = now;
if (g_inWorldSinceMs != 0 &&
Time::Clock::Elapsed(g_inWorldSinceMs, now) >= kSettleMs)
g_settled = true;
}
// Proactive local-inventory prefetch (once per enter-world session).
// The engine does NOT query bag item stats on enter-world or bag draw:
// GetContainerItemInfo and the icon getter both do a null-callback
// (pure lookup) _GetRecord, and an uncached bag item resolves
// displayInfoID = 0, which the icon path draws as INV_Misc_QuestionMark
// ("?"). (Equipped items are exempt — their icon comes from the
// server-pushed visible-items table.) So on a cold WDB the player's
// carried items stay "?" until something else queries them — a hover, a
// loot, or an addon's GetItemInfo. Seed the queue with every carried
// item so they issue on the owned priority lane ahead of any addon's
// background flood, giving complete carried-item coverage regardless of
// addon behavior. Bounded (<= kMaxOwned) and issued under the same owned
// budget as everything else, so it adds no burst (issue #25). Deferred
// to post-settle like every other query, so it can't orphan an entry
// before the engine's network/opcode dispatch is ready. Gate on a live
// inventory manager so a too-early settle retries next tick instead of
// latching on an empty walk.
if (g_settled && !g_ownedPrefetched &&
Unit::Identity::PlayerInventoryManager() != nullptr) {
RebuildOwned();
g_ownedBuiltMs = now;
for (int i = 0; i < g_ownedCount; ++i)
Track(g_owned[i], /*implicit=*/true);
g_ownedPrefetched = true;
}
// Refresh the carried-item set while there is queued work, at most
// once per kOwnedRefreshMs — the set changes slowly and the walk is
// pointer-chasing, not free.
@@ -567,6 +618,12 @@ static int __fastcall Script_RequestLoadItemData(void *L) {
}
static void RegisterLuaFunctions() {
// Re-arm the proactive carried-item prefetch for this enter-world
// session. Fires on login and /reload (this callback runs per in-game
// Lua init). g_settled is deliberately NOT reset — it is a one-way
// process latch (see WarmCache); on a /reload the cache is warm, so the
// re-run resolves as cache hits and does nothing.
g_ownedPrefetched = false;
Game::Lua::RegisterTableFunction("C_Item", "IsItemDataCachedByID",
&Script_IsItemDataCachedByID);
Game::Lua::RegisterTableFunction("C_Item", "IsItemDataCached", &Script_IsItemDataCached);
+90
View File
@@ -0,0 +1,90 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// getfenv / setfenv `__environment` protection — Lua 5.1 backport.
//
// A sandbox hands restricted code an environment table E and puts a metatable
// on it carrying `__environment = <safe value>`. From then on, code inside the
// sandbox that calls `getfenv()` gets the safe value instead of E (so it can't
// reach or mutate the real environment), and `setfenv` refuses to change E.
//
// 1.12's engine ALREADY implements this protection — its `getfenv`
// (0x00702AC0) and `setfenv` (0x00702BF8) share one predicate (0x00702BA0)
// that reads a marker off the environment and, when present, substitutes it
// (getfenv) or errors "cannot change a protected environment" (setfenv). But
// 1.12 reads a RAW `__fenv` field on the env TABLE, whereas Lua 5.1 (verified
// against 3.3.5's `FUN_0084F2F0`) reads a RAW `__environment` field off the
// env's METATABLE. Modern addons written for 5.1 use the metatable form.
//
// We co-hook the ONE shared predicate and REPLACE it (the original is never
// called) so both getfenv and setfenv gain the 5.1 semantics at once. The
// reimplementation preserves the predicate's exact stack contract — push the
// function's environment, then push the marker value (or nil), net +2, and
// return whether the marker is non-nil — which getfenv (`settop -2`) and
// setfenv (`settop -3`, then error) both depend on.
//
// Order: metatable `__environment` (5.1) is checked first; the raw `__fenv`
// field (1.12's original) is kept as a fallback, so anything still relying on
// `__fenv` — e.g. compiled FrameXML we can't see on disk — keeps working. The
// change is additive: nothing on disk sets either marker, so for every
// existing addon the predicate returns nil and getfenv/setfenv behave exactly
// as before. It only activates when code deliberately marks an environment.
#include "Game.h"
#include "Offsets.h"
namespace LuaEnv {
namespace {
// Raw engine primitives not exposed via Game::Lua.
using GetFenv_t = void(__fastcall *)(void *L, int idx);
using GetMetatable_t = int(__fastcall *)(void *L, int idx);
const auto GetFenv = reinterpret_cast<GetFenv_t>(Offsets::LUA_GET_FENV);
const auto GetMetatable = reinterpret_cast<GetMetatable_t>(Offsets::LUA_GET_METATABLE);
// Replacement for the shared getfenv/setfenv protection predicate
// (FUN_LUA_ENV_PROTECT_PREDICATE). Contract mirrored exactly from the engine
// original: the function whose environment to test is at stack top (-1); on
// return the stack has grown by 2 (env, marker) and eax is non-zero iff the
// environment is protected.
int __fastcall EnvProtectPredicate_h(void *L) {
GetFenv(L, -1); // push the function's environment -> [.., env]
bool found = false;
if (GetMetatable(L, -1) != 0) { // -> [.., env, metatable]
Game::Lua::PushLString(L, "__environment", 13);
Game::Lua::RawGet(L, -2); // raw metatable["__environment"] -> [.., env, mt, marker]
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_NIL) {
Game::Lua::Remove(L, -2); // drop the metatable -> [.., env, marker]
found = true;
} else {
Game::Lua::SetTop(L, -3); // drop the nil + metatable -> [.., env]
}
}
if (!found) {
// Fallback: 1.12's original raw `__fenv` field on the env table.
Game::Lua::PushLString(L, "__fenv", 6);
Game::Lua::RawGet(L, -2); // raw env["__fenv"] -> [.., env, marker|nil]
}
return Game::Lua::Type(L, -1) != Game::Lua::TYPE_NIL;
}
// Full replacement — the original predicate is never invoked (nullptr trampoline).
const Game::HookAutoRegister _hook{Offsets::FUN_LUA_ENV_PROTECT_PREDICATE,
reinterpret_cast<void *>(&EnvProtectPredicate_h),
nullptr};
} // namespace
} // namespace LuaEnv
+31
View File
@@ -0,0 +1,31 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
namespace LuaSyntax {
// Pushes the per-addon shared namespace table for `name` onto the Lua stack,
// creating it on first use. The same table object is returned on every call
// for a given name, so the internal addon-args preamble (which hands a file
// its `(addonName, addonTable)` varargs) and the public gated accessor
// `C_AddOns.GetAddOnLocalTable` share one table per addon. Backed by a map in
// the Lua registry, hidden from addons. Net stack effect: +1. `name` must be
// non-null.
//
// This is the ONE place the namespace map is read/created — do not duplicate
// the registry-key or map access elsewhere, or the two paths would hand out
// different tables for the same addon.
void PushAddonNamespace(void *L, const char *name);
} // namespace LuaSyntax
+205
View File
@@ -0,0 +1,205 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Lua 5.1 string methods (`("asd"):upper()`, `s:format(...)`) on the 5.0 VM.
//
// Lua 5.0 has no string methods AT ALL — the shared string metatable with
// `__index = string` is a 5.1 addition (`createmetatable` in 5.1's
// lstrlib.c). Nothing was stripped from this build: both halves of the
// would-be mechanism match stock 5.0 source exactly —
// * `lua_setmetatable` (0x006F4020) accepts only tables/userdata, so no
// API call can attach a metatable to the string TYPE; and
// * `luaT_gettmbyobj` (0x006F7BD0) hardcodes `&nilobject` for anything
// that isn't a table or userdata, so the VM would never read one.
// So this is the runtime half of the 5.1 backport (the syntax half is
// Transpile.cpp): we transplant 5.1's mechanism at the read side.
//
// Mechanism: co-hook FUN_LUA_INDEX_NONTABLE (0x006F7EE0) — luaV_gettable's
// non-table continuation, the ONE choke every string index reaches (3 call
// sites: luaV_gettable's else-branch + luaV_execute's OP_GETTABLE/OP_SELF
// fallbacks; see Offsets.h). When the indexed object is a string, answer
// with `luaV_gettable(L, <string library table>, key, loop)` — byte-for-byte
// what the original's own TM-table branch would do had gettmbyobj returned
// that table (it passes `loop` through unchanged too, 0x006F7F23). The
// engine's unmodified lookup then does everything, including exact 5.1 miss
// semantics: `("x").nonexistent` → plain table miss → nil, not an error
// (verified: FUN_006F7D60 returns &nilobject when the table has no
// metatable). Non-strings fall through to the original — userdata __index,
// and the stock "attempt to index" error for everything else. `settable`
// on a string is a different continuation (FUN_006F7F40), untouched: still
// an error, which is also 5.1's behavior.
//
// Hotness: cold. Table `__index` dispatch (all frame/OO method traffic)
// reads the table's OWN metatable via FUN_006F7D60/fasttm and never lands
// here; today's only callers are about-to-error non-table indexes. No other
// Octo DLL hooks the Lua VM internals.
//
// State/GC lifecycle: one slot per Lua state (in-game + glue), keyed by the
// state's global_State (`*(L+0x10)`) so coroutine threads — which share G
// with their parent — resolve too. Each capture anchors the string table in
// that state's registry, so the hook can never serve a GC-collected table:
// even if an addon nils/replaces `_G.string`, resolution keeps the snapshot
// (5.1 parity — its metatable pins the open-time table the same way).
// Captures re-run per /reload (ModuleAutoRegister) and per glue boot
// (GlueModuleAutoRegister), overwriting the anchor so the previous table is
// released. Slots are cleared BEFORE their state can die, so a capture can
// never outlive the table it points at: PrepareForReload (called from
// FrameScript_Initialize_h, which fires on /reload AND /logout before the
// world Lua state is reset) clears the game slot, and the in-game
// registration clears the glue slot (once world scripts are loading, the
// glue state is gone; the next glue boot re-captures before any glue Lua
// runs). A cleared slot fails closed — strings error on index exactly as
// stock 1.12 until the re-capture.
//
// Kill switch: `_classicapi_SetStringMethods(false)` (registered on both
// states) makes the hook forward everything to the original resolver —
// exact stock behavior — for "is this backport breaking this addon?"
// diagnosis, mirroring `_classicapi_SetTranspileOption`.
#include "StringMethods.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace LuaSyntax::StringMethods {
namespace {
// `global_State *` slot in lua_State — the per-universe object shared by
// every coroutine thread of a state. Verified: luaT_gettmbyobj reads the
// tmname array at `*(L+0x10) + 0x80`, lua_setmetatable resolves the
// REGISTRY pseudo-index via `*(L+0x10) + 0x30`. Single-consumer offset,
// local per the Offsets.h rule.
constexpr uintptr_t OFF_L_GLOBALSTATE = 0x10;
// This build's TValue is 16 bytes with `tt` FIRST: {tt @ +0, value @ +8} —
// NOT stock 5.0's {value, tt} order. Verified from lua_setmetatable
// (tag = `*tv`, GC pointer = `tv[2]`, Table->metatable at hvalue+0x08 /
// fast-TM flags byte at hvalue+0x06) and the push helpers' 0x10 stride.
struct TValueMirror {
int tt = 0;
int pad0 = 0;
const void *value = nullptr; // Table* when tt == TYPE_TABLE
int pad1 = 0;
};
static_assert(sizeof(TValueMirror) == 16, "TValue is 16 bytes in this build");
// Captured `string` library table for one Lua state. `G` doubles as the
// valid flag — nullptr means "no capture, pass everything through".
struct StateSlot {
const void *G = nullptr;
TValueMirror tv{};
};
StateSlot g_gameSlot;
StateSlot g_glueSlot;
// Runtime kill switch — `_classicapi_SetStringMethods(false)` reverts to
// exact stock behavior (the hook forwards everything). Process-wide; both
// states share it.
bool g_enabled = true;
// Registry key anchoring the captured table against GC (rawset — the
// registry has no metatable). One key per state's registry; re-capture
// overwrites it, releasing the previously anchored table.
constexpr const char *kAnchorKey = "__classicapi_string_lib";
// FUN_LUA_INDEX_NONTABLE / FUN_LUA_V_GETTABLE shared convention:
// __fastcall(L /*ecx*/, tv /*edx*/, key /*stack*/, loop /*stack*/), RET 8,
// result TValue* in eax (see Offsets.h for the disassembly evidence).
using IndexResolve_t = void *(__fastcall *)(void *L, const void *tv, void *key, int loop);
IndexResolve_t g_indexNonTableOriginal = nullptr;
void *__fastcall IndexNonTable_h(void *L, const void *obj, void *key, int loop) {
// 5.1 backport: a string's `__index` is the `string` library table.
if (g_enabled && obj != nullptr &&
*static_cast<const int *>(obj) == Game::Lua::TYPE_STRING) {
const void *G = Game::Read<const void *>(L, OFF_L_GLOBALSTATE);
const StateSlot *slot = nullptr;
if (g_gameSlot.G == G)
slot = &g_gameSlot;
else if (g_glueSlot.G == G)
slot = &g_glueSlot;
if (slot != nullptr && slot->tv.value != nullptr)
return reinterpret_cast<IndexResolve_t>(Offsets::FUN_LUA_V_GETTABLE)(
L, &slot->tv, key, loop);
}
return g_indexNonTableOriginal(L, obj, key, loop);
}
const Game::HookAutoRegister _hook{Offsets::FUN_LUA_INDEX_NONTABLE,
reinterpret_cast<void *>(&IndexNonTable_h),
reinterpret_cast<void **>(&g_indexNonTableOriginal)};
// Captures `_G.string` for the CURRENT state (whatever VAR_LUA_STATE points
// at — the registration callbacks below run inside the right window for
// each) into `slot`, anchoring it in that state's registry.
void CaptureFor(StateSlot &slot) {
slot.G = nullptr;
slot.tv.value = nullptr;
void *L = Game::Lua::State();
if (L == nullptr)
return;
Game::Lua::PushString(L, "string");
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // [stringlib?]
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE) {
Game::Lua::PushString(L, kAnchorKey); // [tbl, key]
Game::Lua::PushValue(L, -2); // [tbl, key, tbl]
Game::Lua::RawSet(L, Game::Lua::REGISTRY_INDEX); // registry[key]=tbl. [tbl]
slot.tv.tt = Game::Lua::TYPE_TABLE;
slot.tv.value = Game::Lua::ToPointer(L, -1); // hvalue (Table*)
slot.G = Game::Read<const void *>(L, OFF_L_GLOBALSTATE);
}
Game::Lua::SetTop(L, -2); // pop
}
// `_classicapi_SetStringMethods(enabled)` — diagnostic kill switch. While
// false, string index falls through to the original resolver and errors as
// stock 1.12. Use only to answer "is this backport breaking this addon?".
int __fastcall Script_SetStringMethods(void *L) {
g_enabled = Game::Lua::ToBoolean(L, 1) != 0;
return 0;
}
void RegisterLuaFunctions() {
// World scripts are loading ⇒ the glue state is gone (destroyed on the
// glue→world handoff). Clear its slot so a future glue global_State
// that lands at the same address can never match this stale capture.
g_glueSlot.G = nullptr;
g_glueSlot.tv.value = nullptr;
CaptureFor(g_gameSlot);
Game::Lua::RegisterGlobalFunction("_classicapi_SetStringMethods",
&Script_SetStringMethods);
}
void RegisterGlueFunctions() {
CaptureFor(g_glueSlot);
Game::Lua::RegisterGlueFunction("_classicapi_SetStringMethods",
&Script_SetStringMethods);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Game::GlueModuleAutoRegister _glueAutoreg{&RegisterGlueFunctions};
} // namespace
void PrepareForReload() {
// The world Lua state is about to be reset (/reload) or destroyed
// (/logout); its string table may not survive. Fail closed until the
// next LoadScriptFunctions re-capture — a cleared slot means strings
// error on index exactly as stock, and nothing can read a freed table.
g_gameSlot.G = nullptr;
g_gameSlot.tv.value = nullptr;
}
} // namespace LuaSyntax::StringMethods
+25
View File
@@ -0,0 +1,25 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
namespace LuaSyntax::StringMethods {
// Forget the in-game state's captured `string` table. Called from
// FrameScript_Initialize_h BEFORE the engine resets (/reload) or destroys
// (/logout) the world Lua state, so the capture can never outlive the table
// it points at. Fails closed: until the ModuleAutoRegister re-capture at the
// next LoadScriptFunctions, strings error on index exactly as stock 1.12.
void PrepareForReload();
} // namespace LuaSyntax::StringMethods
File diff suppressed because it is too large Load Diff
+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
+64
View File
@@ -0,0 +1,64 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `table.maxn(t)` — Lua 5.1 addition (luaB_maxn, ltablib.c): the largest
// positive numeric key of `t`, or 0 when it has none. Unlike `#`/getn it
// scans EVERY key, so it sees past nil holes — common in Ace3-era code
// for sparse arrays. Port of the 5.1 reference: full `lua_next` walk,
// tracking the maximum numeric key.
//
// Registered on BOTH states (language-level helper, both are 5.0).
#include "Game.h"
namespace Table::Maxn {
namespace {
int __fastcall Script_table_maxn(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, "bad argument #1 to 'maxn' (table expected)");
return 0; // unreachable
}
double max = 0.0;
Game::Lua::PushNil(L);
while (Game::Lua::Next(L, 1) != 0) {
Game::Lua::SetTop(L, -2); // pop value, keep key for the next iter
// Type-gate before ToNumber: lua_tonumber would convert a numeric
// STRING key in place, corrupting the key `lua_next` resumes from.
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_NUMBER) {
const double k = Game::Lua::ToNumber(L, -1);
if (k > max)
max = k;
}
}
Game::Lua::PushNumber(L, max);
return 1;
}
void RegisterInGame() {
Game::Lua::RegisterTableFunction("table", "maxn", &Script_table_maxn);
}
void RegisterGlue() {
// RegisterTableFunction writes to whichever state VAR_LUA_STATE
// references — the glue state inside this callback.
Game::Lua::RegisterTableFunction("table", "maxn", &Script_table_maxn);
}
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace Table::Maxn
+211 -110
View File
@@ -1225,24 +1225,6 @@ void __fastcall WrapStepper_h(void *font, uint8_t *text, float fontH, float wrap
float slack = probeW - pWidth;
if (slack < 0.0f)
slack = 0.0f;
// AUTO-WIDTH bail: the whole text fits its budget with at
// most ~a glyph of slack — the signature of a
// single-anchor, no-width fontstring whose wrap budget is
// its own text-only measure plus a ~1-glyph engine margin
// (measured 1.07×fontH on pfUI's addon-list labels; the
// half-glyph first cut missed it). There is no real frame
// edge to protect there: the box is synthetic, the icons
// draw past it harmlessly, and any shrink FABRICATES a
// wrap on a line the engine would never have wrapped.
// Real-width content stays covered: an overflowing line
// breaks (lineLen < len), and a fitting line in a real
// frame carries frame-sized slack (the Marks harness line
// measures ~3.5×fontH) — only a text that lands within
// 1.5 glyphs of exactly filling its frame gives up its
// shrink, and its overflow is bounded by its icon sum
// (the pre-hook status quo).
if (pass == 0 && lineLen == len && slack <= glyphU * 1.5f)
break; // s == 0, best unset → final shrink = 0
// Half-glyph feasibility tolerance. An AUTO-WIDTH
// fontstring's wrap width IS the icon-inclusive string
// width (the GetStringWidth hook feeds the effective-width
@@ -1647,6 +1629,78 @@ static const Game::HookAutoRegister _emitterHook{Offsets::FUN_TEXT_EMITTER,
using Paint_t = void(__fastcall *)(void *layout);
Paint_t g_paintOriginal = nullptr;
// Builds the region placements for `node`'s recorded icons, expressed RELATIVE
// to the owning fontstring `fs`'s rect min corner (the pool anchors them there,
// so the engine moves them with the line). Fills `out` and returns true ONLY
// when everything the placement needs is ready this instant — K live (raster
// globals written) and the fs rect resolved. Returns false otherwise, and the
// caller must NOT queue an empty set (that would hide the icons and the dedup
// would freeze them hidden — the scroll-landing bug's second head). Shared by
// the paint flush and the SMF-refresh early apply so both produce
// bit-identical placements (the pool's dedup depends on that). The per-icon
// geometry mirrors exactly what the emitter reserves — see IconAdvancePen.
bool ComputePlacementsForNode(void *node, void *fs,
std::vector<Text::InlineTexturePool::Placement> &out) {
if (!LooksReadable(node) || !LooksReadable(fs))
return false;
auto itIcons = g_nodeIcons.find(node);
if (itIcons == g_nodeIcons.end() || itIcons->second.empty())
return false;
const PenScale K = PenPerAnchor();
if (!(K.x > 1.0f) || !(K.y > 1.0f))
return false;
auto *n = reinterpret_cast<uint8_t *>(node);
const float ox = Game::Read<float>(n, Offsets::OFF_TEXT_NODE_ORIGIN_X);
const float oy = Game::Read<float>(n, Offsets::OFF_TEXT_NODE_ORIGIN_Y);
// Same pixel-snap mode the emitter honours (bit-7 clear) — gates the
// drawn-rect snap in the placement loop below.
const bool snapNode = (Game::Read<uint32_t>(n, Offsets::OFF_TEXT_NODE_FLAGS) & 0x80u) == 0;
const float *rc = Game::Ptr<const float>(fs, Offsets::OFF_REGION_RECT);
const float fsBottom = (rc[0] < rc[2]) ? rc[0] : rc[2];
const float fsLeft = (rc[1] < rc[3]) ? rc[1] : rc[3];
if (rc[1] == rc[3])
return false; // unresolved rect reads 0-width
for (const IconRecord &r : itIcons->second) {
// Screen left = pen + the FULL lead pad (the emitter reserves
// w + 1.5×pad in the advance: lead 1×, trail 0.5×). Pad is
// fontH-relative — must match IconAdvancePen exactly.
const float cx = r.x + ox + r.offsetX + r.fontH * g_iconPadFrac + r.outlineInk * 0.5f;
// Centre on the line: penY sits near the text top. offsetY shifts up
// (WoW convention), so subtract it. Tall icons centre in the grown line
// (the string-height co-hook added the overflow).
const float cy = r.y + r.fontH * g_centerFrac + oy + g_vBias - r.offsetY;
float rx = cx + g_regionCalX;
float y0 = cy + g_regionCalY - r.h * 0.5f;
float w = r.w, h = r.h;
if (snapNode) {
// Land the drawn rect on WHOLE render-target pixels, like the
// engine's own glyphs (crisp texel sampling). Size snaps
// independently of position so a :16 icon is EXACTLY 16px.
rx = std::floor(rx + 0.5f);
y0 = std::floor(y0 + 0.5f);
w = std::floor(w + 0.5f);
h = std::floor(h + 0.5f);
if (w < 1.0f)
w = 1.0f;
if (h < 1.0f)
h = 1.0f;
}
Text::InlineTexturePool::Placement p;
p.path = r.path;
p.x0 = rx / K.x - fsLeft;
p.y0 = y0 / K.y - fsBottom;
p.x1 = (rx + w) / K.x - fsLeft;
p.y1 = (y0 + h) / K.y - fsBottom;
p.color = r.color;
p.u0 = r.u0;
p.v0 = r.v0;
p.u1 = r.u1;
p.v1 = r.v1;
out.push_back(std::move(p));
}
return true;
}
// Walks the layout's live render nodes and, for each recorded icon, computes
// its screen geometry (node-local pen coords translated by the node's screen
// origin +0x70/+0x74 — the SAME transform the paint pass applies to glyph
@@ -1786,99 +1840,17 @@ void FlushLayout(void *layout) {
}
}
}
const float ox = Game::Read<float>(n, Offsets::OFF_TEXT_NODE_ORIGIN_X);
const float oy = Game::Read<float>(n, Offsets::OFF_TEXT_NODE_ORIGIN_Y);
// Same pixel-snap mode the emitter honours (bit-7 clear) — gates
// the drawn-rect snap in the placement loop below.
const bool snapNode =
(Game::Read<uint32_t>(n, Offsets::OFF_TEXT_NODE_FLAGS) & 0x80u) == 0;
// The fs rect, read HERE in the same flush as the icon coords — a
// coherent snapshot. Placements are stored FS-RELATIVE: an
// apply-time rect read raced the chat relayout (SetText invalidates
// the rect briefly), and a placement applied against a mid-relayout
// rect parked the icon off the line — then the dedup (unchanged
// absolute want) froze it there. Relative offsets are also
// position-invariant, so scrolling no longer re-places anything.
const PenScale K = PenPerAnchor();
float fsLeft = 0.0f, fsBottom = 0.0f;
bool fsRectValid = false;
if (fs != nullptr) {
const float *rc = Game::Ptr<const float>(fs, Offsets::OFF_REGION_RECT);
fsBottom = (rc[0] < rc[2]) ? rc[0] : rc[2];
fsLeft = (rc[1] < rc[3]) ? rc[1] : rc[3];
fsRectValid = rc[1] != rc[3]; // unresolved rect reads 0-width
}
// Geometry + readiness gate factored into ComputePlacementsForNode
// (shared with the SMF-refresh early apply so both produce
// bit-identical placements — the pool dedup depends on it). It
// returns false when K or the fs rect isn't ready; SKIP the queue
// then and retry next paint. An empty queue here would HIDE the
// line's icons and the dedup would freeze it hidden forever
// (identical empty re-queues never dirty) — the scroll-landing
// bug's second head, where lines painted before K was available
// queued {} and stayed iconless until their text changed.
std::vector<Text::InlineTexturePool::Placement> places;
for (const IconRecord &r : it->second) {
// Screen left = pen + the FULL lead pad. The emitter reserves
// w + 1.5×pad in the advance (lead 1×, trail 0.5×) — drawing at
// the raw pen put all of that gap AFTER the icon, which is why
// a string-final icon (money-string copper) looked jammed
// against its digits and grew per-coin offset hacks. The pad is
// fontH-relative (NOT r.w) — must match IconAdvancePen exactly.
const float cx =
r.x + ox + r.offsetX + r.fontH * g_iconPadFrac + r.outlineInk * 0.5f;
// Centre on the line: penY sits near the text top, so add a
// fraction of the font height (plus the fine-tune bias). offsetY
// shifts up (WoW convention), so subtract it.
//
// TALL icons (h > fontH) centre too: the string-height co-hook
// grew the line by the overflow, and the text centres itself
// inside the grown rect (verified in-game — a bottom-aligned
// icon clipped the neighbouring line while retail shows tall
// emotes centred with no clip), so the extra space appears half
// above and half below the text. A centred icon fills exactly
// that — the retail look.
const float cy = r.y + r.fontH * g_centerFrac + oy + g_vBias - r.offsetY;
if (K.x > 1.0f && K.y > 1.0f && fs != nullptr && fsRectValid) {
float rx = cx + g_regionCalX;
float y0 = cy + g_regionCalY - r.h * 0.5f;
float w = r.w, h = r.h;
if (snapNode) {
// Land the drawn rect on WHOLE render-target pixels,
// like the engine's own glyphs (rounded origin +
// truncated advances). Pen px = render px, the fsLeft/
// fsBottom terms cancel exactly through the anchor
// round-trip (the engine adds the same rect corner
// back at resolve), and the pool's width convergence
// preserves the linear factor — so an integral pen
// rect lands integral on screen: 1:1-sized icons
// sample texel centres (crisp) instead of blending
// four neighbours (soft). Size snaps independently of
// position so a :16 icon is EXACTLY 16px, never 15/17
// from the two edges rounding apart.
rx = std::floor(rx + 0.5f);
y0 = std::floor(y0 + 0.5f);
w = std::floor(w + 0.5f);
h = std::floor(h + 0.5f);
if (w < 1.0f)
w = 1.0f;
if (h < 1.0f)
h = 1.0f;
}
Text::InlineTexturePool::Placement p;
p.path = r.path;
p.x0 = rx / K.x - fsLeft;
p.y0 = y0 / K.y - fsBottom;
p.x1 = (rx + w) / K.x - fsLeft;
p.y1 = (y0 + h) / K.y - fsBottom;
p.color = r.color;
p.u0 = r.u0;
p.v0 = r.v0;
p.u1 = r.u1;
p.v1 = r.v1;
places.push_back(std::move(p));
}
}
// The queue gate must match the per-icon build gate EXACTLY —
// including K. If any input wasn't ready this paint (unresolved fs
// rect, raster globals not yet written), SKIP the queue and retry
// next paint: an empty queue here would HIDE the line's icons and
// the dedup would freeze it hidden forever (identical empty
// re-queues never dirty). That was the scroll-landing bug's second
// head: lines painted before K was available queued {} and stayed
// iconless until their text changed.
if (fs != nullptr && fsRectValid && K.x > 1.0f && K.y > 1.0f)
if (ComputePlacementsForNode(node, fs, places))
Text::InlineTexturePool::QueuePlacements(fs, std::move(places));
} else if (fs != nullptr) {
Text::InlineTexturePool::QueuePlacements(fs, {});
@@ -1908,6 +1880,121 @@ static const Game::HookAutoRegister _paintHook{Offsets::FUN_TEXT_PAINT,
reinterpret_cast<void *>(&Paint_h),
reinterpret_cast<void **>(&g_paintOriginal)};
// --- SMF-refresh early apply (kills the one-frame chat-icon lag) -------------
//
// The lag: a ScrollingMessageFrame re-SetTexts every visible line slot on each
// new message (FUN_00788750), but the engine builds each line lazily at PAINT.
// So a line's icons are recorded — and its regions placed — one frame AFTER its
// glyphs draw, because a frame's textures (our icon regions) draw before its
// fontstring glyphs and the placement isn't computed until the glyph build runs
// mid-paint. This co-hook closes it: after the refresh sets up the lines (still
// PRE-RENDER — the refresh runs from the SMF's Lua/event-driven methods, never
// mid-render), force each icon-bearing line to resolve+build and apply its icon
// placement synchronously, so the icons draw with their glyphs the same frame.
//
// Additive and best-effort: any line we can't resolve/build in time (rect still
// pending) falls back to the existing paint-flush + FrameTick pipeline (correct,
// one-frame lagged). Gated by its own toggle (default on) so it can be A/B'd and
// disabled independently of the whole feature; the SEH latch trips it on a fault.
bool g_earlyApply = true;
using SmfRefresh_t = void(__fastcall *)(void *smf, void *edx, int newestIdx);
SmfRefresh_t g_smfRefreshOriginal = nullptr;
using EnsureBuilt_t = int(__fastcall *)(void *node);
using RealizeAnchor_t = void(__thiscall *)(void *anchor, int flag);
using RebuildStringFs_t = void(__fastcall *)(void *fs);
// Force `fs` to resolve its rect and lay out its text node NOW (pre-render),
// mirroring what the engine's pending-layout pass + paint would do later.
// Returns the live text node, or nullptr if it couldn't be built yet (rect
// still unresolved — caller falls back to the lagged pipeline). Uses only the
// engine's own supported force paths (the same Realize/RebuildString/ensure-
// built the Show and paint paths call).
void *ForceBuildFontString(void *fs) {
// 1. Synchronous rect resolve (Realize flag 0 — the call Show itself uses;
// it unlinks the anchor from the pending-layout list and resolves now).
// The caller walks lines bottom-up, so this line's relativeTo (the line
// below it / the frame) is already resolved when we get here.
reinterpret_cast<RealizeAnchor_t>(Offsets::FUN_REGION_LAYOUT_REALIZE)(
reinterpret_cast<uint8_t *>(fs) + Offsets::OFF_REGION_ANCHOR, 0);
// 2. Rebuild the text block — creates the fresh node (but only if the rect
// is now resolved). Internally gated on the fs dirty bit, so a clean
// line is a no-op and we avoid depending on reading that bit ourselves.
reinterpret_cast<RebuildStringFs_t>(Offsets::FUN_FONTSTRING_REBUILD_STRING)(fs);
void *block = Game::Read<void *>(fs, Offsets::OFF_FONTSTRING_TEXT_BLOCK);
if (!LooksReadable(block))
return nullptr; // rect still unresolved — no node created
void *node = Game::Read<void *>(block, Offsets::OFF_TEXTBLOCK_NODE);
if (!LooksReadable(node))
return nullptr;
// 3. Lay the node out now: runs the glyph emitter (our Emitter_h records the
// icons) and finalizes the node origin. Idempotent — paint's own ensure-
// built re-runs later and the flush dedups the identical placement.
reinterpret_cast<EnsureBuilt_t>(Offsets::FUN_TEXT_ENSURE_BUILT)(node);
return node;
}
// Walk the SMF's visible line array and early-apply each icon-bearing line.
// The C++-object-holding body is kept out of the SEH frame (see
// SafeSmfEarlyApply) — MSVC won't allow __try where objects need unwinding.
void SmfEarlyApplyImpl(void *smf) {
if (!LooksReadable(smf))
return;
const int count = Game::Read<int>(smf, Offsets::OFF_SMF_LINE_COUNT);
if (count <= 0 || count > 1024)
return;
auto *arrayBase = Game::Read<uint8_t *>(smf, Offsets::OFF_SMF_LINE_ARRAY);
if (!LooksReadable(arrayBase))
return;
for (int i = 0; i < count; ++i) {
uint8_t *entry = arrayBase + i * Offsets::SMF_LINE_STRIDE;
if (!LooksReadable(entry))
break;
void *fs = Game::Read<void *>(entry, Offsets::OFF_SMF_LINE_FONTSTRING);
if (!LooksReadable(fs))
continue;
// Only shown lines (the desired-shown latch the SMF just set) that carry
// inline markup — a plain chat line costs nothing but the text scan.
if (Game::Read<uint32_t>(fs, Offsets::OFF_REGION_DESIRED_SHOWN) == 0)
continue;
const uint8_t *text = Game::Read<const uint8_t *>(fs, Offsets::OFF_FONTSTRING_TEXT);
if (!LooksReadable(text))
continue;
int len = 0;
while (len < 0x4000 && text[len] != '\0')
++len;
if (!HasInlineTexture(text, len))
continue;
void *node = ForceBuildFontString(fs);
if (node == nullptr || NodeEditable(node))
continue; // couldn't build in time, or editable (raw markup)
std::vector<Text::InlineTexturePool::Placement> places;
if (ComputePlacementsForNode(node, fs, places))
Text::InlineTexturePool::ApplyNow(fs, std::move(places));
}
}
// SEH latch mirroring SafeFlush — a fault disables the early path for the
// session (the baseline paint+tick pipeline still runs) instead of crashing.
void SafeSmfEarlyApply(void *smf) {
__try {
SmfEarlyApplyImpl(smf);
} __except (EXCEPTION_EXECUTE_HANDLER) {
g_earlyApply = false;
}
}
void __fastcall SmfRefresh_h(void *smf, void *edx, int newestIdx) {
g_smfRefreshOriginal(smf, edx, newestIdx);
if (g_inlineEnabled && g_earlyApply)
SafeSmfEarlyApply(smf);
}
static const Game::HookAutoRegister _smfRefreshHook{
Offsets::FUN_SMF_DISPLAY_REFRESH, reinterpret_cast<void *>(&SmfRefresh_h),
reinterpret_cast<void **>(&g_smfRefreshOriginal)};
// --- Lua control surface ---------------------------------------------------
// _classicapi_InlineTexEnable([on]) -> enabled. The feature kill switch (also
@@ -1921,8 +2008,22 @@ int __fastcall Script_InlineTexEnable(void *L) {
return 1;
}
// _classicapi_InlineTexEarly([on]) -> enabled. Toggles the SMF-refresh
// pre-render early apply (the chat-icon-lag fix) independently of the whole
// feature — for A/B'ing the lag against the baseline paint+tick pipeline, which
// keeps running either way. Also what the SMF SEH latch trips on a fault.
int __fastcall Script_InlineTexEarly(void *L) {
if (Game::Lua::GetTop(L) == 0)
g_earlyApply = true;
else
g_earlyApply = Game::Lua::ToBoolean(L, 1) != 0;
Game::Lua::PushBool(L, g_earlyApply);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("_classicapi_InlineTexEnable", &Script_InlineTexEnable);
Game::Lua::RegisterGlobalFunction("_classicapi_InlineTexEarly", &Script_InlineTexEarly);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+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
+73 -18
View File
@@ -27,33 +27,85 @@ namespace {
bool g_detected = false;
char g_version[32] = {0};
// Which Lua state is live right now: the glue screen (true) or the in-world
// FrameScript state (false). The `TURTLE_*` glue markers are set by Turtle's
// GlueXML and are only trustworthy on the glue state — no user addon runs
// there. In-world, ANY addon can define a global by one of those names (a
// Turtle-UI port shipping the strings, say), which must NOT latch Turtle
// detection. So the marker probe is gated on this. Driven by the engine's own
// per-state script-registration hooks below; starts true (the client boots to
// the glue screen).
bool g_onGlue = true;
void Probe() {
if (g_detected)
return;
if (g_detected && g_version[0] != '\0')
return; // fully resolved (Turtle + version known)
void *L = Game::Lua::State();
if (L == nullptr)
return;
// Read `_G["TURTLE_WOW_VERSION"]` without disturbing the caller's
// stack. RawGet avoids any (nonexistent, but free-to-skip) metatable
// side effects on the globals table.
const int top = Game::Lua::GetTop(L);
Game::Lua::PushString(L, "TURTLE_WOW_VERSION");
Game::Lua::RawGet(L, Game::Lua::GLOBALS_INDEX);
if (Game::Lua::IsString(L, -1)) {
const char *v = Game::Lua::ToString(L, -1);
if (v != nullptr) {
const size_t n = std::strlen(v);
const size_t copy =
(n < sizeof(g_version) - 1) ? n : sizeof(g_version) - 1;
std::memcpy(g_version, v, copy);
g_version[copy] = '\0';
// In-world: `TURTLE_WOW_VERSION` (an engine-set global — NOT defined in
// Turtle's FrameXML Lua) both proves Turtle and gives the version string.
// RawGet avoids any (nonexistent, but free-to-skip) globals metatable.
if (g_version[0] == '\0') {
Game::Lua::PushString(L, "TURTLE_WOW_VERSION");
Game::Lua::RawGet(L, Game::Lua::GLOBALS_INDEX);
if (Game::Lua::IsString(L, -1)) {
const char *v = Game::Lua::ToString(L, -1);
if (v != nullptr) {
const size_t n = std::strlen(v);
const size_t copy =
(n < sizeof(g_version) - 1) ? n : sizeof(g_version) - 1;
std::memcpy(g_version, v, copy);
g_version[copy] = '\0';
}
g_detected = true;
}
Game::Lua::SetTop(L, top);
}
// Glue screen: `TURTLE_WOW_VERSION` is not on the glue Lua state, but
// Turtle's GlueXML sets a family of `TURTLE_*` string globals
// (GlueStrings.lua) at glue boot — before the addon-registration scan
// runs — so their presence marks a Turtle-lineage client. Clones keep the
// NAMES (Octo rebrands only the URL values), so checking name presence is
// clone-robust. Latch detected; the version stays empty until in-world.
// This is what lets `Addons::FlavorToc` select `_Turtle.toc` consistently
// at BOTH the glue registration scan and the in-world load pass.
//
// Only consult the markers while actually ON the glue state (`g_onGlue`).
// In-world these names are forgeable by any loaded addon, so trusting them
// there would let a stock client be mis-detected as Turtle for the session.
if (!g_detected && g_onGlue) {
static const char *const kGlueMarkers[] = {
"AUTH_TURTLE_WEBSITE", "TURTLE_ARMORY", "TURTLE_KNOWLEDGE_DATABASE"};
for (const char *marker : kGlueMarkers) {
Game::Lua::PushString(L, marker);
Game::Lua::RawGet(L, Game::Lua::GLOBALS_INDEX);
const bool present = Game::Lua::IsString(L, -1);
Game::Lua::SetTop(L, top);
if (present) {
g_detected = true;
break;
}
}
g_detected = true;
}
Game::Lua::SetTop(L, top);
}
// Track the live Lua state via the engine's per-state script-registration
// hooks. The in-world registration (`ModuleAutoRegister`) fires on each world
// load BEFORE the addon load pass, so `g_onGlue` is already false by the time
// any addon's `FlavorToc` calls `Detected()`; the glue registration
// (`GlueModuleAutoRegister`) fires on every glue boot (initial launch + each
// world->glue logout). Neither reads a forgeable Lua global.
void OnInWorldRegister() { g_onGlue = false; }
void OnGlueRegister() { g_onGlue = true; }
const Game::ModuleAutoRegister _inWorldReg{&OnInWorldRegister};
const Game::GlueModuleAutoRegister _glueReg{&OnGlueRegister};
} // namespace
bool Detected() {
@@ -63,7 +115,10 @@ bool Detected() {
const char *Version() {
Probe();
return g_detected ? g_version : nullptr;
// nullptr until the in-world `TURTLE_WOW_VERSION` fills g_version — a
// glue-only detection latches Detected() with the version still empty, and
// the header promises nullptr (not "") in that window.
return (g_detected && g_version[0] != '\0') ? g_version : nullptr;
}
} // namespace Turtle
+18 -13
View File
@@ -14,24 +14,29 @@
#pragma once
// Turtle WoW client detection — the gate for every module under
// `src/turtle/`. Turtle's FrameXML sets a `TURTLE_WOW_VERSION` global (see
// `FrameXML/Globals.lua`, e.g. `"1.18.1"`); its presence is the
// authoritative "this is the Turtle client" signal — no realm-list
// sniffing (which conflates "connected to Turtle" with "running the
// Turtle client" and was rejected for the Rip combo-duration work), no
// binary fingerprinting.
// `src/turtle/`. Two signals, so it works in-world AND on the glue screen:
// * in-world: the engine-set `TURTLE_WOW_VERSION` global (e.g. `"1.18.1"`;
// not defined in Turtle's FrameXML Lua — it's set by the client binary);
// * glue screen: Turtle's GlueXML `TURTLE_*` string globals (GlueStrings.lua,
// e.g. `AUTH_TURTLE_WEBSITE` / `TURTLE_ARMORY`), set at glue boot.
// Clones keep those NAMES (they rebrand only the URL/value), so name presence
// is clone-robust. No realm-list sniffing (which conflates "connected to
// Turtle" with "running the Turtle client", and varies per clone — rejected
// for the Rip combo-duration work), no binary fingerprinting.
namespace Turtle {
// True iff running on a Turtle WoW client. Reads the `TURTLE_WOW_VERSION`
// global and LATCHES true once seen. Safe to call any time: it returns
// false until the in-game Lua state exists AND FrameXML's Globals.lua has
// run (so it can read false very early in boot / on the glue screen), then
// flips true and stays true. Never caches a false result, so an early
// probe can't wrongly pin a Turtle client as non-Turtle.
// True iff running on a Turtle WoW (or clone) client. LATCHES true once either
// signal is seen. Safe to call from any state / any time — including during the
// glue-time addon-registration scan, which is what lets `Addons::FlavorToc`
// pick `_Turtle.toc` consistently at both scan and in-world load. Never caches
// a false result, so an early probe (before either signal exists) can't wrongly
// pin a Turtle client as non-Turtle.
bool Detected();
// The Turtle client version string (e.g. `"1.18.1"`), or nullptr when not
// Turtle / not yet detected. Points at an internally-cached copy.
// Turtle / not yet in-world (glue detection latches Detected() without a
// version — it fills once `TURTLE_WOW_VERSION` is readable in-world). Points at
// an internally-cached copy.
const char *Version();
} // namespace Turtle
+10 -14
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