This checkout had no git history at all, so this one commit carries the whole
of the Everwood work as a single import on top of chairmanpow/benilla-octo's
`main` (706cd71). The two trees have genuinely diverged — each has files the
other does not — so this is a fork point, not a catch-up.
Today's work, which is the reason a baseline exists at all:
- Booth previews no longer cast world shadows (`NO_BOOTH_SHADOW`). Portrait
characters were entering the sun's shadow cascades, so every skinned-rig
shadow specialization compiled live mid-frame; the hitch detector logged
"main thread blocked 1316 ms" beside a `compiled LIVE` warning. This was
the Fashion/Tmog stutter.
- Per-pane dress-up: each `DressUpModel` pane assembles and stages its own
tried-on look instead of every pane mirroring the live player.
- Item template cache is append-only JSONL written on the IO pool, replacing
an 11.6 MB whole-map JSON rewrite on the main thread every 10 s.
- `WOW_SYSTEM_TIMING` rebuilt per-thread: it took 2 mutexes per system enter
and 4 per exit, costing 15 ms a frame (590 fps -> 60) and corrupting the
measurements it was taken to produce.
- The visibility-churn sweep moved to its own `WOW_FLASH_CHURN` switch, so a
frame-cost reading no longer pays 2.7 ms a frame for it.
Local state (`benilla-config/`, `logs/`, `target/`) is excluded by the
existing .gitignore; the account credential it holds is not in this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
836 lines
37 KiB
Rust
836 lines
37 KiB
Rust
//! The sandbox and the 1.12 stdlib layer on top of stock Lua 5.1.
|
|
//!
|
|
//! - [`sandbox`]: no filesystem, OS or native reach (`io`, `os`, `package`, `require`, `dofile`,
|
|
//! `loadfile`, `load`, `module`, `newproxy` and `debug` removed), the reference's `debugstack`,
|
|
//! and a text-only `loadstring`.
|
|
//! - [`install`]: the 1.12 bare globals (string, table and math aliases, the `debug*` family, the
|
|
//! error-handler pair, the clock and zone getters), `time` and `date`, and a `string.format`
|
|
//! that accepts positional `%N$` specs as the 1.12 client's does, rewriting them into a
|
|
//! sequential format. Mixing positional and sequential specs raises, as in the reference,
|
|
//! whose single argument cursor a positional spec desyncs.
|
|
|
|
use mlua::{Lua, Value, Variadic};
|
|
|
|
/// An mlua error's bare Lua message, as `loadstring` returns it: without the `syntax error: ` or
|
|
/// `runtime error: ` word mlua's `Display` adds and the reference never does.
|
|
pub(super) fn lua_message(e: &mlua::Error) -> String {
|
|
match e {
|
|
mlua::Error::SyntaxError { message, .. } => message.clone(),
|
|
mlua::Error::RuntimeError(m) => m.clone(),
|
|
other => other.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Sandbox the VM: no filesystem, OS or native reach, the reference's `debugstack`, and text-only
|
|
/// chunk loading.
|
|
pub(super) fn sandbox(lua: &Lua) -> mlua::Result<()> {
|
|
let g = lua.globals();
|
|
|
|
// Set to nil explicitly, whatever mlua's safe stdlib already leaves out.
|
|
for name in [
|
|
"io", "os", "package", "require", "dofile", "loadfile", "load", "module", "newproxy",
|
|
"debug",
|
|
] {
|
|
g.set(name, Value::Nil)?;
|
|
}
|
|
|
|
// debugstack([start [, count1 [, count2]]]): the reference's traceback, frames only, in Lua
|
|
// 5.0's wording. There is no `stack traceback:` header and line 1 is the caller's frame:
|
|
// `AceDB-2.0.lua:742` skips one line to read its caller's addon folder, and `AceLibrary.lua:70`
|
|
// reads line 1 as a frame. `AceLibrary.lua:139` needs 5.0's ``in function `name'`` quoting,
|
|
// not later Luas' `'name'`. `start` defaults to 1, the caller; `count1`/`count2` are 5.0's
|
|
// `LEVELS1`/`LEVELS2`, which `FuBarPlugin-2.0.lua:752` relies on with `debugstack(6, 1, 0)`.
|
|
g.set(
|
|
"debugstack",
|
|
lua.create_function(|lua, args: Variadic<Value>| {
|
|
let num = |v: Option<&Value>, default: usize| match v {
|
|
Some(Value::Integer(i)) => usize::try_from(*i).unwrap_or(0),
|
|
Some(Value::Number(n)) if *n >= 0.0 => *n as usize,
|
|
_ => default,
|
|
};
|
|
let start = num(args.first(), 1);
|
|
let count1 = num(args.get(1), 12);
|
|
let count2 = num(args.get(2), 10);
|
|
Ok(traceback_frames(lua, start, count1, count2))
|
|
})?,
|
|
)?;
|
|
|
|
// Text-only `loadstring`: a binary chunk is rejected. The BOM strip and `#`-line skip apply,
|
|
// as in the reference they live inside `luaL_loadbuffer`/`luaX_setinput`.
|
|
let loadstring = lua.create_function(
|
|
|lua, (src, chunkname): (mlua::String, Option<mlua::String>)| {
|
|
let raw = src.as_bytes();
|
|
let bytes = crate::source::chunk(&raw).to_vec();
|
|
// The chunk name is the caller's string verbatim, defaulting to the source itself
|
|
// (`luaB_loadstring` `0x703280`, `0x70329a`), which `luaO_chunkid` `0x6f5c40` renders
|
|
// as `[string "…"]`. It comes from the raw bytes: `luaL_loadbuffer` `0x6f5690` strips
|
|
// a BOM only after `0x703296` took the name.
|
|
let name = match &chunkname {
|
|
Some(n) => String::from_utf8_lossy(&n.as_bytes()).into_owned(),
|
|
None => String::from_utf8_lossy(&raw).into_owned(),
|
|
};
|
|
let chunk = lua
|
|
.load(bytes)
|
|
.set_name(name)
|
|
.set_mode(mlua::ChunkMode::Text);
|
|
match chunk.into_function() {
|
|
Ok(f) => Ok((Value::Function(f), Value::Nil)),
|
|
// The failure leg returns nil and Lua's own message, unprefixed (`0x7032c6`,
|
|
// `0x7032d2`).
|
|
Err(e) => Ok((
|
|
Value::Nil,
|
|
Value::String(lua.create_string(lua_message(&e))?),
|
|
)),
|
|
}
|
|
},
|
|
)?;
|
|
g.set("loadstring", loadstring)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Install the stdlib layer: the bare globals, `time`/`date` and the positional `format`.
|
|
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
|
// The default `geterrorhandler()` reports into the host's script-error channel; the
|
|
// `__benilla_` prefix marks host plumbing, not a 1.12 global.
|
|
lua.globals().set(
|
|
"__benilla_script_error",
|
|
lua.create_function(|lua, msg: mlua::Value| {
|
|
let text = match &msg {
|
|
mlua::Value::String(s) => s.to_string_lossy(),
|
|
other => format!("{other:?}"),
|
|
};
|
|
lua.app_data_mut::<super::Model>()
|
|
.expect("model app_data")
|
|
.errors
|
|
.push(text);
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
install_time(lua)?;
|
|
lua.load(WOW_STDLIB)
|
|
.set_name("=[benilla wow stdlib]")
|
|
.set_mode(mlua::ChunkMode::Text)
|
|
.exec()?;
|
|
// Kept by identity: dispatch skips the default handler, whose channel already has the
|
|
// message, and fires only one that FrameXML or an addon set.
|
|
let default: mlua::Function = lua.load("return geterrorhandler()").eval()?;
|
|
lua.set_named_registry_value(super::REG_DEFAULT_ERRORHANDLER, default)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// The stdlib layer in Lua, run once at construction.
|
|
const WOW_STDLIB: &str = r#"
|
|
-- ── the bare string family ─────────────────────────────────────────────────────────────────────
|
|
-- Every name here is present in the REAL 1.12 client's global table — the in-world `_G` captured
|
|
-- from the running reference client (19,572 entries), which is the
|
|
-- authority this file answers to.
|
|
--
|
|
-- `strmatch`, `strrev`, `gmatch`, `strlenutf8` and `strcmputf8i` come from Blizzard's *Era*
|
|
-- enumeration, which a Classic Era addon may reach for. **None of them exist in
|
|
-- 1.12** — `string.match`/`gmatch` are Lua 5.1 additions the 5.0 client never had — and Era is
|
|
-- not our target. They stay out; adding a global the client does not have is how an addon that
|
|
-- feature-detects gets sent down a path we cannot honour.
|
|
strlen = string.len
|
|
strsub = string.sub
|
|
strupper = string.upper
|
|
strlower = string.lower
|
|
strrep = string.rep
|
|
strfind = string.find
|
|
strbyte = string.byte
|
|
strchar = string.char
|
|
gsub = string.gsub
|
|
tinsert = table.insert
|
|
tremove = table.remove
|
|
-- `getn`, `sort`, `foreach` and `foreachi` are bound by `lua50`, which owns the
|
|
-- 5.0 shapes of `table.getn`/`setn` and must not be shadowed by a second implementation here.
|
|
|
|
-- ── the bare math aliases (the same 1.12 global family; the reference FrameXML uses them
|
|
-- unqualified — WorldMapFrame's overlay pool calls ceil/mod, CooldownFrame floor, …). `mod` is
|
|
-- fmod under a 5.0-era name (stock 5.1 dropped math.mod).
|
|
ceil = math.ceil
|
|
floor = math.floor
|
|
abs = math.abs
|
|
max = math.max
|
|
min = math.min
|
|
sqrt = math.sqrt
|
|
mod = math.mod -- 5.0's name for fmod; `math.fmod` is removed by `lua50`
|
|
random = math.random
|
|
-- `randomseed` is an ENGINE global in 1.12 exactly like `random` beside it (the captured `_G` types
|
|
-- both `function engine`), and it is the half that was missing: `IgniteStatus` calls it at file
|
|
-- scope in its OnLoad and dies on `attempt to call global 'randomseed'`. Seeding a PRNG is the one
|
|
-- thing an addon does that has no FrameXML counterpart to copy, so the bare name is all it has.
|
|
randomseed = math.randomseed
|
|
-- `PI` is an ENGINE global in 1.12, not a FrameXML one: the reference reads it bare (UIParent.lua's
|
|
-- `elapsedTime * 2 * PI * ROTATIONS_PER_SECOND`, TabardFrame.lua l.61-68) and no shipped FrameXML
|
|
-- file ever assigns it (grepped over the whole 1.12 extraction) — so the client supplies it, here,
|
|
-- beside the rest of the bare math family.
|
|
PI = math.pi
|
|
-- The 1.12 bare trig globals are DEGREE-based (the reference CombatText's fountain scroll calls
|
|
-- cos/sin with degree arguments); rad/deg ride along as the same family.
|
|
function sin(d) return math.sin(math.rad(d)) end
|
|
function cos(d) return math.cos(math.rad(d)) end
|
|
rad = math.rad
|
|
deg = math.deg
|
|
-- The rest of the bare math family, each verified present in the real 1.12 client's global
|
|
-- table (the captured `_G`).
|
|
-- `tan` and the inverses follow sin/cos into DEGREES — same family, same convention; that is
|
|
-- consistent-with the verified sin/cos finding rather than separately byte-verified, and it is
|
|
-- the convention every addon rotation helper assumes (`atan2` returning degrees is why
|
|
-- `atan2(dy, dx)` feeds a texture rotation directly).
|
|
function tan(d) return math.tan(math.rad(d)) end
|
|
function asin(x) return math.deg(math.asin(x)) end
|
|
function acos(x) return math.deg(math.acos(x)) end
|
|
function atan(x) return math.deg(math.atan(x)) end
|
|
function atan2(y, x) return math.deg(math.atan2(y, x)) end
|
|
exp = math.exp
|
|
log = math.log
|
|
log10 = math.log10
|
|
frexp = math.frexp
|
|
ldexp = math.ldexp
|
|
|
|
-- ── the debug* family: six verified STUBS and two real ones ────────────────────────────────────
|
|
--
|
|
-- All eight, read at the bytes (`0x7027e0`..`0x702860`):
|
|
-- `debuginfo`, `debugload`, `debugprint`, `debugdump`, `debugbreak` and `debugtimestamp` are
|
|
-- **byte-identical `xor eax,eax; ret` stubs** — three bytes, no `call`, no memory write. They
|
|
-- cannot print, log, write or set a global, and they return ZERO Lua values. So these are not
|
|
-- no-ops we invented under a real name (an "absent capability"); they are the
|
|
-- reference's own no-ops, transcribed.
|
|
--
|
|
-- That is what lets `BasicControls.xml` stop guarding its `debuginfo()` call and transcribe
|
|
-- `_ERRORMESSAGE` verbatim, which it now does.
|
|
--
|
|
-- `debugprofilestart`/`debugprofilestop` are the two that are REAL (RDTSC via `0x4293d0`): start
|
|
-- latches, stop answers the elapsed milliseconds since it. Modelled on `GetTime`'s own clock —
|
|
-- the same monotonic session seconds the tick advances — because benilla has no cycle counter and
|
|
-- an addon uses these to time its own work, which milliseconds answer honestly.
|
|
do
|
|
local function noop() end
|
|
debuginfo = noop
|
|
debugload = noop
|
|
debugprint = noop
|
|
debugdump = noop
|
|
debugbreak = noop
|
|
debugtimestamp = noop
|
|
|
|
local profileStart = 0
|
|
function debugprofilestart() profileStart = GetTime() end
|
|
function debugprofilestop() return (GetTime() - profileStart) * 1000 end
|
|
end
|
|
|
|
-- ── the error handler ──────────────────────────────────────────────────────────────────────────
|
|
-- `seterrorhandler(f)` / `geterrorhandler()` are ENGINE globals in 1.12 (the captured `_G` says
|
|
-- so), and `_ERRORMESSAGE` — the default handler they start out holding — is FrameXML's. That
|
|
-- split is why the pair lives here and the default is a plain function rather than a Rust binding:
|
|
-- our own transcribed UI can replace it exactly as the reference's `UIErrorsFrame` does.
|
|
--
|
|
-- The idiom this exists for is `geterrorhandler()(msg)` — an addon's pcall wrapper reporting a
|
|
-- caught error the way the client would. Without the pair that line is `attempt to call a nil
|
|
-- value` INSIDE an error path, which turns a recoverable addon fault into a dead addon.
|
|
do
|
|
local handler = function(msg) __benilla_script_error(msg) end
|
|
function seterrorhandler(f) handler = f end
|
|
function geterrorhandler() return handler end
|
|
end
|
|
|
|
-- ── _G accessors (getglobal/setglobal: _G[name] get/set) ───────────────────────────────
|
|
function getglobal(name) return _G[name] end
|
|
function setglobal(name, value) _G[name] = value end
|
|
|
|
-- ── GetLocale: benilla ships/reads enUS data only (the 5875 MPQs + Spell.dbc enUS column) ──────
|
|
function GetLocale() return "enUS" end
|
|
|
|
-- ── GetTime: the FrameXML session clock (seconds, monotonic, arbitrary epoch — like the real
|
|
-- client's uptime-based GetTime). `__benilla_now` is advanced by `UiScript::tick` in the same
|
|
-- call that fires OnUpdate, so `GetTime()` deltas and accumulated `elapsed` agree. Reference
|
|
-- FrameXML (CastingBarFrame & co.) anchors cast windows on it.
|
|
__benilla_now = 0.0
|
|
function GetTime() return __benilla_now end
|
|
|
|
-- ── The zone-text family.
|
|
-- The app pushes the host globals on an area change (the same shape as
|
|
-- GetTime) and fires MINIMAP_ZONE_CHANGED / the ZONE_CHANGED family; these getters just read the
|
|
-- cached slots, like the real bindings (0x48a0a0/c0/e0/100 read BSS caches). GetZoneText = the
|
|
-- zone name, replaced by the WMO interior's name indoors; GetRealZoneText = the WMO-immune zone
|
|
-- name; GetSubZoneText = the leaf subzone, "" when the leaf IS the zone (and indoors);
|
|
-- GetMinimapZoneText = subzone-else-zone. GetZonePVPInfo returns (pvpType or nil, factionName or
|
|
-- nil, isArena) — pvpType is friendly/hostile/contested, never "arena".
|
|
__benilla_zone_text = ""
|
|
function GetMinimapZoneText() return __benilla_zone_text end
|
|
__benilla_zone_name = ""
|
|
__benilla_real_zone_name = ""
|
|
__benilla_subzone_name = ""
|
|
__benilla_pvp_type = ""
|
|
__benilla_pvp_faction = ""
|
|
__benilla_pvp_arena = false
|
|
function GetZoneText() return __benilla_zone_name end
|
|
function GetRealZoneText() return __benilla_real_zone_name end
|
|
function GetSubZoneText() return __benilla_subzone_name end
|
|
function GetZonePVPInfo()
|
|
local t, f = __benilla_pvp_type, __benilla_pvp_faction
|
|
if t == "" then t = nil end
|
|
if f == "" then f = nil end
|
|
-- isArena is 1/nil, never a Lua boolean: the reference's third slot is `(nil) | (number)`
|
|
-- like every other 1.12 predicate.
|
|
return t, f, __benilla_pvp_arena and 1 or nil
|
|
end
|
|
|
|
-- ── GetGameTime: the server's in-game clock (hour, minute) — the reference reads the
|
|
-- SMSG_LOGIN_SETTIMESPEED-seeded clock the client advances by its timescale. The app pushes the
|
|
-- host globals when the game minute ticks (`crate::minimap::feed_game_time` — same shape as the
|
|
-- zone-text family); minute resolution is the API's own (the binding returns no seconds).
|
|
-- 0:00 until the first time packet lands.
|
|
__benilla_game_hour = 0
|
|
__benilla_game_minute = 0
|
|
function GetGameTime() return __benilla_game_hour, __benilla_game_minute end
|
|
|
|
-- ── Beyond 1.12: wipe / strsplit / strjoin / trim / explode ───────────────────────────────────
|
|
-- Names 1.12 does NOT have: absent from `reference/1.12-globals.tsv`, absent from the stock
|
|
-- chain (no stock `Interface\FrameXML` file calls any of them — `UIParent.lua`, `ChatFrame.lua`,
|
|
-- `MainMenuBar.lua` and `FriendsFrame.lua` read clean), and absent from every registrar table. They
|
|
-- are here anyway because a MODIFIED 1.12 UI ships them: TurtleWoW's `patch-4.mpq` replaces
|
|
-- `UIParent.lua` with a longer 2.0-era file whose `UIFrameFade_CreateInfo` calls `wipe(frame.fadeInfo)`
|
|
-- at line 1420, and replaces `FriendsFrame.lua` with one whose guild-roster filter calls
|
|
-- `trim(strlower(GuildFrameSearchBox:GetText()))` at line 490. Each raises
|
|
-- `attempt to call global '<name>' (a nil value)` and takes the whole UI down with it. The exception
|
|
-- list in `tests/reference_surface.rs` carries the same reason.
|
|
--
|
|
-- `trim` is not Blizzard's name at all — 1.12 has no `strtrim` either. It is TurtleWoW's own, for
|
|
-- `strtrim` semantics: every caller defines it identically as `gsub(str, "^%s*(.-)%s*$", "%1")`
|
|
-- (aux-addon's `util.lua`, ShaguDPS's `parser.lua`), which is the shape transcribed here.
|
|
--
|
|
-- This is a deviation from the reference, kept because 1.12's own UI cannot load without it. The
|
|
-- shapes are 2.0's, not 1.12's, and each is scoped to what the reference-corpus callers use:
|
|
-- `wipe` is single-argument (no corpus caller passes a key) and hands back a table for nil —
|
|
-- TurtleWoW's `Blizzard_RaidUI.lua:77` clears `RAID_SUBGROUP_LISTS` entries with `= wipe(t[i])`
|
|
-- and then `tinsert`s into them, and `strsplit` splits on PLAIN text.
|
|
do
|
|
local _find, _sub, _insert, _concat, _len = string.find, string.sub, table.insert, table.concat, string.len
|
|
local _gsub = string.gsub
|
|
|
|
function wipe(t)
|
|
-- `Globals.lua:126`'s body: the `tremove` walk down from `getn` resets 5.0's remembered
|
|
-- table size, which a bare `pairs` clear leaves stale — a list refilled after `wipe` would
|
|
-- then count the old length and read holes (LFT's group list). A nil (or non-table) hands
|
|
-- back a table, as TurtleWoW's `Blizzard_RaidUI.lua:77,116` clears `RAID_SUBGROUP_LISTS`
|
|
-- with `t[i] = wipe(t[i])` and then `tinsert`s into it.
|
|
if type(t) ~= "table" then
|
|
return {}
|
|
end
|
|
for i = getn(t), 1, -1 do
|
|
tremove(t, i)
|
|
end
|
|
for k in pairs(t) do
|
|
rawset(t, k, nil)
|
|
end
|
|
return t
|
|
end
|
|
|
|
-- `explode(subject, sep [, out])`: 2.0-era split into a TABLE, unlike `strsplit`'s vararg
|
|
-- shape, appending into `out` when given. TurtleWoW's `HonorFrame.lua:46` parses its
|
|
-- `TW_HONOR` addon message with `explode(arg2, ADDON_MSG_FIELD_DELIMITER)` and its
|
|
-- `WorldStateFrame.lua:233-245` reuses the same `out` tables across calls. A nil subject or
|
|
-- separator comes back whole, so a missing delimiter global cannot take the UI down.
|
|
function explode(str, sep, out)
|
|
out = out or {}
|
|
if str == nil then return out end
|
|
if sep == nil or sep == "" then
|
|
_insert(out, str)
|
|
return out
|
|
end
|
|
local pos = 1
|
|
local sep_len = _len(sep)
|
|
while 1 do
|
|
local e = _find(str, sep, pos, true)
|
|
if e == nil then
|
|
_insert(out, _sub(str, pos))
|
|
break
|
|
end
|
|
_insert(out, _sub(str, pos, e - 1))
|
|
pos = e + sep_len
|
|
end
|
|
return out
|
|
end
|
|
|
|
-- TurtleWoW's addon-message field delimiters (`TW_HONOR`, `TW_BG`), named where its
|
|
-- `HonorFrame.lua:46` and `WorldStateFrame.lua:331-335` split addon payloads. The values are
|
|
-- the same `;`/`:`/`|` its `WorldStateFrame.lua:233-245` writes literally for the fields,
|
|
-- arrays and subfields of the same protocol.
|
|
ADDON_MSG_FIELD_DELIMITER = ";"
|
|
ADDON_MSG_ARRAY_DELIMITER = ":"
|
|
ADDON_MSG_SUBFIELD_DELIMITER = "|"
|
|
|
|
function strsplit(delimiter, subject)
|
|
-- 5.0's `find` answers an empty plain pattern as `(from, from - 1)`, a zero-width match that
|
|
-- never advances `from`; looping on it fills the table until it overflows. There is nothing
|
|
-- to split on, so the subject comes back whole — the one shape a plain find cannot produce.
|
|
if delimiter == "" then return subject end
|
|
local result = {}
|
|
local from = 1
|
|
local dfrom, dto = _find(subject, delimiter, from, true)
|
|
while dfrom do
|
|
_insert(result, _sub(subject, from, dfrom - 1))
|
|
from = dto + 1
|
|
dfrom, dto = _find(subject, delimiter, from, true)
|
|
end
|
|
_insert(result, _sub(subject, from))
|
|
return unpack(result)
|
|
end
|
|
|
|
-- `...` reads as an EXPRESSION nowhere in 5.0 (`lua50` deletes that `simpleexp` arm), so
|
|
-- `strjoin` takes its arguments out of `arg`, which holds the VARARGS alone — 5.0's rule, and
|
|
-- `function(a, ...)` called with three answers `arg.n == 2`. The named `delimiter` is not in it.
|
|
function strjoin(delimiter, ...)
|
|
local out = {}
|
|
local n = arg.n
|
|
for i = 1, n do
|
|
out[i] = arg[i]
|
|
end
|
|
return _concat(out, delimiter)
|
|
end
|
|
|
|
-- `(.-)` is lazy, so the trailing `%s*` consumes to the LAST space and the leading one to the
|
|
-- first: both ends go, interior spacing stays. Only `%s` counts as space, as in every caller.
|
|
function trim(str)
|
|
local trimmed = _gsub(str, "^%s*(.-)%s*$", "%1")
|
|
return trimmed
|
|
end
|
|
|
|
-- `sizeof(t)`: TurtleWoW's `Globals.lua:111` table-entry count. Its patched FrameXML uses it
|
|
-- wherever a list's length is wanted — `Turtle_TransmogUI.lua:677` counts
|
|
-- `AvailableTransmogItems[InventorySlotId]`, its `TransmogOutfits` pages, `OptionsFrame.lua`
|
|
-- the arena-team rows, and `Blizzard_BindingUI` the search results. 1.12 has no such name
|
|
-- (`reference/1.12-globals.tsv` is silent and no stock file calls it); the exception list in
|
|
-- `tests/reference_surface.rs` carries the same reason. The body is the reference's verbatim:
|
|
-- a non-table is 0, and `pairs` counts every key, hash and array.
|
|
function sizeof(t)
|
|
if type(t) ~= "table" then
|
|
return 0
|
|
end
|
|
local s = 0
|
|
for k in pairs(t) do
|
|
s = s + 1
|
|
end
|
|
return s
|
|
end
|
|
end
|
|
|
|
-- ── NOT here: tostringall / strconcat / strtrim ────────────────────────────────────────────────
|
|
-- Three more 2.0+ names benilla used to define, kept out for the reason the four above were:
|
|
-- 1.12 has none of them, and no modified-1.12 UI ships a caller that needs them. The corpus
|
|
-- callers are multi-client addons whose vanilla paths raise on a real 1.12 client too, or that
|
|
-- define the name themselves under their own namespace. An addon reaching for one of these is
|
|
-- reaching for a later client's API, and it should find what it would find there: nothing.
|
|
|
|
-- ── TurtleWoW's arena-score request ────────────────────────────────────────────────────────────
|
|
-- TurtleWoW's `WorldStateFrame.lua:217` calls `RequestArenaScoreInfo()` on the battleground score
|
|
-- events. The function is native to its client — the reply fills `this.arenaData`, which its Lua
|
|
-- only reads — and benilla has no arena scoreboard (1.12 has no arenas, and the reply is parsed
|
|
-- by TurtleWoW's native code, never this Lua). So the request is a no-op that keeps the frame
|
|
-- from raising; `RequestCustomScoreInfo`, the battleground twin, is defined by the file itself.
|
|
RequestArenaScoreInfo = function() end
|
|
|
|
-- ── Blizzard's positional string.format (%N$) ──────────────────────────────────────────────────
|
|
do
|
|
-- `_find`/`_sub` are captured for the same reason `_format` is: this wrapper runs on every
|
|
-- `format` call in the UI and must not follow an addon's later replacement of `string.*`.
|
|
-- They are CALLS and not method syntax because 1.12 installs no string metatable, so
|
|
-- `fmt:find(...)` raises there, and here too (`lua50::install` removes the metatable). No
|
|
-- chunk of ours may use what the reference's VM cannot resolve; the parser enforces that
|
|
-- for the grammar, and this is its runtime twin.
|
|
local _format, _find, _sub, _len = string.format, string.find, string.sub, string.len
|
|
local CONV = "diouxXeEfgGqscp" -- Lua 5.1 conversion letters
|
|
|
|
local function reformat(fmt, ...)
|
|
if type(fmt) ~= "string" then
|
|
return _format(fmt, unpack(arg, 1, arg.n))
|
|
end
|
|
-- fast path: no "%<digit>" at all ⇒ definitely no positional spec.
|
|
if not _find(fmt, "%%%d") then
|
|
return _format(fmt, unpack(arg, 1, arg.n))
|
|
end
|
|
|
|
local args = arg
|
|
local pieces = {} -- rebuilt (sequential) format fragments
|
|
local order = {} -- for each real conversion: the source arg index, or false=sequential
|
|
local seen_pos, seen_seq = false, false
|
|
local npieces, norder = 0, 0
|
|
local i, len = 1, _len(fmt)
|
|
|
|
while i <= len do
|
|
local c = _sub(fmt, i, i)
|
|
if c ~= "%" then
|
|
npieces = npieces + 1; pieces[npieces] = c
|
|
i = i + 1
|
|
elseif _sub(fmt, i + 1, i + 1) == "%" then
|
|
npieces = npieces + 1; pieces[npieces] = "%%"
|
|
i = i + 2
|
|
else
|
|
local j = i + 1
|
|
local ds, de = _find(fmt, "^%d+%$", j) -- optional N$
|
|
if ds then
|
|
seen_pos = true
|
|
norder = norder + 1; order[norder] = tonumber(_sub(fmt, ds, de - 1))
|
|
j = de + 1
|
|
else
|
|
seen_seq = true
|
|
norder = norder + 1; order[norder] = false
|
|
end
|
|
-- copy flags/width/precision + the conversion letter
|
|
local ce = _find(fmt, "[" .. CONV .. "]", j)
|
|
if not ce then
|
|
error("invalid conversion in format string", 2)
|
|
end
|
|
npieces = npieces + 1; pieces[npieces] = "%" .. _sub(fmt, j, ce)
|
|
i = ce + 1
|
|
end
|
|
end
|
|
|
|
if seen_pos and seen_seq then
|
|
error("cannot mix positional and sequential arguments in format string", 2)
|
|
end
|
|
|
|
local out, seq = {}, 0
|
|
for k = 1, norder do
|
|
local idx = order[k]
|
|
if idx == false then
|
|
seq = seq + 1
|
|
out[k] = args[seq]
|
|
else
|
|
out[k] = args[idx]
|
|
end
|
|
end
|
|
return _format(table.concat(pieces, "", 1, npieces), unpack(out, 1, norder))
|
|
end
|
|
|
|
string.format = reformat
|
|
format = reformat
|
|
end
|
|
"#;
|
|
|
|
/// `time()` and `date([format [, when]])`: 1.12 engine globals (`0x7035a0`, `0x7033a0`), Lua 5.0's
|
|
/// `os.time`/`os.date` hoisted. `time` is wall-clock epoch seconds, as addons persist it across
|
|
/// sessions.
|
|
///
|
|
/// Deviation: `date` formats UTC where the reference uses local time, because the tree has no
|
|
/// timezone database.
|
|
fn install_time(lua: &Lua) -> mlua::Result<()> {
|
|
let g = lua.globals();
|
|
g.set(
|
|
"time",
|
|
lua.create_function(|_, ()| Ok(crate::civil::unix_seconds()))?,
|
|
)?;
|
|
g.set(
|
|
"date",
|
|
lua.create_function(|lua, (fmt, when): (Option<String>, Option<i64>)| {
|
|
// Bare `date()` is `%c`, as in Lua.
|
|
let fmt = fmt.unwrap_or_else(|| "%c".to_string());
|
|
let raw = when.unwrap_or_else(crate::civil::unix_seconds);
|
|
// A leading `!` selects UTC, as in Lua; without it the player's own zone applies, to
|
|
// the explicit timestamp as well as to the implicit now. The offset is the host's
|
|
// (`Model::local_utc_offset_secs`), 0 in a VM with no host — which is the UTC this
|
|
// whole tree rendered before, so a host-less run is unchanged.
|
|
let body = match fmt.strip_prefix('!') {
|
|
Some(rest) => rest,
|
|
None => &fmt,
|
|
};
|
|
let secs = if fmt.starts_with('!') {
|
|
raw
|
|
} else {
|
|
let model = lua.app_data_ref::<crate::script::Model>().expect("model");
|
|
raw + i64::from(model.local_utc_offset_secs)
|
|
};
|
|
// `*t` answers a table, as in Lua: an addon that loops until its `wday` matches
|
|
// (`Accountant_WeekStart`) spins forever on anything else.
|
|
if body == "*t" {
|
|
let c = crate::civil::from_unix(secs);
|
|
let t = lua.create_table()?;
|
|
t.set("year", c.year)?;
|
|
t.set("month", c.month)?;
|
|
t.set("day", c.day)?;
|
|
t.set("hour", c.hour)?;
|
|
t.set("min", c.min)?;
|
|
t.set("sec", c.sec)?;
|
|
// Lua counts weekdays 1..7 from Sunday; ours is the 0-based index into `DAYS`.
|
|
t.set("wday", c.weekday + 1)?;
|
|
// Already 1-based, as in Lua.
|
|
t.set("yday", c.yearday)?;
|
|
// UTC has no daylight saving.
|
|
t.set("isdst", false)?;
|
|
return Ok(mlua::Value::Table(t));
|
|
}
|
|
Ok(mlua::Value::String(
|
|
lua.create_string(format_epoch(secs, body))?,
|
|
))
|
|
})?,
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
const DAYS: [&str; 7] = [
|
|
"Sunday",
|
|
"Monday",
|
|
"Tuesday",
|
|
"Wednesday",
|
|
"Thursday",
|
|
"Friday",
|
|
"Saturday",
|
|
];
|
|
const MONTHS: [&str; 12] = [
|
|
"January",
|
|
"February",
|
|
"March",
|
|
"April",
|
|
"May",
|
|
"June",
|
|
"July",
|
|
"August",
|
|
"September",
|
|
"October",
|
|
"November",
|
|
"December",
|
|
];
|
|
|
|
/// The `strftime` subset addons use; an unknown specifier is emitted verbatim.
|
|
fn format_epoch(secs: i64, fmt: &str) -> String {
|
|
let crate::civil::Civil {
|
|
year,
|
|
month,
|
|
day,
|
|
hour,
|
|
min,
|
|
sec,
|
|
weekday: wday,
|
|
yearday: yday,
|
|
} = crate::civil::from_unix(secs);
|
|
let mut out = String::with_capacity(fmt.len() + 16);
|
|
let mut chars = fmt.chars().peekable();
|
|
while let Some(c) = chars.next() {
|
|
if c != '%' {
|
|
out.push(c);
|
|
continue;
|
|
}
|
|
let Some(spec) = chars.next() else {
|
|
out.push('%');
|
|
break;
|
|
};
|
|
let hour12 = match hour % 12 {
|
|
0 => 12,
|
|
h => h,
|
|
};
|
|
match spec {
|
|
'a' => out.push_str(&DAYS[wday as usize][..3]),
|
|
'A' => out.push_str(DAYS[wday as usize]),
|
|
'b' | 'h' => out.push_str(&MONTHS[(month - 1) as usize][..3]),
|
|
'B' => out.push_str(MONTHS[(month - 1) as usize]),
|
|
'c' => out.push_str(&format!(
|
|
"{} {} {:2} {:02}:{:02}:{:02} {}",
|
|
&DAYS[wday as usize][..3],
|
|
&MONTHS[(month - 1) as usize][..3],
|
|
day,
|
|
hour,
|
|
min,
|
|
sec,
|
|
year
|
|
)),
|
|
'd' => out.push_str(&format!("{day:02}")),
|
|
'H' => out.push_str(&format!("{hour:02}")),
|
|
'I' => out.push_str(&format!("{hour12:02}")),
|
|
'j' => out.push_str(&format!("{yday:03}")),
|
|
'm' => out.push_str(&format!("{month:02}")),
|
|
'M' => out.push_str(&format!("{min:02}")),
|
|
'p' => out.push_str(if hour < 12 { "AM" } else { "PM" }),
|
|
'S' => out.push_str(&format!("{sec:02}")),
|
|
'w' => out.push_str(&format!("{wday}")),
|
|
'x' => out.push_str(&format!("{month:02}/{day:02}/{:02}", year.rem_euclid(100))),
|
|
'X' => out.push_str(&format!("{hour:02}:{min:02}:{sec:02}")),
|
|
'y' => out.push_str(&format!("{:02}", year.rem_euclid(100))),
|
|
'Y' => out.push_str(&format!("{year}")),
|
|
'%' => out.push('%'),
|
|
other => {
|
|
out.push('%');
|
|
out.push(other);
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
/// `luaO_chunkid` (`0x6f5c40`), for `debugstack`'s `short_src`. An `@` name longer than
|
|
/// [`CHUNKID_KEEP`] keeps only its tail behind `"..."`, so a long addon path loses the `\AddOns\`
|
|
/// that corpus libraries search for.
|
|
fn chunk_id(source: &str) -> String {
|
|
if let Some(rest) = source.strip_prefix('=') {
|
|
// A `=name` chunk is taken verbatim, clipped to the buffer from the front.
|
|
return rest.chars().take(LUA_IDSIZE - 1).collect();
|
|
}
|
|
if let Some(rest) = source.strip_prefix('@') {
|
|
let n = rest.chars().count();
|
|
if n <= CHUNKID_KEEP {
|
|
return rest.to_string();
|
|
}
|
|
let tail: String = rest.chars().skip(n - CHUNKID_KEEP).collect();
|
|
return format!("...{tail}");
|
|
}
|
|
// A string chunk: `[string "<first line, clipped>"]`.
|
|
let first = source.split('\n').next().unwrap_or_default();
|
|
let budget = LUA_IDSIZE - STRING_CHUNK_OVERHEAD;
|
|
if first.chars().count() > budget || first.len() < source.len() {
|
|
let clipped: String = first.chars().take(budget).collect();
|
|
format!("[string \"{clipped}...\"]")
|
|
} else {
|
|
format!("[string \"{first}\"]")
|
|
}
|
|
}
|
|
|
|
/// `LUA_IDSIZE`: the `short_src` buffer, 60 bytes with the terminator.
|
|
const LUA_IDSIZE: usize = 60;
|
|
|
|
/// Characters an `@` name keeps, tail-first, behind `"..."` (`0x6f5c40`).
|
|
const CHUNKID_KEEP: usize = 52;
|
|
|
|
/// What `[string "…"]` costs the budget in the third `luaO_chunkid` arm.
|
|
const STRING_CHUNK_OVERHEAD: usize = 17;
|
|
|
|
/// One `debugstack` frame, `short_src ":" [currentline ":"] DESC`, the caller pushing its `\n`.
|
|
/// DESC (`0x7038fa`) is `` in function `%s' `` for a named frame (`0x872c98`), else by `what`:
|
|
/// `" in main chunk"` (`0x872c88`), `" ?"` for C or a tail call (`0x872c6c`), or
|
|
/// `" in function <%s:%d>"` (`0x872c70`).
|
|
fn traceback_frame(d: &mlua::Debug) -> String {
|
|
let src = d.source();
|
|
let names = d.names();
|
|
let short = src
|
|
.source
|
|
.as_deref()
|
|
.map(chunk_id)
|
|
.or_else(|| src.short_src.as_deref().map(str::to_owned))
|
|
.unwrap_or_else(|| "?".to_string());
|
|
let mut line = String::with_capacity(short.len() + 32);
|
|
line.push_str(&short);
|
|
line.push(':');
|
|
if let Some(n) = d.current_line() {
|
|
line.push_str(&n.to_string());
|
|
line.push(':');
|
|
}
|
|
// A tail call has nothing to name it from and takes the `" ?"` arm, known by its `what` or as
|
|
// the `(tail call)` pseudo-frame.
|
|
let tail_call = src.what == "t" || short == "(tail call)";
|
|
match names.name.as_deref() {
|
|
Some(name) if !name.is_empty() && !tail_call => {
|
|
line.push_str(" in function `");
|
|
line.push_str(name);
|
|
line.push('\'');
|
|
}
|
|
_ if src.what == "main" => line.push_str(" in main chunk"),
|
|
_ if src.what == "C" || tail_call => line.push_str(" ?"),
|
|
_ => {
|
|
line.push_str(" in function <");
|
|
line.push_str(&short);
|
|
line.push(':');
|
|
line.push_str(&src.line_defined.unwrap_or(0).to_string());
|
|
line.push('>');
|
|
}
|
|
}
|
|
line
|
|
}
|
|
|
|
/// `debugstack`'s walk (`0x703760`): Lua 5.0's `db_errorfb` without its header, each frame
|
|
/// followed by `"\n"` (`0x703971`) rather than preceded by `"\n\t"`, so line 1 is a frame.
|
|
/// Frames print while the level is `<= start + count1` (`0x703857`, `jbe`); then a failed
|
|
/// `getstack(level + count2)` probe prints that level as an ordinary frame, else `"...\n"` and
|
|
/// the last `count2` follow. So `count1 = 1` gives two frames on a two-deep stack, not one.
|
|
fn traceback_frames(lua: &Lua, start: usize, count1: usize, count2: usize) -> String {
|
|
let mut out = String::new();
|
|
let mut level = start;
|
|
let mut first_part = true;
|
|
while lua.inspect_stack(level, |_| ()).is_some() {
|
|
level += 1;
|
|
if level > start + count1 && first_part {
|
|
first_part = false;
|
|
if lua.inspect_stack(level + count2, |_| ()).is_none() {
|
|
level -= 1; // nothing to elide: print this level after all
|
|
} else {
|
|
out.push_str("...\n");
|
|
while lua.inspect_stack(level + count2, |_| ()).is_some() {
|
|
level += 1;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
if let Some(f) = lua.inspect_stack(level - 1, traceback_frame) {
|
|
out.push_str(&f);
|
|
out.push('\n');
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
impl crate::script::UiScript {
|
|
/// Publish the player's UTC offset in seconds — the host's own, since `benilla-app` owns the
|
|
/// OS call and this crate stays free of them. See
|
|
/// [`crate::script::Model::local_utc_offset_secs`] for why `date()` and
|
|
/// `C_DateAndTime.GetServerTimeLocal` must both know it.
|
|
pub fn set_local_utc_offset(&mut self, secs: i32) {
|
|
self.model_mut().local_utc_offset_secs = secs;
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod date_table_tests {
|
|
use crate::script::UiScript;
|
|
|
|
/// `Accountant.lua:364-375` steps back a day until `date("*t", ct).wday` matches, which a
|
|
/// string return never does.
|
|
#[test]
|
|
fn date_star_t_answers_a_table_that_walks_with_its_argument() {
|
|
let s = UiScript::new().unwrap();
|
|
// A Wednesday at 00:00 UTC: wday 4, counting from Sunday = 1.
|
|
let t: i64 = 1_786_492_800;
|
|
assert_eq!(
|
|
s.eval::<(i64, i64, i64, i64, bool)>(&format!(
|
|
"local d = date(\"*t\", {t}) return d.year, d.month, d.day, d.wday, d.isdst"
|
|
))
|
|
.unwrap(),
|
|
(2026, 8, 12, 4, false)
|
|
);
|
|
assert_eq!(
|
|
s.eval::<(i64, i64, i64, i64)>(&format!(
|
|
"local d = date(\"*t\", {t} + 3661) return d.hour, d.min, d.sec, d.yday"
|
|
))
|
|
.unwrap(),
|
|
(1, 1, 1, 224)
|
|
);
|
|
|
|
// Accountant's loop: one step back from Wednesday reaches Tuesday.
|
|
assert_eq!(
|
|
s.eval::<i64>(&format!(
|
|
"local ct = {t} local n = 0 \
|
|
while date(\"*t\", ct).wday ~= 3 and n < 100 do ct = ct - 86400 n = n + 1 end \
|
|
return n"
|
|
))
|
|
.unwrap(),
|
|
1,
|
|
"Wednesday(4) back to Tuesday(3) is exactly one step; a constant wday would spin"
|
|
);
|
|
|
|
// `!` is consumed, in the table form and the string form.
|
|
assert_eq!(
|
|
s.eval::<i64>(&format!("return date(\"!*t\", {t}).wday"))
|
|
.unwrap(),
|
|
4
|
|
);
|
|
assert!(
|
|
!s.eval::<String>(&format!("return date(\"!%A\", {t})"))
|
|
.unwrap()
|
|
.contains('!'),
|
|
"the UTC flag is consumed, not printed"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<String>(&format!("return date(\"%A\", {t})"))
|
|
.unwrap(),
|
|
"Wednesday"
|
|
);
|
|
}
|
|
}
|