diff --git a/crates/benilla-app/src/chat_bubble.rs b/crates/benilla-app/src/chat_bubble.rs index 71565963..088d0749 100644 --- a/crates/benilla-app/src/chat_bubble.rs +++ b/crates/benilla-app/src/chat_bubble.rs @@ -304,6 +304,9 @@ struct Bubble { /// Stand-box height × model scale, latched at spawn as the reference does (`bubble+0x354`). lift: f32, fade: Fade, + /// The squared camera distance the bubble was last stacked by (`[bubble+0x350]`); `None` for + /// a new line, which the next pass stacks (the spawn flag, `[bubble+0x320] & 0x8`). + depth: Option, } /// Whether the speaker was drawn in the last render: the reference's scene-node test `0x6704c0` @@ -322,13 +325,67 @@ fn speaker_drawn( .is_ok_and(|kids| kids.iter().any(|c| meshes.get(c).is_ok_and(|v| v.get()))) } -/// The live bubbles by speaker guid, at most one per unit, and the next line's number. +/// The live bubbles by speaker guid, at most one per unit, their stacking, and the next line's +/// number. #[derive(Resource, Default)] struct Bubbles { live: HashMap, + /// The stacking, farthest first (the list at `0xb6f038`); the index is the frame level. + stack: Vec, + /// The camera position the stack was last sorted from (`0xb6eff0`, held once `0xb6effc` is + /// set). + eye: Option, next_line: u64, } +impl Bubbles { + /// A new line from `guid`: it replaces the speaker's old bubble, which goes back to the pool + /// (`0x608bb4` → `0x608c00`), and the new one is linked at the stack's tail (`0x608bbf` → + /// `0x4b1870`, `0x4b18f0`), unstacked until the next pass. + fn spawn(&mut self, guid: u64, bubble: Bubble) { + self.stack.retain(|g| *g != guid); + self.stack.push(guid); + self.live.insert(guid, bubble); + } + + /// The stacking step of the bubble pass (`0x4b1060`). The camera moved when any coordinate + /// differs from the held one, compared exactly (`0x4b10a5`–`0x4b10d3`); then every bubble is + /// re-stacked, else only a new line (`0x4b11c7`), each from the held position to its speaker + /// (`pos`), into the stack before the first one nearer (`0x4b1360`). The rest keep their + /// place, so a speaker walking under a still camera keeps its stacking. + fn restack(&mut self, camera: Option, pos: impl Fn(u64) -> Option) { + let moved = camera.is_some() && camera != self.eye; + if moved { + self.eye = camera; + } + let Self { + live, stack, eye, .. + } = self; + stack.retain(|g| live.contains_key(g)); + // Pulled out in stack order, then each re-inserted (`0x4b11d7`, `0x4b1220`). + let dirty: Vec = stack + .iter() + .copied() + .filter(|g| moved || live[g].depth.is_none()) + .collect(); + stack.retain(|g| !dirty.contains(g)); + for g in dirty { + let Some(at) = pos(g) else { + continue; + }; + let d = eye.map_or(0.0, |e| e.distance_squared(at)); + if let Some(b) = live.get_mut(&g) { + b.depth = Some(d); + } + let slot = stack + .iter() + .position(|o| live[o].depth.is_some_and(|od| od < d)) + .unwrap_or(stack.len()); + stack.insert(slot, g); + } + } +} + /// This frame's live bubbles for the widget layer, farthest from the camera first, and the sizes /// the window gives them; `None` with no viewport to lay out against. [`crate::ui_chat::bubbles`] /// hands them to the VM. @@ -411,7 +468,7 @@ fn drive_bubbles( let line = bubbles.next_line; bubbles.next_line += 1; // Replace, never queue (`0x608bb4`): the old line's frame goes back to the pool. - bubbles.live.insert( + bubbles.spawn( guid, Bubble { line, @@ -425,6 +482,7 @@ fn drive_bubbles( born: now, lift, fade: Fade::spawn(now), + depth: None, }, ); } @@ -444,23 +502,16 @@ fn drive_bubbles( let cam = camera.single().ok(); let window = window.single().ok(); - // The order is the frame levels (`0x4b1360`, stamped `0x4b12d5`-`0x4b1312`): the camera's - // distance to the speaker, farthest first. The reference re-sorts only when the camera moved - // or a line spawned (`0x4b10a5`-`0x4b10d3`, `0x4b11c7`); this sorts every frame, so a speaker - // moving under a still camera can re-stack here and not there. - let mut order: Vec<(u64, f32)> = bubbles - .live - .keys() - .filter_map(|guid| { - let (entity, _, tf) = find(*guid)?; + // The order is the frame levels (`0x4b1360`, stamped `0x4b12d5`-`0x4b1312`), farthest from + // the camera first. + bubbles.restack(cam.map(|(_, pose)| pose.translation), |guid| { + find(guid).map(|(_, _, tf)| tf.translation) + }); + for guid in &bubbles.stack { + if let Some((entity, _, _)) = find(*guid) { active.0.insert(entity); - let d2 = cam.map_or(0.0, |(_, pose)| { - pose.translation.distance_squared(tf.translation) - }); - Some((*guid, d2)) - }) - .collect(); - stack_sort(&mut order); + } + } // Sizes from the camera's viewport, else the window's; with neither there is nothing to lay // out against. @@ -481,9 +532,10 @@ fn drive_bubbles( text_height: text_px(TEXT_H, basis) / seam, device_px: seam * scale, }); - feed.states = order + feed.states = bubbles + .stack .iter() - .filter_map(|(guid, _)| { + .filter_map(|guid| { let b = bubbles.live.get(guid)?; let (entity, _, tf) = find(*guid)?; // The anchor (`0x4b0c30`), from this frame's `Transform` and the latched lift. @@ -558,12 +610,6 @@ fn trace_seat( ); } -/// Farthest camera distance first, so the index is the frame level. Ties break on guid, as the -/// reference keeps its order across an exact tie and a `HashMap` walk cannot. -fn stack_sort(order: &mut [(u64, f32)]) { - order.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0))); -} - /// The bubble stage; [`crate::nameplates`] orders after it to read [`BubblesActive`]. #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] pub(crate) struct BubbleSet; @@ -859,18 +905,82 @@ mod tests { ); } - /// Farthest camera distance first, guid breaking an exact tie. - #[test] - fn the_stack_is_sorted_farthest_first() { - let mut p = vec![(7, 25.0), (3, 400.0), (9, 1.0), (2, 400.0)]; - stack_sort(&mut p); - assert_eq!( - p.iter().map(|q| q.0).collect::>(), - vec![2, 3, 7, 9], - "farthest first; the 400-yd² pair ordered by guid" + /// A line spoken by `guid` into `b`, as the spawn gate leaves it. + fn speak(b: &mut Bubbles, guid: u64) { + let line = b.next_line; + b.next_line += 1; + b.spawn( + guid, + Bubble { + line, + text: String::new(), + color: [1.0; 3], + born: 0, + duration: 0, + lift: 0.0, + fade: Fade::spawn(0), + depth: None, + }, ); } + /// Farthest from the camera first, an exact tie keeping the earlier line in front of the + /// later; the stack stands while the camera does, whoever walks, and a camera move re-sorts. + #[test] + fn the_stack_resorts_on_a_camera_move_or_a_new_line_only() { + let mut at: HashMap = HashMap::from([ + (1, Vec3::new(5.0, 0.0, 0.0)), + (2, Vec3::new(20.0, 0.0, 0.0)), + (3, Vec3::new(20.0, 0.0, 0.0)), + ]); + let mut b = Bubbles::default(); + for g in [1, 2, 3] { + speak(&mut b, g); + } + let mut camera = Vec3::ZERO; + b.restack(Some(camera), |g| at.get(&g).copied()); + assert_eq!(b.stack, [2, 3, 1], "farthest first; the tie in line order"); + + // Speaker 1 walks behind the others under a still camera. + at.insert(1, Vec3::new(40.0, 0.0, 0.0)); + b.restack(Some(camera), |g| at.get(&g).copied()); + assert_eq!(b.stack, [2, 3, 1], "a still camera keeps the stacking"); + + // A new line stacks at its own distance; the others keep their stale ones. + speak(&mut b, 4); + at.insert(4, Vec3::new(10.0, 0.0, 0.0)); + b.restack(Some(camera), |g| at.get(&g).copied()); + assert_eq!(b.stack, [2, 3, 4, 1]); + + // The camera moves, by any amount: every bubble re-sorts. + camera.y = 1e-3; + b.restack(Some(camera), |g| at.get(&g).copied()); + assert_eq!(b.stack, [1, 2, 3, 4]); + + // A bubble that ends leaves the stack. + b.live.remove(&2); + b.restack(Some(camera), |g| at.get(&g).copied()); + assert_eq!(b.stack, [1, 3, 4]); + } + + /// A speaker who speaks again goes to the tail before the pass re-stacks the new lines in list + /// order (`0x4b1870`, `0x4b11d7`), so in a tie the later line lands in front. + #[test] + fn a_repeat_line_moves_its_speaker_to_the_tail() { + let at = |_: u64| Some(Vec3::new(10.0, 0.0, 0.0)); + let mut b = Bubbles::default(); + for g in [1, 2] { + speak(&mut b, g); + } + b.restack(Some(Vec3::ZERO), at); + assert_eq!(b.stack, [1, 2]); + // In one frame, 3 speaks, then 1 again, all at one distance. + speak(&mut b, 3); + speak(&mut b, 1); + b.restack(Some(Vec3::ZERO), at); + assert_eq!(b.stack, [2, 3, 1], "1's new line came after 3's"); + } + /// 16 px at 1024-wide 4:3 (width/64), damped past the plate knee. #[test] fn border_unit_is_a_64th_of_the_width() { diff --git a/crates/benilla-app/src/ui_layout.rs b/crates/benilla-app/src/ui_layout.rs index 2894c0de..64216958 100644 --- a/crates/benilla-app/src/ui_layout.rs +++ b/crates/benilla-app/src/ui_layout.rs @@ -2,16 +2,11 @@ //! (`frame+0xb4 & 0x1000`), restored at the next login. The engine half (snapshot, restore, dirty //! bit) is `benilla_ui::script`'s `layout_cache`; this is the file and when it is written. //! -//! The reference writes `WTF/Account////layout-cache.txt` with `Frame:`, -//! `FrameLevel:`, `X:`, `Y:`, `W:` and `H:` lines; ours is -//! `benilla-config/layout/-.txt`, keeping `Frame:`, `W:` and `H:`. -//! Deviation: one `Point: ` line per anchor, `-` for -//! the screen root, instead of `X:`/`Y:`. A drag leaves one TOPLEFT against the screen root -//! (`0x768430`), whose line holds the reference's X and Y unrounded, where the reference saves -//! integers (`0x490fc4`–`0x490fdf`); a frame user-placed without a drag keeps its authored -//! anchors here, where the reference saves its rect's top-left. There is no -//! `FrameLevel:`: the reference saves an undocked window's raise, and benilla's raise does not -//! persist. +//! The reference writes `WTF/Account////layout-cache.txt`; ours is +//! `benilla-config/layout/-.txt`, in its form: per frame a `Frame:` line, then +//! `FrameLevel:`, `X:`, `Y:`, `W:` and `H:` (`0x490e60`), each `Name: value` and an integer, the +//! records back to back. A file of benilla's earlier anchor form, one `Point:` line per anchor, is +//! read once and rewritten in this form at the next save. //! //! The reference writes the file only in its UI shutdown tail (`0x490bd0`), at `0x490c79`, //! after `PLAYER_LOGOUT` (`0x490c2a`) and before the flat saved file (`0x490c7e`), on logout, @@ -23,22 +18,19 @@ use std::path::PathBuf; use bevy::prelude::*; -use benilla_ui::script::{FrameLayout, LayoutPoint, UiScript}; +use benilla_ui::script::{AnchorLayout, FrameLayout, LayoutPoint, UiScript}; use crate::ui_script::VmMemo; /// How long a moved window sits before the autosave: one gesture coalesced, at most one lost. const SAVE_QUIET: std::time::Duration = std::time::Duration::from_secs(1); -/// The screen root in the file, where `GetPoint` answers `nil`; not `UIParent`, a real frame. +/// The screen root as a target in the anchor form, where `GetPoint` answers `nil`. const SCREEN_TOKEN: &str = "-"; -/// The file's header, a `#` block the parser skips. +/// The file's header, a `#` line the parser skips. 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; a dragged window has one, TOPLEFT on the screen's TOPLEFT. -# `-` as a Point: target means the screen root. +# benilla window layout: every frame the player has moved or resized, as layout-cache.txt has it. "; /// The character's file: its path, the VM it was restored into, and whether a write is owed. @@ -54,54 +46,134 @@ pub(crate) struct LayoutFile { last_change: Option, } -/// Render the cache as [`parse`] reads it, frames name-sorted as the engine hands them. +/// Render the cache as [`parse`] reads it, frames in the order the engine hands them; a field +/// the row does not carry has no line, as the writer's flag gates leave it (`0x490f99`, +/// `0x49109f`). 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 - )); + let fields = [ + ("FrameLevel", f.level.map(i32::from)), + ("X", f.x), + ("Y", f.y), + ("W", f.width), + ("H", f.height), + ]; + for (key, value) in fields { + if let Some(v) = value { + out.push_str(&format!("{key}: {v}\n")); + } } } out } -/// Parse the cache: keys match case-insensitively, and an unknown key, a malformed `Point:` or a -/// value line before any `Frame:` costs only its own line. -pub(crate) fn parse(text: &str) -> Vec { +/// What a layout file holds. +#[derive(Debug, PartialEq)] +pub(crate) enum Layout { + /// The reference's rect form. + Rect(Vec), + /// benilla's earlier anchor form, which carries `Point:` lines. + Anchors(Vec), +} + +/// Parse the cache: keys match case-insensitively, and an unknown key costs only its own line. A +/// value reads as `atoi` reads it (`0x64ac60`). The value lines before any `Frame:`, or after one +/// with no name, belong to no frame and are dropped, as the reference applies them to a null frame +/// (`0x4905e6`); a name no frame has drops its lines at the restore the same way. +/// +/// Deviation: a record's fields start empty. The reference's parser clears X, Y, W and the level +/// at each `Frame:` (`ebx`, `edi`, `esi`, `[ebp-0x4]` at `0x49045b`–`0x490469`) but not H +/// (`[ebp-0xc]`), so a record with no `H:` takes the one before's; benilla writes `H:` whenever it +/// writes `W:`, so only a hand-edited file reaches the slip, which would size a window from +/// another window's row. +pub(crate) fn parse(text: &str) -> Layout { + if lines(text).any(|(key, _)| key.eq_ignore_ascii_case("Point")) { + return Layout::Anchors(parse_anchor_form(text)); + } let mut out: Vec = Vec::new(); - for line in text.lines() { + let mut current = false; + for (key, rest) in lines(text) { + if key.eq_ignore_ascii_case("Frame") { + current = !rest.is_empty(); + if current { + out.push(FrameLayout { + name: rest.to_owned(), + ..FrameLayout::default() + }); + } + continue; + } + let Some(frame) = out.last_mut().filter(|_| current) else { + continue; // no frame owns the line + }; + let v = atoi(rest); + if key.eq_ignore_ascii_case("FrameLevel") { + // Below zero is the parser's "none" (`0x490462`), which the apply skips (`0x4905f0`). + frame.level = (v >= 0).then(|| u16::try_from(v).unwrap_or(u16::MAX)); + } else if key.eq_ignore_ascii_case("X") { + frame.x = Some(v); + } else if key.eq_ignore_ascii_case("Y") { + frame.y = Some(v); + } else if key.eq_ignore_ascii_case("W") { + frame.width = Some(v); + } else if key.eq_ignore_ascii_case("H") { + frame.height = Some(v); + } + } + Layout::Rect(out) +} + +/// The file's `Key: value` lines, trimmed, with blanks and `#` lines skipped. +fn lines(text: &str) -> impl Iterator { + text.lines().filter_map(|line| { let line = line.trim(); if line.is_empty() || line.starts_with('#') { - continue; + return None; } - let Some((key, rest)) = line.split_once(':') else { - continue; - }; - let (key, rest) = (key.trim(), rest.trim()); + let (key, rest) = line.split_once(':')?; + Some((key.trim(), rest.trim())) + }) +} + +/// C's `atoi`: an optional sign and the leading digits, zero when there are none. +fn atoi(s: &str) -> i32 { + let (neg, digits) = match s.as_bytes().first() { + Some(b'-') => (true, &s[1..]), + Some(b'+') => (false, &s[1..]), + _ => (false, s), + }; + let n = digits + .bytes() + .take_while(u8::is_ascii_digit) + .fold(0i64, |n, d| { + (n * 10 + i64::from(d - b'0')).min(i64::from(i32::MAX) + 1) + }); + let n = if neg { -n } else { n }; + n.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32 +} + +/// The anchor form: `W:`, `H:` and one `Point: ` line +/// per anchor, `-` for the screen root. A malformed `Point:` costs only its own line. +fn parse_anchor_form(text: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut current = false; + for (key, rest) in lines(text) { if key.eq_ignore_ascii_case("Frame") { - if rest.is_empty() { - continue; + current = !rest.is_empty(); + if current { + out.push(AnchorLayout { + name: rest.to_owned(), + width: 0.0, + height: 0.0, + points: Vec::new(), + }); } - 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 + let Some(frame) = out.last_mut().filter(|_| current) else { + continue; }; if key.eq_ignore_ascii_case("W") { frame.width = rest.parse().unwrap_or(0.0); @@ -110,11 +182,11 @@ pub(crate) fn parse(text: &str) -> Vec { } 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}"); + warn!("layout: malformed Point line ignored: {key}: {rest}"); continue; } let (Ok(x), Ok(y)) = (f[3].parse::(), f[4].parse::()) else { - warn!("layout: Point line with unparsable offsets ignored: {line}"); + warn!("layout: Point line with unparsable offsets ignored: {key}: {rest}"); continue; }; frame.points.push(LayoutPoint { @@ -161,18 +233,30 @@ pub(crate) fn load_layout( return; } }; - let frames = parse(&text); - if frames.is_empty() { + let (count, anchor_form) = match parse(&text) { + Layout::Rect(frames) => { + let n = frames.len(); + script.restore_user_placed_layouts(frames); + (n, false) + } + Layout::Anchors(frames) => { + let n = frames.len(); + script.restore_anchor_layouts(frames); + (n, true) + } + }; + if count == 0 { return; } - info!( - "layout: {} frames restored from {}", - frames.len(), - path.display() - ); - script.restore_user_placed_layouts(frames); + info!("layout: {count} frames restored from {}", path.display()); // Drain the dirty bit the restore set, so the first save does not rewrite what was just read. script.take_user_placed_change(); + if anchor_form { + // The anchor form is read once: the autosave rewrites it in the rect form once the + // restored windows have settled. + *file.dirty.get(&script) = true; + file.last_change = Some(std::time::Instant::now()); + } } /// Drain the engine's user-placed-frame-moved bit into the dirty flag. @@ -255,13 +339,10 @@ impl Plugin for UiLayoutPlugin { 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, + fn rows(layout: Layout) -> Vec { + match layout { + Layout::Rect(rows) => rows, + Layout::Anchors(rows) => panic!("read as the anchor form: {rows:?}"), } } @@ -270,53 +351,155 @@ mod tests { let frames = vec![ FrameLayout { name: "ChatFrame1".into(), - width: 512.0, - height: 180.5, - points: vec![point( - "BOTTOMLEFT", - Some("UIParent"), - "BOTTOMLEFT", - 40.0, - 120.0, - )], + level: Some(0), + x: Some(38), + y: Some(-225), + width: Some(431), + height: Some(400), }, + // Movable only: no `W:`/`H:`, as the reference writes `KLHTM_Frame`. 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), - ], + level: Some(4), + x: Some(-2), + y: Some(0), + width: None, + height: None, }, ]; - assert_eq!(parse(&render(&frames)), frames); + let text = render(&frames); + assert!( + text.ends_with( + "Frame: ChatFrame1\nFrameLevel: 0\nX: 38\nY: -225\nW: 431\nH: 400\n\ + Frame: Floater\nFrameLevel: 4\nX: -2\nY: 0\n" + ), + "the reference's lines, in its order:\n{text}" + ); + assert_eq!(rows(parse(&text)), frames); } #[test] fn the_header_is_skipped_not_parsed() { assert!(render(&[]).starts_with('#')); - assert_eq!(parse(HEADER), vec![]); + assert_eq!(parse(HEADER), Layout::Rect(vec![])); } #[test] fn junk_costs_only_its_own_line() { - let got = parse( + let got = rows(parse( "W: 100\n\ Frame: A\n\ - FrameLevel: 4\n\ - Point: TOPLEFT -\n\ - W: 200\n\ - point: bottomleft - BOTTOMLEFT 1 2\n", - ); + Shade: 4\n\ + FrameLevel: -1\n\ + x: 12.7\n\ + Y: -3px\n\ + W: wide\n\ + H: 200\n\ + Frame:\n\ + X: 99\n\ + H: 7\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)], + level: None, + x: Some(12), + y: Some(-3), + width: Some(0), + height: Some(200), }] ); } + + #[test] + fn a_point_line_marks_the_anchor_form() { + let text = "\ +# benilla window layout — every frame the player has moved or resized (the client's userPlaced +Frame: ChatFrame1 +W: 512 +H: 180.5 +Point: BOTTOMLEFT UIParent BOTTOMLEFT 40 120 +Frame: Floater +W: 0 +H: 0 +Point: TOPLEFT - TOPLEFT -1.5 2 +Point: TOPLEFT - +"; + let point = |p: &str, rel: Option<&str>, rp: &str, x: f32, y: f32| LayoutPoint { + point: p.into(), + relative_to: rel.map(str::to_owned), + relative_point: rp.into(), + x, + y, + }; + assert_eq!( + parse(text), + Layout::Anchors(vec![ + AnchorLayout { + name: "ChatFrame1".into(), + width: 512.0, + height: 180.5, + points: vec![point( + "BOTTOMLEFT", + Some("UIParent"), + "BOTTOMLEFT", + 40.0, + 120.0 + )], + }, + AnchorLayout { + name: "Floater".into(), + width: 0.0, + height: 0.0, + points: vec![point("TOPLEFT", None, "TOPLEFT", -1.5, 2.0)], + }, + ]) + ); + } + + /// A file of the anchor form seats the windows where they were and saves back in the rect + /// form, so a player's windows survive the change of format. + #[test] + fn an_anchor_form_file_migrates_to_the_rect_form() { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + s.run( + r#" + local b = CreateFrame("Frame", "Base") + b:SetPoint("BOTTOMLEFT", 0, 0) b:SetWidth(1024) b:SetHeight(768) + local f = CreateFrame("Frame", "Moved") + f:SetPoint("CENTER", 0, 0) f:SetWidth(100) f:SetHeight(50) + f:SetMovable(true) f:SetResizable(true) + local g = CreateFrame("Frame", "Glider") + g:SetPoint("CENTER", 0, 0) g:SetWidth(30) g:SetHeight(30) + g:SetMovable(true) + "#, + ) + .unwrap(); + let old = "\ +Frame: Glider +W: 30 +H: 30 +Point: TOPLEFT - TOPLEFT 10.4 -20.6 +Frame: Moved +W: 213.6 +H: 80 +Point: BOTTOMLEFT Base BOTTOMLEFT 61 29 +"; + let Layout::Anchors(rows) = parse(old) else { + panic!("an old file reads as the anchor form"); + }; + s.restore_anchor_layouts(rows); + let text = render(&s.user_placed_layouts()); + assert!( + text.contains( + "Frame: Moved\nFrameLevel: 0\nX: 61\nY: -659\nW: 214\nH: 80\n\ + Frame: Glider\nFrameLevel: 0\nX: 10\nY: -21\n" + ), + "the same windows, where they were, in the rect form, in creation order:\n{text}" + ); + assert!(!text.contains("Point:"), "{text}"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); + } } diff --git a/crates/benilla-app/src/ui_script/chat_resize_tests.rs b/crates/benilla-app/src/ui_script/chat_resize_tests.rs index 1ba2225c..166d7948 100644 --- a/crates/benilla-app/src/ui_script/chat_resize_tests.rs +++ b/crates/benilla-app/src/ui_script/chat_resize_tests.rs @@ -361,7 +361,9 @@ fn the_geometry_round_trips_through_the_save_file() { assert_eq!(saved.len(), 1, "one window was placed: {saved:?}"); assert_eq!(saved[0].name, "ChatFrame1"); let text = crate::ui_layout::render(&saved); - let read_back = crate::ui_layout::parse(&text); + let crate::ui_layout::Layout::Rect(read_back) = crate::ui_layout::parse(&text) else { + panic!("written in the rect form:\n{text}"); + }; assert_eq!( read_back, saved, "the file expresses what the seam produced" diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index 96e17a88..b7b2ba99 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -494,7 +494,12 @@ pub(super) fn tick_script( if script.vm_kind() == benilla_ui::script::VmKind::InGame { // A display change re-applies the UI scale (`0x492e90`), whose narrow-aspect cap // follows the window; a moved dial applies once, as its CVar callback does - // (`0x490770`). + // (`0x490770`). Deviation: the reference then re-reads the aspect (`0x492f13`), and on + // a change of 0.001 or more calls `0x491380`, which sets the reload trigger + // (`[0xb4b3f4]`) when the script-context check `0x494a50(0xa)` allows it; here a + // resize re-lays out the live UI, the chat bubbles' window-derived `BubbleGeometry` + // with it, because the window resizes freely and a reload on each resize would tear + // down every addon's state mid-play. if resized { let display = (w.round() as u32, h.round() as u32); let mut none = None; diff --git a/crates/benilla-app/src/ui_script/world_entry_tests.rs b/crates/benilla-app/src/ui_script/world_entry_tests.rs index d01dbc0b..2adef821 100644 --- a/crates/benilla-app/src/ui_script/world_entry_tests.rs +++ b/crates/benilla-app/src/ui_script/world_entry_tests.rs @@ -1022,8 +1022,8 @@ fn a_repeating_error_is_one_row_with_a_count_not_a_flood() { // A `/reload` never leaves `InWorld`, so only a saver in the shutdown tail sees every root. /// A window the player has placed, made as a drag makes one: `SetUserPlaced` refuses a frame that -/// is neither movable nor resizable. Parentless, so it anchors to the screen root (the file's `-` -/// target). Both flags: the cache applies position only to a movable frame, size to a resizable. +/// is neither movable nor resizable. Both flags: the cache writes `X:`/`Y:` only for a movable +/// frame and `W:`/`H:` only for a resizable one. fn place_a_window(world: &mut World) { world .get_non_send_resource_mut::() @@ -1045,12 +1045,7 @@ fn layout_cache(character: &str) -> Option { /// What a saved window's row has to say for the player to get it back. fn assert_probe_row(text: &str) { - for want in [ - "Frame: LayoutProbe", - "W: 413", - "H: 147", - "Point: BOTTOMLEFT - BOTTOMLEFT 61 29", - ] { + for want in ["Frame: LayoutProbe", "X: 61", "W: 413", "H: 147"] { assert!( text.contains(want), "the saved row is missing `{want}`:\n{text}" @@ -1128,6 +1123,51 @@ fn each_character_gets_its_own_layout_cache() { let _ = std::fs::remove_dir_all(&tmp); } +/// A file of benilla's earlier anchor form seats the window through the loader, and the next save +/// writes it back in the reference's rect form, so a player's windows survive the change. +#[test] +fn an_anchor_form_file_is_restored_and_rewritten_as_the_rect() { + use bevy::ecs::system::RunSystemOnce; + + let _l = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (tmp, _c, _h) = hermetic_probe("layout-anchor-form"); + let mut world = booted_world(); + world.init_resource::(); + + log_in_as(&mut world, "Onehunter", 1); + let path = crate::local_state::layout_character_path("Realm", "Onehunter") + .expect("a layout path in the hermetic state folder"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + &path, + "Frame: LayoutProbe\nW: 413\nH: 147\nPoint: BOTTOMLEFT - BOTTOMLEFT 61 29\n", + ) + .unwrap(); + author_a_window(&mut world); + world + .run_system_once(crate::ui_layout::load_layout) + .expect("the layout loader ran"); + let (w, h, left, _) = window_geometry(&world); + assert_eq!( + (w, h, left), + (413.0, 147.0, 61.0), + "seated from the anchor form" + ); + + super::end_ui_session(&mut world); + let text = layout_cache("Onehunter").expect("the logout wrote the layout cache"); + assert_probe_row(&text); + assert!( + !text.contains("Point:"), + "rewritten in the rect form:\n{text}" + ); + + drop(world); + let _ = std::fs::remove_dir_all(&tmp); +} + /// The probe window as FrameXML authors it, the fresh tree a relog meets: not user-placed, with the /// authored `movable`/`resizable` pair the cache's apply reads off the live frame. fn author_a_window(world: &mut World) { @@ -1143,14 +1183,14 @@ fn author_a_window(world: &mut World) { .expect("author the probe window"); } -/// The probe window's live geometry, as the player sees it. -fn window_geometry(world: &World) -> (f32, f32, String, f32, f32) { +/// The probe window's live geometry, as the player sees it: its size, left and top. +fn window_geometry(world: &World) -> (f32, f32, f32, f32) { world .get_non_send_resource::() .expect("a VM") - .eval::<(f32, f32, String, f32, f32)>( - "local p, _, _, x, y = LayoutProbe:GetPoint(1) \ - return LayoutProbe:GetWidth(), LayoutProbe:GetHeight(), p, x, y", + .eval::<(f32, f32, f32, f32)>( + "return LayoutProbe:GetWidth(), LayoutProbe:GetHeight(), \ + LayoutProbe:GetLeft(), LayoutProbe:GetTop()", ) .expect("read the probe window back") } diff --git a/crates/benilla-app/src/ui_script/world_map_tests.rs b/crates/benilla-app/src/ui_script/world_map_tests.rs index bf6609fa..846ed53b 100644 --- a/crates/benilla-app/src/ui_script/world_map_tests.rs +++ b/crates/benilla-app/src/ui_script/world_map_tests.rs @@ -752,19 +752,15 @@ fn a_stale_layout_row_cannot_seat_a_stock_frame() { assert!(failures.is_empty(), "manifest load errors: {failures:#?}"); s.resolve(); - // The row as `benilla-config/layout/-.txt` carries it: Cartographer's saved - // `LookNFeel` offsets and the size it forces. + // The row as the reference's `layout-cache.txt` carries it after Cartographer's `LookNFeel`: + // the size it forces, centred by its drag. let row = || benilla_ui::script::FrameLayout { name: "WorldMapFrame".into(), - width: 1024.0, - height: 768.0, - points: vec![benilla_ui::script::LayoutPoint { - point: "CENTER".into(), - relative_to: Some("UIParent".into()), - relative_point: "CENTER".into(), - x: -0.6369222, - y: 53.861614, - }], + level: None, + x: Some(307), + y: Some(-128), + width: Some(1024), + height: Some(768), }; s.restore_user_placed_layouts([row()]); @@ -813,7 +809,14 @@ fn a_stale_layout_row_cannot_seat_a_stock_frame() { ); let saved = s.user_placed_layouts(); assert_eq!(saved.len(), 1, "which is what puts the row back: {saved:?}"); - assert_eq!(saved[0], row(), "unchanged through the round trip"); + assert_eq!( + benilla_ui::script::FrameLayout { + level: None, + ..saved[0].clone() + }, + row(), + "unchanged through the round trip" + ); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } diff --git a/crates/benilla-ui/src/script/chat_bubble.rs b/crates/benilla-ui/src/script/chat_bubble.rs index 8b70f958..3c2e4ed3 100644 --- a/crates/benilla-ui/src/script/chat_bubble.rs +++ b/crates/benilla-ui/src/script/chat_bubble.rs @@ -31,7 +31,9 @@ const FIRST_LEVEL: u16 = 2; /// The bubble geometry that follows from the window rather than the line, in the `WorldFrame`'s /// units. Written into a bubble when it changes, so an addon's own anchors survive between -/// resizes. +/// resizes. A resize re-sizes the live bubbles as part of the live re-layout's deviation, where +/// the reference can reload the UI on an aspect change (`0x492f13` → `0x491380`); the `Deviation:` +/// line is at the resize in `benilla_app`'s `extract::tick_script`. #[derive(Clone, Copy, Debug, PartialEq)] pub struct BubbleGeometry { /// The border unit (`0x4b09d2`-`0x4b0a2f`): the backdrop's edge size, tile size and four diff --git a/crates/benilla-ui/src/script/layout_cache.rs b/crates/benilla-ui/src/script/layout_cache.rs index 2b9dcc27..447b0dae 100644 --- a/crates/benilla-ui/src/script/layout_cache.rs +++ b/crates/benilla-ui/src/script/layout_cache.rs @@ -3,17 +3,39 @@ //! engine half, a snapshot out, a restore in and a dirty bit; the app owns the file //! (`benilla_app::ui_layout`). //! -//! A saved frame carries its name, size and whole anchor list. An anchor's target is the screen -//! root (saved as no target, the `nil` `GetPoint` answers there) or a named frame; a frame anchored -//! to anything else is skipped whole, never half-restored. +//! A saved frame is its rect, as the reference's writer (`0x490e60`) takes it: the frame level, +//! the top-left against the screen's top-left and the extent, in the frame's own units and rounded +//! to integers. The apply (`0x4905e0`) pins TOPLEFT on the screen there and sets the size, so the +//! frame's authored anchors do not survive it. -use crate::layout::{Anchor, Anchors}; +use crate::layout::{Anchor, Anchors, Point}; use crate::widget::FrameHandle; -use super::object::{point_from_str, point_name}; -use super::{Model, SCREEN}; +use super::object::point_from_str; +use super::object::{eff_scale, settle_frame}; +use super::SCREEN; -/// One anchor of a saved frame, in the spelling `GetPoint`/`SetPoint` use. +/// One user-placed frame's persisted geometry, one field per line of the reference's file. A +/// field is `None` where its line is absent. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FrameLayout { + /// The frame's global name, which addresses the row across sessions (`Frame:`). + pub name: String, + /// The frame level (`FrameLevel:`, `frame+0xc4`), written for every row (`0x490f55`). + pub level: Option, + /// The rect's left, against the screen's left (`X:`), written for a movable frame + /// (`0x490f99`). + pub x: Option, + /// The rect's top, against the screen's top (`Y:`), so zero or below. + pub y: Option, + /// The rect's width (`W:`), written for a resizable frame (`0x49109f`). + pub width: Option, + /// The rect's height (`H:`). + pub height: Option, +} + +/// One anchor of a frame saved in benilla's earlier anchor form, in the spelling +/// `GetPoint`/`SetPoint` use. #[derive(Clone, Debug, PartialEq)] pub struct LayoutPoint { /// The point on the saved frame (`"BOTTOMLEFT"`, …). @@ -28,62 +50,170 @@ pub struct LayoutPoint { pub y: f32, } -/// One user-placed frame's persisted geometry. +/// A row of the anchor form benilla wrote before it saved the reference's rect: the authored +/// size and the whole anchor list. Read once, to carry a player's windows into the rect form. #[derive(Clone, Debug, PartialEq)] -pub struct FrameLayout { - /// The frame's global name, which addresses the row across sessions. +pub struct AnchorLayout { + /// The frame's global name. pub name: String, - /// The frame's authored width (`LayoutInput::width`), which a resize drag writes. + /// The frame's authored width (`LayoutInput::width`). pub width: f32, /// The frame's authored height. pub height: f32, - /// Every anchor the frame carries, in `GetPoint` order. + /// Every anchor the frame carried, in `GetPoint` order. pub points: Vec, } impl super::UiScript { - /// Snapshot, sorted by name so the file is stable, every frame the reference's writer - /// (`0x490e60`) writes: a non-empty name (`0x490e79`, `0x490e82`), `userPlaced` (`0x490e8e`, - /// `0x1000`) and `movable` or `resizable` (`0x490e97`, `0x100|0x200`), with every anchor - /// nameable. Every drag stamps `userPlaced` (`0x7652b0` at `0x7652e5`), so the last test is - /// what drops the row of a stock frame an addon once moved, once that addon is gone. + /// Snapshot, in creation order as the writer walks the list of every frame (`0x490e24`, + /// `[root+0xcc4]`, appended by the constructor at `0x76930d`), every frame it writes: a + /// non-empty name (`0x490e79`, `0x490e82`), `userPlaced` (`0x490e8e`, `0x1000`) and `movable` + /// or `resizable` (`0x490e97`, `0x100|0x200`), with a rect to read. Every drag stamps + /// `userPlaced` (`0x7652b0` at `0x7652e5`), so the last test is what drops the row of a stock + /// frame an addon once moved, once that addon is gone. + /// + /// The rect is the one the frame holds, stale or not (`0x768320`, its valid bit): only a frame + /// holding none is resolved now (`0x490eed`, `0x7680e0(1)`) and read again, and still none + /// writes no row (`0x490f00`). The root's rect is the screen, which is always held, so the + /// second bail (`0x490f17`) never takes here. The level and the two flags are read after the + /// rect (`0x490f55`, `0x490f93`, `0x491099`), since a resolve can run script. + /// + /// `X:`/`Y:` round half away from zero (`0x490fc4`–`0x490fdf`, then `0x40a2b0`'s truncating + /// `fistp`); `W:`/`H:` add a half and truncate (`0x4910cd`–`0x4910d3`). pub fn user_placed_layouts(&self) -> Vec { - let model = self.model_ref(); - let mut out: Vec = model + let mut candidates: Vec<(u64, FrameHandle, String)> = self + .model_ref() .arena .iter_frames() .filter(|(_, f)| f.user_placed && (f.movable || f.resizable)) .filter_map(|(h, f)| { - let name = f.name.clone()?; - let input = model.layout_inputs.get(&h)?; - let points = input - .anchors - .iter() - .map(|a| saved_point(&model, a)) - .collect::>>()?; - Some(FrameLayout { - name, - width: input.width, - height: input.height, - points, - }) + let name = f.name.clone().filter(|n| !n.is_empty())?; + Some((f.created(), h, name)) }) .collect(); - out.sort_by(|a, b| a.name.cmp(&b.name)); + candidates.sort_unstable_by_key(|c| c.0); + let mut out = Vec::with_capacity(candidates.len()); + for (_, h, name) in candidates { + if !self.model_ref().resolved.contains_key(&h) { + settle_frame(self.lua(), h); + } + let model = self.model_ref(); + let Some(r) = model.resolved.get(&h).copied() else { + continue; + }; + let Some((movable, resizable, level)) = model + .arena + .frame(h) + .map(|f| (f.movable, f.resizable, f.level)) + else { + continue; // the resolve's script destroyed it + }; + let root = model.screen; + let scale = f64::from(eff_scale(&model, h)); + let own = |v: f32| f64::from(v) / scale; + let (x, y) = if movable { + ( + Some(round_offset(own(r.left - root.left))), + Some(round_offset(own(r.top - root.top))), + ) + } else { + (None, None) + }; + let (width, height) = if resizable { + ( + Some(round_extent(own(r.right - r.left))), + Some(round_extent(own(r.top - r.bottom))), + ) + } else { + (None, None) + }; + out.push(FrameLayout { + name, + level: Some(level), + x, + y, + width, + height, + }); + } out } - /// Seat saved geometry back onto the frames that exist, each row whole or not at all. The - /// apply is per arm (`0x4905e0`): the position needs `movable` (`0x490600`) and anchors (the - /// no-position bail, `0x490613`), the size needs `resizable` (`0x490689`) and a non-zero size - /// (`0x49069a`), and each arm that applies stamps `userPlaced` (`0x49067e`, `0x490706`), which - /// keeps `UIParent_ManageFramePositions` off the frame. Marks nothing dirty: the values came + /// Seat saved rows back onto the frames that exist, as the apply (`0x4905e0`) does. A level of + /// zero or more is set, carried to same-strata children (`0x4905ee`–`0x4905f5`). The position + /// needs `movable` (`0x490600`) and a non-zero `X` or `Y` (`0x490607`–`0x490613`): it clears + /// every anchor (`0x49061a`) and pins TOPLEFT on the screen's TOPLEFT there (`0x490670`). The + /// size needs `resizable` (`0x490689`) and a non-zero `W` or `H` (`0x49068e`–`0x49069a`), and + /// sets each non-zero one (`0x4906c7`, `0x4906fa`). Each arm that applies stamps `userPlaced` + /// (`0x49067e`, `0x490706`), which keeps `UIParent_ManageFramePositions` off the frame. Each + /// row ends in a forced resolve of its frame, shown or hidden (`0x490710`, `0x7680e0(1)`), so + /// its `OnSizeChanged` fires before the next row applies. Marks nothing dirty: the values came /// from the file. pub fn restore_user_placed_layouts(&mut self, layouts: impl IntoIterator) { + for l in layouts { + if let Some(h) = self.apply_layout_row(l) { + settle_frame(self.lua(), h); + } + } + } + + /// One row of [`Self::restore_user_placed_layouts`], up to its invalidate; the frame it + /// applied to. + fn apply_layout_row(&mut self, l: FrameLayout) -> Option { + let mut model = self.model_mut(); + // A window this session does not have applies nothing (`0x4905e6`). + let h = model.arena.lookup(&l.name)?; + if let Some(level) = l.level { + model.arena.set_frame_level(h, level, true); + } + let (movable, resizable) = model.arena.frame(h).map(|f| (f.movable, f.resizable))?; + // An absent line reads as the reference's cleared accumulator, zero. + let (x, y) = (l.x.unwrap_or(0), l.y.unwrap_or(0)); + let (w, ht) = (l.width.unwrap_or(0), l.height.unwrap_or(0)); + let seat_position = movable && (x != 0 || y != 0); + let seat_size = resizable && (w != 0 || ht != 0); + let input = model.layout_inputs.entry(h).or_default(); + let mut changed = false; + if seat_position { + let pin = Anchors::from([Anchor::new( + Point::TopLeft, + SCREEN, + Point::TopLeft, + x as f32, + y as f32, + )]); + changed |= !input.anchors.bits_eq(&pin); + input.anchors = pin; + } + if seat_size && w != 0 { + changed |= input.width.to_bits() != (w as f32).to_bits(); + input.width = w as f32; + } + if seat_size && ht != 0 { + changed |= input.height.to_bits() != (ht as f32).to_bits(); + input.height = ht as f32; + } + if seat_size { + model.note_authored_size(h); + } + if seat_position || seat_size { + if let Some(f) = model.arena.frame_mut(h) { + f.user_placed = true; + } + } + // Every row that names a frame ends in an invalidate (`0x490710`). + model.invalidate_frame(h, changed); + Some(h) + } + + /// Seat rows of benilla's earlier anchor form, each whole or not at all, under the apply's arm + /// gates (`0x490600`, `0x490689`) and stamps (`0x49067e`, `0x490706`). The next save writes + /// the frames back in the rect form. + pub fn restore_anchor_layouts(&mut self, layouts: impl IntoIterator) { let mut model = self.model_mut(); for l in layouts { let Some(h) = model.arena.lookup(&l.name) else { - continue; // a window this build does not have + continue; }; let mut anchors = Anchors::new(); let mut usable = true; @@ -110,7 +240,6 @@ impl super::UiScript { if !usable { continue; } - // The two arm gates, read before the layout input is borrowed mutably. let Some((movable, resizable)) = model.arena.frame(h).map(|f| (f.movable, f.resizable)) else { continue; @@ -120,9 +249,7 @@ impl super::UiScript { if !seat_position && !seat_size { continue; } - let Some(input) = model.layout_inputs.get_mut(&h) else { - continue; - }; + let input = model.layout_inputs.entry(h).or_default(); let mut changed = false; if seat_position { changed |= input.anchors != anchors; @@ -134,13 +261,9 @@ impl super::UiScript { input.width = l.width; input.height = l.height; } - // Only an arm that applied stamps, so a row neither arm took falls out of the file. - if seat_position || seat_size { - if let Some(f) = model.arena.frame_mut(h) { - f.user_placed = true; - } + if let Some(f) = model.arena.frame_mut(h) { + f.user_placed = true; } - // A row applied invalidates the frame (`0x490710`). model.invalidate_frame(h, changed); } } @@ -151,19 +274,28 @@ impl super::UiScript { } } -/// An anchor's saved form, or `None` when its target is an unnamed frame or a region. -fn saved_point(model: &Model, a: &Anchor) -> Option { - let relative_to = if a.relative_to == SCREEN { - None - } else { - let h: FrameHandle = model.id_to_frame.get(&a.relative_to).copied()?; - Some(model.arena.frame(h)?.name.clone()?) - }; - Some(LayoutPoint { - point: point_name(a.point).to_owned(), - relative_to, - relative_point: point_name(a.relative_point).to_owned(), - x: a.x_off, - y: a.y_off, - }) +/// `X:`/`Y:`'s rounding: a half added, or taken off below zero (`fcom 0.0; test ah,0x5; jp`), +/// then truncated toward zero (`0x40a2b0`), so half rounds away from zero. +fn round_offset(v: f64) -> i32 { + (if v < 0.0 { v - 0.5 } else { v + 0.5 }).trunc() as i32 +} + +/// `W:`/`H:`'s rounding: a half added with no sign test, then truncated toward zero. +fn round_extent(v: f64) -> i32 { + (v + 0.5).trunc() as i32 +} + +#[cfg(test)] +mod tests { + use super::{round_extent, round_offset}; + + #[test] + fn offsets_round_half_away_from_zero_and_extents_add_a_half() { + assert_eq!( + [2.5, 2.49, -2.5, -2.49, 0.0, -0.4].map(round_offset), + [3, 2, -3, -2, 0, 0] + ); + // No sign test on an extent: a negative half truncates toward zero. + assert_eq!([2.5, 2.49, 0.0, -0.7].map(round_extent), [3, 2, 0, 0]); + } } diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index efd9978c..db0cb085 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -226,7 +226,7 @@ pub(crate) use button::{set_label_font_justify_h_lua, LabelFont}; pub use inspect::{InspectView, UnitReach}; pub use item_stats::{item_usable, ItemSetView, ItemTemplateView, PlayerReqState}; pub use item_text::ItemTextState; -pub use layout_cache::{FrameLayout, LayoutPoint}; +pub use layout_cache::{AnchorLayout, FrameLayout, LayoutPoint}; pub use loot::{LootRow, LootState, LOOT_PORTRAIT_UNIT}; pub use loot_roll::{LootRollEntry, LootRollsState}; pub use macros::{MacroBinding, MacroState, MacroView, MAX_MACROS, MAX_MACRO_BODY, MAX_MACRO_NAME}; diff --git a/crates/benilla-ui/src/script/object.rs b/crates/benilla-ui/src/script/object.rs index c979b432..06729f85 100644 --- a/crates/benilla-ui/src/script/object.rs +++ b/crates/benilla-ui/src/script/object.rs @@ -23,7 +23,7 @@ mod events_regions; mod frame_state; mod layout_methods; pub(crate) use layout_methods::eff_scale; -pub(in crate::script) use layout_methods::settle_region; +pub(in crate::script) use layout_methods::{settle_frame, settle_region}; pub(crate) mod movable; pub(crate) mod toplevel; pub(crate) use movable::{cancel_on_hide, pump_move_or_size, FrameMove}; diff --git a/crates/benilla-ui/src/script/tests/drag_pin.rs b/crates/benilla-ui/src/script/tests/drag_pin.rs index f17aa0ee..a507be71 100644 --- a/crates/benilla-ui/src/script/tests/drag_pin.rs +++ b/crates/benilla-ui/src/script/tests/drag_pin.rs @@ -3,7 +3,7 @@ //! anchor and size. use super::common::script; -use crate::script::{FrameLayout, LayoutPoint, UiScript}; +use crate::script::{FrameLayout, UiScript}; /// A 200×100 frame CENTER-anchored in a full-screen holder on an 800×600 screen: its rect is /// left 300, right 500, bottom 250, top 350. @@ -261,25 +261,23 @@ fn a_frame_with_no_rect_starts_no_drag() { assert!(s.errors().is_empty(), "{:?}", s.errors()); } -/// After a drag the layout cache holds the one TOPLEFT anchor and the size, the reference's `X:`, -/// `Y:`, `W:` and `H:` (`0x490e60`), and its apply seats that back. +/// After a drag the layout cache holds the pin as the reference's `X:`, `Y:`, `W:` and `H:`, with +/// the frame level (`0x490e60`), and its apply seats all of that back (`0x4905e0`). #[test] fn the_layout_cache_round_trips_the_pin() { let mut s = centred_box(); s.run("Box:StartMoving()").unwrap(); s.mouse_move(430.0, 280.0); - s.run("Box:StopMovingOrSizing()").unwrap(); + s.run("Box:StopMovingOrSizing() Box:SetFrameLevel(7)") + .unwrap(); + s.resolve(); // drawn there before the save reads its held rect let want = FrameLayout { name: "Box".into(), - width: 200.0, - height: 100.0, - points: vec![LayoutPoint { - point: "TOPLEFT".into(), - relative_to: None, - relative_point: "TOPLEFT".into(), - x: 330.0, - y: -270.0, - }], + level: Some(7), + x: Some(330), + y: Some(-270), + width: Some(200), + height: Some(100), }; let saved = s.user_placed_layouts(); assert_eq!(saved, std::slice::from_ref(&want)); @@ -288,6 +286,196 @@ fn the_layout_cache_round_trips_the_pin() { fresh.restore_user_placed_layouts(saved); assert_eq!(edges(&mut fresh), (330.0, 530.0, 230.0, 330.0)); assert_eq!(anchor(&fresh), pinned_at(330.0, -270.0)); + assert_eq!( + fresh.eval::("return Box:GetFrameLevel()").unwrap(), + 7, + "the level is applied too (`0x4905f5`)" + ); assert_eq!(fresh.user_placed_layouts(), [want]); assert!(fresh.errors().is_empty(), "{:?}", fresh.errors()); } + +/// A drag that leaves the frame off the unit grid saves whole units: `X:`/`Y:` round half away +/// from zero (`0x490fc4`–`0x490fdf`), and the apply seats the rounded spot. +#[test] +fn a_sub_unit_drag_saves_and_restores_rounded() { + let mut s = centred_box(); + s.run("Box:StartMoving()").unwrap(); + s.mouse_move(430.25, 279.5); + s.run("Box:StopMovingOrSizing()").unwrap(); + assert_eq!( + anchor(&s), + pinned_at(330.25, -270.5), + "the live pin is exact" + ); + s.resolve(); + let saved = s.user_placed_layouts(); + assert_eq!( + (saved[0].x, saved[0].y), + (Some(330), Some(-271)), + "330.25 rounds down, -270.5 away from zero" + ); + + let mut fresh = centred_box(); + fresh.restore_user_placed_layouts(saved); + assert_eq!(anchor(&fresh), pinned_at(330.0, -271.0)); +} + +/// A frame set user-placed without a drag keeps its authored anchors while it lives, but its row +/// is its rect, and the apply clears those anchors (`0x49061a`) and pins TOPLEFT on the screen +/// (`0x490670`): restored, it no longer follows the frame it was authored against. +#[test] +fn a_frame_placed_without_a_drag_restores_at_its_saved_top_left() { + let s = centred_box(); + s.run("Box:SetUserPlaced(true)").unwrap(); + let saved = s.user_placed_layouts(); + assert_eq!( + (saved[0].x, saved[0].y, saved[0].width, saved[0].height), + (Some(300), Some(-250), Some(200), Some(100)), + "the rect, not the CENTER anchor" + ); + + let mut fresh = centred_box(); + fresh + .run("Holder:ClearAllPoints() Holder:SetPoint('BOTTOMLEFT', 100, 0)") + .unwrap(); + fresh.restore_user_placed_layouts(saved); + assert_eq!( + edges(&mut fresh), + (300.0, 500.0, 250.0, 350.0), + "where it was saved, though its holder moved" + ); + assert_eq!(anchor(&fresh), pinned_at(300.0, -250.0)); + assert!(fresh.errors().is_empty(), "{:?}", fresh.errors()); +} + +/// The rows are written in creation order, the writer's walk of every frame (`0x490e24`), and +/// applied in file order, each level carried to same-strata children (`0x76a4f0`): the parent's +/// row lands before its child's, so both come back at their saved levels whatever their names. +#[test] +fn a_parent_and_child_restore_their_saved_levels() { + let make = || { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + Zed = CreateFrame("Frame", "Zed") + Zed:SetPoint("TOPLEFT", 100, -100); Zed:SetWidth(200); Zed:SetHeight(200) + Zed:SetMovable(true); Zed:SetUserPlaced(true) + Alpha = CreateFrame("Frame", "Alpha", Zed) + Alpha:SetPoint("TOPLEFT", 10, -10); Alpha:SetWidth(50); Alpha:SetHeight(50) + Alpha:SetMovable(true); Alpha:SetUserPlaced(true) + "#, + ) + .unwrap(); + s.resolve(); + s + }; + let s = make(); + s.run("Zed:SetFrameLevel(5) Alpha:SetFrameLevel(9)") + .unwrap(); + let saved = s.user_placed_layouts(); + assert_eq!( + saved.iter().map(|l| l.name.as_str()).collect::>(), + ["Zed", "Alpha"], + "creation order, not name order" + ); + + let mut fresh = make(); + fresh.restore_user_placed_layouts(saved); + assert_eq!( + fresh + .eval::<(i64, i64)>("return Zed:GetFrameLevel(), Alpha:GetFrameLevel()") + .unwrap(), + (5, 9) + ); + assert!(fresh.errors().is_empty(), "{:?}", fresh.errors()); +} + +/// Each row ends in a forced resolve of its frame (`0x490710`, `0x7680e0(1)`), hidden or not, so +/// its `OnSizeChanged` runs inside the restore. +#[test] +fn the_restore_resolves_each_frame_at_once_even_hidden() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + fires = 0 + Panel = CreateFrame("Frame", "Panel") + Panel:SetPoint("TOPLEFT", 10, -10); Panel:SetWidth(50); Panel:SetHeight(50) + Panel:SetResizable(true) + "#, + ) + .unwrap(); + s.resolve(); + s.run( + r#" + Panel:Hide() + Panel:SetScript("OnSizeChanged", function() fires = fires + 1; last_w = arg1 end) + "#, + ) + .unwrap(); + s.restore_user_placed_layouts([FrameLayout { + name: "Panel".into(), + width: Some(120), + height: Some(80), + ..FrameLayout::default() + }]); + assert_eq!( + s.eval::<(i64, f64)>("return fires, last_w").unwrap(), + (1, 120.0), + "fired inside the restore, though the frame is hidden" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// The writer reads the rect the frame holds, stale or not (`0x768320`, its valid bit), and +/// resolves only a frame that holds none (`0x490eed`): a size set since the last resolve is not +/// saved, and the save runs no script. +#[test] +fn the_save_reads_the_held_rect_and_runs_no_script() { + let mut s = centred_box(); + s.run( + r#" + fires = 0 + Box:SetUserPlaced(true) + Box:SetScript("OnSizeChanged", function() fires = fires + 1 end) + Box:SetWidth(300) + "#, + ) + .unwrap(); + let saved = s.user_placed_layouts(); + assert_eq!( + (saved[0].x, saved[0].width), + (Some(300), Some(200)), + "the held rect, from before the width moved" + ); + assert_eq!(s.eval::("return fires").unwrap(), 0, "nothing fired"); + s.resolve(); + assert_eq!(s.user_placed_layouts()[0].width, Some(300)); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// A frame with no rect is resolved inside the save (`0x490eed`), which can run its script; the +/// flags are read after that (`0x490f93`, `0x491099`), so what the script set is what is written. +#[test] +fn the_save_reads_the_flags_after_the_rect() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + Late = CreateFrame("Frame", "Late") + Late:SetMovable(true); Late:SetUserPlaced(true) + Late:SetScript("OnSizeChanged", function() this:SetResizable(true) end) + Late:SetPoint("TOPLEFT", 40, -60); Late:SetWidth(70); Late:SetHeight(30) + "#, + ) + .unwrap(); + let saved = s.user_placed_layouts(); + assert_eq!( + (saved[0].x, saved[0].y, saved[0].width, saved[0].height), + (Some(40), Some(-60), Some(70), Some(30)), + "resized by its own OnSizeChanged inside the save, so W and H are written" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} diff --git a/crates/benilla-ui/src/script/tests/hover_walk.rs b/crates/benilla-ui/src/script/tests/hover_walk.rs index 65deda38..dadcd292 100644 --- a/crates/benilla-ui/src/script/tests/hover_walk.rs +++ b/crates/benilla-ui/src/script/tests/hover_walk.rs @@ -288,7 +288,6 @@ fn a_drag_cut_short_by_the_pointer_leaving_keeps_the_last_real_position() { .find(|l| l.name == "Here") .expect("Here is saved"); // Saved as the drag's TOPLEFT pin (`0x768430`): top 210 on a 768 screen. - assert_eq!(here.points[0].point, "TOPLEFT"); - assert_eq!((here.points[0].x, here.points[0].y), (130.0, -558.0)); + assert_eq!((here.x, here.y), (Some(130), Some(-558))); assert!(s.errors().is_empty(), "{:?}", s.errors()); } diff --git a/crates/benilla-ui/src/script/tests/movable.rs b/crates/benilla-ui/src/script/tests/movable.rs index 723ce8cd..419a4797 100644 --- a/crates/benilla-ui/src/script/tests/movable.rs +++ b/crates/benilla-ui/src/script/tests/movable.rs @@ -608,19 +608,15 @@ fn the_write_filter_is_user_placed_and_movable_or_resizable() { /// neither flag is left alone. #[test] fn the_apply_seats_position_behind_movable_and_size_behind_resizable() { - use crate::script::{FrameLayout, LayoutPoint}; + use crate::script::FrameLayout; let row = |name: &str| FrameLayout { name: name.to_owned(), - width: 200.0, - height: 80.0, - points: vec![LayoutPoint { - point: "BOTTOMLEFT".into(), - relative_to: None, - relative_point: "BOTTOMLEFT".into(), - x: 300.0, - y: 200.0, - }], + level: None, + x: Some(300), + y: Some(-200), + width: Some(200), + height: Some(80), }; // Three frames, one per flag state, all authored identically. let mut s = script(); @@ -672,6 +668,42 @@ fn the_apply_seats_position_behind_movable_and_size_behind_resizable() { assert!(s.errors().is_empty(), "{:?}", s.errors()); } +/// The apply sets a saved level of zero or more whatever the flags (`0x4905ee`–`0x4905f5`), and +/// takes a zero `X` and `Y` for no position (`0x490607`–`0x490613`), so the frame keeps its anchors. +#[test] +fn the_apply_sets_the_level_and_reads_a_zero_position_as_none() { + use crate::script::FrameLayout; + + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + F = CreateFrame("Frame", "Corner") + F:SetPoint("BOTTOMLEFT", 10, 10); F:SetWidth(50); F:SetHeight(50) + F:SetMovable(true) + "#, + ) + .unwrap(); + s.restore_user_placed_layouts([FrameLayout { + name: "Corner".into(), + level: Some(6), + x: Some(0), + y: Some(0), + width: None, + height: None, + }]); + s.resolve(); + assert_eq!( + s.eval::<(f64, i64, bool)>( + "return Corner:GetLeft(), Corner:GetFrameLevel(), Corner:IsUserPlaced()" + ) + .unwrap(), + (10.0, 6, false), + "levelled, but left on its anchors and unstamped" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + // ── The drag slot is taken, never refused (`0x7652b0`) ────────────────────────────────────────── /// A title-region frame `A` at (100, 100) 200×80 and a movable `B` at (500, 100), on 800×600. diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index d355d648..6e18c3f6 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -233,6 +233,13 @@ pub struct Frame { next_decl: u32, } +impl Frame { + /// Its place on the list of every live frame (`root+0xcc4`), creation order. + pub(crate) fn created(&self) -> u64 { + self.created + } +} + /// A region leaf: a texture or font string owned by one frame. #[derive(Clone, Debug, PartialEq)] pub struct Region {