Files
benilla-octo/crates/benilla-ui/src/script/tests/layout_gate.rs
T
SamandClaude Opus 5 c48d8c6b7e other players walk the world, and NPCs speak your name
- Other players walk the world. A watched player's motion between server updates now runs through
  the same grounded movement resolve as your own character — same capsule, same colliders, same
  step-up and ground election — so they follow terrain every frame instead of every packet, and
  stop at a wall instead of walking into it and being snapped back. Their jumps are swept against
  the world too, and a standstill jump steered mid-air reports that steer, the one mid-air press
  the reference client broadcasts rather than defers.
- The move relay rebuilt: relayed moves ride a per-unit chain on real time, a due move never jumps
  ahead of its unit's queue, and SMSG_COMPRESSED_MOVES — the batched carrier the server sends under
  load — is decoded rather than skipped. Our own send path now matches the reference client's:
  facing streams while moving, Nagle is off so each packet is one write, and the strafe axis nets so
  left and right can never ride the same packet.
- NPC and quest text speaks the whole $-macro grammar, carved from the reference bytes — your name,
  class and race, the gendered forms, and the server's live world states — and the same
  substitution feeds gossip, quest text, the quest log and unit frames. Greetings are a weighted
  draw, rolled per NPC per open, and a quest giver with no quest to give is no longer clickable,
  where it used to open a dialog holding nothing but a placeholder greeting.
- Spell tooltips finish the reference's line law: cast time and cooldown behind the real passive
  gate, the required tool or form, the reagents block, the chance-to-X line, a multi-bit
  equipped-item requirement named properly instead of by raw bits, and a buff's dispel class.
- The UI layout engine got a dense, reusable anchor solver, and a change gate that skips the resolve
  entirely on frames where no layout input moved — in practice, almost every idle frame. Pets are
  named by their pet number rather than a creature-template entry, so the client asks the query the
  server can actually answer. And the asset extractor grew a stray-geometry census for tracking
  down geometry that renders where it should not.

This snapshot replaces the six dated `sync` commits that stood here before it, whose subjects said
nothing but the date. If you cloned before now: `git fetch && git reset --hard origin/main`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 14:57:54 +07:00

158 lines
4.7 KiB
Rust

//! The layout change gate: a resolve whose inputs are byte-identical to the last converged one is
//! skipped outright (`script::layout::InputFingerprint`).
//!
//! These assert on `Model::layout_solves` — the count of times the fixpoint actually ran — rather
//! than on the rects, because rect equality alone cannot distinguish "the gate skipped" from "the
//! gate re-solved and got the same answer". The value of the gate is precisely the difference.
use super::common::script;
use crate::script::{Model, UiScript};
/// How many times the fixpoint has run.
fn solves(s: &UiScript) -> u64 {
s.lua()
.app_data_ref::<Model>()
.expect("model app_data")
.layout_solves
}
/// A frame anchored to the screen, plus a child hanging off it — enough that a move has to
/// propagate, so a wrongly-skipped resolve would be visible in the child too.
fn setup(s: &UiScript) {
s.run(
r#"
parent = CreateFrame("Frame", "Parent", nil)
parent:SetWidth(100); parent:SetHeight(40)
parent:SetPoint("TOPLEFT", nil, "TOPLEFT", 10, -10)
child = CreateFrame("Frame", "Child", parent)
child:SetWidth(20); child:SetHeight(20)
child:SetPoint("TOPLEFT", parent, "BOTTOMRIGHT", 0, 0)
"#,
)
.expect("setup");
}
#[test]
fn an_unchanged_resolve_does_not_run_the_fixpoint() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.resolve();
let after_first = solves(&s);
assert_eq!(after_first, 1, "the first resolve must run");
// Nothing touched in between: every further resolve is a no-op.
for _ in 0..5 {
s.resolve();
}
assert_eq!(
solves(&s),
after_first,
"resolves with identical inputs must be skipped, not re-run"
);
}
#[test]
fn moving_a_frame_reopens_the_gate_and_propagates() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.resolve();
s.resolve(); // settle: the gate is now closed
let before = solves(&s);
let child_left = |s: &UiScript| -> f32 { s.eval::<f32>("return Child:GetLeft()").unwrap() };
let first = child_left(&s);
s.run("parent:SetPoint(\"TOPLEFT\", nil, \"TOPLEFT\", 60, -10)")
.expect("move");
s.resolve();
assert_eq!(solves(&s), before + 1, "a SetPoint must reopen the gate");
assert!(
(child_left(&s) - (first + 50.0)).abs() < 0.001,
"the move must propagate to the child: {} -> {}",
first,
child_left(&s)
);
// And it closes again once the move has settled.
let after = solves(&s);
s.resolve();
assert_eq!(
solves(&s),
after,
"the gate must close again after the move"
);
}
/// Resizing the window moves every top-level frame — the gate must never swallow it.
#[test]
fn a_screen_resize_reopens_the_gate() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.resolve();
s.resolve();
let before = solves(&s);
s.set_screen_size(1024.0, 768.0);
s.resolve();
assert_eq!(
solves(&s),
before + 1,
"a screen-size change must reopen the gate"
);
}
/// A region's own `SetPoint` writes `region_data`, not `layout_inputs` — the half of the read set
/// that is easiest to leave out of a change gate, and the one the region sweep consumes.
#[test]
fn moving_a_region_reopens_the_gate() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.run(
r#"
tex = parent:CreateTexture(nil, "ARTWORK")
tex:SetWidth(10); tex:SetHeight(10)
tex:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, 0)
"#,
)
.expect("region");
s.resolve();
s.resolve();
let before = solves(&s);
s.run("tex:SetPoint(\"TOPLEFT\", parent, \"TOPLEFT\", 25, 0)")
.expect("move region");
s.resolve();
assert_eq!(
solves(&s),
before + 1,
"a region SetPoint must reopen the gate"
);
}
/// Hiding a frame does NOT move any rect (the client resolves hidden frames too — visibility is an
/// extract-time filter), so the gate is right to stay closed. Pinned deliberately: it is the one
/// place where "nothing to re-solve" is surprising, and a future reader tempted to dirty on
/// show/hide should see that the current model does not need it.
#[test]
fn hiding_a_frame_does_not_reopen_the_gate() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.resolve();
s.resolve();
let before = solves(&s);
s.run("parent:Hide()").expect("hide");
s.resolve();
assert_eq!(
solves(&s),
before,
"visibility does not move rects, so the gate stays closed"
);
}