the client starts remembering: zoom, camera, filters, a Combat page
- minimap zoom and the camera's distance and pitch persist across sessions - the trainer's point-filter is a saved variable now and survives a relog, resetting per packet like the reference - the options window gains its Combat page, over rows that can write the UI's own saved globals - the step-up is a rise, not a jump forward — and the descent cone's cap belongs to the body, on every leg Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -93,6 +93,28 @@ impl super::UiScript {
|
||||
}
|
||||
}
|
||||
|
||||
/// An **engine-side** write: a value the engine owns moved, and the CVar table mirrors it. The
|
||||
/// minimap's zoom is the case that needs it — the client's `set_zoom` (`0x6da8e0`) writes the live
|
||||
/// index *and* `CVar::Set`s `minimapZoom`/`minimapInsideZoom` in the same breath, which is exactly
|
||||
/// why the level survives a restart. So this queues the change like a Lua `SetCVar` would (the host
|
||||
/// must hear it and dirty the config file), unlike [`super::UiScript::set_cvar_host`], whose whole
|
||||
/// point is *not* to echo the value it just loaded.
|
||||
///
|
||||
/// A name the host never registered is a **silent** no-op here, not a warning: engine writes are
|
||||
/// code, not UI content, so a miss means this build's host doesn't back the var (a bare test VM) —
|
||||
/// and the registered set is welded to the code truths by `crate::cvars`'s own test.
|
||||
pub(super) fn set_from_engine(model: &mut Model, name: &str, value: String) {
|
||||
let Some(slot) = model.cvars.get_mut(&name.to_ascii_lowercase()) else {
|
||||
return;
|
||||
};
|
||||
if slot.value == value {
|
||||
return;
|
||||
}
|
||||
slot.value = value.clone();
|
||||
let registered = slot.name.clone();
|
||||
model.cvar_changes.push((registered, value));
|
||||
}
|
||||
|
||||
/// Push the warn-once for an unknown CVar name into the model's warning stream.
|
||||
fn warn_unknown(model: &mut Model, name: &str) {
|
||||
let key = name.to_ascii_lowercase();
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
//! (decision 0203). The model attrs (`minimapPlayerModel=`/`minimapArrowModel=`) are not
|
||||
//! modeled yet — deferred with phase 3's blips.
|
||||
//!
|
||||
//! **The level persists** (decision 1131). The client keeps two halves: the live indices
|
||||
//! (`0x86f698` outdoor / `0x86f69c` indoor — our [`MinimapState`]) and the two CVar objects
|
||||
//! `minimapZoom`/`minimapInsideZoom` (both registered default `"3"`), which are what `Config.wtf`
|
||||
//! actually stores. `set_zoom` writes *both*; the minimap reset path (`0x6d9008`–`0x6d901f`)
|
||||
//! re-seeds the live index from the CVar's parsed int. benilla runs the same two halves at the same
|
||||
//! seam: `SetZoom` below writes the index and the CVar, and the app seeds the widget from the CVar
|
||||
//! table once, when the in-game UI materializes ([`super::UiScript::set_minimap_zoom`]).
|
||||
//!
|
||||
//! The methods live in their own registry table, consulted by the frame `__index` dispatcher only
|
||||
//! for Minimap frames — the same duck-typing posture as StatusBar's.
|
||||
|
||||
@@ -57,7 +65,24 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// inside flag selects, so the +/- buttons zoom the map you're actually looking at and
|
||||
// each mode keeps its own level.
|
||||
let clamped = (zoom.max(0.0) as u8).min(MINIMAP_ZOOM_LEVELS - 1);
|
||||
with_minimap(lua, &this, |m| m.set_active_zoom(clamped))
|
||||
let inside = with_minimap(lua, &this, |m| {
|
||||
m.set_active_zoom(clamped);
|
||||
m.inside
|
||||
})?;
|
||||
// …and it persists the level in the same breath: `set_zoom` writes the live index AND
|
||||
// `CVar::Set`s the matching CVar (`minimapInsideZoom` indoors, `minimapZoom` out), which
|
||||
// is the whole reason a zoom level survives a restart. The borrow above is released
|
||||
// before this one — both reach the same `Model` app_data.
|
||||
super::cvars::set_from_engine(
|
||||
&mut lua.app_data_mut::<Model>().expect("model app_data"),
|
||||
if inside {
|
||||
"minimapInsideZoom"
|
||||
} else {
|
||||
"minimapZoom"
|
||||
},
|
||||
clamped.to_string(),
|
||||
);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
|
||||
@@ -79,6 +79,7 @@ mod pvp;
|
||||
mod quest;
|
||||
mod quest_log;
|
||||
mod region;
|
||||
mod saved;
|
||||
mod scrollframe;
|
||||
mod session;
|
||||
mod shapeshift;
|
||||
@@ -278,6 +279,7 @@ impl UiScript {
|
||||
death::install(&lua)?;
|
||||
aura::install(&lua)?;
|
||||
cvars::install(&lua)?;
|
||||
saved::install(&lua)?;
|
||||
keybind::install(&lua)?;
|
||||
sound::install(&lua)?;
|
||||
pointer::install(&lua)?;
|
||||
@@ -375,6 +377,25 @@ impl UiScript {
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed every Minimap widget's two zoom indices from the persisted levels — the client's
|
||||
/// minimap reset path copying each CVar object's parsed int into its live index
|
||||
/// (`[0x86f698] ← [[0xb4b410]+0x28]`, `[0x86f69c] ← [[0xb4d90c]+0x28]`; wow-re
|
||||
/// `wmo-interior-minimap.md`, VERIFIED). Called **once**, when the in-game UI materializes and
|
||||
/// the widget exists — not per frame: from then on the widget's index is the live truth and
|
||||
/// `Minimap:SetZoom` keeps the CVar following it, so a repeated push would fight the +/- buttons.
|
||||
/// Both indices clamp into `[0, MINIMAP_ZOOM_LEVELS)` exactly like `set_zoom`, so a hand-edited
|
||||
/// `config.toml` cannot seed an out-of-range level (decision 1131).
|
||||
pub fn set_minimap_zoom(&mut self, zoom: u8, inside_zoom: u8) {
|
||||
let top = crate::widget::MINIMAP_ZOOM_LEVELS - 1;
|
||||
let (zoom, inside_zoom) = (zoom.min(top), inside_zoom.min(top));
|
||||
for (_, frame) in self.model_mut().arena.iter_frames_mut() {
|
||||
if let KindState::Minimap(m) = &mut frame.kind_state {
|
||||
m.zoom = zoom;
|
||||
m.inside_zoom = inside_zoom;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load and run a Lua chunk (text-only; the sandbox rejects bytecode). Errors propagate to the
|
||||
/// caller (a *load-time* error is the caller's to see; *handler* errors during events go to
|
||||
/// [`UiScript::errors`]).
|
||||
|
||||
@@ -215,6 +215,10 @@ pub(crate) struct Model {
|
||||
/// Unknown CVar names already warned about (warn-once, the era-atlas-miss posture).
|
||||
pub(crate) cvars_warned: HashSet<String>,
|
||||
|
||||
/// The globals `RegisterForSave` declared, in registration order — the saved-variables set the
|
||||
/// host writes out at logout/exit and re-executes at load (decision 1128, [`super::saved`]).
|
||||
pub(crate) saved_names: Vec<String>,
|
||||
|
||||
/// The key-binding table (decision 0997, [`super::keybind`]) — the chord→command store the
|
||||
/// Key Bindings window edits, plus its stored account/character sets. The CVar table's twin:
|
||||
/// host-registered commands, Lua reads/writes synchronously, the app re-derives dispatch when
|
||||
@@ -868,6 +872,7 @@ impl Model {
|
||||
cvars: HashMap::new(),
|
||||
cvar_changes: Vec::new(),
|
||||
cvars_warned: HashSet::new(),
|
||||
saved_names: Vec::new(),
|
||||
keybinds: super::keybind::KeybindState::default(),
|
||||
actions: HashMap::new(),
|
||||
action_states: HashMap::new(),
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
//! **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 {
|
||||
let names = self.saved_variable_names();
|
||||
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, 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.
|
||||
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;
|
||||
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
@@ -126,3 +126,66 @@ fn minimap_indoor_and_outdoor_zoom_indices_are_independent() {
|
||||
"the outdoor index survived an indoor zoom"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The level persists** (decision 1131). `SetZoom` writes the live index *and* the matching CVar
|
||||
/// — `minimapInsideZoom` while inside a WMO, `minimapZoom` outside — the client's own `set_zoom` →
|
||||
/// `CVar::Set` pair, which is the whole reason a zoom survives a restart. The host push in the
|
||||
/// other direction (the seed) does *not* echo back as a change.
|
||||
#[test]
|
||||
fn setzoom_persists_the_level_through_the_cvar_it_belongs_to() {
|
||||
let mut s = script();
|
||||
s.register_cvars([("minimapZoom", "3"), ("minimapInsideZoom", "3")]);
|
||||
s.run(r#"m = CreateFrame("Minimap", "TestMinimap")"#)
|
||||
.unwrap();
|
||||
|
||||
// The seed is a HOST write: it moves both live indices and queues nothing (the host is the
|
||||
// one that just read them off disk — an echo would re-dirty the file it loaded).
|
||||
s.set_minimap_zoom(1, 4);
|
||||
assert_eq!(s.eval::<u8>("return m:GetZoom()").unwrap(), 1);
|
||||
assert!(s.take_cvar_changes().is_empty(), "the seed must not echo");
|
||||
|
||||
// Outdoors, a zoom writes the outdoor CVar and only that one.
|
||||
s.run("m:SetZoom(5)").unwrap();
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![("minimapZoom".to_string(), "5".to_string())]
|
||||
);
|
||||
assert_eq!(s.cvar("minimapInsideZoom").as_deref(), Some("3"));
|
||||
|
||||
// Indoors it writes the indoor one — the two levels persist separately, like the indices.
|
||||
s.set_minimap_inside(true);
|
||||
s.run("m:SetZoom(0)").unwrap();
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![("minimapInsideZoom".to_string(), "0".to_string())]
|
||||
);
|
||||
assert_eq!(s.cvar("minimapZoom").as_deref(), Some("5"));
|
||||
|
||||
// A no-op zoom queues nothing: quiet frames stay quiet, and the config stays clean.
|
||||
s.run("m:SetZoom(0)").unwrap();
|
||||
assert!(s.take_cvar_changes().is_empty());
|
||||
|
||||
// The seed clamps like `set_zoom` does, so a hand-edited config.toml cannot seed out of range.
|
||||
s.set_minimap_zoom(99, 99);
|
||||
s.set_minimap_inside(false);
|
||||
assert_eq!(
|
||||
s.eval::<u8>("return m:GetZoom()").unwrap(),
|
||||
MINIMAP_ZOOM_LEVELS - 1
|
||||
);
|
||||
}
|
||||
|
||||
/// A VM whose host registered nothing (a bare test harness, a glue-only run) still zooms — the
|
||||
/// engine-side CVar write is a silent no-op there, not a warning: engine writes are code, not UI
|
||||
/// content, so a miss means this build's host does not back the var.
|
||||
#[test]
|
||||
fn zooming_without_a_registered_cvar_table_is_silent() {
|
||||
let mut s = script();
|
||||
s.run(r#"m = CreateFrame("Minimap", "TestMinimap") m:SetZoom(4)"#)
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<u8>("return m:GetZoom()").unwrap(), 4);
|
||||
assert!(s.take_cvar_changes().is_empty());
|
||||
assert!(
|
||||
s.take_warnings().is_empty(),
|
||||
"no warning for an engine write"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -279,6 +279,31 @@ impl super::UiScript {
|
||||
}
|
||||
}
|
||||
|
||||
/// **Reset the state filter and the collapse set — one `SMSG_TRAINER_LIST` arriving.**
|
||||
///
|
||||
/// Byte-verified (wow-re `system/ui/scratch/trainer-service-suppression.md`, decision 1128): the
|
||||
/// list builder writes the filter mask itself on every packet — `0x4d75d9 mov ds:0xb73a1c,3`
|
||||
/// (available|unavailable, "already known" OFF), or `5` (available|used) when `trainerType == 1`
|
||||
/// — alongside `ds:0xb73a20 = ds:0xb73a24 = 0xffffffff`, which is "no group collapsed". So the
|
||||
/// player's filter choice does NOT live in the engine across trainer visits in the reference: it
|
||||
/// lives in the saved variable `TRAINER_FILTER_*`, and the window's show handler pushes it back
|
||||
/// over this reset (decision 1128; `TrainerFrame.xml`'s `BenillaTrainerFrame_ApplyFilter`).
|
||||
///
|
||||
/// This is the **packet** edge, not the content edge — [`Self::set_trainer`] runs on every
|
||||
/// snapshot change (an item template landing, a name resolving), and the reference's repaint path
|
||||
/// `0x4d7d40` does not touch either mask. Reset there and a filter would evaporate mid-window.
|
||||
pub fn reset_trainer_filter(&mut self, trainer_type: u32) {
|
||||
let mut model = self.model_mut();
|
||||
// Mask 5 at a mount/"talent" trainer: available + already-known, which is what makes a
|
||||
// known mount visible under its "My Talents" header at all (decision 1124's group -1).
|
||||
model.trainer_filter = if trainer_type == TRAINER_TYPE_MOUNT {
|
||||
[true, false, true]
|
||||
} else {
|
||||
[true, true, false]
|
||||
};
|
||||
model.trainer_collapsed.clear();
|
||||
}
|
||||
|
||||
/// Drain the **spell ids** `BuyTrainerService` queued since the last call (the engine resolves each
|
||||
/// clicked filtered row to its service's spell id, so the app sends `CMSG_TRAINER_BUY_SPELL`
|
||||
/// without needing to know the filter/index mapping).
|
||||
|
||||
Reference in New Issue
Block a user