- the cooldown pie, the GCD sweep, minimap pings and the map arrow draw again after a logout, login or /reload; they went blank until a restart, which read as OmniCC killing the cooldowns (2113) - an addon's own font on a button label keeps its face, size and outline instead of being undone every frame, MikScrollingBattleText's reported look (2112) - an empty label reads as nothing rather than an empty string, so Cartographer's map label keeps its colour; a slider thumb with no authored size takes its art's own size, so Dewdrop's Look'n'Feel slider moves (2109, 2110) - Bagnon's saved bag list comes back in the order it was written, so the keyring stops jumping to the front after a restart; hovering a Bagnon bag costs a third of what it did (2111, 2114) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
378 lines
17 KiB
Rust
378 lines
17 KiB
Rust
//! **Saved variables** — the Lua-level settings that survive a restart (decision 1128).
|
|
//!
|
|
//! The reference client has *two* mechanisms for this, byte-verified in wow-re
|
|
//! (`system/ui/scratch/savedvariables-protocol.md`):
|
|
//!
|
|
//! 1. **`RegisterForSave("NAME")`** — the Lua API FrameXML uses for its **38** globals
|
|
//! (`LOCK_ACTIONBAR`, `CHAT_LOCKED`, the eleven `COMBAT_TEXT_*`, …), written to the flat
|
|
//! `WTF/Account/<ACC>/SavedVariables.lua` and read back by a hand-rolled line parser
|
|
//! (`0x4913b0`, strings and numbers only) before `VARIABLES_LOADED` fires.
|
|
//! 2. **`## SavedVariables` in a `.toc`** — one file per addon, *executed as a Lua chunk*
|
|
//! (`AddOn_Load 0x51f240` step 4, before `ADDON_LOADED` at step 6). Exactly one stock addon
|
|
//! uses it: `Blizzard_TrainerUI`, for the trainer window's three filter globals.
|
|
//!
|
|
//! benilla's ported UI is **one FrameXML tree, not addons** ([`crate::script`]'s manifest), so the
|
|
//! two collapse into one here: the *declaration* API is (1) — the one our XML can call — and the
|
|
//! *load* is (2)'s, a real chunk, because we already have a Lua VM and executing beats a line
|
|
//! parser that cannot carry a table. One file, install-scoped (the `benilla/` folder is already
|
|
//! per-install, so it IS the reference's account scope — decision 0954). The host side — path,
|
|
//! load seam, write triggers — is the app's `ui_saved` module's.
|
|
//!
|
|
//! **Deliberate divergences from the reference's serializer**, all recorded in 1128: LF and no
|
|
//! leading blank line (its `"\r\n"`-per-variable prefix means every file it writes *starts* with a
|
|
//! blank line); Rust's shortest round-tripping float formatting instead of `%.16g` (which loses a
|
|
//! double needing 17 digits); table entries emitted in a **sorted** order instead of raw `pairs`
|
|
//! order, so a settings file diffs cleanly; `\r` escaped rather than written raw (raw CR inside a
|
|
//! quoted string is what the reference emits and it does not re-parse); and a cycle is dropped
|
|
//! rather than cut by writing a lightuserdata sentinel *into the caller's table*. What is faithful
|
|
//! is the grammar — `NAME = value`, `nil` written out, bracketed keys, TAB indent, trailing comma —
|
|
//! so a file is readable by, and swappable with, the reference's own.
|
|
|
|
use std::collections::HashSet;
|
|
use std::ffi::c_void;
|
|
use std::fmt::Write as _;
|
|
|
|
use mlua::{Lua, Table, Value};
|
|
|
|
use super::Model;
|
|
|
|
/// How deep a table may nest before the serializer gives up. The reference recurses uncapped (only
|
|
/// its indent saturates at `0x80`); a settings file is not a place where 32 levels is anything but
|
|
/// a bug, and a bound is what keeps a hostile or accidental structure from eating the stack.
|
|
const MAX_DEPTH: usize = 32;
|
|
|
|
impl super::UiScript {
|
|
/// The registered names, in registration order — what the host writes out (the reference's
|
|
/// own emission order, and stable across runs because the load order is).
|
|
pub fn saved_variable_names(&self) -> Vec<String> {
|
|
self.model_mut().saved_names.clone()
|
|
}
|
|
|
|
/// Serialize every registered global into the settings file's body, `NAME = value` per line.
|
|
///
|
|
/// A name whose value cannot round-trip through a Lua chunk (a function, a widget reference,
|
|
/// any userdata — and `inf`/`NaN`, which are not Lua literals) is **skipped with a warning**
|
|
/// rather than written as something that would fail to load; the reference loses the same set,
|
|
/// silently. `nil` IS written (`NAME = nil`), which is how the reference records a toggle that
|
|
/// has never been touched.
|
|
pub fn saved_variables_text(&self) -> String {
|
|
self.saved_variables_text_for(&self.saved_variable_names())
|
|
}
|
|
|
|
/// [`UiScript::saved_variables_text`] over an explicit name list — an addon's own
|
|
/// `## SavedVariables` set (1188 phase 3), which is declared in its manifest rather than
|
|
/// through `RegisterForSave`. Same grammar, same skip rules; only the source of the names
|
|
/// differs, which is exactly the difference between the reference's two mechanisms.
|
|
pub fn saved_variables_text_for(&self, names: &[String]) -> String {
|
|
let mut out = String::new();
|
|
let mut unwritable = Vec::new();
|
|
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) {
|
|
Some(text) => {
|
|
let _ = writeln!(out, "{name} = {text}");
|
|
}
|
|
None => unwritable.push(name.clone()),
|
|
}
|
|
}
|
|
if !unwritable.is_empty() {
|
|
self.model_mut().warnings.push(format!(
|
|
"saved variables: not serializable, skipped: {}",
|
|
unwritable.join(", ")
|
|
));
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
/// One Lua value as the text of a Lua expression, or `None` when it cannot be one.
|
|
///
|
|
/// `depth` is the indent level of the *contents* of a table at this position (so a top-level value
|
|
/// starts at 1); `seen` carries the table identities on the current path — a repeat is a cycle and
|
|
/// drops that entry. Note it is the *path*, not every table ever visited: a shared subtable is
|
|
/// legitimately written twice (as it must be, since the file has no way to express aliasing).
|
|
fn serialize(v: &Value, depth: usize, seen: &mut HashSet<*const c_void>) -> Option<String> {
|
|
match v {
|
|
Value::Nil => Some("nil".to_string()),
|
|
Value::Boolean(b) => Some(b.to_string()),
|
|
Value::Integer(i) => Some(i.to_string()),
|
|
Value::Number(n) => number(*n),
|
|
Value::String(s) => Some(quote(&s.to_string_lossy())),
|
|
Value::Table(t) => table(t, depth, seen),
|
|
// Functions, threads, userdata (every widget reference is one — a frame's Lua value is a
|
|
// table whose `[0]` is a lightuserdata handle, RF-0023) cannot be written as a literal.
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// A Lua number literal. Integral values print without a fractional part (`1`, not `1.0` — the
|
|
/// reference's `%.16g` does the same, and our XML compares these against integers); everything
|
|
/// else takes Rust's shortest form that round-trips exactly. `inf`/`NaN` have no literal at all.
|
|
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 escapes exactly four characters (`\000`, `\n`, `\"`, `\\`)
|
|
/// and writes CR and every high byte raw; we add `\r` (raw CR in a quoted string is not something
|
|
/// its own loader could read back) and keep high bytes raw, so localized text stays legible.
|
|
fn quote(s: &str) -> String {
|
|
let mut out = String::with_capacity(s.len() + 2);
|
|
out.push('"');
|
|
for c in s.chars() {
|
|
match c {
|
|
'\\' => out.push_str("\\\\"),
|
|
'"' => out.push_str("\\\""),
|
|
'\n' => out.push_str("\\n"),
|
|
'\r' => out.push_str("\\r"),
|
|
'\0' => out.push_str("\\000"),
|
|
c => out.push(c),
|
|
}
|
|
}
|
|
out.push('"');
|
|
out
|
|
}
|
|
|
|
/// A table constructor: `{\n<tabs>[key] = value,\n<tabs-1>}`. Keys are always bracketed (the
|
|
/// reference's shape — its writer emits `[key] = value` for **every** table shape and never a bare
|
|
/// positional entry, byte-verified in wow-re `system/ui/scratch/lua-table-storage-and-next-order.md`
|
|
/// §Q5 — and it sidesteps every reserved-word and non-identifier question); entries are **sorted**
|
|
/// — integer keys ascending, then strings alphabetically — so the file is stable across runs
|
|
/// instead of following Lua's hash order.
|
|
///
|
|
/// **A list written this way still reloads as a list**, and that is a property of the *parser*, not
|
|
/// of this emitter: 1.12's `recfield` gives a `[expr] = v` field neither size hint, so the table is
|
|
/// born on the dummy node, the first store rehashes, and the dense integer keys land in an array
|
|
/// part that `next` walks ascending. Our vendored parser was restored to that placement in decision
|
|
/// 2111 — before it, this exact file shape came back keyed 1..n in the *hash* part and Bagnon's
|
|
/// keyring drew first.
|
|
fn table(t: &Table, depth: usize, seen: &mut HashSet<*const c_void>) -> Option<String> {
|
|
if depth > MAX_DEPTH || !seen.insert(t.to_pointer()) {
|
|
return None;
|
|
}
|
|
let mut ints: Vec<(i64, String)> = Vec::new();
|
|
let mut strs: Vec<(String, String)> = Vec::new();
|
|
for pair in t.pairs::<Value, Value>() {
|
|
let Ok((k, v)) = pair else { continue };
|
|
let Some(value) = serialize(&v, depth + 1, seen) 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.to_string_lossy(), value)),
|
|
_ => continue, // a table/bool/function key cannot be written as a literal
|
|
}
|
|
}
|
|
seen.remove(&t.to_pointer());
|
|
ints.sort_by_key(|(k, _)| *k);
|
|
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 = String::from("{\n");
|
|
for (k, v) in ints {
|
|
let _ = writeln!(out, "{indent}[{k}] = {v},");
|
|
}
|
|
for (k, v) in strs {
|
|
let _ = writeln!(out, "{indent}[{}] = {v},", quote(&k));
|
|
}
|
|
out.push_str(&close);
|
|
out.push('}');
|
|
Some(out)
|
|
}
|
|
|
|
/// Register the `RegisterForSave` global.
|
|
///
|
|
/// Divergence, disclosed: the reference's binding is **taint-gated to Blizzard code**
|
|
/// (`0x4884e0`), because a third-party addon writing into the shared account file would be a
|
|
/// security surface. benilla has no taint model yet and loads no third-party addons, so the gate
|
|
/// has nothing to gate; when addon loading lands, this is one of the bindings that needs it (and
|
|
/// an addon's own `## SavedVariables` file is the mechanism it should use instead — the `.toc`
|
|
/// directive [`crate::toc::Toc::list`] already parses).
|
|
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;
|
|
|
|
/// **A saved LIST comes back a list** — Bagnon's inventory grid, at 1:1 scale, and the
|
|
/// round-trip half of decision 2111 (the dialect half is `lua50`'s own tests).
|
|
///
|
|
/// `Bagnon_Core` keeps its frame's bag order in a saved table, sorts it so the keyring
|
|
/// (`KEYRING_CONTAINER` = -2) lands last, and then lays the grid out by walking it with
|
|
/// **`pairs`** (`BagnonFrame_Generate`'s `for _, bagID in pairs(BagnonSets[frameName].bags)`).
|
|
/// Session one has no file: the table is the literal `{-2,0,1,2,3,4}`, an array table, and
|
|
/// `pairs` walks it 1..6 — keyring last, as the reference shows it. Session two reads the
|
|
/// serializer's `[k] = v` form back, and before 2111 that came back keyed 1..6 in the **hash**
|
|
/// part, walked in slot order, with key 6 — the keyring — **first**, ahead of the backpack.
|
|
/// That is the director's report; the writer is unchanged and the parser is what was wrong.
|
|
#[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_text();
|
|
let fresh = UiScript::new().unwrap();
|
|
fresh.run(&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{text}"
|
|
);
|
|
}
|
|
|
|
/// The round trip that is the whole point: what the writer emits, the loader's own chunk
|
|
/// restores — through a fresh VM, exactly as a restart does.
|
|
#[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 text = s.saved_variables_text();
|
|
// The grammar: one statement per line, `nil` written out, integral numbers bare, keys
|
|
// bracketed and SORTED (integers ascending, then strings), tab-indented, trailing comma.
|
|
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");
|
|
|
|
// A fresh VM (the restart), the file's chunk, and every value is back.
|
|
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());
|
|
}
|
|
|
|
/// A value with no Lua literal is skipped and named, never written as something that would
|
|
/// break the next load — the one place we improve on the reference, which loses these silently.
|
|
#[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_text();
|
|
// The cycle's own entry drops; the table itself still writes (empty here).
|
|
assert_eq!(text, "KEPT = 7\nCYCLE = {\n}\n", "got:\n{text}");
|
|
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"));
|
|
// And what it wrote is loadable.
|
|
UiScript::new().unwrap().run(&text).unwrap();
|
|
}
|
|
}
|