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.
This commit is contained in:
brues-code
2026-08-21 20:16:54 -05:00
committed by GitHub
parent 998deeac4a
commit a4d6290b80
21 changed files with 3472 additions and 110 deletions
+6 -1
View File
@@ -18,7 +18,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| Namespace | Calls |
|-----------|-------|
| [Action](docs/API.md#action) | `GetActionInfo` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnLocalTable`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem` |
| [Bindings](docs/API.md#bindings) | `SetBindingSpell`, `SetBindingItem`, `SetBindingMacro`, `SetBindingClick`, `SetOverrideBinding`, `SetOverrideBindingSpell`, `SetOverrideBindingItem`, `SetOverrideBindingMacro`, `SetOverrideBindingClick`, `ClearOverrideBindings` |
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
@@ -234,6 +234,11 @@ Transparent engine tweaks — no API to call, they just fix a vanilla limitation
|-------|--------|
| Tooltip line cap | Lifts `GameTooltip`'s hard 30-line limit to 60 for every `GameTooltipTemplate` frame (`GameTooltip`, `ShoppingTooltip1/2`, `ItemRefTooltip`, AtlasLoot, …). Stat-heavy tooltips and comparison blocks (e.g. pfUI's eqcompare) no longer have their extra lines silently dropped. Done in pure C++ by growing the engine's FontString pool at tooltip-creation time. |
| Inline textures | Draws inline texture markup (`\|T…\|t`) as icons in FontStrings, chat, and tooltips, the way 4.3.4+ clients do. Vanilla 1.12 shows the raw escape as literal text instead. This covers item and spell icons, raid-target markers, and the coin icons in money strings. `GetStringWidth` and `GetStringHeight` count the icons, so measured width and text wrapping stay correct. Done in pure C++ by hooking the engine's text pipeline — no addon. |
| Lua 5.1 syntax | Compiles the Lua 5.1 length (`#`), modulo (`%`), `...`-expression, `0x` hex-literal, and leveled long bracket (`[=[ ]=]`) syntax that vanilla's Lua 5.0 rejects, by rewriting addon source before it compiles. Each addon file also receives its `(name, table)` through `...` (`local name, tbl = ...`). See [Lua 5.1 syntax](docs/API.md#lua-51-syntax). |
| Lua 5.1 environment protection | `getfenv` / `setfenv` honor a `__environment` metatable field — the Lua 5.1 sandbox form — in addition to vanilla's raw `__fenv`. See [getfenv / setfenv environment protection](docs/API.md#getfenv--setfenv-environment-protection). |
| Multi-flavor & conditional TOC loading | Loads modern multi-flavor addons that ship one folder. Selects a version-specific TOC (`<Name>_ClassicAPI.toc` or `<Name>_Turtle.toc`) and the matching keybinding file (`Bindings_ClassicAPI.xml` / `Bindings_Turtle.xml`), accepts a comma-separated `## Interface:` version list (compatible when it includes the client version `11200`), and honors per-line `[AllowLoadGameType]` / `[AllowLoadTextLocale]` conditions and `[Family]` / `[Game]` / `[TextLocale]` path variables inside a TOC. See [Conditional and multi-flavor TOC loading](docs/API.md#conditional-and-multi-flavor-toc-loading). |
| SavedVariables loaded first | Honors the modern `## LoadSavedVariablesFirst` TOC directive: a flagged addon's SavedVariables load before its Lua runs, so file-scope code sees restored config (instead of vanilla's `nil`). See [SavedVariables loaded first](docs/API.md#savedvariables-loaded-first). |
| Modern script-handler arguments | Frame-script handlers receive their values as positional arguments — `OnMouseWheel(self, delta)`, `OnClick(self, button)`, `OnEvent(self, event, ...)`, etc. — the way 5.1+ clients do, so modern addon ports work unmodified. The `this` / `arg1` globals stay set. A handler that declares no parameters is unaffected. A handler that declared a parameter and expected nil now receives its real value. On by default. `SetModernScriptArgs(false)` restores exact vanilla dispatch. See [SetModernScriptArgs](docs/API.md#setmodernscriptargsenable--getmodernscriptargs). |
## 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.
+257 -10
View File
@@ -21,6 +21,9 @@ 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)
@@ -340,6 +343,8 @@ build instructions.
- [`C_LossOfControl.GetActiveLossOfControlData(index)`](#c_lossofcontrolgetactivelossofcontroldataindex)
- [Lua](#lua)
- [Lua 5.1 syntax](#lua-51-syntax)
- [`getfenv` / `setfenv` environment protection](#getfenv--setfenv-environment-protection)
- [`select(index, ...)`](#selectindex-)
- [`table.wipe(t)`](#tablewipet)
- [`table.count(tbl)`](#tablecounttbl)
@@ -926,6 +931,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)`
@@ -4474,26 +4614,35 @@ 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)
```
## FriendList
@@ -8346,6 +8495,104 @@ 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?".
### `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:
+96
View File
@@ -4679,6 +4679,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 +4745,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 +4820,45 @@ enum Offsets {
// `rawset(nil)` clear leaves it stale and subsequent `table.insert`
// appends past the wiped slots.
LUAL_SETN = 0x6F4EA0,
// `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
@@ -5139,6 +5198,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
+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
+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);
}
+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
File diff suppressed because it is too large Load Diff
+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