feat(lua): backport Lua 5.1 string methods (("s"):upper(), s:format(...))

Lua 5.0 has no string methods at all -- the shared string metatable with
__index = string is a 5.1 addition, and both halves of the would-be
mechanism in this build match stock 5.0 exactly: lua_setmetatable
(0x006F4020) accepts only tables/userdata, and luaT_gettmbyobj
(0x006F7BD0) hardcodes &nilobject for every other type. So nothing can
be re-enabled; the 5.1 read path has to be added.

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

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

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

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

Verified in-game: literal and variable receivers, :format, 5.1 nil-miss,
and resolution inside a coroutine thread.
This commit is contained in:
Brues
2026-08-22 00:09:59 -05:00
parent bac27b9b2b
commit 9b9d80d807
3 changed files with 220 additions and 7 deletions
+31 -7
View File
@@ -344,6 +344,7 @@ build instructions.
- [Lua](#lua)
- [Lua 5.1 syntax](#lua-51-syntax)
- [String methods (`s:upper()`, `s:format(...)`)](#string-methods-supper-sformat)
- [`getfenv` / `setfenv` environment protection](#getfenv--setfenv-environment-protection)
- [`select(index, ...)`](#selectindex-)
- [`table.wipe(t)`](#tablewipet)
@@ -8571,6 +8572,34 @@ needs that addon's opt-in.
reverts affected chunks to the state that fails to compile, so use it
only to answer "is the rewrite breaking this addon?".
### String methods (`s:upper()`, `s:format(...)`)
In Lua 5.1, every string value accepts method calls: `s:method(...)`
resolves through the `string` table. Lua 5.0 has no string methods — on
stock 1.12, any `s:upper()` fails with `attempt to index a string value`.
ClassicAPI restores the 5.1 behavior:
```lua
("asd"):upper() -- "ASD"
("%d gold"):format(price) -- method call on a literal
msg:match("^!(%w+)") -- method call on a variable
link:sub(1, 5)
```
- Both call forms work and give the same result: `s:upper()` and
`string.upper(s)`.
- Every function in the `string` table is reachable as a method. That
includes the ClassicAPI additions ([`match` /
`gmatch`](#stringmatch--stringgmatch),
[`reverse`](#stringreverses--strrevs)) and any function an addon adds to
`string`.
- Reading an unknown key gives `nil`, the Lua 5.1 result: `("x").nope` is
`nil`, and `("x"):nope()` fails with `attempt to call`, not `attempt to
index`.
- Writing to a string (`("x").y = 1`) still fails, the same as Lua 5.1.
- Works everywhere strings do: in the world, on the login screen, and
inside coroutines.
### `getfenv` / `setfenv` environment protection
A sandbox can protect the environment table it gives to restricted code.
@@ -8715,13 +8744,8 @@ string.match("no digits", "%d+") -- nil
for word in string.gmatch("a,bb,ccc", "[^,]+") do print(word) end -- a / bb / ccc
```
> **Only the `string.foo(s, ...)` call form works — not the `("x"):foo(...)`
> method sugar.** WoW's Lua VM strips type-metatables for every value except
> tables and userdata (both `lua_setmetatable` and the VM's own
> `luaT_gettmbyobj` hard-return "no metamethod" for strings), so string values
> have no `__index` and there's no client-side way to add one short of hooking
> a hot VM-core function. This is the long-standing vanilla 1.12 constraint —
> always write `string.match(s, p)`, never `s:match(p)`.
Both call forms work: `string.match(s, p)` and `s:match(p)`. See
[String methods](#string-methods-supper-sformat).
### `strsplit(sep, str [, pieces])`
+31
View File
@@ -4820,6 +4820,37 @@ enum Offsets {
// `rawset(nil)` clear leaves it stale and subsequent `table.insert`
// appends past the wiped slots.
LUAL_SETN = 0x6F4EA0,
// `luaV_gettable(L, t, key, loop)` — the VM's generic index resolver.
// __fastcall(L /*ecx*/, t /*edx*/, key /*stack*/, loop /*stack*/),
// RET 8. Returns the result TValue* in eax (Ghidra types it void; the
// table-hit path demonstrably reaches the epilogue with eax =
// luaH_get's return — `CALL 0x006fa7a0; CMP [EAX],0; JNZ epilogue` at
// 0x006F7D1D..25). Table case: luaH_get (0x006FA7A0); nil result →
// the metatable continuation FUN_006F7D60, which reads the TABLE'S
// OWN metatable (hvalue+0x08, fast-TM flag byte hvalue+0x06 — 5.0
// fasttm) and returns &nilobject (0x00811BC0) when there is none
// (miss = nil, never an error). Non-table case → the continuation
// below, loop+1. Depth-guarded at 100 ("loop in gettable").
FUN_LUA_V_GETTABLE = 0x006F7CF0,
// The non-table branch of luaV_gettable — stock Lua 5.0's "index a
// non-table value" continuation. Same convention/return as
// FUN_LUA_V_GETTABLE (result TValue* in eax, RET 8). Body: __index
// tag method via luaT_gettmbyobj (0x006F7BD0 — which HARDCODES
// &nilobject for anything but table/userdata; stock 5.0, nothing
// stripped); nil → luaG_typeerror "attempt to index" (0x006FC620);
// function TM → call it; table TM → recurse FUN_LUA_V_GETTABLE with
// `loop` passed through UNCHANGED (0x006F7F23). Exactly 3 call
// sites: luaV_gettable's else-branch + luaV_execute's OP_GETTABLE /
// OP_SELF non-table fallbacks (0x006F8A14 / 0x006F8C17 in
// FUN_006F8720) — so it fires ONLY when code indexes a non-table,
// which in stock 5.0 is always about to raise "attempt to index"
// (table `__index` dispatch goes through FUN_006F7D60's fasttm and
// never lands here). A cold, uncontested hook site. Prologue is
// plain PUSH/MOV — MinHook-relocatable. Co-hooked by
// LuaSyntax::StringMethods to backport Lua 5.1 string methods
// (`("x"):upper()`): strings get the `string` library table as their
// __index, exactly 5.1's shared-string-metatable mechanism.
FUN_LUA_INDEX_NONTABLE = 0x006F7EE0,
// `luaL_loadbuffer(L, buff, size, chunkname)` — compiles a source
// buffer into a Lua function on the stack (does NOT run it). Returns
// an int status (0 = ok) in eax; Ghidra types it void because the
+158
View File
@@ -0,0 +1,158 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Lua 5.1 string methods (`("asd"):upper()`, `s:format(...)`) on the 5.0 VM.
//
// Lua 5.0 has no string methods AT ALL — the shared string metatable with
// `__index = string` is a 5.1 addition (`createmetatable` in 5.1's
// lstrlib.c). Nothing was stripped from this build: both halves of the
// would-be mechanism match stock 5.0 source exactly —
// * `lua_setmetatable` (0x006F4020) accepts only tables/userdata, so no
// API call can attach a metatable to the string TYPE; and
// * `luaT_gettmbyobj` (0x006F7BD0) hardcodes `&nilobject` for anything
// that isn't a table or userdata, so the VM would never read one.
// So this is the runtime half of the 5.1 backport (the syntax half is
// Transpile.cpp): we transplant 5.1's mechanism at the read side.
//
// Mechanism: co-hook FUN_LUA_INDEX_NONTABLE (0x006F7EE0) — luaV_gettable's
// non-table continuation, the ONE choke every string index reaches (3 call
// sites: luaV_gettable's else-branch + luaV_execute's OP_GETTABLE/OP_SELF
// fallbacks; see Offsets.h). When the indexed object is a string, answer
// with `luaV_gettable(L, <string library table>, key, loop)` — byte-for-byte
// what the original's own TM-table branch would do had gettmbyobj returned
// that table (it passes `loop` through unchanged too, 0x006F7F23). The
// engine's unmodified lookup then does everything, including exact 5.1 miss
// semantics: `("x").nonexistent` → plain table miss → nil, not an error
// (verified: FUN_006F7D60 returns &nilobject when the table has no
// metatable). Non-strings fall through to the original — userdata __index,
// and the stock "attempt to index" error for everything else. `settable`
// on a string is a different continuation (FUN_006F7F40), untouched: still
// an error, which is also 5.1's behavior.
//
// Hotness: cold. Table `__index` dispatch (all frame/OO method traffic)
// reads the table's OWN metatable via FUN_006F7D60/fasttm and never lands
// here; today's only callers are about-to-error non-table indexes. No other
// Octo DLL hooks the Lua VM internals.
//
// State/GC lifecycle: one slot per Lua state (in-game + glue), keyed by the
// state's global_State (`*(L+0x10)`) so coroutine threads — which share G
// with their parent — resolve too. Each capture anchors the string table in
// that state's registry, so the hook can never serve a GC-collected table:
// even if an addon nils/replaces `_G.string`, resolution keeps the snapshot
// (5.1 parity — its metatable pins the open-time table the same way).
// Captures re-run per /reload (ModuleAutoRegister) and per glue boot
// (GlueModuleAutoRegister), overwriting the anchor so the previous table is
// released. Between a state teardown and its re-capture a slot can be
// stale, but no Lua executes in those windows (engine C init only — and on
// this 5.0 engine no pre-existing code indexes strings anyway, since doing
// so always errored).
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace LuaSyntax::StringMethods {
namespace {
// `global_State *` slot in lua_State — the per-universe object shared by
// every coroutine thread of a state. Verified: luaT_gettmbyobj reads the
// tmname array at `*(L+0x10) + 0x80`, lua_setmetatable resolves the
// REGISTRY pseudo-index via `*(L+0x10) + 0x30`. Single-consumer offset,
// local per the Offsets.h rule.
constexpr uintptr_t OFF_L_GLOBALSTATE = 0x10;
// This build's TValue is 16 bytes with `tt` FIRST: {tt @ +0, value @ +8} —
// NOT stock 5.0's {value, tt} order. Verified from lua_setmetatable
// (tag = `*tv`, GC pointer = `tv[2]`, Table->metatable at hvalue+0x08 /
// fast-TM flags byte at hvalue+0x06) and the push helpers' 0x10 stride.
struct TValueMirror {
int tt = 0;
int pad0 = 0;
const void *value = nullptr; // Table* when tt == TYPE_TABLE
int pad1 = 0;
};
static_assert(sizeof(TValueMirror) == 16, "TValue is 16 bytes in this build");
// Captured `string` library table for one Lua state. `G` doubles as the
// valid flag — nullptr means "no capture, pass everything through".
struct StateSlot {
const void *G = nullptr;
TValueMirror tv{};
};
StateSlot g_gameSlot;
StateSlot g_glueSlot;
// Registry key anchoring the captured table against GC (rawset — the
// registry has no metatable). One key per state's registry; re-capture
// overwrites it, releasing the previously anchored table.
constexpr const char *kAnchorKey = "__classicapi_string_lib";
// FUN_LUA_INDEX_NONTABLE / FUN_LUA_V_GETTABLE shared convention:
// __fastcall(L /*ecx*/, tv /*edx*/, key /*stack*/, loop /*stack*/), RET 8,
// result TValue* in eax (see Offsets.h for the disassembly evidence).
using IndexResolve_t = void *(__fastcall *)(void *L, const void *tv, void *key, int loop);
IndexResolve_t g_indexNonTableOriginal = nullptr;
void *__fastcall IndexNonTable_h(void *L, const void *obj, void *key, int loop) {
// 5.1 backport: a string's `__index` is the `string` library table.
if (obj != nullptr && *static_cast<const int *>(obj) == Game::Lua::TYPE_STRING) {
const void *G = Game::Read<const void *>(L, OFF_L_GLOBALSTATE);
const StateSlot *slot = nullptr;
if (g_gameSlot.G == G)
slot = &g_gameSlot;
else if (g_glueSlot.G == G)
slot = &g_glueSlot;
if (slot != nullptr && slot->tv.value != nullptr)
return reinterpret_cast<IndexResolve_t>(Offsets::FUN_LUA_V_GETTABLE)(
L, &slot->tv, key, loop);
}
return g_indexNonTableOriginal(L, obj, key, loop);
}
const Game::HookAutoRegister _hook{Offsets::FUN_LUA_INDEX_NONTABLE,
reinterpret_cast<void *>(&IndexNonTable_h),
reinterpret_cast<void **>(&g_indexNonTableOriginal)};
// Captures `_G.string` for the CURRENT state (whatever VAR_LUA_STATE points
// at — the registration callbacks below run inside the right window for
// each) into `slot`, anchoring it in that state's registry.
void CaptureFor(StateSlot &slot) {
slot.G = nullptr;
slot.tv.value = nullptr;
void *L = Game::Lua::State();
if (L == nullptr)
return;
Game::Lua::PushString(L, "string");
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // [stringlib?]
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE) {
Game::Lua::PushString(L, kAnchorKey); // [tbl, key]
Game::Lua::PushValue(L, -2); // [tbl, key, tbl]
Game::Lua::RawSet(L, Game::Lua::REGISTRY_INDEX); // registry[key]=tbl. [tbl]
slot.tv.tt = Game::Lua::TYPE_TABLE;
slot.tv.value = Game::Lua::ToPointer(L, -1); // hvalue (Table*)
slot.G = Game::Read<const void *>(L, OFF_L_GLOBALSTATE);
}
Game::Lua::SetTop(L, -2); // pop
}
void RegisterLuaFunctions() { CaptureFor(g_gameSlot); }
void RegisterGlueFunctions() { CaptureFor(g_glueSlot); }
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Game::GlueModuleAutoRegister _glueAutoreg{&RegisterGlueFunctions};
} // namespace
} // namespace LuaSyntax::StringMethods