Files
benilla-octo/crates/benilla-ui/src/layout.rs
T
SamandClaude Fable 5 21e3fc4f06 the performance hunt, day one: the frame sheds 1.85 ms
- UI layout solves what moved, not what exists — hovering a full bag no longer re-solves the whole interface
- world albedo textures stop keeping a second copy of themselves in RAM
- collision gets a coarse grid for oversized slabs (32x fewer triangles tested), idle units stop re-asking the ground every frame, and anchors spawn on first consumer instead of by the thousand
- aura and loot watchers scan when the store changes rather than on the clock, and static-transform tracking and plate reaping join the sweep

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 17:49:54 +07:00

1510 lines
59 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The frame anchor/layout resolver — anchor graph → resolved rects (decision 0068).
//!
//! This is a faithful transcription of the WoW 1.12.1 client's `CLayoutFrame` geometry resolver,
//! whose math is reverse-engineered bit-exact against `WoW.exe` in the sibling repo `wow-5875-re`
//! (`system/ui/scratch/layout.md`, `.../rf32-composed-resolver.md`, `.../frame-model.md`,
//! `.../propagation.md`). The leaf float kernels and the recursion-guarded per-edge composition are
//! transcribed from that repo's own verified `w5875-ui::layout` crate
//! (`wow-5875-re/crates/ui/src/layout.rs`), which is gated bit-exact under a Unicorn/`WoW.exe`
//! difftest harness (per-function diffs + the composed `assemble` over 6006 synthesized cases).
//! Matching that crate's operations and their order — not "improving" the arithmetic — is the goal.
//!
//! ## Precision — why `f64` intermediates, not naive `f32`
//!
//! The client is an x87 binary: every intermediate lives in an FPU register at PC_53 (53-bit
//! mantissa = IEEE `f64` precision) and is narrowed to `f32` *only* at the documented `fstp m32`
//! store sites (each sub-edge result before a `combine_*`; each final edge into the out-rect; the
//! `width·scale` span). A leaf like `(right + left) * 0.5 + xOff·scale` therefore rounds **once**
//! (at the final store), whereas doing every operation in `f32` would round at each step and
//! diverge. So "the client is f32" is true only at the storage boundaries: to be bit-faithful the
//! chain of `+`/`-`/`*0.5` runs in `f64` (host-exact — every operand is `f32`-derived, so each op is
//! exact in `f64`) with an explicit `as f32` at exactly the sites the RE pins. This mirrors
//! `w5875-ui::layout` verbatim.
//!
//! ## Coordinate space
//!
//! Rects are stored in the client's own convention: `[bottom, left, top, right]` (frame-absolute
//! `+0x64/+0x68/+0x6c/+0x70`; VERIFIED via `GetRect 0x768320` + the Lua `GetBottom/Left/Top/Right`
//! readers, `layout.md`). WoW UI space is **y-up**: `top > bottom`, the screen root's rect is the
//! physical screen (the anchor chain bottoms out at the `CSimpleTop` root), and `width = right −
//! left`, `height = top − bottom`. Coordinates are screen pixels throughout; `scale` (layoutScale =
//! effective scale, `propagation.md`) scales only the per-anchor offsets and the `width/height` span.
use std::collections::VecDeque;
// ─────────────────────────────────────────────────────────────────────────────────────────────
// Constants (objdump-VERIFIED in layout.md "Constants")
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// UNSET coordinate sentinel: `+Infinity` (the `.data` slot `0xcf550c`, runtime-initialized from
/// `[0x81d56c] = 0x7f800000`, `layout.md`). "coordinate is set" ⇔ `x != +Inf`. Intermediates carry
/// this in `f64`; the `f32` narrowing preserves it (`+Inf as f32 == +Inf`).
const UNSET: f64 = f64::INFINITY;
/// `OnSizeChanged` trigger ε — `_DAT_008029d4` = `0x34800000` ≈ 2.384e-7 (`layout.md` Constants;
/// applied in `ApplyRect 0x76b580`). `OnSizeChanged(width,height)` fires iff `|Δwidth| ≥ ε` OR
/// `|Δheight| ≥ ε` between the old and new resolved rect. See [`size_changed`].
pub const SIZE_EPS: f64 = 2.384_185_791_015_625e-7;
/// Recompute change threshold ε — `_DAT_0080c5c8` = `0x3727c5ac` ≈ 9.96e-6 (`layout.md`; applied in
/// `recompute 0x768d20`): an edge must move at least this far vs the cached rect for a re-store +
/// `ApplyRect`. Benilla owns its own caching (the caller's dirty gate); this const records the client's
/// documented threshold for fidelity work that needs it.
pub const RESOLVE_EPS: f64 = f32::from_bits(0x3727_c5ac) as f64;
/// point-id → X column (VERIFIED table `0x81c3b8`.., `layout.md` "Point-id → coordinate"):
/// `{0,3,6}` (LEFT column) → 0, `{1,4,7}` (center) → 1, `{2,5,8}` (RIGHT column) → 2.
const X_COL: [u8; 9] = [0, 1, 2, 0, 1, 2, 0, 1, 2];
/// point-id → Y row: `{0,1,2}` (TOP row) → 0, `{3,4,5}` (center) → 1, `{6,7,8}` (BOTTOM row) → 2.
const Y_ROW: [u8; 9] = [0, 0, 0, 1, 1, 1, 2, 2, 2];
// ─────────────────────────────────────────────────────────────────────────────────────────────
// Public types
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// The nine anchor points, with the client's exact discriminants (VERIFIED static tables
/// `0x81c3b8–0x81c3fc`, `layout.md`): the id is the slot index in the client's `anchorPoints[9]`.
#[repr(u8)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Point {
TopLeft = 0,
Top = 1,
TopRight = 2,
Left = 3,
Center = 4,
Right = 5,
BottomLeft = 6,
Bottom = 7,
BottomRight = 8,
}
impl Point {
/// The point-id (0..=8), which is also the `anchorPoints[9]` slot index.
#[inline]
pub const fn id(self) -> u8 {
self as u8
}
/// Build a [`Point`] from its client id, `None` if out of range.
pub const fn from_id(id: u8) -> Option<Point> {
Some(match id {
0 => Point::TopLeft,
1 => Point::Top,
2 => Point::TopRight,
3 => Point::Left,
4 => Point::Center,
5 => Point::Right,
6 => Point::BottomLeft,
7 => Point::Bottom,
8 => Point::BottomRight,
_ => return None,
})
}
}
/// An opaque handle to an anchor target. This crate does not own the frame arena; the caller maps
/// handles to frames / external rects. (In the client this is the `CAnchor+0xc relativeTo` pointer.)
pub type Handle = u32;
/// A resolved rect in the client's `[bottom, left, top, right]` convention (screen pixels, y-up).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Rect {
pub bottom: f32,
pub left: f32,
pub top: f32,
pub right: f32,
}
impl Rect {
/// Construct from the four edges in client order.
pub const fn new(bottom: f32, left: f32, top: f32, right: f32) -> Rect {
Rect {
bottom,
left,
top,
right,
}
}
/// `[bottom, left, top, right]` — the client's cached-rect layout (`+0x64..0x70`).
#[inline]
pub const fn to_array(self) -> [f32; 4] {
[self.bottom, self.left, self.top, self.right]
}
/// From `[bottom, left, top, right]`.
#[inline]
pub const fn from_array(a: [f32; 4]) -> Rect {
Rect::new(a[0], a[1], a[2], a[3])
}
/// `right − left` (`GetWidth` semantics, `layout.md`).
#[inline]
pub fn width(self) -> f32 {
self.right - self.left
}
/// `top − bottom` (`GetHeight` semantics; y-up).
#[inline]
pub fn height(self) -> f32 {
self.top - self.bottom
}
}
/// One anchor of a frame — a `CAnchor` record (`{xOff@4, yOff@8, relativeTo@0xc, relativePoint@0x10}`,
/// `frame-model.md`). `point` is the point *on this frame* being pinned (its `anchorPoints[9]` slot);
/// `relative_point` is the point *on the target* it pins to.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Anchor {
/// The point on *this* frame that is being anchored (its `anchorPoints[9]` slot index).
pub point: Point,
/// The target frame/region this anchor is relative to (`CAnchor+0xc`).
pub relative_to: Handle,
/// The point on the *target* this anchor pins to (`CAnchor+0x10 relativePoint`).
pub relative_point: Point,
/// X offset (`CAnchor+4`), scaled by the requesting frame's `scale`.
pub x_off: f32,
/// Y offset (`CAnchor+8`), scaled by `scale`.
pub y_off: f32,
}
impl Anchor {
/// Convenience constructor.
pub fn new(
point: Point,
relative_to: Handle,
relative_point: Point,
x_off: f32,
y_off: f32,
) -> Anchor {
Anchor {
point,
relative_to,
relative_point,
x_off,
y_off,
}
}
}
/// A frame's full layout input: its anchors, its optional explicit size, its scale, and the
/// clamp-to-screen flag + extents. Mirrors the client's geometry subobject fields consulted by the
/// resolver (`frame-model.md`, `rf32-composed-resolver.md`): `anchorPoints[9]` (`G+0x28`), `width`
/// (`G+0x50`), `height` (`G+0x54`), `layoutScale` (`G+0x58`), flags bit4 = clamp (`G+0x3c`).
#[derive(Clone, Debug, PartialEq)]
pub struct LayoutInput {
/// The frame's anchors. Placed into the nine `anchorPoints` slots by their `point`; if two share
/// a `point`, the *last* wins (the client's `SetPoint 0x767c70` overwrites the slot).
pub anchors: Vec<Anchor>,
/// Explicit width (`G+0x50`); **`0.0` ⇒ derive from opposing anchors** (the no-explicit-size
/// sentinel `DAT_007ffd74`, `layout.md`).
pub width: f32,
/// Explicit height (`G+0x54`); `0.0` ⇒ derive from opposing anchors.
pub height: f32,
/// `layoutScale` = effective scale (`parentScale · ownScale`, ε-gated, `propagation.md`); the
/// px↔coord multiplier applied to offsets and the size span. Default `1.0`.
pub scale: f32,
/// Clamp the assembled rect into `[0, extent]` per axis (`G` flags bit4, `layout.md` Assemble).
pub clamp: bool,
/// Screen X extent, only consulted when `clamp` (client `0x41ae60` return, default `0.8`).
pub extent_x: f32,
/// Screen Y extent, only consulted when `clamp` (client `0x41ae70` return, default `0.6`).
pub extent_y: f32,
}
impl Default for LayoutInput {
fn default() -> LayoutInput {
LayoutInput {
anchors: Vec::new(),
width: 0.0,
height: 0.0,
scale: 1.0,
clamp: false,
extent_x: 0.8,
extent_y: 0.6,
}
}
}
impl LayoutInput {
/// A frame with the given anchors and explicit size, scale `1.0`, no clamp.
pub fn sized(anchors: Vec<Anchor>, width: f32, height: f32) -> LayoutInput {
LayoutInput {
anchors,
width,
height,
..LayoutInput::default()
}
}
}
/// The outcome of resolving one frame's rect.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ResolveOutcome {
/// Every edge resolved → the cached rect `[bottom, left, top, right]` (`assemble` returned 1).
Resolved(Rect),
/// At least one edge stayed `+Inf` — under-constrained (`assemble 0x767a20` returns 0, caller
/// sets flags bit3 "resolution-failed").
Unresolvable,
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// Leaf float kernels — the owned-fidelity math (layout.md). Transcribed from w5875-ui::layout,
// binary-diffed. Each returns its coordinate in `f64` (the un-narrowed x87 `st0` observable);
// callers narrow to `f32` at the documented store sites.
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// `combineEdge 0x767440(center, opp, span)` (`layout.md`): resolve one edge from the opposite
/// edge, the axis center, and the signed `size·scale` span, in that fallback order.
///
/// - opposite known and non-zero span → `opp + span` (edge = opposite ± size·scale);
/// - else center known: non-zero span → `span·0.5 + center` (edge = center ± half-size);
/// else opposite known → `(center − opp) + center` (= 2·center − opposite, the mirror);
/// - else → `+Inf` (unresolvable).
///
/// The `+Inf`/`0.0` sentinels are compared with IEEE equality, matching the binary's `fcomp`
/// against the `.data` slots (a NaN operand is unordered ⇒ "not equal to the sentinel").
#[allow(clippy::float_cmp)]
pub fn combine_edge(center: f32, opp: f32, span: f32) -> f64 {
let (c, o, s) = (f64::from(center), f64::from(opp), f64::from(span));
if o != UNSET && s != 0.0 {
return o + s;
}
if c != UNSET {
if s != 0.0 {
return s * 0.5 + c;
}
if o != UNSET {
return (c - o) + c;
}
}
UNSET
}
/// `combineCenter 0x7672d0(lo, hi, span)` (`layout.md`): resolve an axis center from the low edge,
/// the high edge, and the signed `size·scale` span.
///
/// - low known: high known → `(lo+hi)·0.5`; else non-zero span → `span·0.5 + lo`;
/// - else high known and non-zero span → `hi − span·0.5`;
/// - else → `+Inf`.
#[allow(clippy::float_cmp)]
pub fn combine_center(lo: f32, hi: f32, span: f32) -> f64 {
let (l, h, s) = (f64::from(lo), f64::from(hi), f64::from(span));
if l != UNSET {
if h != UNSET {
return (l + h) * 0.5;
}
if s != 0.0 {
return s * 0.5 + l;
}
}
if h != UNSET && s != 0.0 {
return h - s * 0.5;
}
UNSET
}
/// `CAnchor::ResolveX 0x7a2f90(scale)` (`layout.md`): resolve this anchor's X coordinate against its
/// `relativeTo`'s cached rect.
///
/// `rect` = the target's cached rect `[bottom, left, top, right]` when `GetRect` succeeded (cache
/// valid), `None` when it failed ⇒ `+Inf`. `mirror` = the target's geometry-vtable `+0x24`
/// normalize predicate (RTL/local-origin): when set, X edges are translated so `left → 0`,
/// `right → right − left` (each stored back as `f32`, the binary's `fstp m32`). For an ordinary
/// frame the predicate returns 0, so `mirror` is `false` (VERIFIED `0x46ff60 = xor eax,eax; ret`,
/// `rf32-composed-resolver.md`). `coord = xOff·scale + edge` for the relativePoint's column
/// (center = mid-X + xOff·scale). `rel_point > 8` ⇒ `+Inf`.
pub fn anchor_resolve_x(
scale: f32,
x_off: f32,
rel_point: u32,
rect: Option<[f32; 4]>,
mirror: bool,
) -> f64 {
let Some([_bottom, mut left, _top, mut right]) = rect else {
return UNSET;
};
if mirror {
right = (-f64::from(left) + f64::from(right)) as f32;
left = 0.0;
}
if rel_point > 8 {
return UNSET;
}
let (s, xo) = (f64::from(scale), f64::from(x_off));
let (l, r) = (f64::from(left), f64::from(right));
match X_COL[rel_point as usize] {
0 => s * xo + l, // relLeft
1 => (r + l) * 0.5 + s * xo, // center
_ => s * xo + r, // relRight
}
}
/// `CAnchor::ResolveY 0x7a3070(scale)` — the Y twin of [`anchor_resolve_x`] (`layout.md`). `mirror`
/// normalizes the Y edges (`bottom → 0`, `top → top − bottom`); `coord = yOff·scale + edge` for the
/// relativePoint's row (center = mid-Y + yOff·scale). `rect` = `[bottom, left, top, right]`.
pub fn anchor_resolve_y(
scale: f32,
y_off: f32,
rel_point: u32,
rect: Option<[f32; 4]>,
mirror: bool,
) -> f64 {
let Some([mut bottom, _left, mut top, _right]) = rect else {
return UNSET;
};
if mirror {
top = (-f64::from(bottom) + f64::from(top)) as f32;
bottom = 0.0;
}
if rel_point > 8 {
return UNSET;
}
let (s, yo) = (f64::from(scale), f64::from(y_off));
let (b, t) = (f64::from(bottom), f64::from(top));
match Y_ROW[rel_point as usize] {
0 => s * yo + t, // relTop
1 => (t + b) * 0.5 + s * yo, // center
_ => s * yo + b, // relBottom
}
}
/// The assembled (and possibly clamped) rect + whether every edge resolved.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AssembleRect {
pub ok: bool,
/// `[bottom, left, top, right]` (meaningful only when `ok`).
pub rect: [f32; 4],
}
/// `assemble 0x767a20` (`layout.md`): given the four resolved edge coordinates `[bottom, left, top,
/// right]` (each `+Inf` ⇒ unresolvable), fail if any is `+Inf`; else optionally clamp into
/// `[0, extent]` per axis.
///
/// Clamp (geom flags bit4): a low edge `< 0` shifts to 0 carrying the span to the high edge; a high
/// edge past the screen extent shifts back carrying to the low edge, high edge = extent. Each store
/// is narrowed to `f32`.
#[allow(clippy::float_cmp)]
pub fn assemble_rect(edges: [f32; 4], clamp: bool, extent_x: f32, extent_y: f32) -> AssembleRect {
let [mut bottom, mut left, mut top, mut right] = edges;
let inf = f32::INFINITY;
if bottom == inf || left == inf || top == inf || right == inf {
return AssembleRect {
ok: false,
rect: edges,
};
}
if clamp {
if (left as f64) < 0.0 {
right = (f64::from(right) - f64::from(left)) as f32;
left = 0.0;
}
if (bottom as f64) < 0.0 {
top = (f64::from(top) - f64::from(bottom)) as f32;
bottom = 0.0;
}
if (right as f64) > (extent_x as f64) {
let over = f64::from(right) - f64::from(extent_x);
left = (f64::from(left) - over) as f32;
right = extent_x;
}
if (top as f64) > (extent_y as f64) {
let over = f64::from(top) - f64::from(extent_y);
bottom = (f64::from(bottom) - over) as f32;
top = extent_y;
}
}
AssembleRect {
ok: true,
rect: [bottom, left, top, right],
}
}
/// `OnSizeChanged` fire test (`ApplyRect 0x76b580`, `layout.md`): fires iff `|Δwidth| ≥ [`SIZE_EPS`]`
/// OR `|Δheight| ≥ SIZE_EPS`, with `width = right − left`, `height = top − bottom`. Deltas are `f64`
/// of the `f32` edges (the binary's `fsub` of the `fld`'d rect components).
pub fn size_changed(old: Rect, new: Rect) -> bool {
let dw =
(f64::from(new.right) - f64::from(new.left)) - (f64::from(old.right) - f64::from(old.left));
let dh =
(f64::from(new.top) - f64::from(new.bottom)) - (f64::from(old.top) - f64::from(old.bottom));
dw.abs() >= SIZE_EPS || dh.abs() >= SIZE_EPS
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The composed resolver — recursion-guarded per-edge sequencing over the leaves above.
// Transcribed from w5875-ui::layout (RF-0032, rf32-composed-resolver.md): bit-exact over the
// binary's `assemble 0x767a20` → six per-edge resolvers → anchor scans → leaf kernels. Adds NO new
// arithmetic — only integer control flow + the documented `fstp m32` `f32` narrowing at each site.
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// A resolved anchor for the internal edge tree: like [`Anchor`] but with the target's rect already
/// looked up (`None` ⇒ target had no valid rect ⇒ `GetRect`-fail leg).
#[derive(Clone, Copy)]
struct AnchorRel {
x_off: f32,
y_off: f32,
rel_point: u32,
rel_rect: Option<[f32; 4]>,
mirror: bool,
}
/// Internal per-edge resolver input: the nine `anchorPoints` slots (by point-id) with rects
/// resolved, plus the size/scale/clamp fields.
struct EdgeInput {
anchors: [Option<AnchorRel>; 9],
width: f32,
height: f32,
scale: f32,
clamp: bool,
extent_x: f32,
extent_y: f32,
}
// recursion-guard bits (the binary's `G+0x28` bitfield, layout.md).
const G_LEFT: u32 = 0x01;
const G_TOP: u32 = 0x02;
const G_RIGHT: u32 = 0x04;
const G_BOTTOM: u32 = 0x08;
const G_XC: u32 = 0x10;
const G_YC: u32 = 0x20;
// per-edge point-id scan tables (`.rdata` 0x81c3b8..0x81c3f4, rf32-composed-resolver.md).
const IDS_LEFT: [usize; 3] = [0, 3, 6];
const IDS_RIGHT: [usize; 3] = [2, 5, 8];
const IDS_TOP: [usize; 3] = [0, 1, 2];
const IDS_BOTTOM: [usize; 3] = [6, 7, 8];
const IDS_XC: [usize; 3] = [1, 4, 7];
const IDS_YC: [usize; 3] = [3, 4, 5];
/// `anchorScanX 0x7671a0`: over the edge's three point-ids, the first present anchor whose
/// `ResolveX` is non-`+Inf` wins; else `+Inf`.
#[allow(clippy::float_cmp)]
fn anchor_scan_x(ids: &[usize; 3], f: &EdgeInput) -> f64 {
for &id in ids {
if let Some(a) = f.anchors[id] {
let v = anchor_resolve_x(f.scale, a.x_off, a.rel_point, a.rel_rect, a.mirror);
if v != UNSET {
return v;
}
}
}
UNSET
}
/// `anchorScanY 0x7671f0` — the Y twin.
#[allow(clippy::float_cmp)]
fn anchor_scan_y(ids: &[usize; 3], f: &EdgeInput) -> f64 {
for &id in ids {
if let Some(a) = f.anchors[id] {
let v = anchor_resolve_y(f.scale, a.y_off, a.rel_point, a.rel_rect, a.mirror);
if v != UNSET {
return v;
}
}
}
UNSET
}
/// `width·scale` / `height·scale` — the `layout_767_span` PRIMITIVE (`rf32-composed-resolver.md`):
/// operands are `f32` fields, the product narrowed to `f32` at the `fstp [esp]` site, negated for
/// LEFT/BOTTOM.
///
/// **`size` is not always the authored field, and this is the trap** (decision 1349, wow-re
/// `region-size-fallback.md`): each caller's `call [eax+0x1c]` is **virtual**. A plain frame lands on
/// `0x768420 fld [G+0x50]` — the flat read this signature assumes — but a `CSimpleTexture` lands on
/// `0x770720` and a `CSimpleFontString` on `0x772930`, both of which substitute a **content-derived**
/// extent when the authored value is exactly `0.0` (the texture's own texel size, one texel per
/// FrameXML unit; the string's measured text, floored at one unit). benilla feeds this the authored
/// number only, so a zero-authored *texture* axis yields a zero span here where the client yields a
/// real one. 1349 §4 carries the change and its scope; do not read this kernel as the whole law.
fn size_span(size: f32, scale: f32, negate: bool) -> f32 {
let span = (f64::from(size) * f64::from(scale)) as f32;
if negate {
-span
} else {
span
}
}
/// `LEFT 0x7673d0` — direct anchor scan, else `combineEdge(Xcenter, RIGHT, −width·scale)`.
#[allow(clippy::float_cmp)]
fn resolve_left(f: &EdgeInput, guard: &mut u32) -> f64 {
if *guard & G_LEFT != 0 {
return UNSET;
}
*guard |= G_LEFT;
let v = anchor_scan_x(&IDS_LEFT, f);
let r = if v != UNSET {
v
} else {
let span = size_span(f.width, f.scale, true);
let opp = resolve_right(f, guard) as f32;
let center = resolve_xcenter(f, guard) as f32;
combine_edge(center, opp, span)
};
*guard &= !G_LEFT;
r
}
/// `RIGHT 0x767540` — direct anchor scan, else `combineEdge(Xcenter, LEFT, +width·scale)`.
#[allow(clippy::float_cmp)]
fn resolve_right(f: &EdgeInput, guard: &mut u32) -> f64 {
if *guard & G_RIGHT != 0 {
return UNSET;
}
*guard |= G_RIGHT;
let v = anchor_scan_x(&IDS_RIGHT, f);
let r = if v != UNSET {
v
} else {
let span = size_span(f.width, f.scale, false);
let opp = resolve_left(f, guard) as f32;
let center = resolve_xcenter(f, guard) as f32;
combine_edge(center, opp, span)
};
*guard &= !G_RIGHT;
r
}
/// `TOP 0x7674d0` — direct anchor scan, else `combineEdge(Ycenter, BOTTOM, +height·scale)`.
#[allow(clippy::float_cmp)]
fn resolve_top(f: &EdgeInput, guard: &mut u32) -> f64 {
if *guard & G_TOP != 0 {
return UNSET;
}
*guard |= G_TOP;
let v = anchor_scan_y(&IDS_TOP, f);
let r = if v != UNSET {
v
} else {
let span = size_span(f.height, f.scale, false);
let opp = resolve_bottom(f, guard) as f32;
let center = resolve_ycenter(f, guard) as f32;
combine_edge(center, opp, span)
};
*guard &= !G_TOP;
r
}
/// `BOTTOM 0x7675b0` — direct anchor scan, else `combineEdge(Ycenter, TOP, −height·scale)`.
#[allow(clippy::float_cmp)]
fn resolve_bottom(f: &EdgeInput, guard: &mut u32) -> f64 {
if *guard & G_BOTTOM != 0 {
return UNSET;
}
*guard |= G_BOTTOM;
let v = anchor_scan_y(&IDS_BOTTOM, f);
let r = if v != UNSET {
v
} else {
let span = size_span(f.height, f.scale, true);
let opp = resolve_top(f, guard) as f32;
let center = resolve_ycenter(f, guard) as f32;
combine_edge(center, opp, span)
};
*guard &= !G_BOTTOM;
r
}
/// `Xcenter 0x767260` — direct anchor scan, else `combineCenter(LEFT, RIGHT, +width·scale)`.
#[allow(clippy::float_cmp)]
fn resolve_xcenter(f: &EdgeInput, guard: &mut u32) -> f64 {
if *guard & G_XC != 0 {
return UNSET;
}
*guard |= G_XC;
let v = anchor_scan_x(&IDS_XC, f);
let r = if v != UNSET {
v
} else {
let span = size_span(f.width, f.scale, false);
let lo = resolve_left(f, guard) as f32;
let hi = resolve_right(f, guard) as f32;
combine_center(lo, hi, span)
};
*guard &= !G_XC;
r
}
/// `Ycenter 0x767360` — direct anchor scan, else `combineCenter(BOTTOM, TOP, +height·scale)`.
#[allow(clippy::float_cmp)]
fn resolve_ycenter(f: &EdgeInput, guard: &mut u32) -> f64 {
if *guard & G_YC != 0 {
return UNSET;
}
*guard |= G_YC;
let v = anchor_scan_y(&IDS_YC, f);
let r = if v != UNSET {
v
} else {
let span = size_span(f.height, f.scale, false);
let lo = resolve_bottom(f, guard) as f32;
let hi = resolve_top(f, guard) as f32;
combine_center(lo, hi, span)
};
*guard &= !G_YC;
r
}
/// `assemble 0x767a20` composed: resolve all four edges (each `fst`'d to the `f32` out-rect),
/// returning [`ResolveOutcome::Unresolvable`] if any stayed `+Inf`, else the assembled (and, when
/// `clamp`, screen-clamped) rect. The four top-level resolves share a fresh guard; the binary
/// clears each edge's bit on return, so order is immaterial — we keep the binary's LEFT→BOTTOM→
/// RIGHT→TOP order. The `+Inf` test is on the un-narrowed `f64` edge (the binary's `fcomp` before
/// the `fst`).
#[allow(clippy::float_cmp)]
fn resolve_edges(f: &EdgeInput) -> ResolveOutcome {
let mut guard = 0u32;
let left = resolve_left(f, &mut guard);
let bottom = resolve_bottom(f, &mut guard);
let right = resolve_right(f, &mut guard);
let top = resolve_top(f, &mut guard);
if left == UNSET || bottom == UNSET || right == UNSET || top == UNSET {
return ResolveOutcome::Unresolvable;
}
let edges = [bottom as f32, left as f32, top as f32, right as f32];
let a = assemble_rect(edges, f.clamp, f.extent_x, f.extent_y);
ResolveOutcome::Resolved(Rect::from_array(a.rect))
}
/// Resolve one frame's rect. `target_rect` supplies each anchor target's already-resolved rect
/// (`None` ⇒ the target has no valid rect ⇒ that anchor contributes `+Inf`, the `GetRect`-fail
/// leg). Anchors are placed into the nine `anchorPoints` slots by their `point`; on a duplicate the
/// later anchor wins (the client's `SetPoint` overwrite).
///
/// This is the fidelity surface: it builds the client's `anchorPoints[9]` view and runs the
/// verified composed `assemble` (`rf32-composed-resolver.md`).
pub fn resolve_rect(
frame: &LayoutInput,
target_rect: impl FnMut(Handle) -> Option<Rect>,
) -> ResolveOutcome {
resolve_edges(&build_edge_input(frame, target_rect))
}
/// Build the client's `anchorPoints[9]` view + size/scale/clamp fields for `frame`, resolving each
/// anchor target's rect through `target_rect`. Shared by [`resolve_rect`] (all-or-nothing) and
/// [`resolve_rect_edges`] (per-edge).
fn build_edge_input(
frame: &LayoutInput,
mut target_rect: impl FnMut(Handle) -> Option<Rect>,
) -> EdgeInput {
let mut slots: [Option<AnchorRel>; 9] = [None; 9];
for a in &frame.anchors {
slots[a.point.id() as usize] = Some(AnchorRel {
x_off: a.x_off,
y_off: a.y_off,
rel_point: a.relative_point.id() as u32,
rel_rect: target_rect(a.relative_to).map(Rect::to_array),
// Ordinary frames' normalize predicate returns 0 (rf32-composed-resolver.md); RTL/mirror
// frames are not modeled here (the leaf `anchor_resolve_*` covers that leg).
mirror: false,
});
}
EdgeInput {
anchors: slots,
width: frame.width,
height: frame.height,
scale: frame.scale,
clamp: frame.clamp,
extent_x: frame.extent_x,
extent_y: frame.extent_y,
}
}
/// Resolve one frame's four edges, returning each as `Some(edge)` when the anchor graph + explicit
/// size determined it, or `None` when it stayed `+Inf` (unset). The per-edge view **regions** need:
/// a region inherits any edge its anchors + size don't pin from its owner frame (decision 0068's v1
/// region model — see [`crate::script`]'s region resolution), so it must see *which* edges resolved,
/// not the all-or-nothing verdict [`resolve_rect`] gives. Order matches [`Rect`]: `[bottom, left,
/// top, right]`. No `assemble`/clamp is applied (regions never clamp to screen in v1).
#[allow(clippy::float_cmp)]
pub fn resolve_rect_edges(
frame: &LayoutInput,
target_rect: impl FnMut(Handle) -> Option<Rect>,
) -> [Option<f32>; 4] {
let input = build_edge_input(frame, target_rect);
let mut guard = 0u32;
let left = resolve_left(&input, &mut guard);
let bottom = resolve_bottom(&input, &mut guard);
let right = resolve_right(&input, &mut guard);
let top = resolve_top(&input, &mut guard);
let cvt = |v: f64| if v == UNSET { None } else { Some(v as f32) };
[cvt(bottom), cvt(left), cvt(top), cvt(right)]
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// Graph driver — resolve a set of frames in dependency order (anchor targets before dependents),
// detecting cycles. This is benilla's orchestration, not client fidelity math: the client resolves
// lazily-on-read + per-frame-batched via a dependency-ordered deferred queue (layout.md "Resolve /
// dirty / cache flow"); we express the same dependency ordering as an explicit topological pass.
//
// ## Why a dense, reusable solver rather than a fresh map-backed graph
//
// The caller re-solves the whole widget set every round of its fixpoint, and the fixpoint runs
// every frame. A map-backed graph rebuilt per round paid, per round: one `LayoutInput` clone
// (heap `Vec<Anchor>`) per frame, a `BTreeMap` insert per node, and a `BTreeMap` probe per anchor
// target during the solve. Measured on the shipped default UI (1881 frames + 5478 regions,
// release): 1.36 ms per resolve, of which ~0.25 ms was graph construction and ~0.64 ms the solve
// itself — paid on a completely quiet frame.
//
// [`Handle`]s are the caller's own dense id space (benilla's `Model::next_id`, monotonic from 1
// with frames and regions sharing the counter), so every per-node map collapses to an array index
// and every buffer can live across calls. `begin` clears only the slots the previous round
// touched, so a round costs O(nodes), never O(id space). Iteration order is kept **ascending by
// handle** — identical to the `BTreeMap` it replaces — because cycle members are resolved
// best-effort in that order and the result depends on it.
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// A reusable dependency solver over a set of anchored frames plus *external* pre-resolved rects
/// (the screen root — the client's `CSimpleTop`, whose rect is the physical screen — and any fixed
/// targets, such as already-resolved regions).
///
/// One solver is kept alive across frames so its buffers are reused. A round is:
/// [`begin`](Self::begin) → [`set_external`](Self::set_external) /
/// [`set_frame`](Self::set_frame) → [`solve`](Self::solve) → [`rect`](Self::rect).
#[derive(Clone, Debug, Default)]
pub struct LayoutSolver {
/// Per-handle layout input. Persistent storage: [`set_frame`](Self::set_frame) overwrites in
/// place so the anchors `Vec` keeps its allocation across rounds.
input: Vec<LayoutInput>,
/// Is `input[h]` a frame to solve this round?
is_frame: Vec<bool>,
/// Per-handle rect: externals as seeded, frames as they resolve. `None` = not (yet) known.
rect: Vec<Option<Rect>>,
/// Kahn in-degree, and the reverse edges (each inner `Vec` is cleared, never freed).
indeg: Vec<u32>,
dependents: Vec<Vec<Handle>>,
/// Handles whose slots were written this round — `begin` resets exactly these.
touched: Vec<Handle>,
/// Is `h` already in `touched`?
marked: Vec<bool>,
/// The frames to solve, ascending (the `BTreeMap` iteration order this replaces).
live: Vec<Handle>,
/// Kahn scratch + output.
queue: VecDeque<Handle>,
emitted: Vec<bool>,
order: Vec<Handle>,
cycle: Vec<Handle>,
unresolvable: Vec<Handle>,
}
impl LayoutSolver {
/// A new, empty solver.
pub fn new() -> LayoutSolver {
LayoutSolver::default()
}
/// Grow the per-handle arrays to cover `h`.
fn ensure(&mut self, h: Handle) {
let need = h as usize + 1;
if self.input.len() < need {
self.input.resize_with(need, LayoutInput::default);
self.is_frame.resize(need, false);
self.rect.resize(need, None);
self.indeg.resize(need, 0);
self.dependents.resize_with(need, Vec::new);
self.marked.resize(need, false);
self.emitted.resize(need, false);
}
}
/// Mark `h` as written this round (so [`begin`](Self::begin) will reset its slot).
fn touch(&mut self, h: Handle) {
let i = h as usize;
if !self.marked[i] {
self.marked[i] = true;
self.touched.push(h);
}
}
/// Start a round: reset every slot the previous round touched (O(nodes), not O(id space)).
pub fn begin(&mut self) {
for &h in &self.touched {
let i = h as usize;
self.is_frame[i] = false;
self.rect[i] = None;
self.indeg[i] = 0;
self.dependents[i].clear();
self.marked[i] = false;
self.emitted[i] = false;
}
self.touched.clear();
self.live.clear();
self.queue.clear();
self.order.clear();
self.cycle.clear();
self.unresolvable.clear();
}
/// Seed a pre-resolved rect at `h` — an anchor target this solve does not compute (the screen
/// root, an already-resolved region). Also used to publish a rect mid-sweep so later nodes in
/// the same pass see it.
pub fn set_external(&mut self, h: Handle, rect: Rect) {
self.ensure(h);
self.touch(h);
self.rect[h as usize] = Some(rect);
}
/// Register a frame to solve, copying `src` into the solver's persistent slot (the anchors
/// `Vec` is refilled in place, so a steady round allocates nothing).
pub fn set_frame(&mut self, h: Handle, src: &LayoutInput) {
self.ensure(h);
self.touch(h);
let dst = &mut self.input[h as usize];
dst.anchors.clear();
dst.anchors.extend_from_slice(&src.anchors);
Self::copy_fields(dst, src);
self.is_frame[h as usize] = true;
self.live.push(h);
}
/// [`set_frame`](Self::set_frame) with `src`'s anchors replaced by exactly `anchor` — the
/// ScrollFrame child override (decision 0112), which otherwise cost a full input clone plus a
/// one-element `Vec` per scroll child per round.
pub fn set_frame_anchored(&mut self, h: Handle, src: &LayoutInput, anchor: Anchor) {
self.ensure(h);
self.touch(h);
let dst = &mut self.input[h as usize];
dst.anchors.clear();
dst.anchors.push(anchor);
Self::copy_fields(dst, src);
self.is_frame[h as usize] = true;
self.live.push(h);
}
/// The non-anchor fields, copied verbatim.
fn copy_fields(dst: &mut LayoutInput, src: &LayoutInput) {
dst.width = src.width;
dst.height = src.height;
dst.scale = src.scale;
dst.clamp = src.clamp;
dst.extent_x = src.extent_x;
dst.extent_y = src.extent_y;
}
/// Resolve every registered frame in dependency order (each anchor target before its
/// dependents), detecting cycles. Cycle members are still resolved best-effort, ascending by
/// handle, against whatever rects were available — matching the map-backed driver this
/// replaces.
pub fn solve(&mut self) {
self.live.sort_unstable();
// Frame→frame anchor edges. A dependency on an external or an unknown handle does not
// constrain ordering (its rect is fixed / missing, not produced by this pass); only
// frame→frame edges do. A self-anchor counts as an edge, so a self-referential frame is
// never emitted ⇒ reported as a cycle. Duplicate targets count once (an anchor slot set is
// at most 9, so the dedupe is a linear scan, not a set).
for idx in 0..self.live.len() {
let h = self.live[idx];
let mut seen: [Handle; 9] = [0; 9];
let mut n_seen = 0usize;
for a_idx in 0..self.input[h as usize].anchors.len() {
let d = self.input[h as usize].anchors[a_idx].relative_to;
if !self.is_frame.get(d as usize).copied().unwrap_or(false) {
continue;
}
if seen[..n_seen].contains(&d) {
continue;
}
if n_seen < seen.len() {
seen[n_seen] = d;
n_seen += 1;
}
self.indeg[h as usize] += 1;
self.dependents[d as usize].push(h);
}
}
for &h in &self.live {
if self.indeg[h as usize] == 0 {
self.queue.push_back(h);
}
}
while let Some(h) = self.queue.pop_front() {
self.order.push(h);
self.emitted[h as usize] = true;
for i in 0..self.dependents[h as usize].len() {
let dep = self.dependents[h as usize][i];
let e = &mut self.indeg[dep as usize];
*e -= 1;
if *e == 0 {
self.queue.push_back(dep);
}
}
}
for i in 0..self.order.len() {
let h = self.order[i];
self.resolve_one(h);
}
// Frames never emitted are on (or behind) a cycle; resolve them best-effort, ascending.
for idx in 0..self.live.len() {
let h = self.live[idx];
if !self.emitted[h as usize] {
self.cycle.push(h);
self.resolve_one(h);
}
}
}
/// Resolve one frame against the rects known so far, recording failure.
fn resolve_one(&mut self, h: Handle) {
let outcome = {
let input = &self.input[h as usize];
let rects = &self.rect;
resolve_rect(input, |t| rects.get(t as usize).copied().flatten())
};
match outcome {
ResolveOutcome::Resolved(r) => self.rect[h as usize] = Some(r),
ResolveOutcome::Unresolvable => self.unresolvable.push(h),
}
}
/// The rect known at `h`: an external as seeded, or a frame's resolved rect. `None` if the
/// handle is unknown or its frame was under-constrained.
#[inline]
pub fn rect(&self, h: Handle) -> Option<Rect> {
self.rect.get(h as usize).copied().flatten()
}
/// Frames on (or behind) an anchor dependency cycle — no valid topological position. Ascending.
pub fn cycle(&self) -> &[Handle] {
&self.cycle
}
/// Frames whose `assemble` returned unresolvable (under-constrained, or depending on an
/// unresolvable/missing target). May overlap [`cycle`](Self::cycle).
pub fn unresolvable(&self) -> &[Handle] {
&self.unresolvable
}
}
#[cfg(test)]
mod tests {
use super::*;
// Screen-root handle used across tests; its rect is the physical screen (y-up).
const SCREEN: Handle = 0;
fn screen_rect() -> Rect {
// [bottom, left, top, right]
Rect::new(0.0, 0.0, 600.0, 800.0)
}
fn bits(r: Rect) -> [u32; 4] {
[
r.bottom.to_bits(),
r.left.to_bits(),
r.top.to_bits(),
r.right.to_bits(),
]
}
fn assert_rect(got: ResolveOutcome, want: Rect) {
match got {
ResolveOutcome::Resolved(r) => {
assert_eq!(bits(r), bits(want), "got {r:?} want {want:?}")
}
ResolveOutcome::Unresolvable => panic!("unresolvable, wanted {want:?}"),
}
}
// ── Leaf kernels ─────────────────────────────────────────────────────────────────────────
// Input probes transcribed from wow-5875-re/crates/ui/tests/difftest/layout.rs (the explicit
// per-leg probes; binary-diffed there). Expected outputs are hand-computed from the layout.md
// formulas — this crate has no WoW.exe emulator, so these are spec-derived, not binary-diffed.
#[test]
fn combine_edge_legs() {
// (center, opp, span)
assert_eq!(combine_edge(10.0, 50.0, 7.0), 57.0); // opp+span
assert_eq!(combine_edge(10.0, f32::INFINITY, 7.0), 7.0 * 0.5 + 10.0); // span/2+center
assert_eq!(combine_edge(10.0, 50.0, 0.0), (10.0 - 50.0) + 10.0); // 2c-opp = -30
assert_eq!(
combine_edge(f32::INFINITY, f32::INFINITY, 0.0),
f64::INFINITY
);
assert_eq!(combine_edge(f32::INFINITY, 50.0, 0.0), f64::INFINITY);
assert_eq!(combine_edge(10.0, f32::INFINITY, 0.0), f64::INFINITY);
// -0.0 span == 0.0 (IEEE) -> the 2c-opp leg
assert_eq!(combine_edge(10.0, 50.0, -0.0), (10.0 - 50.0) + 10.0);
}
#[test]
fn combine_center_legs() {
assert_eq!(combine_center(10.0, 50.0, 7.0), (10.0 + 50.0) * 0.5); // both -> midpoint
assert_eq!(combine_center(10.0, f32::INFINITY, 7.0), 7.0 * 0.5 + 10.0); // span/2+lo
assert_eq!(combine_center(f32::INFINITY, 50.0, 7.0), 50.0 - 7.0 * 0.5); // hi-span/2
assert_eq!(
combine_center(f32::INFINITY, f32::INFINITY, 7.0),
f64::INFINITY
);
assert_eq!(combine_center(10.0, f32::INFINITY, 0.0), f64::INFINITY);
// both finite -> midpoint regardless of span
assert_eq!(combine_center(10.0, 50.0, -0.0), 30.0);
}
#[test]
fn anchor_resolve_x_columns() {
let r = [3.0f32, 100.0, 480.0, 260.0]; // [bottom, left, top, right]
// relLeft, center, relRight (point-ids 0,1,2), scale 1, xOff 5
assert_eq!(anchor_resolve_x(1.0, 5.0, 0, Some(r), false), 5.0 + 100.0);
assert_eq!(
anchor_resolve_x(1.0, 5.0, 1, Some(r), false),
(260.0 + 100.0) * 0.5 + 5.0
);
assert_eq!(anchor_resolve_x(1.0, 5.0, 2, Some(r), false), 5.0 + 260.0);
// scale multiplies the offset
assert_eq!(
anchor_resolve_x(2.0, 5.0, 0, Some(r), false),
2.0 * 5.0 + 100.0
);
// GetRect-fail (None) -> +Inf; out-of-range point -> +Inf
assert_eq!(anchor_resolve_x(1.0, 5.0, 0, None, false), f64::INFINITY);
assert_eq!(anchor_resolve_x(1.0, 5.0, 9, Some(r), false), f64::INFINITY);
// mirror: left->0, right->right-left; center = (r'+0)/2 + xOff
let rp = (260.0f32 - 100.0f32) as f64;
assert_eq!(
anchor_resolve_x(1.0, 0.0, 1, Some(r), true),
(rp + 0.0) * 0.5
);
}
#[test]
fn anchor_resolve_y_rows() {
let r = [3.0f32, 100.0, 480.0, 260.0];
// relTop, center, relBottom (rows: pt 0->top, 3->center, 6->bottom)
assert_eq!(anchor_resolve_y(1.0, 5.0, 0, Some(r), false), 5.0 + 480.0);
assert_eq!(
anchor_resolve_y(1.0, 5.0, 3, Some(r), false),
(480.0 + 3.0) * 0.5 + 5.0
);
assert_eq!(anchor_resolve_y(1.0, 5.0, 6, Some(r), false), 5.0 + 3.0);
}
// ── assemble / clamp ────────────────────────────────────────────────────────────────────
#[test]
fn assemble_passthrough_and_fail() {
let a = assemble_rect([3.0, 100.0, 480.0, 260.0], false, 0.8, 0.6);
assert!(a.ok);
assert_eq!(a.rect, [3.0, 100.0, 480.0, 260.0]);
let bad = assemble_rect([3.0, f32::INFINITY, 480.0, 260.0], false, 0.8, 0.6);
assert!(!bad.ok);
}
#[test]
fn assemble_clamp_low_edge() {
// left < 0 -> shift to 0, carry span to right
let a = assemble_rect([0.1, -0.2, 0.5, 0.3], true, 0.8, 0.6);
assert!(a.ok);
assert_eq!(a.rect[1], 0.0); // left
// right = 0.3 - (-0.2) computed via f64 then narrowed
let want_right = (f64::from(0.3f32) - f64::from(-0.2f32)) as f32;
assert_eq!(a.rect[3].to_bits(), want_right.to_bits());
}
#[test]
fn assemble_clamp_high_edge() {
// right > extent_x -> shift back, right = extent
let a = assemble_rect([0.1, 0.1, 0.5, 1.5], true, 0.8, 0.6);
assert!(a.ok);
assert_eq!(a.rect[3], 0.8);
let over = f64::from(1.5f32) - f64::from(0.8f32);
let want_left = (f64::from(0.1f32) - over) as f32;
assert_eq!(a.rect[1].to_bits(), want_left.to_bits());
}
// ── Composed resolver ─────────────────────────────────────────────────────────────────────
/// VERIFIED numeric vector from wow-5875-re/system/ui/scratch/rf32-composed-resolver.md
/// "Worked oracle (probe 1)" — binary-diffed there against WoW.exe's `assemble 0x767a20`.
#[test]
fn oracle_single_topleft_explicit_size() {
let f = LayoutInput::sized(
vec![Anchor::new(
Point::TopLeft,
SCREEN,
Point::TopLeft,
10.0,
-5.0,
)],
200.0,
50.0,
);
// screen root [0,0,600,800] (probe uses root top=600, right=800)
let got = resolve_rect(&f, |_| Some(Rect::new(0.0, 0.0, 600.0, 800.0)));
assert_rect(got, Rect::new(545.0, 10.0, 595.0, 210.0));
}
#[test]
fn single_anchor_all_nine_points_explicit_size() {
// For each own-point p, anchor p -> the same point on screen, xOff/yOff 0, w100 h40.
// The anchored corner/edge pins directly; the rest derive from size. We assert the pinned
// coordinate and the resulting width/height, computed by hand from layout.md.
let screen = screen_rect();
for &p in &[
Point::TopLeft,
Point::Top,
Point::TopRight,
Point::Left,
Point::Center,
Point::Right,
Point::BottomLeft,
Point::Bottom,
Point::BottomRight,
] {
let f = LayoutInput::sized(vec![Anchor::new(p, SCREEN, p, 0.0, 0.0)], 100.0, 40.0);
let out = resolve_rect(&f, |_| Some(screen));
let r = match out {
ResolveOutcome::Resolved(r) => r,
ResolveOutcome::Unresolvable => panic!("{p:?} unresolvable"),
};
// width/height always preserved
assert_eq!(r.width().to_bits(), 100.0f32.to_bits(), "{p:?} width");
assert_eq!(r.height().to_bits(), 40.0f32.to_bits(), "{p:?} height");
}
}
#[test]
fn two_edge_derivation_no_size() {
// TOPLEFT + BOTTOMRIGHT to screen corners -> all edges direct, w/h ignored.
let f = LayoutInput::sized(
vec![
Anchor::new(Point::TopLeft, SCREEN, Point::TopLeft, 10.0, -10.0),
Anchor::new(Point::BottomRight, SCREEN, Point::BottomRight, -20.0, 30.0),
],
0.0, // no explicit width
0.0, // no explicit height
);
let got = resolve_rect(&f, |_| Some(screen_rect()));
// left=0+10, top=600-10, right=800-20, bottom=0+30
assert_rect(got, Rect::new(30.0, 10.0, 590.0, 780.0));
}
#[test]
fn center_anchor_derives_edges() {
let f = LayoutInput::sized(
vec![Anchor::new(Point::Center, SCREEN, Point::Center, 0.0, 0.0)],
100.0,
40.0,
);
let got = resolve_rect(&f, |_| Some(screen_rect()));
// centered at (400, 300): [bottom 280, left 350, top 320, right 450]
assert_rect(got, Rect::new(280.0, 350.0, 320.0, 450.0));
}
#[test]
fn scale_interaction() {
// scale 2 -> offsets and size span doubled.
let f = LayoutInput {
anchors: vec![Anchor::new(
Point::TopLeft,
SCREEN,
Point::TopLeft,
10.0,
-5.0,
)],
width: 200.0,
height: 50.0,
scale: 2.0,
..LayoutInput::default()
};
let got = resolve_rect(&f, |_| Some(screen_rect()));
// left=2*10=20, top=600+2*(-5)=590, right=20+200*2=420, bottom=590-50*2=490
assert_rect(got, Rect::new(490.0, 20.0, 590.0, 420.0));
}
#[test]
fn chained_frames_via_solver() {
const A: Handle = 1;
const B: Handle = 2;
let mut s = LayoutSolver::new();
s.begin();
s.set_external(SCREEN, screen_rect());
// Register B before A to prove topological ordering, not registration ordering.
s.set_frame(
B,
&LayoutInput::sized(
vec![Anchor::new(Point::TopLeft, A, Point::BottomRight, 0.0, 0.0)],
20.0,
20.0,
),
);
s.set_frame(
A,
&LayoutInput::sized(
vec![Anchor::new(
Point::TopLeft,
SCREEN,
Point::TopLeft,
100.0,
-100.0,
)],
50.0,
50.0,
),
);
s.solve();
assert!(s.cycle().is_empty());
assert!(s.unresolvable().is_empty());
// A: left 100, top 500, right 150, bottom 450
assert_eq!(
bits(s.rect(A).expect("A resolved")),
bits(Rect::new(450.0, 100.0, 500.0, 150.0))
);
// B hangs off A's bottom-right corner (150, 450), size 20
assert_eq!(
bits(s.rect(B).expect("B resolved")),
bits(Rect::new(430.0, 150.0, 450.0, 170.0))
);
}
#[test]
fn no_anchor_is_unresolvable() {
// A frame with no SetPoint cannot resolve (every edge derives circularly to +Inf).
let f = LayoutInput::sized(vec![], 100.0, 40.0);
assert_eq!(
resolve_rect(&f, |_| Some(screen_rect())),
ResolveOutcome::Unresolvable
);
}
#[test]
fn unset_sentinel_propagates() {
// Anchor to a target with no valid rect (GetRect fail) -> that edge is +Inf -> frame
// unresolvable (single anchor, so opposite edges can't derive).
let f = LayoutInput::sized(
vec![Anchor::new(
Point::TopLeft,
SCREEN,
Point::TopLeft,
0.0,
0.0,
)],
100.0,
40.0,
);
assert_eq!(resolve_rect(&f, |_| None), ResolveOutcome::Unresolvable);
}
#[test]
fn dependent_of_unresolvable_is_unresolvable() {
const A: Handle = 1; // no anchors -> unresolvable
const B: Handle = 2; // anchored to A
let mut s = LayoutSolver::new();
s.begin();
s.set_external(SCREEN, screen_rect());
s.set_frame(A, &LayoutInput::sized(vec![], 100.0, 40.0));
s.set_frame(
B,
&LayoutInput::sized(
vec![Anchor::new(Point::TopLeft, A, Point::TopLeft, 0.0, 0.0)],
20.0,
20.0,
),
);
s.solve();
assert!(s.unresolvable().contains(&A));
assert!(s.unresolvable().contains(&B));
assert!(s.rect(A).is_none());
assert!(s.rect(B).is_none());
}
#[test]
fn cycle_detected() {
const A: Handle = 1;
const B: Handle = 2;
let mut s = LayoutSolver::new();
s.begin();
s.set_frame(
A,
&LayoutInput::sized(
vec![Anchor::new(Point::TopLeft, B, Point::TopLeft, 0.0, 0.0)],
20.0,
20.0,
),
);
s.set_frame(
B,
&LayoutInput::sized(
vec![Anchor::new(Point::TopLeft, A, Point::TopLeft, 0.0, 0.0)],
20.0,
20.0,
),
);
s.solve();
assert!(s.cycle().contains(&A));
assert!(s.cycle().contains(&B));
// neither can resolve (each depends on the other's rect, unavailable)
assert!(s.unresolvable().contains(&A));
assert!(s.unresolvable().contains(&B));
}
#[test]
fn self_anchor_is_a_cycle() {
const A: Handle = 1;
let mut s = LayoutSolver::new();
s.begin();
s.set_frame(
A,
&LayoutInput::sized(
vec![Anchor::new(Point::TopLeft, A, Point::BottomRight, 0.0, 0.0)],
20.0,
20.0,
),
);
s.solve();
assert!(s.cycle().contains(&A));
}
#[test]
fn size_changed_epsilon() {
let r = Rect::new(0.0, 0.0, 40.0, 100.0);
// sub-epsilon width change -> not fired
let tiny = Rect::new(0.0, 0.0, 40.0, 100.0 + 1e-7);
assert!(!size_changed(r, tiny));
// supra-epsilon -> fired
let big = Rect::new(0.0, 0.0, 40.0, 100.001);
assert!(size_changed(r, big));
}
/// The solver's buffers live across rounds, so `begin` must leave NO trace of the previous
/// one — a stale rect, in-degree, dependent edge, or `is_frame` flag would silently corrupt
/// the next solve. Round 2 registers a *disjoint* frame set that anchors to a handle round 1
/// used, and must see it as unknown (not round 1's rect).
#[test]
fn begin_leaves_no_state_from_the_previous_round() {
const A: Handle = 1;
const B: Handle = 2;
let mut s = LayoutSolver::new();
// Round 1: A anchored to the screen, B hanging off A.
s.begin();
s.set_external(SCREEN, screen_rect());
s.set_frame(
A,
&LayoutInput::sized(
vec![Anchor::new(
Point::TopLeft,
SCREEN,
Point::TopLeft,
0.0,
0.0,
)],
100.0,
40.0,
),
);
s.set_frame(
B,
&LayoutInput::sized(
vec![Anchor::new(Point::TopLeft, A, Point::BottomRight, 0.0, 0.0)],
20.0,
20.0,
),
);
s.solve();
let a_first = s.rect(A).expect("A resolved in round 1");
assert!(s.rect(B).is_some());
// Round 2: ONLY B, still anchored to A — but A is no longer registered and no external
// seeds it, so B must be unresolvable rather than reusing round 1's stale A rect.
s.begin();
s.set_external(SCREEN, screen_rect());
s.set_frame(
B,
&LayoutInput::sized(
vec![Anchor::new(Point::TopLeft, A, Point::BottomRight, 0.0, 0.0)],
20.0,
20.0,
),
);
s.solve();
assert!(
s.rect(A).is_none(),
"round 1's A rect must not survive begin"
);
assert!(
s.rect(B).is_none(),
"B must not resolve against a stale target"
);
assert!(s.unresolvable().contains(&B));
assert!(s.cycle().is_empty(), "cycle list must be cleared too");
// Round 3: the original pair again — identical results to round 1 (no accumulated
// in-degree or dependent edges from the rounds between).
s.begin();
s.set_external(SCREEN, screen_rect());
s.set_frame(
A,
&LayoutInput::sized(
vec![Anchor::new(
Point::TopLeft,
SCREEN,
Point::TopLeft,
0.0,
0.0,
)],
100.0,
40.0,
),
);
s.set_frame(
B,
&LayoutInput::sized(
vec![Anchor::new(Point::TopLeft, A, Point::BottomRight, 0.0, 0.0)],
20.0,
20.0,
),
);
s.solve();
assert_eq!(
bits(s.rect(A).expect("A resolved in round 3")),
bits(a_first)
);
assert!(s.unresolvable().is_empty());
}
/// A frame registered with the ScrollFrame override keeps its size/scale but drops its own
/// anchors for exactly the override (decision 0112) — and the slot's anchor `Vec` is reused,
/// so the previous round's anchors must not leak through.
#[test]
fn set_frame_anchored_replaces_only_the_anchors() {
const A: Handle = 1;
let mut s = LayoutSolver::new();
let input = LayoutInput::sized(
vec![
Anchor::new(Point::TopLeft, SCREEN, Point::TopLeft, 0.0, 0.0),
Anchor::new(Point::BottomRight, SCREEN, Point::BottomRight, 0.0, 0.0),
],
100.0,
40.0,
);
// Plain: both anchors pin all four edges, so the size is ignored.
s.begin();
s.set_external(SCREEN, screen_rect());
s.set_frame(A, &input);
s.solve();
assert_eq!(
bits(s.rect(A).expect("resolved")),
bits(Rect::new(0.0, 0.0, 600.0, 800.0))
);
// Overridden: the two authored anchors are gone, replaced by one TOPLEFT pin at (10, -5),
// so the explicit 100x40 size now decides the other two edges.
s.begin();
s.set_external(SCREEN, screen_rect());
s.set_frame_anchored(
A,
&input,
Anchor::new(Point::TopLeft, SCREEN, Point::TopLeft, 10.0, -5.0),
);
s.solve();
assert_eq!(
bits(s.rect(A).expect("resolved")),
bits(Rect::new(555.0, 10.0, 595.0, 110.0))
);
}
}