Files
benilla-octo/crates/benilla-app/src/ui_layout.rs
T
SamandClaude Fable 5.1 edec5b2207 settings live in one registry, and the net drain starts to peel
- the CVars are one engine-side table that outlives every interface reload: a change fires its observer, the eight latched rows wait for RestartGx, and /console gains set, cvar_reset, cvar_default, cvarlist and help (2303)
- every push into the interface runs in one feed phase after the net drain and before the tick, held by a structural test on the built schedule (2304)
- subsystems own their packet handlers, dispatched in packet order; the auction house is the first family out of the 273-arm drain (2305)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 16:47:09 +07:00

429 lines
20 KiB
Rust

//! The **layout cache** — where the windows the player has moved or resized are, so they are still
//! there after a relog.
//!
//! The engine has carried the client's userPlaced bit for a long time
//! ([`benilla_ui::script`]'s `movable` cluster — `frame+0xb4 & 0x1000`, set by `SetUserPlaced` and
//! by the drag entries themselves) and **nothing read it**: a chat window dragged across the screen
//! went back to its authored anchor at the next login. This module is the half that was missing.
//! The engine half — enumerate, restore, dirty-bit — is `benilla_ui::script`'s `layout_cache`; this
//! is the two ends the VM cannot own: *where the file lives* and *when it is written*.
//!
//! ## The file, and how it relates to the reference's
//!
//! 1.12 writes `WTF/Account/<ACC>/<REALM>/<CHAR>/layout-cache.txt`, and a real one off the pinned
//! install reads, in full:
//!
//! ```text
//! Frame: ChatFrame2
//! FrameLevel: 4
//! X: 32
//! Y: -578
//! W: 430
//! H: 120
//! ```
//!
//! Ours is `benilla-config/layout/<realm>-<character>.txt`
//! ([`crate::local_state::layout_character_path`]) — the same scope, one folder flatter, with that
//! file's `Frame:`/`W:`/`H:` spellings kept. **Two of its keys are deliberately different, and both
//! are about benilla's own move model rather than about taste:**
//!
//! - **`Point:` lines instead of `X:`/`Y:`.** The reference collapses a dragged frame to a single
//! screen-space position and re-seats it from a `TOPLEFT` anchor; benilla's drag pump keeps the
//! frame's authored anchor SET and shifts every offset in it (the divergence `movable`'s own doc
//! states). Writing an absolute pair would therefore *lose* what the drag produced — ChatFrame1
//! is anchored `BOTTOMLEFT` to UIParent, and restoring it as a TOPLEFT position would change how
//! it follows a resolution change. So the file carries what `GetPoint` answers, one line per
//! anchor: `Point: <point> <relativeTo> <relativePoint> <x> <y>`, with `-` for the screen root
//! (which is where `GetPoint` answers `nil`).
//! - **No `FrameLevel:`.** The reference persists it because an undocked chat window is raised
//! above its neighbours and that raise has to survive; benilla's raise is transient and its
//! docked windows share one level. It joins the file the day a window can be raised for good —
//! the honest-tree rule (1134 §4), the same one the chat-look file applies next door.
//!
//! ## The write posture — the reference's shutdown tail, plus a debounce it has not got
//!
//! **The reference writes this file in exactly one place**: step three of the UI shutdown's
//! ordered tail `0x490bd0` — `PLAYER_LEAVING_WORLD` → `PLAYER_LOGOUT` (`0x490c2a`) →
//! **`layout-cache.txt` (`0x490c79`)** → the flat saved file (`0x490c7e`) → the per-addon files →
//! `AddOns.txt` → the frame teardown. It rides the same five lifecycle roots as SavedVariables —
//! logout to the character screen, quit, disconnect, application exit and **`/reload`** — and
//! there is no autosave, no dirty bit and no per-frame write (wow-5875-re `system/ui/ui.md`;
//! `scratch/camera-settings-persistence.md` §4).
//!
//! Ours takes that slot: [`save_now`], called from [`crate::ui_script::shutdown_ui_state`]
//! between `PLAYER_LOGOUT` and the flat file. **That is the load-bearing part, and it is what
//! B353 was.** The tail is ONE function called from every root, which is the only way a
//! `/reload` gets a write at all: a reload never leaves `InWorld` — `run_pending_reload` calls
//! the shutdown and the rebuild back to back — so a saver hung off `OnExit(InWorld)` is invisible
//! to it, and an unlocked chat window came back on its authored anchors after every `/reload`
//! with `benilla-config/layout/` never created. The same edge is *also* not an ordering: two
//! unconstrained systems on one state edge are placed by the executor, and measured here (bevy
//! 0.18, five systems on one `OnExit`) the placement moves with nothing but the registration
//! positions — a saver that needs the dying VM ran *after* the exclusive session-ender in one
//! arrangement and before it in another. So this module's `OnExit` saver was reading a VM it was
//! racing for. The tail cannot race: it runs inside `end_ui_session`, ahead of the replacement,
//! by construction. That is the mistake [`crate::ui_script::shutdown_ui_state`]'s own doc says
//! the tail exists to prevent, made once more one module over.
//!
//! **The one thing we keep that the reference has not got is a debounced autosave** —
//! [`save_layout`], dirty flag keyed to the VM ([`VmMemo`]), one quiet second. The reference has
//! no crash-path write either (its exception filter reaches no writer) and we would rather not
//! lose a session's windows to one; a drag is a slider-shaped gesture, so the quiet second
//! coalesces a resize's per-mouse-move writes into one. It is a *second* writer of the same
//! file, never the only one: every orderly end goes through the tail.
//!
//! The VM key on the dirty flag is one-way and load-bearing: the geometry lives in the VM, so a
//! plain `bool` surviving a VM replacement would let a save compose the player's file out of a
//! fresh tree that has no user-placed frame in it at all — i.e. wipe it. A fresh VM starts
//! undirty and cannot write until a drag writes.
use std::path::PathBuf;
use bevy::prelude::*;
use benilla_ui::script::{FrameLayout, LayoutPoint, UiScript};
use crate::ui_script::VmMemo;
/// How long a moved window sits before the save fires — [`crate::cvars`]'s own constant and its own
/// reasoning, which a drag fits exactly: long enough to coalesce one gesture, short enough that a
/// crash costs a gesture rather than a session.
const SAVE_QUIET: std::time::Duration = std::time::Duration::from_secs(1);
/// The screen root's spelling in the file — where `GetPoint` answers `nil`. A literal `-` rather
/// than `UIParent`, because benilla has a real frame by that name (`UIParent.xml`) and the two
/// would then be indistinguishable on the way back in.
const SCREEN_TOKEN: &str = "-";
/// The file's header — what these lines are and where the law lives.
const HEADER: &str = "\
# benilla window layout — every frame the player has moved or resized (the client's userPlaced
# bit). A relative of the reference's layout-cache.txt: same scope, same Frame:/W:/H: keys, but
# anchors instead of its X:/Y: pair, because a benilla drag moves a frame's anchors rather than
# collapsing it to a screen position. `-` as a Point: target means the screen root.
";
/// Which character's file we are on, where it lives, whether it has been restored into this VM,
/// and whether it is owed a write.
#[derive(Resource, Default)]
pub(crate) struct LayoutFile {
path: Option<PathBuf>,
/// The `(realm, character)` [`Self::path`] was built for. Session-keyed (1290) like the chat
/// look and the macro loads: the *same* character coming back still meets a fresh VM whose
/// frames are all back on their authored anchors.
identity: VmMemo<Option<(String, String)>>,
/// Whether **this VM** has unsaved drags. Session-keyed for the one-way reason in the module
/// doc: a `bool` that outlived its VM could compose the file from a tree with nothing placed
/// in it.
dirty: VmMemo<bool>,
last_change: Option<std::time::Instant>,
}
/// Render the cache exactly as [`parse`] reads it, frames in the order the engine hands them
/// (name-sorted, so the file is stable across sessions).
///
/// `pub(crate)` for one reason: `ui_script::chat_resize_tests` drives the *whole* round trip — drag
/// the shipped window, snapshot it, write the text, read it back into a fresh VM — and a test that
/// stops at the engine seam would not have caught a file that cannot express what the seam
/// produced.
pub(crate) fn render(frames: &[FrameLayout]) -> String {
let mut out = String::from(HEADER);
for f in frames {
out.push_str(&format!("Frame: {}\n", f.name));
out.push_str(&format!("W: {}\n", f.width));
out.push_str(&format!("H: {}\n", f.height));
for p in &f.points {
out.push_str(&format!(
"Point: {} {} {} {} {}\n",
p.point,
p.relative_to.as_deref().unwrap_or(SCREEN_TOKEN),
p.relative_point,
p.x,
p.y
));
}
}
out
}
/// Parse the cache back. Permissive the way the reference's own readers are, and for the same
/// reason — a hand edit and a later build's extra key must each cost at most the line they are on:
/// keys match case-insensitively, an unknown key is skipped, and a malformed `Point:` drops that
/// anchor rather than the file. A `Point:`/`W:`/`H:` before any `Frame:` has no owner and is
/// dropped.
pub(crate) fn parse(text: &str) -> Vec<FrameLayout> {
let mut out: Vec<FrameLayout> = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, rest)) = line.split_once(':') else {
continue;
};
let (key, rest) = (key.trim(), rest.trim());
if key.eq_ignore_ascii_case("Frame") {
if rest.is_empty() {
continue;
}
out.push(FrameLayout {
name: rest.to_owned(),
width: 0.0,
height: 0.0,
points: Vec::new(),
});
continue;
}
let Some(frame) = out.last_mut() else {
continue; // a value line with no `Frame:` above it owns nothing
};
if key.eq_ignore_ascii_case("W") {
frame.width = rest.parse().unwrap_or(0.0);
} else if key.eq_ignore_ascii_case("H") {
frame.height = rest.parse().unwrap_or(0.0);
} else if key.eq_ignore_ascii_case("Point") {
let f: Vec<&str> = rest.split_whitespace().collect();
if f.len() != 5 {
warn!("layout: malformed Point line ignored: {line}");
continue;
}
let (Ok(x), Ok(y)) = (f[3].parse::<f32>(), f[4].parse::<f32>()) else {
warn!("layout: Point line with unparsable offsets ignored: {line}");
continue;
};
frame.points.push(LayoutPoint {
point: f[0].to_owned(),
relative_to: (f[1] != SCREEN_TOKEN).then(|| f[1].to_owned()),
relative_point: f[2].to_owned(),
x,
y,
});
}
}
out
}
/// Seat the player's saved geometry into the VM, once per character per VM.
///
/// `pub(crate)` so `ui_script::world_entry_tests` can run it against a real post-reload VM:
/// the file half of the loop is only worth anything if a window actually comes BACK, and
/// that is the half a test of `render`/`parse` alone cannot see.
///
/// Runs in `Update` under `InWorld`, which is *after* the UI tree is built and after decision
/// 0272's load-time `UIParent_ManageFramePositions()` bootstrap — both of which matter: the frames
/// have to exist to be looked up by name, and the managed pass has to have had its say first,
/// because from here on it skips these frames (`IsUserPlaced`, `UIParent.xml`).
pub(crate) fn load_layout(
script: Option<NonSendMut<UiScript>>,
roster: Res<crate::char_select::Roster>,
mut file: ResMut<LayoutFile>,
) {
let Some(mut script) = script else { return };
let Some(id) = crate::ui_macro::identity(&roster) else {
return;
};
if file.identity.get(&script).as_ref() == Some(&id) {
return; // already restored for this character, into the VM that is live now
}
file.path = crate::local_state::layout_character_path(&id.0, &id.1);
*file.identity.get(&script) = Some(id);
// A fresh VM has nothing placed, so a character with no file needs nothing pushed — and the
// watcher below must not read a change this load made.
*file.dirty.get(&script) = false;
file.last_change = None;
let Some(path) = file.path.clone() else {
return; // hermetic capture, or no state folder — session-only
};
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
Err(e) => {
warn!("layout: cannot read {}: {e}", path.display());
return;
}
};
let frames = parse(&text);
if frames.is_empty() {
return;
}
info!(
"layout: {} frames restored from {}",
frames.len(),
path.display()
);
script.restore_user_placed_layouts(frames);
// The restore moved anchors the drag pump would otherwise have moved; drain the engine's own
// dirty bit so the very first save is not a rewrite of what was just read.
script.take_user_placed_change();
}
/// Drain the engine's "a user-placed frame moved" bit into the dirty flag. Cheap on a steady frame
/// — the drain is a `take` of a `bool`.
fn watch_layout(script: Option<NonSendMut<UiScript>>, mut file: ResMut<LayoutFile>) {
let Some(mut script) = script else { return };
if !script.take_user_placed_change() {
return;
}
*file.dirty.get(&script) = true;
file.last_change = Some(std::time::Instant::now());
}
/// Dirty + one quiet second → rewrite the file atomically. **The crash writer, and only that**
/// (see the module doc): every orderly end — logout, disconnect, quit, `/reload` — is the
/// shutdown tail's, so this never has to reason about an exit message or an edge.
fn save_layout(script: Option<NonSendMut<UiScript>>, mut file: ResMut<LayoutFile>) {
let Some(script) = script else { return };
if !*file.dirty.get(&script) {
return;
}
if !file.last_change.is_none_or(|t| t.elapsed() >= SAVE_QUIET) {
return;
}
let Some(path) = file.path.clone() else {
// hermetic/session-only: nothing to write, stop retrying
*file.dirty.get(&script) = false;
return;
};
let body = render(&script.user_placed_layouts());
if let Err(e) = crate::local_state::write_atomic(&path, &body) {
// …and don't retry every frame into the same error.
warn!("layout: cannot write {}: {e}", path.display());
}
*file.dirty.get(&script) = false;
}
/// **The reference's own step of the UI shutdown** (`0x490c79`, between `PLAYER_LOGOUT` and the
/// flat saved file): write the player's layout cache from the live VM, now. Called from
/// [`crate::ui_script::shutdown_ui_state`], so it reaches every root the tail does — logout,
/// disconnect, quit and `/reload` — which is what an `OnExit(InWorld)` system could not (module
/// doc, and B353).
///
/// It takes the **identity the tail is holding** rather than reading [`LayoutFile`], for the
/// reason [`crate::ui_script::AddOnIdentity`] exists at all: that is the character the UI
/// actually loaded under, remembered because the roster's pick can be gone by the time the
/// shutdown runs. It is the same `(realm, character)` [`load_layout`] built its path from —
/// both are [`crate::ui_macro::identity`] of the same roster — so the tail writes back to the
/// file the load read.
///
/// Unconditional, like every other resident of the tail: the file is composed whole from the
/// live tree, so a session that placed nothing writes back what it restored, and a session that
/// un-placed a window writes the row's absence. The one guard is the tail's own — a session
/// whose in-game UI never loaded does not run it, so a UI-less VM cannot answer with its
/// emptiness.
pub(crate) fn save_now(script: &UiScript, identity: Option<&(String, String)>) {
let Some((realm, character)) = identity else {
return; // no character loaded under — a capture, a scenario, a test world
};
let Some(path) = crate::local_state::layout_character_path(realm, character) else {
return; // hermetic capture, or no state folder — session-only
};
let body = render(&script.user_placed_layouts());
if let Err(e) = crate::local_state::write_atomic(&path, &body) {
warn!("layout: cannot write {}: {e}", path.display());
}
}
/// The layout cache's plugin. **Not the chat look's shape any more** (2029): that module keeps
/// its own session edge because its reference is the type-7 chat-cache saver `0x499a80`;
/// this file's reference is `0x490bd0`'s ordered tail, so its every orderly write lives in
/// [`save_now`] and the plugin has no edge left to own.
pub(crate) struct UiLayoutPlugin;
impl Plugin for UiLayoutPlugin {
fn build(&self, app: &mut App) {
// **One chain, and no session edges of its own.** Restore, watch, and the crash-only
// debounce; every orderly end is [`crate::ui_script::shutdown_ui_state`]'s [`save_now`],
// which is the reference's own single write site and the only one a `/reload` reaches.
app.init_resource::<LayoutFile>().add_systems(
Update,
(
// The restore is a push the tick should see the frame it lands: the feed phase.
load_layout.in_set(crate::ui_script::UiFeed),
// The watcher and the save read what the drag pump — Lua's, in the tick — did:
// after it. Load precedes watch through the phases, as the old chain had it,
// so the watcher never reads the restore's own move.
(watch_layout, save_layout)
.chain()
.after(crate::ui_script::UiInput),
)
.in_set(crate::char_select::InWorldGated),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn point(p: &str, rel: Option<&str>, rp: &str, x: f32, y: f32) -> LayoutPoint {
LayoutPoint {
point: p.into(),
relative_to: rel.map(str::to_owned),
relative_point: rp.into(),
x,
y,
}
}
/// The file round-trips — what `render` writes is exactly what `parse` reads back, the screen
/// root's `-` included.
#[test]
fn the_file_round_trips() {
let frames = vec![
FrameLayout {
name: "ChatFrame1".into(),
width: 512.0,
height: 180.5,
points: vec![point(
"BOTTOMLEFT",
Some("UIParent"),
"BOTTOMLEFT",
40.0,
120.0,
)],
},
FrameLayout {
name: "Floater".into(),
width: 0.0,
height: 0.0,
points: vec![
point("TOPLEFT", None, "TOPLEFT", -1.5, 2.0),
point("BOTTOMRIGHT", Some("ChatFrame1"), "TOPRIGHT", 0.0, 0.0),
],
},
];
assert_eq!(parse(&render(&frames)), frames);
}
/// The header is a comment block and survives the round trip as one — a reader that choked on
/// its own header would lose the player's windows on the second launch.
#[test]
fn the_header_is_skipped_not_parsed() {
assert!(render(&[]).starts_with('#'));
assert_eq!(parse(HEADER), vec![]);
}
/// Junk costs the line it is on: an orphan value line, a short `Point:`, an unknown key.
#[test]
fn junk_costs_only_its_own_line() {
let got = parse(
"W: 100\n\
Frame: A\n\
FrameLevel: 4\n\
Point: TOPLEFT -\n\
W: 200\n\
point: bottomleft - BOTTOMLEFT 1 2\n",
);
assert_eq!(
got,
vec![FrameLayout {
name: "A".into(),
width: 200.0,
height: 0.0,
points: vec![point("bottomleft", None, "BOTTOMLEFT", 1.0, 2.0)],
}]
);
}
}