// 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 . // 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, , key, loop)` — byte-for-byte // what the original's own TM-table branch would do had gettmbyobj returned // that table (it passes `loop` through unchanged too, 0x006F7F23). The // engine's unmodified lookup then does everything, including exact 5.1 miss // semantics: `("x").nonexistent` → plain table miss → nil, not an error // (verified: FUN_006F7D60 returns &nilobject when the table has no // metatable). Non-strings fall through to the original — userdata __index, // and the stock "attempt to index" error for everything else. `settable` // on a string is a different continuation (FUN_006F7F40), untouched: still // an error, which is also 5.1's behavior. // // Hotness: cold. Table `__index` dispatch (all frame/OO method traffic) // reads the table's OWN metatable via FUN_006F7D60/fasttm and never lands // here; today's only callers are about-to-error non-table indexes. No other // Octo DLL hooks the Lua VM internals. // // State/GC lifecycle: one slot per Lua state (in-game + glue), keyed by the // state's global_State (`*(L+0x10)`) so coroutine threads — which share G // with their parent — resolve too. Each capture anchors the string table in // that state's registry, so the hook can never serve a GC-collected table: // even if an addon nils/replaces `_G.string`, resolution keeps the snapshot // (5.1 parity — its metatable pins the open-time table the same way). // Captures re-run per /reload (ModuleAutoRegister) and per glue boot // (GlueModuleAutoRegister), overwriting the anchor so the previous table is // released. Slots are cleared BEFORE their state can die, so a capture can // never outlive the table it points at: PrepareForReload (called from // FrameScript_Initialize_h, which fires on /reload AND /logout before the // world Lua state is reset) clears the game slot, and the in-game // registration clears the glue slot (once world scripts are loading, the // glue state is gone; the next glue boot re-captures before any glue Lua // runs). A cleared slot fails closed — strings error on index exactly as // stock 1.12 until the re-capture. // // Kill switch: `_classicapi_SetStringMethods(false)` (registered on both // states) makes the hook forward everything to the original resolver — // exact stock behavior — for "is this backport breaking this addon?" // diagnosis, mirroring `_classicapi_SetTranspileOption`. #include "StringMethods.h" #include "Game.h" #include "Offsets.h" #include namespace LuaSyntax::StringMethods { namespace { // `global_State *` slot in lua_State — the per-universe object shared by // every coroutine thread of a state. Verified: luaT_gettmbyobj reads the // tmname array at `*(L+0x10) + 0x80`, lua_setmetatable resolves the // REGISTRY pseudo-index via `*(L+0x10) + 0x30`. Single-consumer offset, // local per the Offsets.h rule. constexpr uintptr_t OFF_L_GLOBALSTATE = 0x10; // This build's TValue is 16 bytes with `tt` FIRST: {tt @ +0, value @ +8} — // NOT stock 5.0's {value, tt} order. Verified from lua_setmetatable // (tag = `*tv`, GC pointer = `tv[2]`, Table->metatable at hvalue+0x08 / // fast-TM flags byte at hvalue+0x06) and the push helpers' 0x10 stride. struct TValueMirror { int tt = 0; int pad0 = 0; const void *value = nullptr; // Table* when tt == TYPE_TABLE int pad1 = 0; }; static_assert(sizeof(TValueMirror) == 16, "TValue is 16 bytes in this build"); // Captured `string` library table for one Lua state. `G` doubles as the // valid flag — nullptr means "no capture, pass everything through". struct StateSlot { const void *G = nullptr; TValueMirror tv{}; }; StateSlot g_gameSlot; StateSlot g_glueSlot; // Runtime kill switch — `_classicapi_SetStringMethods(false)` reverts to // exact stock behavior (the hook forwards everything). Process-wide; both // states share it. bool g_enabled = true; // Registry key anchoring the captured table against GC (rawset — the // registry has no metatable). One key per state's registry; re-capture // overwrites it, releasing the previously anchored table. constexpr const char *kAnchorKey = "__classicapi_string_lib"; // FUN_LUA_INDEX_NONTABLE / FUN_LUA_V_GETTABLE shared convention: // __fastcall(L /*ecx*/, tv /*edx*/, key /*stack*/, loop /*stack*/), RET 8, // result TValue* in eax (see Offsets.h for the disassembly evidence). using IndexResolve_t = void *(__fastcall *)(void *L, const void *tv, void *key, int loop); IndexResolve_t g_indexNonTableOriginal = nullptr; void *__fastcall IndexNonTable_h(void *L, const void *obj, void *key, int loop) { // 5.1 backport: a string's `__index` is the `string` library table. if (g_enabled && obj != nullptr && *static_cast(obj) == Game::Lua::TYPE_STRING) { const void *G = Game::Read(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(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(&IndexNonTable_h), reinterpret_cast(&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(L, OFF_L_GLOBALSTATE); } Game::Lua::SetTop(L, -2); // pop } // `_classicapi_SetStringMethods(enabled)` — diagnostic kill switch. While // false, string index falls through to the original resolver and errors as // stock 1.12. Use only to answer "is this backport breaking this addon?". int __fastcall Script_SetStringMethods(void *L) { g_enabled = Game::Lua::ToBoolean(L, 1) != 0; return 0; } void RegisterLuaFunctions() { // World scripts are loading ⇒ the glue state is gone (destroyed on the // glue→world handoff). Clear its slot so a future glue global_State // that lands at the same address can never match this stale capture. g_glueSlot.G = nullptr; g_glueSlot.tv.value = nullptr; CaptureFor(g_gameSlot); Game::Lua::RegisterGlobalFunction("_classicapi_SetStringMethods", &Script_SetStringMethods); } void RegisterGlueFunctions() { CaptureFor(g_glueSlot); Game::Lua::RegisterGlueFunction("_classicapi_SetStringMethods", &Script_SetStringMethods); } const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions}; const Game::GlueModuleAutoRegister _glueAutoreg{&RegisterGlueFunctions}; } // namespace void PrepareForReload() { // The world Lua state is about to be reset (/reload) or destroyed // (/logout); its string table may not survive. Fail closed until the // next LoadScriptFunctions re-capture — a cleared slot means strings // error on index exactly as stock, and nothing can read a freed table. g_gameSlot.G = nullptr; g_gameSlot.tv.value = nullptr; } static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload}; } // namespace LuaSyntax::StringMethods