feat(baselib): string.gsub takes 5.1 string and table replacements

1.12.1's str_gsub (0x7FD0E0) is Lua 5.0's. Its replacement argument
differs from 5.1 in four ways: a table is rejected ("string or function
expected"); in a replacement string, %0, and %1 when the pattern has no
captures, raise "invalid capture index" (add_s checks every %n with
check_capture, 0x7FCE20); a function that returns nil or false deletes
the match instead of keeping it; and one that returns a table deletes
it too instead of raising an error.

The first two only ever raised errors, so the 5.1 behavior cannot break
an existing addon. The function rules can: SuperAPI's
CombatText_AddMessage returns nil to delete the player's own " [0x...]"
GUID from heal text. So function replacements keep 1.12.1's behavior,
and only string and table replacements change. A scan of every gsub
call in the installed addons and the stock FrameXML found no other code
that depends on the old string or function results.

str_gsub's match loop, anchor, count and buffer stay the engine's. Two
parts are replaced:

- The 60-byte replacement-type gate at 0x7FD15E (lua_gettop / isstring /
  lua_type == function, then luaL_argerror) becomes a call to a
  __fastcall check with ECX = L that accepts a number, string, function
  or table and raises "string/function/table expected" otherwise.
- The one call to add_s (0x7FD203 -> 0x7FD2A0, __fastcall ms/b + s/e on
  the stack, ret 8) goes to a function with the same signature. A
  function replacement calls the engine's add_s unchanged. A string or
  number gets 5.1's add_s, and a table gets 5.1's lookup: the first
  capture or whole match as the key, a nil or false value keeps the
  match, and any other non-string value raises "invalid replacement
  value". Both use the engine's push_onecapture (0x7FCF40, plus 5.1's
  whole-match case for index 0 with no captures), luaL_addlstring
  (0x6F51F0) and luaL_addvalue (0x6F5270).

Both patches check the original bytes or call target and are skipped
when they differ.

The table-replacement shim in StringLib is removed. It wrapped the
pattern in an outer capture so a closure could return the whole match,
and deleted the match for a missing key when the pattern had a
back-reference. string.gsub and gsub are the engine's function again.
This commit is contained in:
Brues
2026-11-03 14:00:04 -06:00
parent 6a24271cd1
commit efcdc54a50
4 changed files with 230 additions and 153 deletions
+34 -13
View File
@@ -682,7 +682,7 @@ build instructions.
- [Stale table lengths (5.1 healing)](#stale-table-lengths-51-healing)
- [`Mixin(object, ...)` / `CreateFromMixins(...)`](#mixinobject---createfrommixins)
- [`string.match` / `string.gmatch`](#stringmatch--stringgmatch)
- [`string.gsub` table replacement](#stringgsub-table-replacement)
- [`string.gsub` replacements](#stringgsub-replacements)
- [`strsplit(sep, str [, pieces])`](#strsplitsep-str--pieces)
- [`strjoin(delimiter, ...)`](#strjoindelimiter-)
- [`strtrim(str [, chars])`](#strtrimstr--chars)
@@ -16254,27 +16254,48 @@ Both call forms work: `string.match(s, p)` and `s:match(p)`. See
global `strmatch`, as on 3.x clients, alongside 1.12's own `strfind`, `strsub`
and the other globals for the 5.0 string functions.
### `string.gsub` table replacement
### `string.gsub` replacements
Lua 5.1 allows a **table** as `gsub`'s replacement: every match looks up
the first capture (the whole match when the pattern declares no
captures) as a key and substitutes the value. A `nil` or `false` value
keeps the original match. The stock 5.0 `gsub` accepts only a string or
function replacement and errors on a table
(`string or function expected`).
`string.gsub(s, pattern, repl [, n])` replaces each match of `pattern` in
`s`. The replacement `repl` can be a string, a number, a table, or a
function.
- **String or number**: the text replaces the match. In this text, `%0` is
the whole match and `%1` to `%9` are the captures. If the pattern has no
captures, `%1` is also the whole match. `%%` is one `%` character.
- **Table**: the first capture is the key. If the pattern has no captures,
the whole match is the key. The value at that key replaces the match.
- **Function**: the function gets the captures as arguments. If the pattern
has no captures, it gets the whole match. Its first return value replaces
the match.
If a table value is `nil` or `false`, the original match stays in the
string. Any other table value that is not a string or a number causes the
error `invalid replacement value (a <type>)`.
If a function returns a value that is not a string or a number, the match is
removed. This includes `nil` and `false`. To keep a match, return the match
itself.
If `repl` is not one of the four types, the call fails with the error
`string/function/table expected`.
```lua
("hello world"):gsub("%w+", "<%0>")
-- "<hello> <world>", 2
("$name the $class"):gsub("%$(%w+)", { name = "Bob", class = "Druid" })
-- "Bob the Druid", 2
("$name and $unknown"):gsub("%$(%w+)", { name = "Bob" })
-- "Bob and $unknown", 2 (a missing key keeps the match, as in 5.1)
-- "Bob and $unknown", 2
("a b c"):gsub("%a", function(c) if c == "b" then return "B" end end)
-- " B ", 3 (the function returns nil for "a" and "c")
```
Both `string.gsub` and the bare `gsub` global have the upgrade; string
and function replacements behave exactly as before. One residual: when
the pattern itself contains a `%1`..`%9` back-reference, a missing table
key removes the match instead of keeping it.
`string.gsub`, the global `gsub`, and the method form `s:gsub(...)` are the
same function.
### `strsplit(sep, str [, pieces])`
+26 -7
View File
@@ -8468,6 +8468,11 @@ enum Offsets {
// `rawset(nil)` clear leaves it stale and subsequent `table.insert`
// appends past the wiped slots.
LUAL_SETN = 0x6F4EA0,
// `luaL_addlstring(B, s, len)` — __fastcall (len on the stack, ret 4) —
// and `luaL_addvalue(B)` — __fastcall, pops the string on top into B.
// Both as called by str_gsub (0x007FD26A) and add_s (0x007FD35E).
LUAL_ADD_LSTRING = 0x6F51F0,
LUAL_ADD_VALUE = 0x6F5270,
// `luaL_getn(L, tableIndex)` — Lua 5.0 auxlib table-length getter,
// `int __fastcall(L /*ecx*/, idx /*edx*/)`, plain RET (no stack
// args), length in eax. Verified by disassembly: a negative idx is
@@ -8898,14 +8903,28 @@ enum Offsets {
// entry before the registration table's NULL terminator, matching
// stock 5.0 order). Verified by decode: args (s, pat, repl, maxN),
// `^` anchor pre-strip, and the luaL_argerror(3) "string or
// function expected" gate. Its add_value helper (`FUN_007fd2a0`)
// matches stock 5.0: a FUNCTION replacement's non-string result is
// popped and NOTHING is appended (match → empty) — 5.1 instead
// keeps the original match for nil/false. That difference is why
// `BaseLib::StringLib`'s 5.1 table-replacement shim wraps the
// pattern in an outer capture (arg 1 = whole match) so its adapter
// closure can hand the match back for missing keys.
// function expected" gate. BaseLib::StringGsub replaces the gate and
// the add_s call below.
FUN_LUA_STR_GSUB = 0x007FD0E0,
// str_gsub's replacement-type gate, ECX = EDI = L on entry: `call
// lua_gettop; cmp eax,3; jl` → argerror, `lua_isstring(L,3)` → pass,
// `lua_type(L,3) == 6` → pass, else `luaL_argerror(L, 3, "string or
// function expected")`. 60 bytes, ending at 0x007FD19A `lea edx,[b]`
// (luaL_buffinit). Nothing outside the range jumps into it.
SITE_LUA_STR_GSUB_REPL_CHECK = 0x007FD15E,
// `add_s(ms, b, s, e)` — __fastcall (ECX = MatchState, EDX = luaL_Buffer,
// s/e on the stack, ret 8): Lua 5.0's add_s, which also does the
// function replacement (a non-string result is popped and nothing is
// added). Each `%n` goes through check_capture (0x007FCE20), so `%0`,
// and `%1` in a pattern with no captures, raise "invalid capture index".
// Its one caller is the call below; BaseLib::StringGsub still calls it
// for a function replacement.
FUN_LUA_STR_ADD_S = 0x007FD2A0,
CALL_LUA_STR_GSUB_ADD_S = 0x007FD203,
// `push_onecapture(ms, i)` — __fastcall, pushes capture `i`: "unfinished
// capture" for length -1, the position for -2, else the substring. 5.0's
// has no `i >= level` test (5.1's pushes the whole match for i == 0).
FUN_LUA_STR_PUSH_ONECAPTURE = 0x007FCF40,
// `str_format` — the Lua 5.0 `string.format` C function (`int
// __fastcall(void *L)`), strlib entry at `0x00822dc0`. Verified by decode:
// reads the format at arg 1, handles `%%`, `%d/i/c`, `%e/E/f/g/G`,
+163
View File
@@ -0,0 +1,163 @@
// 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/>.
// `string.gsub` takes 5.1's string and table replacements.
//
// 1.12's str_gsub (0x7FD0E0) is Lua 5.0's, and differs from 5.1 in its
// replacement argument:
// - it takes a string or a function, not a table;
// - `%0` in a replacement string, and `%1` when the pattern has no
// captures, raise "invalid capture index" (5.1: the whole match);
// - a function that returns nil or false deletes the match (5.1 keeps it),
// and one that returns a table deletes it too (5.1 raises an error).
//
// The first two only ever raised errors, so 5.1's behavior cannot break a
// 1.12 addon. The third does: SuperAPI's CombatText_AddMessage returns nil
// to delete the player's own " [0x...]" GUID from heal text. A function
// replacement therefore stays the engine's own add_s.
//
// str_gsub itself stays: its match loop, anchor, count and buffer are the
// same in both. Two parts are replaced:
// - the argument gate (SITE_LUA_STR_GSUB_REPL_CHECK) becomes CheckReplacement;
// - the one add_s call (CALL_LUA_STR_GSUB_ADD_S) goes to AddValue: 5.1's
// add_s for a string or number, 5.1's table lookup for a table, and the
// engine's add_s for a function. They use the engine's capture helpers.
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace BaseLib::StringGsub {
namespace {
namespace Lua = Game::Lua;
// Lua 5.0's MatchState, as str_gsub builds it on its stack (str_format's
// spec scan reads the same layout: level at +0x0C, capture[0].len at +0x14).
struct MatchState {
const char *srcInit;
const char *srcEnd;
void *L;
int level;
struct {
const char *init;
int len;
} capture[32];
};
using add_s_t = void(__fastcall *)(MatchState *ms, void *b, const char *s, const char *e);
using push_onecapture_t = void(__fastcall *)(MatchState *ms, int i);
using luaL_addlstring_t = void(__fastcall *)(void *b, const char *s, unsigned int len);
using luaL_addvalue_t = void(__fastcall *)(void *b);
const auto EngineAddS = reinterpret_cast<add_s_t>(Offsets::FUN_LUA_STR_ADD_S);
const auto EnginePushOneCapture =
reinterpret_cast<push_onecapture_t>(Offsets::FUN_LUA_STR_PUSH_ONECAPTURE);
const auto AddLString = reinterpret_cast<luaL_addlstring_t>(Offsets::LUAL_ADD_LSTRING);
const auto AddBufferValue = reinterpret_cast<luaL_addvalue_t>(Offsets::LUAL_ADD_VALUE);
// 5.1's push_onecapture: capture `i`, or the whole match for `i == 0` when
// the pattern has no captures.
void PushOneCapture(MatchState *ms, int i, const char *s, const char *e) {
if (i < ms->level)
EnginePushOneCapture(ms, i);
else if (i == 0)
Lua::PushLString(ms->L, s, static_cast<unsigned int>(e - s));
else
Lua::Error(ms->L, "invalid capture index");
}
// 5.1's add_s: a replacement string, where `%0` is the whole match and `%1`
// to `%9` are captures.
void AddString(MatchState *ms, void *b, const char *s, const char *e) {
void *L = ms->L;
const char *news = Lua::ToString(L, 3);
const unsigned int l = Lua::StrLen(L, 3);
for (unsigned int i = 0; i < l; i++) {
if (news[i] != '%') {
AddLString(b, news + i, 1);
continue;
}
i++; // skip the escape
const char c = news[i];
if (c < '0' || c > '9') {
AddLString(b, news + i, 1);
} else if (c == '0') {
AddLString(b, s, static_cast<unsigned int>(e - s));
} else {
PushOneCapture(ms, c - '1', s, e);
AddBufferValue(b);
}
}
}
// 5.1's add_value for a string or table, the engine's add_s for a function.
// Called as add_s: __fastcall, ret 8.
void __fastcall AddValue(MatchState *ms, void *b, const char *s, const char *e) {
void *L = ms->L;
switch (Lua::Type(L, 3)) {
case Lua::TYPE_FUNCTION:
// A result that is not a string or number deletes the match.
EngineAddS(ms, b, s, e);
return;
case Lua::TYPE_TABLE:
break;
default: // number or string
AddString(ms, b, s, e);
return;
}
PushOneCapture(ms, 0, s, e);
Lua::GetTable(L, 3);
if (!Lua::ToBoolean(L, -1)) {
Lua::SetTop(L, -2);
Lua::PushLString(L, s, static_cast<unsigned int>(e - s)); // keep the match
} else if (!Lua::IsString(L, -1)) {
Lua::Error(L, "invalid replacement value (a %s)", Lua::TypeName(L, Lua::Type(L, -1)));
}
AddBufferValue(b);
}
// 5.1's gate on the replacement argument. Called in place of the engine's,
// with ECX = L.
void __fastcall CheckReplacement(void *L) {
const int t = Lua::Type(L, 3);
if (t != Lua::TYPE_NUMBER && t != Lua::TYPE_STRING && t != Lua::TYPE_FUNCTION &&
t != Lua::TYPE_TABLE)
Lua::ArgError(L, 3, "string/function/table expected");
}
// Runs on every registration pass; a site already patched no longer matches
// and is skipped.
void Apply() {
static const uint8_t kGate[60] = {
0xE8, 0x0D, 0x5F, 0xEF, 0xFF, 0x83, 0xF8, 0x03, 0x7C, 0x21, 0xBA, 0x03,
0x00, 0x00, 0x00, 0x8B, 0xCF, 0xE8, 0x9C, 0x63, 0xEF, 0xFF, 0x85, 0xC0,
0x75, 0x22, 0xBA, 0x03, 0x00, 0x00, 0x00, 0x8B, 0xCF, 0xE8, 0x7C, 0x62,
0xEF, 0xFF, 0x83, 0xF8, 0x06, 0x74, 0x11, 0x68, 0x84, 0x23, 0x88, 0x00,
0xBA, 0x03, 0x00, 0x00, 0x00, 0x8B, 0xCF, 0xE8, 0x76, 0x76, 0xEF, 0xFF,
};
Common::ReplaceWithCall(Offsets::SITE_LUA_STR_GSUB_REPL_CHECK, kGate, sizeof kGate,
reinterpret_cast<const void *>(&CheckReplacement));
Common::RedirectCall(Offsets::CALL_LUA_STR_GSUB_ADD_S, Offsets::FUN_LUA_STR_ADD_S,
reinterpret_cast<const void *>(&AddValue));
}
const Game::ModuleAutoRegister _autoreg{&Apply};
const Game::GlueModuleAutoRegister _glueAutoreg{&Apply};
} // namespace
} // namespace BaseLib::StringGsub
+7 -133
View File
@@ -16,7 +16,6 @@
// - `string.match(s, pattern [, init])` — first-match extraction (5.1),
// also the global `strmatch`, as `string.find` is `strfind`.
// - `string.gmatch(s, pattern)` — match iterator (5.1).
// - `string.gsub` TABLE replacement — 5.1 form (see the shim below).
// - `strsplit(sep, str [, pieces])` — WoW global; split on any char.
// - `strjoin(delimiter, ...)` — WoW global; join varargs.
// - `strtrim(str [, chars])` — WoW global; trim a char set.
@@ -341,128 +340,6 @@ int __fastcall Script_string_reverse(void *L) {
return 1;
}
// --- string.gsub table-replacement upgrade (Lua 5.1) ------------------------
//
// 5.1 allows a TABLE as gsub's replacement: each match looks up the first
// capture (the whole match when the pattern declares no captures) in the
// table; a nil/false value keeps the original match; string/number values
// substitute. This engine's gsub (FUN_LUA_STR_GSUB) is stock 5.0 —
// string/function only ("string or function expected" otherwise), and its
// add_value appends NOTHING when a replacement function returns a
// non-string (match → empty; verified by decode, see Offsets.h). So a
// plain table→closure adapter would EAT unmatched tokens where 5.1 keeps
// them, and the closure can't reconstruct the whole match from capture
// args alone.
//
// The shim therefore rewrites the pattern with one OUTER capture —
// `pat` → `(pat)`, keeping a leading `^` and an unescaped trailing `$`
// outside (both are anchors only at their positional ends; inside the
// parens they'd turn literal) — so the adapter closure always receives
// the whole match as arg 1, the pattern's own first capture as arg 2, and
// can hand arg 1 back to reproduce 5.1's keep-match for missing keys.
// Patterns containing a `%1`..`%9` back-reference can't be wrapped (the
// outer capture would renumber what `%N` refers to); those fall back to
// an unwrapped adapter whose missing-key behavior is the engine's own
// (match → empty) — the one documented divergence, and the combination
// (table replacement + pattern back-references) is essentially absent
// from real code. String/function replacements tail-call the engine gsub
// untouched.
// True when the pattern contains a `%1`..`%9` back-reference. Escape-aware:
// the char after any `%` is consumed, so `%%1` (literal percent, then '1')
// doesn't count.
bool PatternHasBackref(const char *p, unsigned len) {
for (unsigned i = 0; i + 1 < len; ++i) {
if (p[i] != '%')
continue;
const char c = p[i + 1];
if (c >= '1' && c <= '9')
return true;
++i; // skip the escaped char
}
return false;
}
// Adapter for the wrapped-pattern path: arg 1 = whole match, arg 2 = the
// pattern's own first capture (when it declared any). Upvalue 1 = the
// replacement table. Missing/false value → return the whole match (5.1's
// keep-match).
int __fastcall GsubTableLookupWrapped(void *L) {
const int nargs = Game::Lua::GetTop(L);
Game::Lua::PushValue(L, nargs >= 2 ? 2 : 1); // the 5.1 lookup key
Game::Lua::GetTable(L, Game::Lua::UpvalueIndex(1)); // tbl[key]
const int t = Game::Lua::Type(L, -1);
if (t == Game::Lua::TYPE_NIL ||
(t == Game::Lua::TYPE_BOOLEAN && Game::Lua::ToBoolean(L, -1) == 0))
Game::Lua::PushValue(L, 1); // keep the original match
return 1; // top value is the result
}
// Adapter for back-reference patterns (unwrapped): arg 1 is already the
// 5.1 key. A missing key returns nil → the engine appends nothing.
int __fastcall GsubTableLookupPlain(void *L) {
Game::Lua::PushValue(L, 1);
Game::Lua::GetTable(L, Game::Lua::UpvalueIndex(1));
return 1;
}
int __fastcall Script_string_gsub(void *L) {
const auto engineGsub =
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_STR_GSUB);
if (Game::Lua::Type(L, 3) != Game::Lua::TYPE_TABLE)
return engineGsub(L); // string/function/error paths untouched
const char *pat = Game::Lua::ToString(L, 2);
if (pat == nullptr)
return engineGsub(L); // let the engine raise its own arg error
const unsigned patLen = Game::Lua::StrLen(L, 2);
Game::Lua::SetTop(L, 4); // normalize to (s, pat, tbl, n) — gsub reads ≤4
if (!PatternHasBackref(pat, patLen)) {
// Build `(body)` with the anchors outside the capture.
unsigned b = 0, e = patLen;
std::string wrapped;
wrapped.reserve(patLen + 3);
if (e > b && pat[0] == '^') {
wrapped += '^';
b = 1;
}
bool anchoredBack = false;
if (e > b && pat[e - 1] == '$') {
// `$` is an anchor only when not %-escaped: an even number of
// `%` immediately before it means the `$` itself is unescaped.
unsigned pc = 0;
for (int idx = static_cast<int>(e) - 2;
idx >= static_cast<int>(b) && pat[idx] == '%'; --idx)
++pc;
anchoredBack = (pc % 2) == 0;
if (anchoredBack)
--e;
}
wrapped += '(';
wrapped.append(pat + b, e - b);
wrapped += ')';
if (anchoredBack)
wrapped += '$';
Game::Lua::PushLString(L, wrapped.data(),
static_cast<unsigned int>(wrapped.size())); // 5
Game::Lua::PushValue(L, 3); // 6: tbl
Game::Lua::PushCClosure(L, &GsubTableLookupWrapped, 1); // 6: closure
Game::Lua::Insert(L, 2); // closure→2: s, closure, pat, tbl, n, wrapped
Game::Lua::Insert(L, 2); // wrapped→2: s, wrapped, closure, pat, tbl, n
Game::Lua::Remove(L, 4); // drop the original pattern
Game::Lua::Remove(L, 4); // drop the table → (s, wrapped, closure, n)
} else {
Game::Lua::PushValue(L, 3); // 5: tbl
Game::Lua::PushCClosure(L, &GsubTableLookupPlain, 1); // 5: closure
Game::Lua::Insert(L, 3); // → s, pat, closure, tbl, n
Game::Lua::Remove(L, 4); // → s, pat, closure, n
}
return engineGsub(L);
}
// --- Documentation ----------------------------------------------------------
const Game::Doc::Field kStrlenutf8Args[] = {
@@ -515,22 +392,19 @@ const Game::Doc::Function kStrconcat{
void RegisterFns() {
Game::Lua::RegisterTableFunction("string", "match", &Script_string_match);
Game::Lua::RegisterTableFunction("string", "gmatch", Script_string_gmatch);
Game::Lua::RegisterTableFunction("string", "gsub", &Script_string_gsub);
Game::Lua::RegisterTableFunction("string", "reverse", &Script_string_reverse);
Game::Lua::RegisterTableFunction("string", "split", &Script_strsplit);
Game::Lua::RegisterTableFunction("string", "trim", &Script_strtrim);
Game::Lua::RegisterTableFunction("string", "join", &Script_strjoin);
Game::Lua::RegisterTableFunction("string", "concat", &Script_strconcat, &kStrconcat);
// 4.3.4's compat.lua binds each short global and its string-table twin
// to ONE function by assignment (`gsub = str.gsub`, `strmatch =
// str.match`, `strrev = str.reverse`, `str.trim = strtrim`, `str.split =
// strsplit`, `str.join = strjoin`), so `strtrim == string.trim`. A second
// Register* call would mint a second closure over the same C function, so
// the globals alias the table entries by value instead. The string-table
// half is what lets `(" x "):trim()` work and libraries capture it at
// file scope (AceConfigCmd-3.0: `strtrim = string.trim`); the bare `gsub`
// gets the table-replacement upgrade by being the same function.
Game::Lua::RegisterGlobalAlias("gsub", "string", "gsub");
// to ONE function by assignment (`strmatch = str.match`, `strrev =
// str.reverse`, `str.trim = strtrim`, `str.split = strsplit`, `str.join =
// strjoin`), so `strtrim == string.trim`. A second Register* call would
// mint a second closure over the same C function, so the globals alias
// the table entries by value instead. The string-table half is what lets
// `(" x "):trim()` work and libraries capture it at file scope
// (AceConfigCmd-3.0: `strtrim = string.trim`).
Game::Lua::RegisterGlobalAlias("strmatch", "string", "match");
Game::Lua::RegisterGlobalAlias("strsplit", "string", "split");
Game::Lua::RegisterGlobalAlias("strjoin", "string", "join");