Layout file saves the rect; bubbles re-sort on change; resize Deviation (#981)

- **The layout file** now saves what the reference's writer does (`0x490e60`): `X`, `Y`, `W`, `H` and `FrameLevel` from the frame's held rect, rows in creation order, and only frames that are user-placed and movable or resizable. The restore (`0x4905e0`) pins TOPLEFT to the screen, sets the size and level, and resolves each frame as it goes (`0x490710`). Old anchor-form files still load and are rewritten once.
- **Chat bubbles** re-rank only on a camera move or a new line (`0x4b11ff`), and a repeat speaker moves to the tail, as `0x4b1870` links it.
- **The live re-layout on a window resize** gets its `Deviation:` line. 1.12.1 reloads the UI on an aspect change of 0.001 or more (`0x492f26`).

Tests cover parent/child level restore, the held rect, the hidden-frame resolve, the junk lines, the migration and the bubble tie order.

Fixes #954
Fixes #955
Fixes #847

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-12 12:26:11 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 8ccaa7b411
commit 0c58d85cf9
14 changed files with 948 additions and 245 deletions
+145 -35
View File
@@ -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<f32>,
}
/// 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<u64, Bubble>,
/// The stacking, farthest first (the list at `0xb6f038`); the index is the frame level.
stack: Vec<u64>,
/// The camera position the stack was last sorted from (`0xb6eff0`, held once `0xb6effc` is
/// set).
eye: Option<Vec3>,
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<Vec3>, pos: impl Fn(u64) -> Option<Vec3>) {
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<u64> = 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<_>>(),
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<u64, Vec3> = 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() {
+274 -91
View File
@@ -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/<ACC>/<REALM>/<CHAR>/layout-cache.txt` with `Frame:`,
//! `FrameLevel:`, `X:`, `Y:`, `W:` and `H:` lines; ours is
//! `benilla-config/layout/<realm>-<character>.txt`, keeping `Frame:`, `W:` and `H:`.
//! Deviation: one `Point: <point> <relativeTo> <relativePoint> <x> <y>` 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/<ACC>/<REALM>/<CHAR>/layout-cache.txt`; ours is
//! `benilla-config/layout/<realm>-<character>.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<std::time::Instant>,
}
/// 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<FrameLayout> {
/// What a layout file holds.
#[derive(Debug, PartialEq)]
pub(crate) enum Layout {
/// The reference's rect form.
Rect(Vec<FrameLayout>),
/// benilla's earlier anchor form, which carries `Point:` lines.
Anchors(Vec<AnchorLayout>),
}
/// 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<FrameLayout> = 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<Item = (&str, &str)> {
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: <point> <relativeTo> <relativePoint> <x> <y>` line
/// per anchor, `-` for the screen root. A malformed `Point:` costs only its own line.
fn parse_anchor_form(text: &str) -> Vec<AnchorLayout> {
let mut out: Vec<AnchorLayout> = 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<FrameLayout> {
} 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::<f32>(), f[4].parse::<f32>()) 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<FrameLayout> {
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());
}
}
@@ -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"
@@ -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;
@@ -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::<benilla_ui::script::UiScript>()
@@ -1045,12 +1045,7 @@ fn layout_cache(character: &str) -> Option<String> {
/// 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::<crate::ui_layout::LayoutFile>();
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::<benilla_ui::script::UiScript>()
.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")
}
@@ -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/<realm>-<character>.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());
}
+3 -1
View File
@@ -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
+196 -64
View File
@@ -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<u16>,
/// The rect's left, against the screen's left (`X:`), written for a movable frame
/// (`0x490f99`).
pub x: Option<i32>,
/// The rect's top, against the screen's top (`Y:`), so zero or below.
pub y: Option<i32>,
/// The rect's width (`W:`), written for a resizable frame (`0x49109f`).
pub width: Option<i32>,
/// The rect's height (`H:`).
pub height: Option<i32>,
}
/// 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<LayoutPoint>,
}
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<FrameLayout> {
let model = self.model_ref();
let mut out: Vec<FrameLayout> = 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::<Option<Vec<_>>>()?;
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<Item = FrameLayout>) {
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<FrameHandle> {
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<Item = AnchorLayout>) {
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<LayoutPoint> {
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]);
}
}
+1 -1
View File
@@ -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};
+1 -1
View File
@@ -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};
+201 -13
View File
@@ -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::<i64>("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::<Vec<_>>(),
["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::<i64>("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());
}
@@ -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());
}
+42 -10
View File
@@ -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.
+7
View File
@@ -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 {