Files
benilla-octo/crates/benilla-ui/src/script/saved.rs
T
gromandClaude Opus 5 15f069667e saved: a widget writes as its name, so a saved frame comes back a frame
Decursive raised `attempt to call method 'AddMessage' (a nil value)` on every
login (`Decursive.lua:1393`). It keeps its output window in its own saved table
and guards the reload with `if Dcr_Saved.Dcr_OutputWindow == nil or not ...`
(`:1245`) — which a non-nil *data* table passes.

Our writer walked the frame like any other table: his `Decursive.lua` came back
holding ChatFrame1's own fields (`buttonSide`, `channelList`, `editBox`, …),
twice the size of the real client's file and with no methods on it at all.

The reference tests the value's `T[0]` identity before its fields —
`lua_rawgeti(v, 0)` then `lua_touserdata`, `0x704613`-`0x704628`, reading the
lightuserdata `0x701bd0` wrote — and never walks a widget. A named one writes as
its **bare global name**, a Lua expression, so the reloaded chunk's own lookup
hands the live object back; an anonymous one as `nil --[[ unnamed object ]]`
(`0x872e0c`). His own real-client file is the proof:

    ["Dcr_OutputWindow"] = ChatFrame1,

Deviation: a name that is no Lua identifier writes as an anonymous object, where
the reference writes it raw and would leave a file that will not parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-17 18:14:02 -06:00

500 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Saved variables: the Lua settings that survive a restart. The reference saves FrameXML's
//! `RegisterForSave` globals to one file read back by a line parser (`0x4913b0`, strings and
//! numbers only), and an addon's `## SavedVariables` to its own file, executed as a chunk before
//! `ADDON_LOADED` (`AddOn_Load` `0x51f240`). Deviation: the `RegisterForSave` file is executed as a
//! chunk too, because the line parser cannot carry a table. The app owns the files (`ui_saved`).
//!
//! The grammar is the reference's: `NAME = value`, `nil` written out, bracketed keys, tab indent,
//! trailing comma. Deviations, so a file diffs cleanly and reloads exactly: LF line ends with no
//! leading blank line, the shortest round-tripping float where the reference's `%.16g` can lose a
//! digit, entries sorted rather than in `pairs` order, `\r` escaped where the reference writes it
//! raw and cannot read it back, and a cycle dropped where the reference writes a sentinel into the
//! caller's table.
use std::collections::HashSet;
use std::ffi::c_void;
use mlua::{Lua, Table, Value};
use super::Model;
/// Deviation: nesting stops at 32 where the reference recurses uncapped (its indent saturates at
/// `0x80`), so a hostile structure cannot exhaust the stack.
const MAX_DEPTH: usize = 32;
impl super::UiScript {
/// Hold a saved-variables file that failed to load, so the shutdown write leaves it alone.
/// Deviation: the reference rewrites it from the live defaults (`0x51f865`–`0x51f970`), which
/// would cost the player the whole file for one typo in a hand edit.
pub fn hold_saved_file(&self, path: &std::path::Path) {
let mut model = self.model_mut();
if !model.held_saved_files.iter().any(|p| p == path) {
model.held_saved_files.push(path.to_path_buf());
}
}
/// Whether this session's load failed on `path`.
pub fn saved_file_held(&self, path: &std::path::Path) -> bool {
self.model_ref().held_saved_files.iter().any(|p| p == path)
}
/// The registered names in registration order, the reference's own write order.
pub fn saved_variable_names(&self) -> Vec<String> {
self.model_mut().saved_names.clone()
}
/// Serialize every registered global as `NAME = value` lines, in bytes, since a Lua string is
/// bytes and the reference writes them raw. A value with no literal (a function, userdata,
/// `inf`, `NaN`) is skipped with a warning where the reference drops it silently; `nil` is
/// written, as the reference records an untouched toggle.
pub fn saved_variables_bytes(&self) -> Vec<u8> {
self.saved_variables_bytes_for(&self.saved_variable_names())
}
/// [`UiScript::saved_variables_bytes`] over an explicit name list, an addon's own
/// `## SavedVariables` set.
pub fn saved_variables_bytes_for(&self, names: &[String]) -> Vec<u8> {
let mut out = Vec::new();
let mut unwritable = Vec::new();
// A widget writes as its global name, and only the model can turn a `T[0]` id into one.
let name_of = |id: u32| -> Option<String> {
let model = self.model_ref();
model
.id_to_frame
.get(&id)
.and_then(|h| model.arena.frame(*h))
.and_then(|f| f.name.clone())
.or_else(|| super::region::region_name_of(&model, id))
};
for name in names {
let value: Value = match self.lua().globals().get(name.as_str()) {
Ok(v) => v,
Err(e) => {
unwritable.push(format!("{name} ({e})"));
continue;
}
};
let mut seen = HashSet::new();
match serialize(&value, 1, &mut seen, &name_of) {
Some(text) => {
out.extend_from_slice(name.as_bytes());
out.extend_from_slice(b" = ");
out.extend_from_slice(&text);
out.push(b'\n');
}
None => unwritable.push(name.clone()),
}
}
if !unwritable.is_empty() {
self.model_mut().record_warning(format!(
"saved variables: not serializable, skipped: {}",
unwritable.join(", ")
));
}
out
}
}
/// One Lua value as Lua expression text, or `None` when it has none. `depth` is the indent of a
/// table's contents here (1 at top level); `seen` holds the tables on the current path, so a repeat
/// is a cycle and drops, while a shared subtable is written each time it appears.
fn serialize(
v: &Value,
depth: usize,
seen: &mut HashSet<*const c_void>,
name_of: &dyn Fn(u32) -> Option<String>,
) -> Option<Vec<u8>> {
match v {
Value::Nil => Some(b"nil".to_vec()),
Value::Boolean(b) => Some(b.to_string().into_bytes()),
Value::Integer(i) => Some(i.to_string().into_bytes()),
Value::Number(n) => number(*n).map(String::into_bytes),
Value::String(s) => Some(quote(&s.as_bytes())),
// A widget is tested before its fields: it writes as a name, never as a table.
Value::Table(t) => widget(t, name_of).or_else(|| table(t, depth, seen, name_of)),
// Functions, threads and userdata have no literal; a frame's `[0]` handle is a
// lightuserdata (`0x701bd0`).
_ => None,
}
}
/// A Lua number literal: integral values bare, as the reference's `%.16g` prints them, others in
/// the shortest form that round-trips; `inf` and `NaN` have none.
fn number(n: f64) -> Option<String> {
if !n.is_finite() {
return None;
}
if n.fract() == 0.0 && n.abs() < 9_007_199_254_740_992.0 {
return Some(format!("{}", n as i64));
}
Some(format!("{n}"))
}
/// A quoted Lua string: the reference's four escapes (`\000`, `\n`, `\"`, `\\`) plus `\r`, every
/// other byte raw.
fn quote(s: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(s.len() + 2);
out.push(b'"');
for &b in s {
match b {
b'\\' => out.extend_from_slice(b"\\\\"),
b'"' => out.extend_from_slice(b"\\\""),
b'\n' => out.extend_from_slice(b"\\n"),
b'\r' => out.extend_from_slice(b"\\r"),
0 => out.extend_from_slice(b"\\000"),
b => out.push(b),
}
}
out.push(b'"');
out
}
/// A widget wrapper's value text, or `None` for a plain table.
///
/// The reference asks this first and never walks a widget's fields: the table branch of its value
/// writer does `lua_rawgeti(v, 0)` and `lua_touserdata` (`0x704613`-`0x704628`, the identity
/// `0x701bd0` wrote), and on a hit writes the object's **own global name, bare** — a Lua
/// expression, so the reloaded chunk's lookup hands the live object back — or
/// `nil --[[ unnamed object ]]` (`0x872e0c`) for an anonymous one.
///
/// His own real-client file is the proof: `["Dcr_OutputWindow"] = ChatFrame1,`. Serializing the
/// frame's *fields* instead reloads a data table with no methods, which is past the owning addon's
/// guard (`Decursive.lua:1245` accepts any non-nil) and raises on the first call — the
/// `attempt to call method 'AddMessage' (a nil value)` in his log.
///
/// Deviation: a name that is no Lua identifier writes as an anonymous object, where the reference
/// writes it raw and would leave a file that will not parse.
fn widget(t: &Table, name_of: &dyn Fn(u32) -> Option<String>) -> Option<Vec<u8>> {
let Ok(Value::LightUserData(l)) = t.raw_get::<Value>(0) else {
return None;
};
let id = l.0 as usize as u32;
Some(match name_of(id).filter(|n| is_lua_name(n)) {
Some(name) => name.into_bytes(),
None => b"nil --[[ unnamed object ]]".to_vec(),
})
}
/// Whether a name can be written bare and read back as the same global.
fn is_lua_name(s: &str) -> bool {
const KEYWORDS: [&str; 21] = [
"and", "break", "do", "else", "elseif", "end", "false", "for", "function", "if", "in",
"local", "nil", "not", "or", "repeat", "return", "then", "true", "until", "while",
];
!s.is_empty()
&& !s.starts_with(|c: char| c.is_ascii_digit())
&& s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
&& !KEYWORDS.contains(&s)
}
/// A table constructor, `{\n<tabs>[key] = value,\n<tabs-1>}`: every key bracketed, as the
/// reference writes for every table shape (`[0x704607, 0x704989)`), integer keys ascending, then
/// strings. It reloads a list as a list through the 1.12 parser: `recfield` gives a `[k] = v` field
/// no size hint, so dense integer keys land in the array part, which `next` walks in order.
fn table(
t: &Table,
depth: usize,
seen: &mut HashSet<*const c_void>,
name_of: &dyn Fn(u32) -> Option<String>,
) -> Option<Vec<u8>> {
if depth > MAX_DEPTH || !seen.insert(t.to_pointer()) {
return None;
}
let mut ints: Vec<(i64, Vec<u8>)> = Vec::new();
let mut strs: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
for pair in t.pairs::<Value, Value>() {
let Ok((k, v)) = pair else { continue };
let Some(value) = serialize(&v, depth + 1, seen, name_of) else {
continue; // an unserializable entry drops, like the reference's
};
match k {
Value::Integer(i) => ints.push((i, value)),
// A float key that is integral is the same slot as the integer in Lua 5.1.
Value::Number(n) if n.fract() == 0.0 => ints.push((n as i64, value)),
Value::String(s) => strs.push((s.as_bytes().to_vec(), value)),
_ => continue, // a table/bool/function key cannot be written as a literal
}
}
seen.remove(&t.to_pointer());
ints.sort_by_key(|(k, _)| *k);
// Byte order, the same as `str` order for UTF-8 keys.
strs.sort_by(|a, b| a.0.cmp(&b.0));
let indent = "\t".repeat(depth);
let close = "\t".repeat(depth.saturating_sub(1));
let mut out = b"{\n".to_vec();
let mut entry = |key: &[u8], value: &[u8]| {
out.extend_from_slice(indent.as_bytes());
out.push(b'[');
out.extend_from_slice(key);
out.extend_from_slice(b"] = ");
out.extend_from_slice(value);
out.extend_from_slice(b",\n");
};
for (k, v) in ints {
entry(k.to_string().as_bytes(), &v);
}
for (k, v) in strs {
entry(&quote(&k), &v);
}
out.extend_from_slice(close.as_bytes());
out.push(b'}');
Some(out)
}
/// Register `RegisterForSave`, with no taint gate: the reference refuses it outside Blizzard code
/// (`0x4884e0`), and benilla has no taint model.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.globals().set(
"RegisterForSave",
lua.create_function(|lua, name: String| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if !model.saved_names.contains(&name) {
model.saved_names.push(name);
}
Ok(())
})?,
)
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
/// Bagnon's shape: a saved bag list sorted with the keyring (-2) last, laid out by `pairs`.
#[test]
fn a_saved_list_still_walks_in_index_order_after_a_restart() {
let s = UiScript::new().unwrap();
s.run(
r#"
BAGS = { -2, 0, 1, 2, 3, 4 }
table.sort(BAGS, function(a, b)
if a == -2 then return false
elseif b == -2 then return true
else return a < b end
end)
RegisterForSave("BAGS")
"#,
)
.unwrap();
let walk = |vm: &UiScript| {
vm.eval::<String>(
"local out = '' for _, v in pairs(BAGS) do out = out .. v .. ',' end return out",
)
.unwrap()
};
assert_eq!(walk(&s), "0,1,2,3,4,-2,", "the live table, before any save");
let text = s.saved_variables_bytes();
let fresh = UiScript::new().unwrap();
fresh.run_chunk(&text).unwrap();
assert_eq!(
walk(&fresh),
"0,1,2,3,4,-2,",
"the restart must walk the same order the live session did; wrote:\n{}",
String::from_utf8_lossy(&text)
);
}
#[test]
fn the_declared_globals_round_trip_through_a_fresh_vm() {
let mut s = UiScript::new().unwrap();
s.run(
r#"
TRAINER_FILTER_AVAILABLE = 1
TRAINER_FILTER_UNAVAILABLE = 0
NEVER_TOUCHED = nil
CHAT_LABEL = "say \"hi\"\nnow"
OPACITY = 0.5
SPELLBOOK_PAGENUMBERS = { [2] = 3, [1] = 1, ["odd key"] = { [1] = true } }
RegisterForSave("TRAINER_FILTER_AVAILABLE")
RegisterForSave("TRAINER_FILTER_UNAVAILABLE")
RegisterForSave("NEVER_TOUCHED")
RegisterForSave("CHAT_LABEL")
RegisterForSave("OPACITY")
RegisterForSave("SPELLBOOK_PAGENUMBERS")
RegisterForSave("TRAINER_FILTER_AVAILABLE")
"#,
)
.unwrap();
assert_eq!(
s.saved_variable_names(),
vec![
"TRAINER_FILTER_AVAILABLE",
"TRAINER_FILTER_UNAVAILABLE",
"NEVER_TOUCHED",
"CHAT_LABEL",
"OPACITY",
"SPELLBOOK_PAGENUMBERS",
],
"registration order, and a re-register is not a second entry"
);
let bytes = s.saved_variables_bytes();
let text = String::from_utf8(bytes).expect("every value here is UTF-8");
assert_eq!(
text,
"TRAINER_FILTER_AVAILABLE = 1\n\
TRAINER_FILTER_UNAVAILABLE = 0\n\
NEVER_TOUCHED = nil\n\
CHAT_LABEL = \"say \\\"hi\\\"\\nnow\"\n\
OPACITY = 0.5\n\
SPELLBOOK_PAGENUMBERS = {\n\
\t[1] = 1,\n\
\t[2] = 3,\n\
\t[\"odd key\"] = {\n\
\t\t[1] = true,\n\
\t},\n\
}\n",
"got:\n{text}"
);
assert!(s.take_warnings().is_empty(), "nothing was unserializable");
let fresh = UiScript::new().unwrap();
fresh.run(&text).unwrap();
assert_eq!(
fresh
.eval::<i64>("return TRAINER_FILTER_AVAILABLE + TRAINER_FILTER_UNAVAILABLE")
.unwrap(),
1
);
assert!(fresh.eval::<bool>("return NEVER_TOUCHED == nil").unwrap());
assert_eq!(
fresh.eval::<String>("return CHAT_LABEL").unwrap(),
"say \"hi\"\nnow"
);
assert_eq!(fresh.eval::<f64>("return OPACITY").unwrap(), 0.5);
assert_eq!(
fresh
.eval::<i64>("return SPELLBOOK_PAGENUMBERS[2]")
.unwrap(),
3
);
assert!(fresh
.eval::<bool>("return SPELLBOOK_PAGENUMBERS['odd key'][1]")
.unwrap());
}
#[test]
fn unserializable_values_are_skipped_with_one_warning() {
let mut s = UiScript::new().unwrap();
s.run(
r#"
A_FUNCTION = function() end
NOT_A_NUMBER = 1/0
KEPT = 7
CYCLE = {}
CYCLE.self = CYCLE
RegisterForSave("A_FUNCTION")
RegisterForSave("NOT_A_NUMBER")
RegisterForSave("KEPT")
RegisterForSave("CYCLE")
"#,
)
.unwrap();
let text = s.saved_variables_bytes();
// The cycle's own entry drops; the table itself still writes (empty here).
assert_eq!(text, b"KEPT = 7\nCYCLE = {\n}\n");
let warns = s.take_warnings();
assert_eq!(warns.len(), 1, "one line, not one per name: {warns:?}");
assert!(warns[0].contains("A_FUNCTION") && warns[0].contains("NOT_A_NUMBER"));
UiScript::new().unwrap().run_chunk(&text).unwrap();
}
#[test]
fn a_byte_string_that_is_not_utf8_round_trips_unchanged() {
let s = UiScript::new().unwrap();
s.run(
r#"
PACKED = string.char(65, 200, 255, 0, 66)
KEYS = { [string.char(200)] = 1, [string.char(201)] = 2 }
RegisterForSave("PACKED")
RegisterForSave("KEYS")
"#,
)
.unwrap();
let fresh = UiScript::new().unwrap();
fresh.run_chunk(&s.saved_variables_bytes()).unwrap();
assert!(fresh
.eval::<bool>(
"return string.len(PACKED) == 5 and string.byte(PACKED, 2) == 200 \
and string.byte(PACKED, 3) == 255 and string.byte(PACKED, 4) == 0"
)
.unwrap());
assert!(fresh
.eval::<bool>("return KEYS[string.char(200)] == 1 and KEYS[string.char(201)] == 2")
.unwrap());
}
/// Decursive's shape, and his log's `attempt to call method 'AddMessage' (a nil value)`: it
/// stashes `DEFAULT_CHAT_FRAME` in its saved table and calls a method on whatever comes back
/// (`Decursive.lua:1393`), past a guard that accepts any non-nil (`:1245`). The reference
/// writes a named widget as its bare global name — `["Dcr_OutputWindow"] = ChatFrame1,` in his
/// own real-client file — so the reload hands back the live frame, methods and all.
#[test]
fn a_saved_frame_comes_back_as_the_frame_not_a_copy_of_its_fields() {
let s = UiScript::new().unwrap();
s.run(
r#"
local f = CreateFrame("Frame", "MyOutputWindow")
f.buttonSide = "left"
SAVED = { window = f }
RegisterForSave("SAVED")
"#,
)
.unwrap();
let bytes = s.saved_variables_bytes();
let text = String::from_utf8_lossy(&bytes).to_string();
assert!(
text.contains("[\"window\"] = MyOutputWindow,"),
"a named frame writes as its global name; wrote:\n{text}"
);
assert!(
!text.contains("buttonSide"),
"a widget's own fields are never walked; wrote:\n{text}"
);
// The restart: FrameXML builds the frame, then the saved chunk runs.
let fresh = UiScript::new().unwrap();
fresh
.run(r#"CreateFrame("Frame", "MyOutputWindow")"#)
.unwrap();
fresh.run_chunk(&bytes).unwrap();
assert!(
fresh
.eval::<bool>("return SAVED.window == MyOutputWindow")
.unwrap(),
"the saved value is the live frame again"
);
assert_eq!(
fresh
.eval::<String>("return SAVED.window:GetName()")
.unwrap(),
"MyOutputWindow",
"so a method call on it still works"
);
}
/// An anonymous widget has no expression to write, so it takes the reference's own sentinel
/// (`0x872e0c`) and reloads as nil — which is what the addon's own nil guard is for.
#[test]
fn an_anonymous_frame_saves_as_the_references_unnamed_object() {
let s = UiScript::new().unwrap();
s.run(
r#"
SAVED = { window = CreateFrame("Frame") }
RegisterForSave("SAVED")
"#,
)
.unwrap();
let bytes = s.saved_variables_bytes();
let text = String::from_utf8_lossy(&bytes).to_string();
assert!(
text.contains("[\"window\"] = nil --[[ unnamed object ]],"),
"wrote:\n{text}"
);
let fresh = UiScript::new().unwrap();
fresh.run_chunk(&bytes).unwrap();
assert!(fresh.eval::<bool>("return SAVED.window == nil").unwrap());
}
}