mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-10 20:07:37 +00:00
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:
+31
-7
@@ -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])`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user