Files
ClassicAPI/src/frame/ScriptArgs.cpp
T
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

399 lines
17 KiB
C++

// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Modern positional script arguments — make every frame-script handler
// receive `(self, arg1..argN)` the way later clients do, on top of the
// vanilla `this`/`arg1`/`event` globals.
//
// frame:SetScript("OnMouseWheel", function() print(this, arg1) end) -- vanilla
// frame:SetScript("OnMouseWheel", function(self, delta) print(self, delta) end) -- modern
//
// Vanilla ALWAYS invokes a script handler with ZERO Lua arguments: the two
// runners the engine funnels every dispatch through — FUN_FRAME_INVOKE_SCRIPT
// (no-arg scripts) and FUN_FRAME_RUN_SCRIPT_ARGS (scripts with values) — set
// the `this`/`arg1..argN` globals and then `lua_pcall(handler, /*nargs*/0)`.
// So `function(self, delta)` gets nothing today.
//
// We co-hook both runners and REPLACE their pcall tail: keep setting the same
// 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
// retail (and Frame::Modern's HookScript). An OnEvent dispatch is detected
// structurally at the runner: the handler ref being invoked equals the frame's
// OnEvent slot handler (frame+OFF_FRAME_ONEVENT_SLOT). This is exact — each
// 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 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"
#include "Offsets.h"
#include <cstdint>
namespace Frame::ScriptArgs {
namespace {
// 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;
// --- raw engine primitives not exposed via Game::Lua -----------------------
// lua_rawgeti(L, idx, n) — push table_at_idx[n]. Used with idx = REGISTRY to
// push a value stored under an integer ref (handler / frame object / message
// handler / luaL_ref'd saved globals).
using RawGetI_t = void(__fastcall *)(void *L, int idx, int ref);
// FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER — __thiscall(frame, nameOrNull);
// lazily materializes the frame's Lua-registry ref at frame+OFF_COBJECT_LUA_REF.
using ScriptRegister_t = void(__thiscall *)(void *frame, void *nameOrNull);
const auto RawGetI = reinterpret_cast<RawGetI_t>(Offsets::LUA_RAWGETI);
// Returns the frame's Lua object ref (frame+0x08), lazily creating it exactly
// as the engine runner does (`if (refcount == 0) ScriptRegister(frame, 0)`).
int EnsureLuaRef(void *frame) {
if (Game::Read<int>(frame, Offsets::OFF_COBJECT_LUA_REFCOUNT) == 0)
reinterpret_cast<ScriptRegister_t>(
Offsets::FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER)(frame, nullptr);
return Game::Read<int>(frame, Offsets::OFF_COBJECT_LUA_REF);
}
// Writes "arg" + k (k in 1..19) into buf; buf must hold at least 6 bytes.
void ArgName(char *buf, int k) {
buf[0] = 'a';
buf[1] = 'r';
buf[2] = 'g';
if (k < 10) {
buf[3] = static_cast<char>('0' + k);
buf[4] = '\0';
} else {
buf[3] = static_cast<char>('0' + k / 10);
buf[4] = static_cast<char>('0' + k % 10);
buf[5] = '\0';
}
}
// One parsed positional argument from the runner's printf-style format.
// Numbers ('d'/'u'/'f') land in `num`; strings ('s') keep the char* the engine
// handed us (stable for the duration of the call).
struct Arg {
char name[6]; // "arg1".."arg19"
char kind; // 'd' | 'u' | 'f' | 's'
double num;
const char *str;
};
// Decodes `fmt`/`vaPtr` (the runner's %d/%u/%f/%s specs over a packed vararg
// buffer) into `out`, returning the count (capped at kMaxArgs). Unknown specs
// are skipped and consume no vararg — mirroring the engine runner's switch.
// Pure C, touches no Lua state, so it can never throw.
int ParseArgs(const char *fmt, const void *vaPtr, Arg *out) {
if (fmt == nullptr)
return 0;
const char *cur = static_cast<const char *>(vaPtr);
int n = 0;
for (const char *p = fmt; *p != '\0' && n < kMaxArgs; ++p) {
if (*p != '%')
continue;
++p;
if (*p == '\0')
break;
Arg &a = out[n];
switch (*p) {
case 'd':
a.num = static_cast<double>(*reinterpret_cast<const int *>(cur));
cur += 4;
break;
case 'u':
a.num = static_cast<double>(
*reinterpret_cast<const unsigned int *>(cur));
cur += 4;
break;
case 'f':
a.num = *reinterpret_cast<const double *>(cur);
cur += 8;
break;
case 's':
a.str = *reinterpret_cast<const char *const *>(cur);
cur += 4;
break;
default:
continue; // unknown spec — consume nothing
}
a.kind = *p;
ArgName(a.name, n + 1);
++n;
}
return n;
}
// Pushes a parsed argument's value onto the Lua stack.
void PushArg(void *L, const Arg &a) {
if (a.kind == 's')
Game::Lua::PushString(L, a.str);
else
Game::Lua::PushNumber(L, a.num);
}
// Reimplements the runner tail: set the `this`/`arg1..argN` globals, invoke the
// handler with modern positional args `(self, [event,] arg1..argN)` under the
// engine's own message-handler errfunc, then restore the globals. `fmt`/`vaPtr`
// are null for the no-arg runner (N == 0).
//
// Unlike the engine (and our first cut), the old global values are saved on the
// Lua STACK, not in the registry via luaL_ref. A registry save leaks its ref if
// any push before the protected call longjmps — and out-of-memory, the one
// failure that actually fires here, does exactly that. Stack slots need no
// cleanup: an unwinding error resets `L->top` past them for free, so nothing
// leaks and there's no per-call ref churn on the OnUpdate hot path. The cost is
// a deeper stack (up to ~2*N slots live at once), so we CheckStack up front.
//
// Returns false WITHOUT side effects when it declines (no Lua state, or the
// stack can't grow); the caller then runs the original. Every false path is
// before the first push, so falling back never double-runs the handler.
bool RunModern(int handlerRef, void *frame, const char *fmt, const void *vaPtr) {
using namespace Game::Lua;
void *L = State();
if (L == nullptr)
return false;
// Headroom for the saved-old block plus the transient set/call pushes.
if (CheckStack(L, 2 * kMaxArgs + 16) == 0)
return false;
const bool haveThis = frame != nullptr;
// OnEvent dispatch? The event dispatchers invoke the frame's OnEvent slot
// (frame+0x0C); matching the handler ref against that slot identifies it
// exactly. When so, `event` (a global the dispatcher has set) is prepended
// as the first positional to mirror retail's (self, event, arg1..argN).
const bool isOnEvent =
haveThis &&
Game::Read<int>(frame, Offsets::OFF_FRAME_ONEVENT_SLOT) == handlerRef;
const int selfRef = haveThis ? EnsureLuaRef(frame) : 0;
Arg args[kMaxArgs];
const int n = ParseArgs(fmt, vaPtr, args);
// --- save the globals we're about to clobber, onto the stack ------------
// Absolute indices, live until the closing SetTop(base):
// base+1 = old _G.this (if haveThis)
// savedArgBase+1 .. +n = old _G.arg1..argN
const int base = GetTop(L);
if (haveThis) {
PushString(L, "this");
GetTable(L, GLOBALS_INDEX); // [.. oldThis]
}
const int savedArgBase = base + (haveThis ? 1 : 0);
for (int k = 0; k < n; ++k) {
PushString(L, args[k].name);
GetTable(L, GLOBALS_INDEX); // [.. oldArgk]
}
// --- set the new globals (each op is stack-neutral) ---------------------
if (haveThis) {
PushString(L, "this");
RawGetI(L, REGISTRY_INDEX, selfRef); // frame object
SetTable(L, GLOBALS_INDEX);
}
for (int k = 0; k < n; ++k) {
PushString(L, args[k].name);
PushArg(L, args[k]);
SetTable(L, GLOBALS_INDEX);
}
// --- call handler(self, [event,] arg1..argN) ---------------------------
const int callBase = GetTop(L); // == base + saved-block size
const int errRef = Game::Read<int>(
static_cast<uintptr_t>(Offsets::VAR_FRAMESCRIPT_ERROR_HANDLER_REF));
int errIdx = 0;
if (errRef > 0) {
RawGetI(L, REGISTRY_INDEX, errRef); // message handler
errIdx = callBase + 1; // absolute index of the errfunc
}
RawGetI(L, REGISTRY_INDEX, handlerRef); // handler
if (haveThis)
RawGetI(L, REGISTRY_INDEX, selfRef); // self
else
PushNil(L);
if (isOnEvent) { // event goes before the args, retail-style
PushString(L, "event");
GetTable(L, GLOBALS_INDEX);
}
for (int k = 0; k < n; ++k)
PushArg(L, args[k]);
PCall(L, 1 + (isOnEvent ? 1 : 0) + n, 0, errIdx);
SetTop(L, callBase); // drop the message handler + any leftover error object
// --- restore globals from the saved block, then drop it -----------------
for (int k = 0; k < n; ++k) {
PushString(L, args[k].name);
PushValue(L, savedArgBase + 1 + k);
SetTable(L, GLOBALS_INDEX);
}
if (haveThis) {
PushString(L, "this");
PushValue(L, base + 1);
SetTable(L, GLOBALS_INDEX);
}
SetTop(L, base);
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);
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) {
void *L = Game::Lua::State();
if (L != nullptr && HandlerWantsArgs(L, handlerRef) &&
RunModern(handlerRef, frame, fmt, vaPtr))
return;
}
g_origRunArgs(handlerRef, frame, fmt, vaPtr);
}
// --- FUN_FRAME_INVOKE_SCRIPT co-hook (no-arg scripts) -----------------------
// Same address Tooltip::SetEvents calls directly to fire OnTooltipSet*; with
// the switch enabled those fire with (self) too, which is the modern shape.
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) {
void *L = Game::Lua::State();
if (L != nullptr && HandlerWantsArgs(L, handlerRef) &&
RunModern(handlerRef, frame, /*fmt*/ nullptr, /*vaPtr*/ nullptr))
return;
}
g_origInvoke(handlerRef, frame);
}
const Game::HookAutoRegister _runArgsHook{
Offsets::FUN_FRAME_RUN_SCRIPT_ARGS,
reinterpret_cast<void *>(&RunArgs_h),
reinterpret_cast<void **>(&g_origRunArgs)};
const Game::HookAutoRegister _invokeHook{
Offsets::FUN_FRAME_INVOKE_SCRIPT,
reinterpret_cast<void *>(&Invoke_h),
reinterpret_cast<void **>(&g_origInvoke)};
// --- Lua toggle -------------------------------------------------------------
int __fastcall Script_SetModernScriptArgs(void *L) {
g_enabled = Game::Lua::ToBoolean(L, 1) != 0;
Game::Lua::PushBool(L, g_enabled);
return 1;
}
int __fastcall Script_GetModernScriptArgs(void *L) {
Game::Lua::PushBool(L, g_enabled);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("SetModernScriptArgs",
&Script_SetModernScriptArgs);
Game::Lua::RegisterGlobalFunction("GetModernScriptArgs",
&Script_GetModernScriptArgs);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Frame::ScriptArgs