Files
benilla-octo/crates/benilla-ui/src/script/stdlib.rs
T
SamandClaude Fable 5 e665b0bfc4 script errors reach the player, and the bars stop lying
- an addon error shows the red script-error dialog the reference shows, and a runaway load loop can no longer freeze world entry
- food and drink show stack counts from the first login, and a paladin's aura bar lights the aura that is actually on
- logging out no longer erases the character from saved-bag addons: the player's absence is no data
- MPQ patches are discovered the way the client discovers them, and a slow arrival holds in place instead of falling through the world

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 19:32:44 +07:00

697 lines
32 KiB
Rust

//! The sandbox + the WoW stdlib layer — the global aliases and helpers FrameXML/addon Lua assumes
//! exist on top of stock Lua 5.1 (decision 0068).
//!
//! - **Sandbox** ([`sandbox`]): remove `io`/`os`/`package`/`require`/`dofile`/`loadfile`/`debug`
//! (keeping a `debugstack` stub returning `""`), and replace chunk loading with a **text-only**
//! `loadstring` (bytecode rejected). Decision 0068's threat model is "addon-observable behavior",
//! not anti-automation, but running untrusted addon Lua still means no filesystem/OS/native reach.
//! - **Stdlib** ([`install`]): the nine bare-global aliases probe A found in the real corpus
//! (`format`/`strlen`/`gsub`/`strsub`/`strupper`/`tinsert`/`getn`/`tremove`/`strfind`), plus
//! `strlower`/`strrep`, `getglobal`/`setglobal`, the `strsplit`/`strjoin`/`strtrim`/`strconcat`
//! family, `wipe`, `tostringall`, and — the one non-trivial piece — a `string.format` replacement
//! that supports Blizzard's positional `%N$` extension (probe A confirmed stock 5.1 rejects it).
//!
//! ## The positional `format` (`%N$`)
//!
//! Blizzard patched `string.format` to accept `printf`-style positional specs (`"%2$s %1$s"`) for
//! localization. Stock Lua 5.1 does not (probe A, `semantics.rs` check (b)). We reimplement the
//! reordering in Lua: scan the format, and if any spec carries `N$`, rewrite it into a plain
//! sequential format with the arguments reordered, then delegate to the real `string.format`.
//! **Mixing** positional and sequential specs in one string is an error — and it is in Blizzard's
//! build too (their patch tracks a single "arg cursor" that a positional spec desyncs), so we match
//! by erroring rather than guessing a blend. The wrapper is installed as both `string.format` and
//! the bare global `format`.
use mlua::{Lua, Value, Variadic};
/// Sandbox the VM: strip filesystem/OS/native reach, keep a `debugstack` stub, and make chunk
/// loading text-only.
pub(super) fn sandbox(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// Remove the dangerous surface. (Some may already be absent under mlua's safe stdlib; setting
// nil is idempotent and makes the guarantee explicit rather than mlua-version-dependent.)
for name in [
"io", "os", "package", "require", "dofile", "loadfile", "load", "module", "newproxy",
"debug",
] {
g.set(name, Value::Nil)?;
}
// `debugstack([start [, count1 [, count2]]])` — a REAL traceback, not the `""` stub this used
// to be.
//
// The stub was justified as "real addons call it in error paths; returning \"\" keeps them
// running", and that was true of the addons that only ever DISPLAY it. It is false of the ones
// that PARSE it, and the biggest family in the corpus is one of those: `FuBarPlugin-2.0.lua:752`
// derives every plugin's own folder with
// `string.find(debugstack(6, 1, 0), "\\AddOns\\(.*)\\")`, then formats the capture into an
// icon path. Against `""` the find returns nil, `folderName` stays nil, and the plugin dies in
// `format` — 20 corpus addons, every one of them a FuBar plugin, and the error surfaced as a
// `format` complaint three frames away from the cause.
//
// `start` is the stack level to begin at and defaults to 1, matching the client's own callers
// (`debugstack(6, 1, 0)` walks past FuBar's own wrapper frames to the plugin file). The two
// count arguments bound how many frames are shown at the top and bottom; mlua's traceback does
// not take them, so they are accepted and ignored — stated rather than silently dropped, and
// harmless because every corpus caller passes a shape whose whole purpose is "give me the
// frames", never an exact line count.
g.set(
"debugstack",
lua.create_function(|lua, args: Variadic<Value>| {
let start = match args.first() {
Some(Value::Integer(i)) => *i as usize,
Some(Value::Number(n)) => *n as usize,
_ => 1,
};
// A level past the top of the stack is not an error here, it is an empty trace — the
// same shape the old stub returned, so a caller that guessed too deep still gets a
// string rather than a raise.
Ok(lua
.traceback(None, start)
.and_then(|t| t.to_str().map(|s| s.to_owned()))
.unwrap_or_default())
})?,
)?;
// Text-only `loadstring`: compile a *source* string (never bytecode). Returns `function` on
// success or `(nil, errmsg)` on failure — the stock `loadstring` contract. `set_mode(Text)`
// makes mlua reject a binary chunk (the `\27Lua` signature), the "loadstring of bytecode
// rejected" guarantee.
// The BOM strip and the `#`-line skip ride along, because in the reference they live *inside*
// `luaL_loadbuffer`/`luaX_setinput` — the same door `loadstring` goes through (decision 1193).
let loadstring = lua.create_function(
|lua, (src, chunkname): (mlua::String, Option<mlua::String>)| {
let bytes = crate::source::chunk(&src.as_bytes()).to_vec();
let name = match &chunkname {
Some(n) => format!("={}", n.to_str()?),
None => "=(loadstring)".to_string(),
};
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)),
Err(e) => Ok((Value::Nil, Value::String(lua.create_string(e.to_string())?))),
}
},
)?;
g.set("loadstring", loadstring)?;
Ok(())
}
/// Install the WoW stdlib layer (aliases, `strsplit` family, positional `format`, …).
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// The default `geterrorhandler()` reports into the same channel a failed handler uses, so an
// addon's `geterrorhandler()(msg)` surfaces where every other script error does rather than
// vanishing (decision 1195). Named with the `__benilla_` prefix because it is host plumbing,
// not a 1.12 global — the reference's default handler is FrameXML's `_ERRORMESSAGE`.
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()?;
// Remember the default handler BY IDENTITY, right after installing it — the engine-side
// dispatch (decision 1305) skips it (it reports into the host channel, where every dispatched
// message already is) and fires only a handler somebody *chose*: FrameXML's `_ERRORMESSAGE`
// or an addon's own.
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. One place, so each alias is the one-liner the task calls for and the
/// `format`/`strsplit` logic is readable. Runs 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 (wow-5875-re's item-13 fixture, 19,572 entries), which is the
-- authority this file now answers to (decision 1189).
--
-- 1187 also installed `strmatch`, `strrev`, `gmatch`, `strlenutf8` and `strcmputf8i` from
-- Blizzard's *Era* enumeration, to get a Classic Era addon further. **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 are gone; 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` (decision 1194), 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` (decision 1194)
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 (decision 1189's captured `_G`; 1187 had reached for Blizzard's Era enumeration).
-- `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 ────────────────────────────────────
--
-- wow-re carved all eight (`system/ui/scratch/lua-dialect.md` §3a, and the 2026-08-11 batch):
-- `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 (the "absent capability" class 1203 named); 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 (decision 1195) ──────────────────────────────────────────────────────────
-- `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 (RF-0023 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 (decisions 0203 phase 1 + 0287, byte-pinned by its fold-back; wow-re
-- ui zonetext-pvpinfo.md). 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
return t, f, __benilla_pvp_arena
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
-- ── wipe / tostringall ─────────────────────────────────────────────────────────────────────────
function wipe(t)
for k in pairs(t) do t[k] = nil end
return t
end
function tostringall(...)
local n = select('#', ...)
local t = {}
for i = 1, n do t[i] = tostring((select(i, ...))) end
return unpack(t, 1, n)
end
-- ── the strsplit / strjoin / strtrim / strconcat family ────────────────────────────────────────
-- strsplit(delimiters, str [, limit]): each char of `delimiters` is a single-char delimiter;
-- returns the pieces as multiple values (empty fields preserved), matching WoW's behavior.
function strsplit(delim, str, limit)
local set = "[" .. delim:gsub("(.)", "%%%1") .. "]"
local result = {}
local start = 1
while true do
if limit and #result >= limit - 1 then
result[#result + 1] = str:sub(start)
break
end
local s, e = str:find(set, start)
if not s then
result[#result + 1] = str:sub(start)
break
end
result[#result + 1] = str:sub(start, s - 1)
start = e + 1
end
return unpack(result)
end
-- strjoin(sep, ...): join the varargs with `sep`.
function strjoin(sep, ...)
return table.concat({ ... }, sep)
end
-- strconcat(...): concatenate all args (WoW's join with no separator).
function strconcat(...)
return table.concat({ ... })
end
-- strtrim(s [, chars]): trim leading/trailing chars (default whitespace).
function strtrim(s, chars)
chars = chars or " \t\r\n"
local set = chars:gsub("(.)", "%%%1")
return (s:gsub("^[" .. set .. "]*(.-)[" .. set .. "]*$", "%1"))
end
-- ── Blizzard's positional string.format (%N$) ──────────────────────────────────────────────────
do
local _format = string.format
local CONV = "diouxXeEfgGqscp" -- Lua 5.1 conversion letters
local function reformat(fmt, ...)
if type(fmt) ~= "string" then
return _format(fmt, ...)
end
-- fast path: no "%<digit>" at all ⇒ definitely no positional spec.
if not fmt:find("%%%d") then
return _format(fmt, ...)
end
local args = { ... }
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 i, len = 1, #fmt
while i <= len do
local c = fmt:sub(i, i)
if c ~= "%" then
pieces[#pieces + 1] = c
i = i + 1
elseif fmt:sub(i + 1, i + 1) == "%" then
pieces[#pieces + 1] = "%%"
i = i + 2
else
local j = i + 1
local ds, de = fmt:find("^%d+%$", j) -- optional N$
if ds then
seen_pos = true
order[#order + 1] = tonumber(fmt:sub(ds, de - 1))
j = de + 1
else
seen_seq = true
order[#order + 1] = false
end
-- copy flags/width/precision + the conversion letter
local ce = fmt:find("[" .. CONV .. "]", j)
if not ce then
error("invalid conversion in format string", 2)
end
pieces[#pieces + 1] = "%" .. fmt:sub(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, #order 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), unpack(out, 1, #order))
end
string.format = reformat
format = reformat
end
"#;
/// `time()` and `date([format [, when]])` — engine globals in the 1.12 client's own `_G`, slots 34
/// and 33 of its base registry (`0x7035a0` / `0x7033a0`, wow-re `lua-dialect.md`).
///
/// They are Lua 5.0's `os.time`/`os.date` hoisted to globals, and we lacked both because the
/// sandbox strips `os` wholesale. `time` was the top name in the session-start
/// `attempt to call global` row (6 addons); `date` is a handful more. Every corpus `time()` site is
/// the same shape — an epoch stamp persisted into SavedVariables and compared across sessions
/// (`FTC_Save[k].LastCheck = time()`) — so it has to be real wall-clock seconds, not a
/// session-relative clock like `GetTime`.
///
/// **UTC, and that is a stated divergence.** `os.date` uses LOCAL time and this uses UTC, because
/// resolving a local offset needs a timezone database and this tree has no date dependency at all
/// (deliberately — the format crates here are all in-repo). The corpus's uses are a debug
/// timestamp, a "last scanned" string, and `%M:%S` over a DURATION; only the first two shift, and
/// they shift by a constant. Fixing it means a tz source, not a different algorithm.
fn install_time(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
g.set("time", lua.create_function(|_, ()| Ok(unix_seconds()))?)?;
g.set(
"date",
lua.create_function(|lua, (fmt, when): (Option<String>, Option<i64>)| {
// Bare `date()` is `%c`, as in Lua — `Recap.lua:2690` calls it with no arguments.
let fmt = fmt.unwrap_or_else(|| "%c".to_string());
let secs = when.unwrap_or_else(unix_seconds);
// A leading `!` selects UTC. Every timestamp this engine holds is already UTC — there
// is no local-time conversion anywhere here — so the flag only has to be CONSUMED, not
// acted on. Left in the format string it would print as a literal `!`.
let body = fmt.strip_prefix('!').unwrap_or(&fmt);
// `date("*t")` returns a TABLE, not a string, and leaving it unimplemented was not a
// missing feature — it was a HANG. `Accountant_WeekStart` walks a day at a time until
// the weekday matches its stored `weekstart`:
//
// dt = date("*t", ct); thisDay = dt["wday"]
// while thisDay ~= …weekstart do ct = ct - 86400; … end
//
// Against a string return, `dt["wday"]` is nil, `nil ~= 3` forever, and the addon spins
// the client — which is exactly what it did to the survey the moment a separate fix let
// Accountant reach this function at all.
if body == "*t" {
let (year, month, day, hour, min, sec, wday, yday) = civil_from_epoch(secs);
let t = lua.create_table()?;
t.set("year", year)?;
t.set("month", month)?;
t.set("day", day)?;
t.set("hour", hour)?;
t.set("min", min)?;
t.set("sec", sec)?;
// Lua counts weekdays 1..7 from SUNDAY; ours is the 0-based index into `DAYS`.
t.set("wday", wday + 1)?;
// `yday` is already 1-based out of the conversion, which is Lua's convention too.
t.set("yday", yday)?;
// No timezone and no DST rules here, and `false` is the honest answer rather than
// the nil an absent field would give: this clock never observes daylight saving.
t.set("isdst", false)?;
return Ok(mlua::Value::Table(t));
}
Ok(mlua::Value::String(
lua.create_string(format_epoch(secs, body))?,
))
})?,
)?;
Ok(())
}
/// Wall-clock seconds since the Unix epoch. Before the epoch is not a state a game client is in;
/// a clock that somehow reports it clamps to 0 rather than raising inside an addon's file scope.
fn unix_seconds() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
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",
];
/// Broken-down UTC time from an epoch second: (year, month 1-12, day 1-31, hour, min, sec,
/// weekday 0=Sunday, yearday 1-366).
///
/// The civil-from-days conversion is Howard Hinnant's, shifted to a March-based year so the leap
/// day lands at the end and no month-length table is needed. Chosen over a hand-rolled loop because
/// it is a known-correct closed form with no accumulation error, which matters for the "persisted
/// last session, compared this session" use every corpus caller has.
fn civil_from_epoch(secs: i64) -> (i64, u32, u32, u32, u32, u32, u32, u32) {
let days = secs.div_euclid(86_400);
let rem = secs.rem_euclid(86_400);
let (hour, min, sec) = (
(rem / 3600) as u32,
((rem % 3600) / 60) as u32,
(rem % 60) as u32,
);
// 1970-01-01 was a Thursday (4).
let weekday = (days + 4).rem_euclid(7) as u32;
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let year = if month <= 2 { y + 1 } else { y };
// Day of the year, from the same conversion applied to Jan 1.
let jan1 = days_from_civil(year, 1, 1);
let yearday = (days - jan1 + 1) as u32;
(year, month, day, hour, min, sec, weekday, yearday)
}
/// Days since the epoch for a civil date — the inverse of the above, used only for `%j`.
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y.rem_euclid(400);
let mp = if m > 2 { m - 3 } else { m + 9 } as i64;
let doy = (153 * mp + 2) / 5 + d as i64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
/// The `strftime` subset the corpus actually uses, plus the obvious neighbours.
///
/// Bounded on purpose: the specifiers here are the ones read off real call sites —
/// `%H:%M:%S` (AceDebug), `%A, %B %d, %Y - %H:%M` (FuBar_PotHerbFu), `%M:%S` (FuBar_AnkhTimerFu),
/// `%c` (bare `date()`). An unknown specifier is emitted VERBATIM rather than swallowed, so a
/// format this does not know shows up in the output instead of silently vanishing.
fn format_epoch(secs: i64, fmt: &str) -> String {
let (year, month, day, hour, min, sec, wday, yday) = civil_from_epoch(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('%'),
// Unknown: verbatim, so it is visible rather than swallowed.
other => {
out.push('%');
out.push(other);
}
}
}
out
}
#[cfg(test)]
mod date_table_tests {
use crate::script::UiScript;
/// **`date("*t")` returns a TABLE, and the reason this test exists is that its absence HUNG the
/// client.** `Accountant_WeekStart` (`Accountant.lua:364-375`) walks backwards a day at a time
/// until the weekday matches its stored `weekstart`; with a string return `dt["wday"]` is nil,
/// `nil ~= 3` never becomes false, and the loop never ends.
#[test]
fn date_star_t_answers_a_table_that_walks_with_its_argument() {
let s = UiScript::new().unwrap();
// 2026-08-12 00:00:00 UTC is a Wednesday. Lua counts wday from SUNDAY = 1, so Wednesday
// is 4 — the off-by-one a 0-based weekday index would get wrong.
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)
);
// hour/min/sec and yday come through too — an addon reading any of them must not get nil.
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)
);
// **The loop Accountant actually runs.** Stepping back a day must move `wday`, and the walk
// must terminate — this is the hang, expressed as the addon expresses it.
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"
);
// A `!` prefix selects UTC, which every timestamp here already is — it must be CONSUMED
// rather than printed, and it works on the table form as well as 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"
);
// …and an ordinary format still answers a string.
assert_eq!(
s.eval::<String>(&format!("return date(\"%A\", {t})"))
.unwrap(),
"Wednesday"
);
}
}