Files
benilla-octo/crates/benilla-ui/src/script/stdlib.rs
T
SamandClaude Opus 5 959fb8ba08 duels, inspecting other players, and water that ends at the bank
- Duels. Challenge another player from their right-click menu, or with `/duel`, and
  `/forfeit` to give it up. Both popups are there — the challenge you accept or decline, and
  the warning when you leave the field — with the flag countdown between them, and the outcome
  printed in chat when someone is knocked out or flees. Read off the reference client's own
  duel packets and the three globals it keeps client-side, which is why the challenger reads
  "You have requested a duel." even though the server never echoes that back.
- Inspect. Right-click a player and open their paper doll: their nineteen publicly visible
  equipment slots with icon, name, quality and a linkable item, and their body standing in
  the pane, which follows along if you retarget while the window is open. No stack counts and
  no durability — those are not fields another player publishes, which is exactly why the
  reference client's inspect doll has never shown them either.
- Water knows where it stops. Swimming used to trigger in the dry tunnel thirty yards under
  Stormwind's canal, and in mid-air over Blackrock's lava. A liquid is now the grid the map
  file actually stores — one wet flag per cell, and the surface height a bilinear over the
  four corners of the cell you are standing in, the same sample the reference client takes —
  so the water starts at the bank and its surface sits where your eye puts it. Lava is a
  liquid you swim in, an indoor liquid asks the building you are inside, and `/liquid` prints
  every surface claiming your feet and where each one came from.
- Creature parts authored to appear only on death — the voidwalker, the banshee, the ooze,
  the stone elemental, the infernal — were drawing the whole time: material alpha now bakes
  per animation sequence and the unit lane actually evaluates it. The character and inspect
  model panes are lit by the reference widget's own single light instead of blowing out white,
  the rotate arrows spin while you hold them, and the transparent sort ladder had its sign
  backwards, so overhead names draw over water again instead of being painted out by a canal
  behind them. Underneath, the protocol writer's 95-verb drawer and its thousand-line spell
  module split into per-family files — no behaviour change, just a tree you can find things in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 18:22:05 +07:00

283 lines
12 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)?;
}
// A `debugstack` stub: real addons call it in error paths; returning "" keeps them running.
g.set(
"debugstack",
lua.create_function(|_, _: Variadic<Value>| Ok(String::new()))?,
)?;
// 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.
let loadstring = lua.create_function(
|lua, (src, chunkname): (mlua::String, Option<mlua::String>)| {
let bytes = 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<()> {
lua.load(WOW_STDLIB)
.set_name("=[benilla wow stdlib]")
.set_mode(mlua::ChunkMode::Text)
.exec()?;
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 nine probe-found bare-global aliases (+ strlower/strrep) ────────────────────────────────
strlen = string.len
strsub = string.sub
strupper = string.upper
strlower = string.lower
strrep = string.rep
strfind = string.find
gsub = string.gsub
tinsert = table.insert
tremove = table.remove
getn = table.getn or function(t) return #t end
-- ── 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.fmod
random = math.random
-- `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
-- ── _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
-- ── 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
"#;