Files
benilla-octo/crates/benilla-app/src/ui_script/extract/mod.rs
T
SamandClaude Fable 5.1 afc0731ccc nameplates are frames, guild names go overhead, and the camera pivots
- a nameplate is a real frame under WorldFrame, six regions and a health bar in the reference's order, so pfUI, ShaguTweaks and _Nameplates find and restyle plates instead of seeing none; the plate stays out of the mouse's way in this cut (2148)
- a player's guild shows under their overhead name in angle brackets, the line an NPC's subname already used, gated by the UnitNamePlayerGuild option at its stock on (2149)
- smart camera pivot is on, as 1.12 ships it: a mostly vertical drag with the camera pressed against something tilts the view instead of swinging the arm, easing home on release; in water the pivot rises for a swimmer (2149)
- Follow Terrain and head bob are built and ship off, as in 1.12: Follow Terrain leans the camera up or down the slope you are walking onto, and head bob sways the first-person eye in a figure of eight while you move (2149)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 20:55:15 +07:00

2729 lines
127 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 extraction pass: [`drive_script`] turns [`UiScript::extract`]'s per-frame output into
//! [`UiQuads`] for the render pass (screen size → tick → resolve → extract → quads), including the
//! held-cursor icon overlay ([`cursor_icon_quad`]) — CAPTURE-ONLY since decision 0216 §5, where
//! the held payload's icon became the hardware cursor ([`crate::cursor`]) in a normal run; the
//! quad survives only as the visual harness's machine-checkable view (the OS cursor can't appear
//! in capture pixels). Split out of [`super`] purely for size — the plugin wiring and the input
//! pass live there and in [`super::input`] respectively.
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use benilla_ui::script::{QuadContent, TexCoords, UiScript};
use crate::ui_pass::{UiQuad, UiQuads, UvRect};
use crate::ui_text::UiFontAtlas;
use benilla_assets::WorldAssets;
mod colorselect;
mod text;
/// `WOW_UI_COST=1` — the untraced per-frame cost meter for this system's phases (the premise
/// instrument for the UI epoch-gate lane, 0730's warm slice): one `[ui-cost]` line per frame with
/// each phase's wall μs, the quad counts, the layout gate's decision, and whether the diff found
/// the produced quads changed. Untraced by design — the campaign grades untraced cpu_ms, and
/// `trace_chrome` inflates exactly the fine-grained spans this measures (0718's calibration).
pub(crate) fn ui_cost_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("WOW_UI_COST").as_deref() == Ok("1"))
}
/// `WOW_UI_GATE=1` — the extract gate's miss reporter ([`report_gate_miss`]). Off by default and
/// read once, the `[ui-cost]` meter's posture.
fn gate_log_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("WOW_UI_GATE").as_deref() == Ok("1"))
}
/// `WOW_UI_DIFF=1` — the base-lane rebuild-trigger probe (`ui_pass`' twin). Read once; it also
/// pins the conversion to the FULL path (the probe's job is naming the first differing quad of a
/// whole-list diff, which the per-entry splice below deliberately never computes).
fn ui_diff_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var_os("WOW_UI_DIFF").is_some())
}
/// `WOW_UI_SPLICE_VERIFY=1` — the splice's own adversary (decision 1638). Read once, off by
/// default, and *expensive by design*: it makes every spliced frame also pay the full conversion.
fn splice_verify_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var_os("WOW_UI_SPLICE_VERIFY").is_some())
}
/// `WOW_UI_PICK=<x>,<y>[,<r>]` — **what drew this pixel?** The UI's answer to the world hover
/// inspector. Every converted quad whose screen rect covers that point (or, with `r`, comes
/// within `r` px of it — how a hairline thinner than the probe is caught) names itself once: the
/// `ZTarget` (frame or region handle), the rect, the paint key, and the content — a texture's
/// BLP path and crop included. Coordinates are LOGICAL window px, y-down from the top-left, the
/// same space [`convert_entry`]'s `rect` lives in (a 2x-scale capture's device pixel is half
/// this). Each `z_key` reports once per run, so a 200-frame capture prints the list once.
///
/// Built for a hairline nobody could name (the world map's dark seams): without it, "which
/// region is that one dark row" costs a bisect through FrameXML with a rebuild per guess.
fn ui_pick_point() -> Option<(Vec2, f32)> {
static AT: std::sync::OnceLock<Option<(Vec2, f32)>> = std::sync::OnceLock::new();
*AT.get_or_init(|| {
let raw = std::env::var("WOW_UI_PICK").ok()?;
let mut it = raw.split(',');
let x: f32 = it.next()?.trim().parse().ok()?;
let y: f32 = it.next()?.trim().parse().ok()?;
let r = it.next().and_then(|v| v.trim().parse().ok()).unwrap_or(0.0);
Some((Vec2::new(x, y), r))
})
}
/// One `[ui-pick]` line per covering quad — see [`ui_pick_point`]. Deduped by paint key so a
/// steady UI reports its stack once, not once a frame.
fn report_ui_pick(eq: &benilla_ui::script::ExtractedQuad, rect: Rect, at: Vec2, r: f32) {
use std::collections::HashSet;
use std::sync::Mutex;
static SEEN: std::sync::OnceLock<Mutex<HashSet<u64>>> = std::sync::OnceLock::new();
if !rect.inflate(r).contains(at) {
return;
}
if !SEEN
.get_or_init(|| Mutex::new(HashSet::new()))
.lock()
.map(|mut s| s.insert(eq.z))
.unwrap_or(false)
{
return;
}
let what = match &eq.content {
QuadContent::Frame => "frame-slot".to_string(),
QuadContent::Minimap { .. } => "minimap".to_string(),
QuadContent::Texture {
path,
color,
tex_coords,
..
} => format!(
"texture {:?} color={color:?} crop={tex_coords:?}",
path.as_deref().unwrap_or("<none>")
),
other => format!("{other:?}"),
};
info!(
"[ui-pick] {:?} z={} rect=({:.2},{:.2})-({:.2},{:.2}) alpha={:.3} {what}",
eq.target, eq.z, rect.min.x, rect.min.y, rect.max.x, rect.max.y, eq.alpha,
);
}
/// A content arm's name, for a one-line log ([`report_gate_miss`]'s splice twin). Short and
/// stable: these strings are histogrammed in the shell, not read as prose.
fn content_kind(c: &QuadContent) -> &'static str {
match c {
QuadContent::Frame => "frame",
QuadContent::Minimap { .. } => "minimap",
QuadContent::ModelPane { .. } => "modelpane",
QuadContent::Texture { .. } => "texture",
QuadContent::ColorWheel => "colorwheel",
QuadContent::ColorValue { .. } => "colorvalue",
QuadContent::Backdrop { .. } => "backdrop",
QuadContent::Text { .. } => "text",
}
}
/// Which axes of a texture region's UV mapping run PAST the texture — the reference's tiling idiom
/// (`SetTexCoord(0, n, 0, 1)` repeats the art n times along u) — and so must sample with `Repeat`.
/// Per axis, because the two answers are independent and the wrong one on the bounded axis is a
/// visible bleed (decision 2000): with both axes on `Repeat`, a strip that spans exactly `[0, 1]`
/// in v has its top pixel row filtered against the texture's LAST row. The stance shelf's middle
/// piece keeps an opaque grey row there, and drew it as a hairline along its own top edge at
/// every four-form bar. The same tolerance [`uv_clamp_window`] uses to tell a tiling axis from a
/// whole-texture one, so the two laws cannot disagree about which axis is which.
pub(crate) fn tiling_axes(uv: &UvRect) -> (bool, bool) {
let past = |axis: usize| {
uv.corners
.iter()
.any(|c| !(-0.001..=1.001).contains(&c[axis]))
};
(past(0), past(1))
}
/// The half-texel-inset UV window a **cropped** quad may sample — [`UiQuad::uv_clamp`]'s producer
/// (decision 1608), `None` when neither axis needs one.
///
/// `CLAMP_TO_EDGE` clamps at the IMAGE's edge; a `SetTexCoord` crop into an ATLAS has no such
/// guard, so a magnified cell's outermost destination pixels sample half a texel past the crop and
/// linear-filter in whatever the neighbouring cell authored. Half a texel of inset is exactly where
/// a standalone clamped texture of that cell stops — the edge texel's CENTRE.
///
/// Decided **per axis**, and the two tests are the whole law:
/// - an axis running past `[0,1]` is the reference's TILING idiom (`SetTexCoord(0, n, 0, 1)` on the
/// stance shelf repeats the art n times); insetting it would walk the art along the run, so it is
/// left alone — the same bounded-axis rule [`benilla_ui::script::inset_atlas_bleed`] applies to
/// the `Backdrop` slices;
/// - an axis spanning the whole texture needs nothing: the sampler's own clamp already is this.
fn uv_clamp_window(uv: &UvRect, size: (u32, u32)) -> Option<[f32; 4]> {
let mut out = [1.0, 1.0, 0.0, 0.0]; // both axes off — `min > max` (see `UiQuad::uv_clamp`)
let mut any = false;
for (axis, texels) in [(0usize, size.0), (1usize, size.1)] {
if texels < 2 {
continue; // a 1-texel axis has no interior to inset toward
}
let (lo, hi) = uv.corners.iter().fold((f32::MAX, f32::MIN), |(lo, hi), c| {
(lo.min(c[axis]), hi.max(c[axis]))
});
// Outside the texture (tiling), or the whole of it (the sampler's clamp is the window).
if lo < -0.001 || hi > 1.001 || (lo <= 0.001 && hi >= 0.999) {
continue;
}
let half = 0.5 / texels as f32;
// A crop under one texel wide has no interior either: pin both bounds to its centre, which
// is the one texel it means.
let (lo, hi) = match hi - lo > 2.0 * half {
true => (lo + half, hi - half),
false => {
let mid = 0.5 * (lo + hi);
(mid, mid)
}
};
out[axis] = lo;
out[axis + 2] = hi;
any = true;
}
any.then_some(out)
}
/// One line naming why the extract gate did not skip this frame — the input that differed, and for
/// the render list the first entry that moved. A settled UI is what makes the paint pass free; when
/// it is never settled, this says what is moving.
fn report_gate_miss(
script: &UiScript,
now: &[benilla_ui::script::ExtractedQuad],
prev: &[benilla_ui::script::ExtractedQuad],
dims_eq: bool,
generation_eq: bool,
text_ui_eq: bool,
portraits_eq: bool,
) {
if !dims_eq {
eprintln!("[ui-gate] miss: window size / seam scale / DPI changed");
return;
}
if !generation_eq {
// Named, because it is the one miss with no visible cause at all: the glyph sheet filled
// and was repacked from empty, so every held quad's UV moved. It should be very rare —
// `WOW_GLYPH_CACHE=1` reports the occupancy that led here (decision 1342).
eprintln!("[ui-gate] miss: the glyph sheet reset");
return;
}
if !text_ui_eq {
eprintln!("[ui-gate] miss: focused editbox text-UI changed");
return;
}
if !portraits_eq {
eprintln!("[ui-gate] miss: portrait sources changed");
return;
}
if now.len() != prev.len() {
eprintln!(
"[ui-gate] miss: render list {} -> {} entries",
prev.len(),
now.len()
);
return;
}
match now.iter().zip(prev).position(|(a, b)| a != b) {
Some(i) => {
let (a, b) = (&now[i], &prev[i]);
let (was, now_s) = (format!("{b:?}"), format!("{a:?}"));
let owner = match a.target {
benilla_ui::order::ZTarget::Frame(fh) => script.frame_name(fh),
benilla_ui::order::ZTarget::Region(_) => None,
};
eprintln!(
"[ui-gate] miss: entry {i}/{} target={:?} name={owner:?}\n was {}\n now {}",
now.len(),
a.target,
&was[..was.len().min(280)],
&now_s[..now_s.len().min(280)],
);
}
// Nothing in this pass's own inputs moved — the miss came from the capture-mode arm.
None => eprintln!("[ui-gate] miss: capture mode (the gate never skips under a capture)"),
}
}
/// Last frame's extract-gate inputs (decision 0740), held together as one `Local` — the gate
/// compares all of them or none, and a Bevy system has a hard param budget this was eating.
#[derive(Default)]
pub(super) struct GateInputs {
extracted: Vec<benilla_ui::script::ExtractedQuad>,
text_ui: Option<benilla_ui::script::EditBoxTextUi>,
dims: Option<(u32, u32, u32, u32)>,
portraits: std::collections::HashMap<String, crate::portrait::PortraitSource>,
/// The `UiFontAtlas::generation` the held quads' glyph UVs came from — see the gate's own
/// note below. Moves only when the glyph sheet resets (decisions 1339, 1342).
generation: Option<u64>,
/// Per-entry prefix ends into the conversion's output: entry `i`'s quads occupy
/// `spans[i-1]..spans[i]` of `UiQuads::quads` (0 for `i = 0`). Recorded by the full
/// conversion, kept current by the splice — this is what lets a one-entry change (the resting
/// blink) re-convert one entry instead of the whole interface.
spans: Vec<u32>,
/// The stitch's ping-pong buffer: last frame's quad allocation, emptied. A stitch drains the
/// live list into a fresh one and parks the drained allocation here, so a steady stream of
/// changes (a hovered tooltip re-filling every frame) reuses two buffers forever instead of
/// allocating ~1,400 quads a frame.
held: Vec<UiQuad>,
}
/// How this frame's entry list lines up with last frame's — what the splice re-converts, and
/// where everything it keeps came from.
struct Alignment {
/// For each entry of the NEW list: the OLD index whose already-converted quads it reuses, or
/// `None` — it must be converted. Kept indices are strictly increasing.
source: Vec<Option<usize>>,
/// The OLD entries nothing reuses. Their quads leave the list, so their conversion's side
/// effects (a parked minimap slot, a link span, a tile request) would have to be *undone* — the
/// splice cannot, so it only proceeds when every one of them was quads-only.
dropped: Vec<usize>,
}
/// The alignment: a merge over `z`, which both lists are sorted by ([`benilla_ui::script::UiScript::extract`]).
///
/// **Why `z` and not the index.** `z` is a packed `(strata, level, frame-insertion, layer, …)`
/// key ([`benilla_ui::order::ZKey`]) — an entry's *identity*, unchanged when a neighbour is
/// inserted or removed. A positional compare cannot see that: hovering one item slot inserts a
/// single highlight entry at index 359 of 520 and shifts the 160 entries behind it, every one of
/// which then compares unequal by index and equal by `z`. Before this, that frame took the full
/// conversion — 86 % of all hover frames did (decision 1638).
///
/// **The merge FINDS the alignment; it is never trusted.** Quads are reused only for a pair that
/// compares fully equal, and [`convert_entry`] is a pure function of the entry plus the raster
/// environment the splice's guards pin — so an equal pair converts to equal quads whatever their
/// indices. A merge that mis-pairs therefore costs conversions and never correctness, which is
/// what makes it safe to key on a `z` we do not prove unique or even prove sorted. (It is not
/// quite sorted: a ScrollingMessageFrame's own content line is emitted at its frame's slot and
/// sorts above the frame's BACKGROUND regions, so the list dips locally around a chat window.
/// Identical dips on both sides match anyway; a differing one re-converts a handful of entries.)
fn align_entries(
was: &[benilla_ui::script::ExtractedQuad],
now: &[benilla_ui::script::ExtractedQuad],
) -> Alignment {
let mut source: Vec<Option<usize>> = Vec::with_capacity(now.len());
let mut kept = vec![false; was.len()];
let (mut i, mut j) = (0usize, 0usize);
while j < now.len() {
let Some(w) = was.get(i) else {
// Last frame's list is spent: everything left is new.
source.push(None);
j += 1;
continue;
};
match w.z.cmp(&now[j].z) {
std::cmp::Ordering::Equal => {
let same = *w == now[j];
source.push(same.then_some(i));
kept[i] = same;
i += 1;
j += 1;
}
// An entry that is gone: last frame drew something this frame does not.
std::cmp::Ordering::Less => i += 1,
// An entry that is new.
std::cmp::Ordering::Greater => {
source.push(None);
j += 1;
}
}
}
let dropped = kept
.iter()
.enumerate()
.filter_map(|(i, k)| (!k).then_some(i))
.collect();
Alignment { source, dropped }
}
/// Entry `i`'s quad range under `spans` (the [`GateInputs::spans`] encoding).
fn span_bounds(spans: &[u32], i: usize) -> (usize, usize) {
let a = if i == 0 { 0 } else { spans[i - 1] as usize };
(a, spans[i] as usize)
}
/// Whether an entry's conversion writes **quads and nothing else** — the splice's admission test.
/// The full path plumbs three side channels (the engine's link spans, the parked minimap slot,
/// the booth panes and tile requests); the splice keeps last frame's values for all of them,
/// which is only sound for entries that never write one, or write them idempotently.
fn splice_simple(eq: &benilla_ui::script::ExtractedQuad) -> bool {
match &eq.content {
QuadContent::Frame
| QuadContent::Backdrop { .. }
// A model pane writes at most one quad and one idempotent tile request (decision 2008)
// — and the map's arrow changes facing on every turn while the map is open.
| QuadContent::ModelPane { .. }
// Both colour-picker arms write one quad and nothing else — and they change on every
// step of a drag, which is exactly the traffic the splice exists for.
| QuadContent::ColorWheel
| QuadContent::ColorValue { .. } => true,
// A FontString's text writes nothing but quads, and that is every label, every unit
// frame's name, every tooltip line — the traffic this whole path exists for (decision
// 1638). A FRAME-targeted Text quad is a message-frame ring line, whose hyperlink spans
// are a REPLACE-THE-WHOLE-SET channel (`set_link_spans`): a line that stops carrying a
// link would leave a stale clickable rect behind, and the splice's post-conversion
// tripwire cannot see that, because the scratch it inspects is empty in exactly that
// case. So the target, not the tripwire, is the guard.
QuadContent::Text { .. } => matches!(eq.target, benilla_ui::order::ZTarget::Region(_)),
QuadContent::Texture {
portrait_unit: None,
..
} => true,
_ => false,
}
}
/// The splice's adversary ([`splice_verify_enabled`]): re-run the FULL conversion over the same
/// entry list and prove the spliced list is what it would have produced — quads AND span table.
///
/// This is the check the splice's whole argument rests on, made by machine instead of by
/// reasoning. It is what turns "an equal entry converts to equal quads" from a claim about
/// [`convert_entry`] into a measurement over a real interface: a live client with ~520 entries, a
/// tooltip re-filling every frame, and a hover highlight inserting and removing itself under it
/// (decision 1638). A mismatch names the quad, the entry that owns it, and that entry's owner.
///
/// The side channels are collected into throwaway buffers and dropped — the point is the quads.
/// `booths.panes` is the one exception, because [`convert_entry`] writes it through the shared
/// bridge; re-adding the same tokens is idempotent, so a verify run's panes end up where the full
/// path would have put them anyway.
#[allow(clippy::too_many_arguments)]
fn verify_splice(
prev: &GateInputs,
quads: &UiQuads,
s: f32,
w: f32,
h: f32,
dpi: f32,
assets: &mut Option<ResMut<WorldAssets>>,
images: &mut Assets<Image>,
font_atlas: &mut Option<ResMut<UiFontAtlas>>,
booths: &mut crate::portrait::BoothBridge,
text_ui: Option<&benilla_ui::script::EditBoxTextUi>,
caret_pinned: bool,
script: &UiScript,
) {
let mut check: Vec<UiQuad> = Vec::with_capacity(quads.quads.len());
let mut spans: Vec<u32> = Vec::with_capacity(prev.extracted.len());
let (mut links, mut slot) = (Vec::new(), None);
for eq in prev.extracted.iter().cloned() {
convert_entry(
eq,
s,
w,
h,
dpi,
assets,
images,
font_atlas,
booths,
text_ui,
caret_pinned,
&mut check,
&mut links,
&mut slot,
);
spans.push(check.len() as u32);
}
if spans != prev.spans {
let at = spans
.iter()
.zip(&prev.spans)
.position(|(a, b)| a != b)
.unwrap_or(spans.len().min(prev.spans.len()));
eprintln!(
"[ui-splice] VERIFY FAIL: span table diverges at entry {at} of {} \
(spliced end {:?}, full end {:?})",
prev.extracted.len(),
prev.spans.get(at),
spans.get(at),
);
return;
}
let Some(i) = check
.iter()
.zip(&quads.quads)
.position(|(a, b)| a != b)
.or_else(|| {
(check.len() != quads.quads.len()).then_some(check.len().min(quads.quads.len()))
})
else {
return;
};
// Which entry owns quad `i` — the span table is a prefix-end list, so the first end past it.
let owner = spans.partition_point(|&e| e as usize <= i);
let name = prev
.extracted
.get(owner)
.and_then(|eq| script.target_owner_name(eq.target));
eprintln!(
"[ui-splice] VERIFY FAIL: quad {i} of {}/{} differs — entry {owner} ({}, {})\n spliced {}\n full {}",
quads.quads.len(),
check.len(),
name.as_deref().unwrap_or("?"),
prev.extracted
.get(owner)
.map_or("<none>", |eq| content_kind(&eq.content)),
quad_summary(quads.quads.get(i)),
quad_summary(check.get(i)),
);
}
/// One quad in a log line — enough to see WHICH way two conversions disagree (geometry, paint,
/// crop, or which texture), never the whole struct.
fn quad_summary(q: Option<&UiQuad>) -> String {
let Some(q) = q else {
return "<past the end>".into();
};
format!(
"z={} rect=({:.2},{:.2})-({:.2},{:.2}) color={:?} uv={:?} tex={:?}",
q.z_key,
q.rect.min.x,
q.rect.min.y,
q.rect.max.x,
q.rect.max.y,
q.color,
q.uv.corners,
q.texture.as_ref().map(|t| t.id()),
)
}
/// **Hand the VM the host's font engine** — the one construction site for
/// [`crate::ui_text::AtlasMeasurer`], so a `SetText` → `GetStringWidth` pair *inside one Lua call*
/// returns a real number instead of 0 (the reference answers that getter inline, `0x79e510` →
/// `0x772890`).
///
/// `seam` is the raster seam the atlas answers under ([`super::seam_scale`]): a measurer built
/// under one seam does not answer for another, which is why both callers re-seat rather than
/// check first.
///
/// Two callers, and the second one is the point (decision 2028): this pass, at the seam edge and
/// on the first frame the atlas exists — and [`super::lifecycle::load_ingame_ui_on_world_entry`],
/// **before the manifest loads**, because that is where every `<OnLoad>` in the in-game UI runs.
/// Seated only from here, a VM born and loaded inside one exclusive system — which is exactly
/// what `ReloadUI()` does — runs its whole load edge measuring 0.
pub(crate) fn seat_text_measurer(script: &mut UiScript, atlas: &UiFontAtlas, seam: f32) {
script.set_text_measurer(Box::new(crate::ui_text::AtlasMeasurer::new(
atlas.engine(),
seam,
)));
}
/// Per frame: screen size → `tick` (OnUpdate) → `resolve` → `extract` → [`UiQuads`]. Script errors
/// drain to the log (throttled by being drained — each fires once).
#[allow(clippy::too_many_arguments)] // a Bevy system: each param is one resource, the app's convention
pub(super) fn drive_script(
script: Option<NonSendMut<UiScript>>,
window: Query<&Window, With<PrimaryWindow>>,
// REAL time, deliberately: this drives the VM's `GetTime()` session clock, and the reference
// clock is the OS wall clock. Bevy's default virtual time clamps every frame delta to
// `max_delta` (250 ms), so hitches, loading stalls, and macOS occlusion throttling (~1 fps
// when the window is covered) permanently drop UI-clock time — every GetTime-anchored timer
// (the cooldown sweep, aura expirations, fades) then runs LONG against the wall-clock
// cooldown store and the server, which is exactly "I can charge before the sweep ends"
// (verified live: an occluded run's UI clock fell 27 s behind in 43 s of wall time).
time: Res<Time<Real>>,
mut ui_clock: ResMut<super::UiClock>,
mut quads: ResMut<UiQuads>,
world_assets: Option<ResMut<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
mut font_atlas: Option<ResMut<UiFontAtlas>>,
// Both directions of the booth seam: the token -> off-screen-baked-face bridge a
// `SetPortraitTexture`-bound region samples, and the pane geometry this pass publishes back
// for the booths' projection aspect + render gate (decision 1069).
mut booths: crate::portrait::BoothBridge,
// Two facts about the RUN, tupled to stay inside Bevy's 16-element system-param limit (the
// same squeeze `player::control` and `GateInputs` above record):
// · the held-cursor icon quad (decision 0216 §5) is CAPTURE-ONLY, the same presence check
// every other capture-only system uses (`ui_script::capture_ui_active`'s sibling pattern);
// · whether anything wants this pass's phase split at all ([`super::UiCostWanted`]).
run: (
Option<Res<crate::run_mode::CaptureMode>>,
Res<super::UiCostWanted>,
),
// The append-lane hole this pass parks for its UiQuadAppend producer: the `<Minimap>` widget
// slot (`minimap::emit_minimap` fills it with tile/arrow quads — decision 0203 phase 1). (The
// autocast shine's sites used to ride beside it; the shine is a model tile since 2014.)
mut parked_minimap: ResMut<crate::minimap::MinimapWidget>,
// The seam scale the engine's text-metric caches were answered under. When `s` moves (window
// resize / fullscreen toggle / uiScale change), every cached measure is stale — see the
// invalidation below.
//
// Deliberately NOT a `VmMemo` (decision 1290's sweep): this is a fact about the RASTER, not
// about what the VM has been told, and it stays true across the VM's death and rebirth. A fresh
// VM has no cached measures to invalidate and no measurer at all, and the re-seat below already
// catches that on its own test (`!script.has_text_measurer()`).
mut last_seam: Local<f32>,
// The `scale_factor` this pass last answered measures under — the other half of the same
// staleness edge (decision 1342). `0.0` until the first frame, which is also a real edge.
mut last_dpi: Local<f32>,
// The uiScale dial folded into the seam scale (decision 0584).
ui_scale: Res<super::UiScaleCvar>,
// ── The extract gate's memory (decision 0740): last frame's conversion inputs ─────────────
// The conversion loop below is a pure function of (extracted, text_ui, the RASTER
// ENVIRONMENT, the portrait token map, the glyph-sheet generation) — the sprite caches are
// monotone path→handle, so equal inputs reproduce the same `UiQuads` the diff would then
// discard. Capture mode never skips (the harness wants exact per-frame output, including the
// cursor-icon quad's live mouse position).
//
// The raster environment is window size × seam scale × **`scale_factor`**, and that last term
// is decision 1342's correction. A monitor hop at an unchanged window size moves nothing else
// in this list, but it changes the integer device-pixel size every glyph rasterizes at
// (`TextEngine::ppem`) — so the quads held from last frame are the wrong size and the gate
// must miss. (1339 caught the same hole through an atlas-bake counter, which was the right
// fix for a design where the atlas re-baked; there is nothing to re-bake now, so the term that
// survives is the one that actually moved.)
//
// The generation term is the glyph sheet RESETTING — the one event that moves a cached cell's
// UV, which happens when the sheet fills and is repacked from empty. Held quads carry the old
// UVs and would draw letters as fragments of other letters.
//
// A [`crate::ui_script::VmMemo`] because a skip is not only a quad-conversion skip: it also skips
// `set_link_spans` and the minimap-slot / booth-pane refills, which are pushes INTO the VM. The
// VM lives for one login (decision 1290), so a fresh VM must never be gated on what the previous
// one extracted — its first frame is always a real conversion.
mut prev: Local<crate::ui_script::VmMemo<GateInputs>>,
// This frame's phase split, published for whoever asked (the `[ui-cost]` line, `hover_log`).
mut ui_cost: ResMut<super::UiFrameCost>,
) {
let (capture, ui_cost_wanted) = run;
let Some(mut script) = script else {
// No VM ⇒ no UI is sampling any booth pane. The panes map must not outlive its writer:
// every OTHER return in this system provably keeps pane presence unchanged (the settled
// gate compares the whole extracted list; the splice refuses `portrait_unit` entries),
// but a VM dying mid-world with the character window up would strand its pane here and
// the paper-doll camera would render behind a dead UI until the next full conversion.
booths.panes.0.clear();
return;
};
let prev = prev.get(&script);
let Ok(window) = window.single() else {
// Same law as the no-VM arm: no window, no sampling — a pane map with no writer lies.
booths.panes.0.clear();
return;
};
let (w, h) = (window.width(), window.height());
// The 768-virtual UI space (decision 0582 — byte law: the client's FrameXML space is ALWAYS
// 768 units tall, every aspect, mapped to the window; wow-re ui.md's converter identity
// `f(screenH) = 768` + the caret `H_px/192` law), times the uiScale dial (decision 0584 —
// the VM's screen is `768/uiScale` units tall, so a dial below 1 shrinks everything). The VM
// lives entirely in that space: this seam scales quads ×s on the way out, mouse ÷s on the
// way in (input.rs), and measures ÷s on the way back. At a 768-tall window with the dial at
// 1 the whole pipeline is bit-identical to the pre-virtual behavior.
let s = super::seam_scale(h, ui_scale.0);
// The raster environment moved (resize / fullscreen / uiScale): every text metric the engine
// caches — FontString measures, chat row counts, editbox advance tables, the digit feed — was
// answered under the OLD `s`, and integer-stepped advances measured at one raster size do not
// rescale to another (the font-size snap alone shifts a string's unit width by several
// percent — enough that a boot-size measure fails the fullscreen fit test and the ellipsis
// eats fitting text: the director's "Contr..." rows, reproduced by `WOW_RESIZE`). Declare the
// staleness at the seam and let every round-trip re-answer under the new `s`.
//
// **The DPI is the other half of the same edge** (decision 1342). A logical height becomes an
// integer DEVICE-pixel raster size, so a monitor hop at an unchanged window size leaves `s`
// exactly where it was and still moves every measured width. (1296/1339 caught this through
// the atlas's bake generation, which followed the same two terms; with nothing to re-bake, the
// honest thing to watch is the term itself.)
let dpi = window.scale_factor();
// The tile renderer sizes its cells in device pixels off this (decision 2008).
booths.tiles.dpi = dpi;
let generation = font_atlas.as_deref().map(|a| a.generation);
let seam_moved = *last_seam != s || *last_dpi != dpi;
if seam_moved {
if *last_seam != 0.0 {
script.invalidate_text_measures();
}
*last_seam = s;
*last_dpi = dpi;
}
// …and re-seat the VM's own font engine at the same edge, for the same reason: an
// [`crate::ui_text::AtlasMeasurer`] answers only for the seam it was built under. This is what
// makes a `SetText` → `GetStringWidth` pair *inside one Lua update* return a real number
// instead of 0 — the reference answers that getter inline (`0x79e510` → `0x772890`), and the
// corpus writes it that way (`Bagnon_Forever/database/ui.lua:58-59`).
//
// Seated BEFORE the tick below, so the first update that runs already has it, and rebuilt only
// on the seam edge or the frame the atlas first exists — an `Arc` clone and an `f32`, never a
// per-frame cost. This is the *frame* edge; the *load* edge is
// [`super::lifecycle::load_ingame_ui_on_world_entry`]'s own call to the same seat (2028).
if let Some(atlas) = font_atlas.as_deref() {
if seam_moved || !script.has_text_measurer() {
seat_text_measurer(&mut script, atlas, s);
}
}
// Phase spans (visible under `bevy/trace_chrome`): this system is the biggest flat CPU cost
// on an idle frame, and the ledger can only rank what has a name — tick (Lua OnUpdate),
// resolve (layout), measure (the text round-trips), extract (tree walk + rasterize), diff.
// The phase marks feed two consumers now: the `[ui-cost]` line and the hover recorder
// (`hover_log`), which writes the same split per frame to a file. Either one arms them.
let printing = ui_cost_enabled();
let cost_on = printing || ui_cost_wanted.0;
let solves_before = cost_on.then(|| script.layout_solves());
let derives_before = cost_on.then(|| script.layout_derivations());
// The measure counters are PER FRAME: `measure_fontstrings` adds to them, and both publish
// sites below carry them forward so the two measure passes sum into one frame's row. Nothing
// else zeroes them, so without this the recorder's "re-shaped strings" column is a lifetime
// total wearing a per-frame label — it read a flat 219/frame on a glue screen that measures
// nothing after startup, and cost one wrong theory before the CSV contradicted it.
ui_cost.measured = 0;
ui_cost.measured_texts.clear();
let mut t_mark = cost_on.then(std::time::Instant::now);
// Marks phase boundaries under the meter: returns μs since the previous mark and re-arms.
// With the meter off it does nothing (a run carries six no-op calls, not six clock reads).
let mut lap = move || -> u128 {
if !cost_on {
return 0;
}
t_mark
.replace(std::time::Instant::now())
.map_or(0, |t| t.elapsed().as_micros())
};
{
let _span = bevy::log::info_span!("ui_script: tick").entered();
let resized = script.set_screen_size(w / s, if h > 0.0 { h / s } else { 768.0 });
// A resize re-runs the bottom-stack manage pass (decision 1499). Anchors follow the new
// screen rect by themselves; what does not is a seat somebody COMPUTED from the old
// height — the open-bag stack starts a fresh column when the current one would run off
// the top, and that decision is made from `GetScreenHeight()` at layout time. Without
// this, dragging the window smaller leaves the bag columns wrapped for the old height
// until the next bag opens. Existence-guarded: the pass is defined by `UIParent.xml`,
// which is in-game UI, and this system also runs on the glue screens.
if resized {
let _ = script
.run("if UIParent_ManageFramePositions then UIParent_ManageFramePositions() end");
}
script.tick(time.delta_secs());
}
// The frame's clock pair ([`super::UiClock`]): the VM value the tick just produced, anchored
// at the exact `Instant` whose frame-to-frame deltas that clock accumulates — `Time<Real>`'s
// own last-update. Every Instant→GetTime conversion goes through this pair; sampling
// `Instant::now()` at a conversion site instead re-measures the tick→feed scheduling gap
// every frame and wobbles the derived start by that jitter (the resource's own doc).
*ui_clock = super::UiClock {
anchor: time.last_update().unwrap_or_else(std::time::Instant::now),
ui_now: script.now(),
};
let us_tick = lap();
// ── The FontString measure round-trip, BEFORE the resolve ────────────────────────────────
// `fontstrings_needing_measure` reads only `region_data` — each FontString's text, font, and
// its explicit/wrap-pinned size — and never a resolved rect; nothing in `resolve` writes a
// region's size either. So the answers do not need a layout pass to exist, and running them
// first means the frame's ONE resolve already sees them.
//
// Measuring after the resolve (the old order) cost a second FULL solve on every frame whose
// text changed, because answering a measure moves the anchor solve's read set. On the shipped
// UI that solve walks 2,164 frames and sweeps 6,297 regions per round — for a hover whose
// change touches ten FontStrings inside one frame. Measured on the default UI with a tooltip
// content change per frame (`resolve_bench`): 3.22 ms/frame at 2.00 solves → 2.37 ms at 1.00.
// The live shape this comes from: a hover frame that solved cost ~10 ms against ~0.2 ms for
// one that did not (`WOW_HOVER_LOG`, director's run).
let mut measured_any = false;
if let Some(atlas) = font_atlas.as_deref_mut() {
measured_any = measure_fontstrings(&mut script, atlas, s, &mut ui_cost, ui_cost_wanted.0);
}
{
let _span = bevy::log::info_span!("ui_script: resolve").entered();
script.resolve();
}
// The backstop: only when this frame actually measured something can a fresh request exist
// that the pass above could not have seen. Empty on every frame in the bench — and skipping it
// otherwise keeps the quiet frame at ONE sweep of the 6,297-region map, not two.
if measured_any {
if let Some(atlas) = font_atlas.as_deref_mut() {
if measure_fontstrings(&mut script, atlas, s, &mut ui_cost, ui_cost_wanted.0) {
script.resolve();
}
}
}
let us_resolve = lap();
let measure_span = bevy::log::info_span!("ui_script: measure").entered();
// The message-line half of the round-trip: chat ring lines ask for their wrapped ROW COUNT at
// the frame's resolved width, so the emit pass can stack real content heights (a long line
// pushes older lines up instead of overlapping them). Same-frame like the FontString half, but
// no re-resolve — rows shift only the emitted bands, never the anchor graph.
if let Some(atlas) = font_atlas.as_deref_mut() {
let requests = script.message_lines_needing_measure();
if !requests.is_empty() {
let rows: Vec<(u32, u32, u16, u64)> = requests
.iter()
.map(|r| {
let n = crate::ui_text::measure_wrapped_rows(
&mut atlas.lock(),
&r.text,
// wrap_width is the RESOLVED frame width (scale already in it) — ×s only;
// the font height is frame-local — ×s × the frame's scale, the drawn size.
r.wrap_width * s,
crate::ui_text::FontSpec {
path: r.font.as_deref(),
// The band's own drawn px (one-to-one regime; inert ≤ 32).
height: crate::ui_text::drawn_px(r.height, None, s * r.scale),
outline: r.outline, // step-law width bias, as in the measures above
alpha_gradient: None,
},
);
(r.frame, r.index, n, r.key)
})
.collect();
script.set_message_line_rows(&rows);
}
}
drop(measure_span);
let us_measure = lap();
// The player's half of the error contract before the host's (decision 1305): every caught
// script error goes to the Lua error handler — `_ERRORMESSAGE` → the ScriptErrors dialog
// since BasicControls installs it, an addon's own handler if it chose one — and then the
// same errors drain to the log as always. Dispatch first so a handler that itself fails
// lands in this frame's drain, not the next one's.
script.dispatch_script_errors_to_handler();
for err in script.take_errors() {
warn!("ui_script: {err}");
}
for w in script.take_warnings() {
warn!("ui_script: {w}");
}
let extract_span = bevy::log::info_span!("ui_script: extract").entered();
let mut out = Vec::new();
let mut assets = world_assets;
let mut minimap_slot = None;
// Hyperlink spans collected while rasterizing message-line Text quads (frame-targeted only —
// chat lines; FontString-region links are a later arc), fed back to the engine's click
// hit-test after the loop. Rects flip back to the engine's y-up space.
let mut link_spans: Vec<(
benilla_ui::widget::FrameHandle,
benilla_ui::layout::Rect,
String,
String,
)> = Vec::new();
// The EditBox advance-table answer (the metrics half of the mouse/selection law): the
// focused box's display string measured per-byte under the same shaping + step law as its
// draw, so the engine's click→index, drag-select, and scroll window land exactly on the
// glyphs. (The request reads the region's load-resolved font; the transient state-font
// overlay below never applies to an editbox text region.)
if let Some(atlas) = font_atlas.as_deref_mut() {
if let Some(req) = script.editbox_advances_request() {
let spec = crate::ui_text::FontSpec {
path: req.font.as_deref(),
// Measured at the DRAWN raster size (seam × the box frame's scale) but divided
// back by the seam alone: the advances return in screen UI units, the space the
// engine's ÷s mouse feed and the box's scale-multiplied rect live in (the
// request's `scale` doc).
height: crate::ui_text::drawn_px(req.height, None, s * req.scale),
outline: req.outline,
alpha_gradient: None, // alpha never changes metrics
};
let cum: Vec<f32> = crate::ui_text::line_advances(&mut atlas.lock(), &req.text, spec)
.iter()
.map(|a| a / s)
.collect();
// A multiline box also gets its wrapped-row starts + row pitch — the same wrap pass
// the draw uses, so the engine's (row, x) caret/click law lands on the drawn rows.
// Advances/pitch return in UI units (÷s): the engine's click→index and row math
// compare them against the ÷s mouse feed.
let (rows, cell_h) = match req.wrap_width {
Some(w) => crate::ui_text::line_rows(&mut atlas.lock(), &req.text, w * s, spec),
None => (vec![0], 0.0),
};
script.set_editbox_advances(req.id, req.key, cum, rows, cell_h / s);
}
}
// The focused edit box's text-UI geometry (RF-0082 leaves caret/highlight geometry to the
// host): which Text quad is the box's, the scroll window to draw, and the caret/selection
// x-spans within it — advance-derived engine-side (the blink phase too, `0x77a790`'s 0.5 s
// law in the engine tick), matched in the Text arm below.
let text_ui = script.focused_editbox_text_ui();
let _ = lap(); // re-arm: the editbox seam above is not the walk's cost
let extracted = script.extract();
let us_exm = lap();
let n_extracted = extracted.len();
// ── The extract gate (decision 0740) ──────────────────────────────────────────────────────
// Every input the conversion below reads, compared against last frame's. Equal inputs make
// the loop a pure re-derivation of `UiQuads` the diff at the bottom would discard — at the
// LBRS pin that was every single settled frame, ~0.3 ms/frame of glyph re-rasterization for
// an identical `Vec`. On a skip, `quads`/the parked minimap slot/the
// engine's link spans all keep last frame's values, which the equal inputs prove are this
// frame's values too.
let dims = (w.to_bits(), h.to_bits(), s.to_bits(), dpi.to_bits());
let settled = capture.is_none()
&& prev.dims == Some(dims)
&& prev.generation == generation
&& text_ui == prev.text_ui
&& booths.images.0 == prev.portraits
&& extracted == prev.extracted;
if settled {
drop(extract_span);
let us_cmp = lap();
if cost_on {
let solves = script.layout_solves() - solves_before.unwrap_or(0);
*ui_cost = super::UiFrameCost {
measured: ui_cost.measured,
measured_texts: std::mem::take(&mut ui_cost.measured_texts),
tick: us_tick,
resolve: us_resolve,
measure: us_measure,
extract: us_exm,
convert: us_cmp,
diff: 0,
quads: quads.quads.len(),
solves,
derives: script.layout_derivations() - derives_before.unwrap_or(0),
skipped: true,
spliced: 0,
dropped: 0,
};
}
if printing {
let solves = script.layout_solves() - solves_before.unwrap_or(0);
eprintln!(
"[ui-cost] tick={us_tick} resolve={us_resolve} measure={us_measure} \
exm={us_exm} exa={us_cmp} diff=0 eq={n_extracted} quads={} \
solves={solves} changed=0 skip=1",
quads.quads.len()
);
}
return;
}
// `WOW_UI_GATE=1` — why the gate MISSED. A skip is worth the whole conversion, so the
// question "what moved?" is where a per-frame cost always ends, and a `Vec` inequality does
// not answer it. Off by default, like the `[ui-cost]` meter beside it.
if gate_log_enabled() {
report_gate_miss(
&script,
&extracted,
&prev.extracted,
prev.dims == Some(dims),
prev.generation == generation,
text_ui == prev.text_ui,
booths.images.0 == prev.portraits,
);
}
// ── The per-entry splice: 1361's shape one layer up ──────────────────────────────────────
// The gate above is all-or-nothing, so ONE animating entry (the resting blink, a sweeping
// cooldown) used to re-convert the whole interface every frame. When the only input that
// moved is the extracted list itself, the lists are index-aligned (same length — the
// traversal is stable when nothing was created/destroyed/restacked), few entries differ,
// and every differing entry converts to nothing but quads (no link spans, no minimap slot,
// no booth panes), the changed entries re-convert alone — through the same [`convert_entry`]
// body, which is what makes the spliced output equal the full path's by construction — and
// stitch into last frame's `UiQuads` at the ranges `prev.spans` remembers. Everything else
// (the side channels, the panes map, the engine's link spans) keeps last frame's values,
// which the unchanged entries prove are this frame's too: the settled path's own argument,
// applied per entry.
'splice: {
// `WOW_UI_GATE=1` names why the SPLICE declined too — one line per frame, the same dial
// and the same posture as `[ui-gate]` above, because "the gate missed, and then the
// splice missed too" is one question asked twice. Histogram it in the shell.
macro_rules! no_splice {
($($arg:tt)*) => {{
if gate_log_enabled() {
eprintln!("[ui-splice] miss: {}", format_args!($($arg)*));
}
break 'splice;
}};
}
if capture.is_some() {
no_splice!("capture mode");
}
if ui_diff_enabled() {
no_splice!("WOW_UI_DIFF pins the full path");
}
if prev.dims != Some(dims) {
no_splice!("window dims moved");
}
if prev.generation != generation {
no_splice!("font atlas generation moved");
}
// The focused edit box carries the caret BLINK — wall-clock state that appears in no
// entry, and the one time-dependent input the Text arm reads. It is what makes text
// splice-safe at all (decision 1638): everything else the conversion reads is either in
// the entry or pinned by a guard above.
if text_ui != prev.text_ui {
no_splice!("focused editbox text-ui moved");
}
if booths.images.0 != prev.portraits {
no_splice!("portrait sources moved");
}
// spans describe last conversion's `quads.quads` — if any future writer replaces the
// base lane out from under this pass, degrade to the full conversion instead of
// mis-stitching.
if prev.spans.len() != prev.extracted.len()
|| prev.spans.last().copied().unwrap_or(0) as usize != quads.quads.len()
{
no_splice!("span table stale");
}
let align = align_entries(&prev.extracted, &extracted);
// The entries this frame has to pay for, bounded: a real UI transition (a window
// opening) moves many, and the full conversion is the right tool there anyway.
const SPLICE_MAX: usize = 64;
let changed: Vec<usize> = align
.source
.iter()
.enumerate()
.filter_map(|(j, s)| s.is_none().then_some(j))
.collect();
if changed.len() + align.dropped.len() > SPLICE_MAX {
no_splice!(
"{} entries changed and {} dropped, over the {SPLICE_MAX} bound",
changed.len(),
align.dropped.len()
);
}
// The all-equal case belongs to the settled gate above; reaching here with nothing to do
// means a comparison razor slipped — take the full path rather than risk skipping a
// change. (A frame that only *drops* entries has real work and is not this case.)
if changed.is_empty() && align.dropped.is_empty() {
no_splice!("no entry differs (a comparison razor slipped)");
}
// Checked on both ends: every entry being converted, and every entry LEAVING the list —
// a departing minimap slot would have to be un-parked, which the splice has
// no way to do.
if let Some(eq) = changed
.iter()
.map(|&j| &extracted[j])
.chain(align.dropped.iter().map(|&i| &prev.extracted[i]))
.find(|eq| !splice_simple(eq))
{
no_splice!(
"a {} entry ({}) is not spliceable",
content_kind(&eq.content),
script.target_owner_name(eq.target).unwrap_or_default()
);
}
// Re-convert just those entries. The scratch side channels are a tripwire: `simple`
// makes them unreachable today, and if an arm ever grows a new side effect the full path
// is the only safe answer.
let mut scratch: Vec<UiQuad> = Vec::new();
let mut scratch_ranges: Vec<std::ops::Range<usize>> = Vec::with_capacity(changed.len());
let mut scratch_links = Vec::new();
let mut scratch_slot = None;
for &j in &changed {
let at = scratch.len();
convert_entry(
extracted[j].clone(),
s,
w,
h,
dpi,
&mut assets,
&mut images,
&mut font_atlas,
&mut booths,
text_ui.as_ref(),
capture.is_some(),
&mut scratch,
&mut scratch_links,
&mut scratch_slot,
);
scratch_ranges.push(at..scratch.len());
}
if !scratch_links.is_empty() || scratch_slot.is_some() {
no_splice!("a re-converted entry wrote a side channel");
}
// The in-place case: nothing was inserted or removed, so every kept entry is still at
// its own index and every changed one still owns the same stretch of the list. This is
// the resting blink's shape and it writes bytes without moving any.
let identity = prev.extracted.len() == extracted.len()
&& align
.source
.iter()
.enumerate()
.all(|(j, s)| s.is_none_or(|i| i == j));
let counts_stable = identity
&& changed.iter().zip(&scratch_ranges).all(|(&j, r)| {
let (a, b) = span_bounds(&prev.spans, j);
b - a == r.len()
});
let mut dirtied = false;
if counts_stable {
for (&j, r) in changed.iter().zip(&scratch_ranges) {
let (a, b) = span_bounds(&prev.spans, j);
if quads.quads[a..b] != scratch[r.clone()] {
quads.quads[a..b].clone_from_slice(&scratch[r.clone()]);
dirtied = true;
}
}
} else {
// Stitch a fresh list: the kept runs MOVED out of last frame's (a `UiQuad` carries an
// `Arc` texture handle, and copying ~1,400 of them a frame to rebuild a list is pure
// refcount traffic for no pixel), the re-converted entries from the scratch, and a
// re-derived span table. `prev.held` is the ping-pong buffer: the drain leaves last
// frame's allocation empty and we keep it for the next stitch, so a steady stream of
// tooltip changes allocates nothing.
let mut old = std::mem::take(&mut prev.held);
std::mem::swap(&mut old, &mut quads.quads);
quads.quads.clear();
quads.quads.reserve(old.len() + scratch.len());
let mut new_spans: Vec<u32> = Vec::with_capacity(extracted.len());
{
let mut src = old.drain(..);
// How many of last frame's quads the drain has already yielded — kept runs are
// strictly increasing (the merge only ever advances), so one forward pass does it.
let mut cursor = 0usize;
let mut ci = 0usize;
for s in &align.source {
match s {
Some(i) => {
let (a, b) = span_bounds(&prev.spans, *i);
if a > cursor {
src.by_ref().nth(a - cursor - 1);
}
quads.quads.extend(src.by_ref().take(b - a));
cursor = b;
}
None => {
quads
.quads
.extend_from_slice(&scratch[scratch_ranges[ci].clone()]);
ci += 1;
}
}
new_spans.push(quads.quads.len() as u32);
}
}
prev.held = old;
prev.spans = new_spans;
dirtied = true;
}
if dirtied {
quads.dirty = true;
}
prev.extracted = extracted;
if splice_verify_enabled() {
verify_splice(
prev,
&quads,
s,
w,
h,
dpi,
&mut assets,
&mut images,
&mut font_atlas,
&mut booths,
text_ui.as_ref(),
capture.is_some(),
&script,
);
}
drop(extract_span);
let us_spl = lap();
if cost_on {
let solves = script.layout_solves() - solves_before.unwrap_or(0);
*ui_cost = super::UiFrameCost {
measured: ui_cost.measured,
measured_texts: std::mem::take(&mut ui_cost.measured_texts),
tick: us_tick,
resolve: us_resolve,
measure: us_measure,
extract: us_exm,
convert: us_spl,
diff: 0,
quads: quads.quads.len(),
solves,
derives: script.layout_derivations() - derives_before.unwrap_or(0),
skipped: false,
spliced: changed.len(),
dropped: align.dropped.len(),
};
}
if printing {
let solves = script.layout_solves() - solves_before.unwrap_or(0);
eprintln!(
"[ui-cost] tick={us_tick} resolve={us_resolve} measure={us_measure} \
exm={us_exm} exa={us_spl} diff=0 eq={n_extracted} quads={} \
solves={solves} changed={} skip=0 spliced={} dropped={}",
quads.quads.len(),
u8::from(dirtied),
changed.len(),
align.dropped.len()
);
}
return;
}
prev.dims = Some(dims);
prev.generation = generation;
prev.text_ui = text_ui.clone();
prev.portraits = booths.images.0.clone();
prev.extracted = extracted.clone();
// This frame's booth panes, refilled by the loop below (decision 1069). Cleared only HERE, on
// the un-skipped path: a settled frame draws exactly what the last one did, so the map it left
// is still this frame's truth — clearing it above the gate would make every quiet frame put the
// body panes' cameras to sleep and freeze their animation.
booths.panes.0.clear();
let mut spans: Vec<u32> = Vec::with_capacity(prev.extracted.len());
for eq in extracted {
convert_entry(
eq,
s,
w,
h,
dpi,
&mut assets,
&mut images,
&mut font_atlas,
&mut booths,
text_ui.as_ref(),
capture.is_some(),
&mut out,
&mut link_spans,
&mut minimap_slot,
);
spans.push(out.len() as u32);
}
prev.spans = spans;
// The payload held on the cursor draws last (a 32×32 icon at the mouse, over the whole UI) —
// but ONLY in capture mode (decision 0216 §5): a normal run shows it as the hardware cursor
// instead (`crate::cursor`), which can't appear in a screenshot's pixels, so the quad is the
// capture harness's stand-in. Any arm, matching the hardware cursor's own `payload_icon`
// (item/spell/action alike — the item-only read here predated the spell/action producers).
// Purely visual either way — the drag state lives in the engine; the wire settles the move.
if capture.is_some() {
use benilla_ui::script::CursorPayload;
let texture = script.cursor_payload().and_then(|p| match p {
CursorPayload::Item(i) => i.texture,
CursorPayload::Spell(s) => s.texture,
CursorPayload::Action(a) => a.texture,
CursorPayload::Macro(m) => m.texture,
CursorPayload::PetAction(p) => p.texture,
CursorPayload::StablePet(p) => Some(p.texture),
CursorPayload::Merchant(m) => m.texture,
CursorPayload::Money(m) => {
Some(benilla_ui::script::coin_icon(i64::from(m.copper)).to_string())
}
});
if let (Some(texture), Some(pos)) = (texture, window.cursor_position()) {
if let Some(handle) = assets
.as_mut()
.and_then(|a| a.sprite_texture(&texture, &mut images))
{
out.push(cursor_icon_quad(pos, handle));
}
}
}
// The rasterized hyperlink spans replace last frame's set — the engine's release dispatch
// (`OnHyperlinkClick`) hit-tests against exactly what is on screen.
script.set_link_spans(link_spans);
// Park this frame's Minimap widget slot (or clear it — a hidden cluster extracts nothing);
// `minimap::emit_minimap` runs later in the frame (UiQuadAppend) and fills the hole.
parked_minimap.0 = minimap_slot;
drop(extract_span);
let us_exa = lap();
let _span = bevy::log::info_span!("ui_script: diff").entered();
let n_quads = out.len();
let changed = quads.quads != out;
if changed {
// `WOW_UI_DIFF=1` — the base-lane half of the rebuild-trigger probe (`ui_pass`' twin):
// names the first Lua-UI quad that differs from last frame's extraction.
if ui_diff_enabled() {
match quads.quads.iter().zip(&out).position(|(a, b)| a != b) {
Some(i) => {
let (b, a) = (&quads.quads[i], &out[i]);
eprintln!(
"[ui-diff-base] quad {i}/{n_quads}: tex={:?} rect {:?} -> {:?} uv_changed={} color_changed={}",
a.texture.as_ref().and_then(|t| t.path()),
b.rect,
a.rect,
a.uv != b.uv,
a.color != b.color,
);
}
None => eprintln!(
"[ui-diff-base] quad COUNT changed: {} -> {}",
quads.quads.len(),
out.len()
),
}
}
quads.quads = out;
quads.dirty = true;
}
if cost_on {
let us_diff = lap();
let solves = script.layout_solves() - solves_before.unwrap_or(0);
*ui_cost = super::UiFrameCost {
measured: ui_cost.measured,
measured_texts: std::mem::take(&mut ui_cost.measured_texts),
tick: us_tick,
resolve: us_resolve,
measure: us_measure,
extract: us_exm,
convert: us_exa,
diff: us_diff,
quads: n_quads,
solves,
derives: script.layout_derivations() - derives_before.unwrap_or(0),
skipped: false,
spliced: 0,
dropped: 0,
};
if printing {
eprintln!(
"[ui-cost] tick={us_tick} resolve={us_resolve} measure={us_measure} \
exm={us_exm} exa={us_exa} diff={us_diff} eq={n_extracted} quads={n_quads} \
solves={solves} changed={} skip=0",
u8::from(changed)
);
}
}
}
/// One extracted entry converted to its screen quads, pushed onto `out` — with the side channels
/// some arms carry: chat link spans (Text), the minimap widget slot (Minimap), the booth pane
/// aspects (portrait-bound Texture), the model tile requests (ModelPane, decision 2013). The ONE
/// conversion body: the full pass and the per-entry
/// splice both call this, which is what makes the splice's output equal the full path's by
/// construction. An arm is splice-eligible only if it writes nothing but `out` — keep
/// [`splice_simple`] in agreement when an arm's side effects change.
#[allow(clippy::too_many_arguments)] // the conversion's whole environment, threaded explicitly
fn convert_entry(
eq: benilla_ui::script::ExtractedQuad,
s: f32,
// The window, logical px. `h` flips y-up WoW space into the y-down quad pass; `w` is here for
// the one producer whose law needs the SCREEN's shape — a model tile's layout unit and
// particle unit run on the screen diagonal (decision 2013).
w: f32,
h: f32,
// The window's DEVICE scale, for the one arm that resamples its art to physical pixels: the
// nameplate border's 0188 sharpen, which is a no-op at any other size.
dpi: f32,
assets: &mut Option<ResMut<WorldAssets>>,
images: &mut Assets<Image>,
font_atlas: &mut Option<ResMut<UiFontAtlas>>,
booths: &mut crate::portrait::BoothBridge,
text_ui: Option<&benilla_ui::script::EditBoxTextUi>,
caret_pinned: bool,
out: &mut Vec<UiQuad>,
link_spans: &mut Vec<(
benilla_ui::widget::FrameHandle,
benilla_ui::layout::Rect,
String,
String,
)>,
minimap_slot: &mut Option<crate::minimap::MinimapSlot>,
) {
let Some(r) = eq.rect else { return };
// WoW UI space is y-up from the bottom-left in 768-virtual units; the quad pass is
// y-down window px from the top-left — scale ×s, then flip through the window height.
let rect = Rect::new(r.left * s, h - r.top * s, r.right * s, h - r.bottom * s);
if let Some((at, r)) = ui_pick_point() {
report_ui_pick(&eq, rect, at, r);
}
// The ScrollFrame clip (decision 0112), through the same conversion as `rect` —
// `UiQuad::clip` is the CPU-clip stand-in `ui_pass` already applies uniformly to
// every quad (texture, backdrop, and glyph alike), so this is the entire app-side plumb.
let clip = eq
.clip
.map(|c| Rect::new(c.left * s, h - c.top * s, c.right * s, h - c.bottom * s));
match eq.content {
// Frames draw nothing themselves in v1 (regions carry the visuals).
QuadContent::Frame => {}
// The `<Minimap>` widget's content hole: parked for the minimap renderer (an
// UiQuadAppend producer), which fills it at this exact z — the widget slot itself
// emits nothing here (decision 0203 phase 1).
QuadContent::Minimap { zoom, inside_zoom } => {
*minimap_slot = Some(crate::minimap::MinimapSlot {
rect,
z: eq.z,
zoom,
inside_zoom,
alpha: eq.alpha,
});
}
// A `<Model>`/`<PlayerModel>` pane's content: the off-screen body bake its window keeps,
// sampled square edge to edge. The pane→booth join is
// [`crate::portrait::model_pane_booth`], whose doc says why it is a table of frame names.
//
// This is the same draw the `portrait_unit` arm below makes for a Texture region, and it
// used to BE that draw: until decision 1751 a file of ours put a Texture inside each pane
// and named the slot with `BenillaSetBoothTexture`. A migrated window runs the reference's
// own file, which declares a bare `<PlayerModel>` and no Texture at all — so the widget
// itself has to draw, or the character sheet's paper doll is an empty rectangle.
//
// A FILE pane is a tile (2013; the map arrow among them since 2015); a UNIT pane joins
// the booth its window keeps. A pane with no name, or one no window claims, draws
// nothing. Nothing is stubbed white.
QuadContent::ModelPane {
handle,
name,
model,
facing,
model_scale,
position,
own_alpha,
icon,
camera,
light,
fog,
} => {
use crate::portrait::PortraitSource;
// A FILE pane (decision 2008): publish what the tile renderer needs — the pane's
// device-pixel size, the render law's unit ladder off it, and where the composite
// goes (its rect, paint key, alpha and clip). The request is idempotent, so the
// memoized conversion may re-publish it freely; which panes draw is the engine's
// paint list, and the quad itself is the renderer's per-frame output
// (`ui_models::compose_tiles`, decision 2023) — NOT pushed here, because a cell
// packed after this conversion would wait on a re-conversion nothing triggers.
if let Some(path) = model.as_deref() {
let tiles = &mut booths.tiles;
let dpi = tiles.dpi.max(0.01);
let aspect = if h > 0.0 { w / h } else { 4.0 / 3.0 };
let diag = (aspect * aspect + 1.0).sqrt();
let layout = eq.scale;
let size_px = UVec2::new(
(rect.width() * dpi).round().max(1.0) as u32,
(rect.height() * dpi).round().max(1.0) as u32,
);
tiles.requests.insert(
handle,
crate::ui_models::TileRequest {
path: path.to_string(),
size_px,
// `1 model unit = 1280 · modelScale · layoutScale` FrameXML units (§3).
px_per_unit: 1280.0 * model_scale * layout * s * dpi,
// `SetPosition` is in layout units: `768 · √(a²+1)` FrameXML per unit.
pos_px_per_unit: 768.0 * diag * layout * s * dpi,
// A particle's half-extent: eye space, neither scale (§6).
star_px_per_unit: 768.0 * diag * s * dpi,
facing,
position: Vec3::new(position.0, position.1, position.2),
// The PERSPECTIVE leg's root (decision 2027), which is in model units,
// not pixels: `T(pos · layoutScale) · R(facing) · S(s)` with
// `s = G48·(5/3)·modelScale·layoutScale` — and `G48·(5/3)` is exactly
// `√((4/3)²+1)/√(a²+1)`, the 4:3 renormalizer (camera-law §11.2). The
// camera is carried through the same matrix, so both terms cancel for
// framing; they are here because the record's near/far are NOT scaled
// with them, and because the geometry has to be drawn somewhere.
root_scale: (5.0 / 3.0) / diag * model_scale * layout,
root_pos: Vec3::new(position.0, position.1, position.2) * layout,
camera,
light,
fog,
icon: icon.clone(),
rect,
z_key: eq.z,
// The instance draws at the widget's OWN alpha (render law §4.4).
alpha: own_alpha,
clip,
},
);
if crate::ui_models::trace_on() {
info!(
"tile-trace: composite {path} pane {handle:?} {} rect=({:.1},{:.1})-({:.1},{:.1}) size_px={}x{} z={:?} alpha={own_alpha:.2} cell={:?} atlas={:?}",
name.as_deref().unwrap_or("<anonymous>"),
rect.min.x,
rect.min.y,
rect.max.x,
rect.max.y,
size_px.x,
size_px.y,
eq.z,
tiles.cells.get(&handle),
tiles.atlas_size
);
}
return;
}
let Some(slot) = name.as_deref().and_then(crate::portrait::model_pane_booth) else {
return;
};
// The aspect the bake must render at, and the fact that it is on screen at all
// (decision 1069) — published before the readiness check below for the same reason the
// `portrait_unit` arm does it there: a pane whose bake has not landed yet is still a
// pane being drawn, and gating the publish on the image would be a standoff.
if rect.height() > 0.0 {
booths
.panes
.0
.insert(slot.to_string(), rect.width() / rect.height());
}
// The bake is a render target and carries PREMULTIPLIED colour; the 2D stand-in the
// slot shows while a model streams is an ordinary straight-alpha BLP.
let (handle, premultiplied) = match booths.images.0.get(slot) {
Some(PortraitSource::Live(h)) => (Some(h.clone()), true),
Some(PortraitSource::File(p)) => (
assets.as_mut().and_then(|a| a.sprite_texture(p, images)),
false,
),
None => (None, false),
};
let Some(handle) = handle else {
return;
};
out.push(UiQuad {
rect,
z_key: eq.z,
texture: Some(handle),
color: [1.0, 1.0, 1.0, eq.alpha],
premultiplied,
clip,
..default()
});
}
QuadContent::Texture {
path,
color,
additive,
tex_coords,
circular,
portrait_unit,
rotation,
desaturated,
} => {
// A live unit portrait (`SetPortraitTexture(region, unit)`): sample this token's
// source ([`crate::portrait::PortraitImages`]) — the off-screen model bake, or the
// ref's 2D TemporaryPortrait stand-in while the model streams in. Absent entry (no
// booth yet) draws nothing rather than the run-splitter's white default.
if let Some(token) = &portrait_unit {
use crate::portrait::PortraitSource;
// Publish the rect's aspect so a booth can bake at the shape it will be
// stretched into, and know it is on screen at all (decision 1069). Recorded
// before the readiness `continue` below — a pane whose bake hasn't landed yet is
// still a pane being drawn, which is also what keeps the gate below from being a
// chicken-and-egg (nothing drawn → no bake → nothing drawn). The region's rect is
// the whole answer because no pane crops its bake; a pane that grew `<TexCoords>`
// would have to fold that UV window in here too.
//
// ROUND bindings are recorded too, since 1576. The aspect is inert for them (both
// of 1069's consumers are body-booth-only — see [`crate::portrait::BoothPanes`]);
// what the row carries for a round slot is the fact of being drawn, which is the
// `"targettarget"` portrait's cost gate. The `circular` flag itself still decides
// the draw below, and nothing else changed here.
if rect.height() > 0.0 {
booths
.panes
.0
.insert(token.clone(), rect.width() / rect.height());
}
// The bake is a render target and carries PREMULTIPLIED colour; the 2D stand-in
// is an ordinary straight-alpha BLP. The quad pass has to be told which, or it
// premultiplies the bake a second time and erases every effect the pane draws
// over empty space (see [`crate::ui_pass::UiQuad::premultiplied`]).
let (handle, premultiplied) = match booths.images.0.get(token) {
Some(PortraitSource::Live(h)) => (Some(h.clone()), true),
Some(PortraitSource::File(p)) => (
assets.as_mut().and_then(|a| a.sprite_texture(p, images)),
false,
),
None => (None, false),
};
let Some(handle) = handle else {
return;
};
out.push(UiQuad {
rect,
z_key: eq.z,
texture: Some(handle),
// A portrait binding honours `<TexCoords>`/`SetTexCoord` like any other
// texture region — the bake is just the sampled image. The ref crops one
// this way: the character micro button samples the same portrait slot as
// the unit frame through a narrow (0.2..0.8, 0.0666..0.9) window, and
// swaps that window when the button is pushed. This branch used to pin
// UvRect::FULL, which silently squashed the whole square bake into
// whatever rect the region had.
uv: match tex_coords {
Some(TexCoords::Rect(edges)) => UvRect::from_tex_coords(edges),
Some(TexCoords::Corners(corners)) => UvRect::from_corners(corners),
None => UvRect::FULL,
},
color: [1.0, 1.0, 1.0, eq.alpha],
// The binding's mask flag: `SetPortraitTexture` regions cut the inscribed
// circle (the ref stamps the same shape into its 64² bake's alpha — and
// rounds the square stand-in art the same way); `BenillaSetBoothTexture`
// (the paper-doll model pane, decision 0208 §5) samples the bake square.
circular,
premultiplied,
clip,
..default()
});
return;
}
// An unset texture region (no file, no color — e.g. a cleared icon) draws nothing;
// defaulting it to white would paint phantom quads.
if path.is_none() && color.is_none() {
return;
}
// A portrait region samples the circular-masked variant so the square icon/model
// doesn't poke past the frame ring's thin band (SetPortraitToTexture, decision 0084).
//
// A path the archives don't have draws **nothing** — never a white slab. This arm
// used to fall through to `color.unwrap_or(WHITE)` with a `None` texture, which
// `ui_pass` renders as the shared 1×1 white image tinted white: an opaque white
// rectangle at the region's rect, which is how B221's macro icons reached the
// director's screen. wow-re settles what the reference does: `TextureCreate` does
// build an 8×8 placeholder, but `CSimpleTexture::SetTexture` (`0x770200`) checks the
// status severity and at ≥2 releases it and returns **without touching the widget's
// texture** — the widget keeps what it had, and Lua gets `nil`. Nothing goes white.
// We can't keep the *previous* art (a path is re-resolved per frame at extract, not
// latched at `SetTexture`), so the faithful-enough result is an empty cell; what
// matters is that it is never a phantom quad. The `Backdrop` and live-portrait arms
// already guard exactly this way.
//
// `assets` missing ENTIRELY is a data-less run (the headless UI tests), not a bad
// path — those keep the old behaviour rather than blanking every textured quad.
//
// `<TexCoords>`/`SetTexCoord` slices the sampled sub-rect. The 4-edge form
// `[left,right,top,bottom]` maps to raw UV corners (`left→u0, right→u1, top→v0,
// bottom→v1`) — carried through [`UvRect`] rather than a normalized `Rect` so a
// mirrored slice (`left>right`, e.g. PlayerFrameTexture) keeps its flip to the
// vertex buffer. The 8-arg affine form is already per-corner in the `push_quad`
// winding (the route-line quads). Absent = the full texture. Resolved BEFORE the
// handle because the wrap mode follows it:
let uv = match tex_coords {
Some(TexCoords::Rect(edges)) => UvRect::from_tex_coords(edges),
Some(TexCoords::Corners(corners)) => UvRect::from_corners(corners),
None => UvRect::FULL,
};
// A slice that runs PAST the texture is the reference's tiling idiom, not a crop:
// `SetTexCoord(0, n, 0, 1)` on an n-slots-wide strip repeats the art n times — the
// stance shelf's middle carries one slot per extra form exactly that way
// (stock `ShapeshiftBar_Update`). Clamp-sampled it smears the last column
// across the extra width instead. Clamp/repeat bake into the `Image`, so this picks
// a wrapped GPU image + cache entry — wrapped on the tiling axis ALONE
// (`tiling_axes`, decision 2000): the other axis spans the whole texture, and a
// `Repeat` sampler there is a bleed, not a tile — bilinear at `v = 0` weighs in the
// texture's last row, which on `ShapeshiftBarMiddle` is opaque grey, so every
// four-form stance bar drew a one-px grey hairline along the top of its middle
// strip, over the world. The `Backdrop` arm below keeps both axes on: its bg tiles
// both ways and its edge crops never reach the image edge (`inset_atlas_bleed`).
let wrap = tiling_axes(&uv);
let handle = match (path.as_deref(), assets.as_mut()) {
(Some(p), Some(a)) => {
// The tabard designer's emblem cells (1977): the reference installs a
// generated 128×64 / 128×32 image — white RGB carrying the emblem BLP's own
// alpha — into the Texture the setter was handed; the region carries that as
// a token path and the resolver builds exactly that image.
let resolved = if p == benilla_ui::script::nameplate::BORDER_TEXTURE {
// The V-plate's frame art, resampled to the quad's exact PHYSICAL size
// with the sharp kernel instead of GPU-magnified (0188 — the director's
// "sharpen the same frame", carried across decision 2148's move of the
// plate into the frame system). Keyed by that size, so it re-rasterises on
// a resize and is a cache hit on every frame in between. An addon that
// blanks the border with `SetTexture("")` never reaches here at all: the
// path is gone, and the arm above draws nothing.
let px = |v: f32| (v * dpi).round().max(1.0) as u32;
a.resampled_sprite(
p,
(px(rect.width()), px(rect.height())),
images,
crate::vplates::border::resample_sharp,
)
} else if let Some(blp) = benilla_ui::script::emblem_mask_path(p) {
a.emblem_mask_texture(blp, images)
} else if circular {
a.portrait_texture(p, images)
} else if wrap != (false, false) {
a.sprite_texture_wrapped(p, wrap, images)
} else {
a.sprite_texture(p, images)
};
if resolved.is_none() {
return;
}
resolved
}
_ => None,
};
// The atlas-cell guard (decision 1608): a `SetTexCoord` crop is a cell of a sheet,
// and bilinear magnification reaches past it into the neighbour unless the fragment
// is told where the cell ends. The world map's zone POIs are the case that forced it
// — `POIIcons` cell 15 is fully transparent and the cell above it is a coffin whose
// bottom row is opaque black, so every zone landmark wore a black hairline.
let uv_clamp = handle
.as_ref()
.and_then(|h| images.get(h))
.map(|img| img.texture_descriptor.size)
.and_then(|sz| uv_clamp_window(&uv, (sz.width, sz.height)));
// A pathless Texture region is a solid color; a textured one tints by it.
let mut color = color.unwrap_or([1.0, 1.0, 1.0, 1.0]);
color[3] *= eq.alpha;
out.push(UiQuad {
rect,
z_key: eq.z,
texture: handle,
uv,
uv_clamp,
color,
additive,
clip,
// The engine's SetRotation is counterclockwise-positive; the quad pass spins
// clockwise-on-screen (`UiQuad::rotation`) — negate to convert.
rotation: -rotation,
desaturated,
..default()
});
}
// The colour picker's hue disc: a generated image (there is no BLP that is a colour
// wheel), tinted by the widget's value. See [`colorselect`] for why one static image
// covers every colour.
QuadContent::ColorWheel => {
let Some(assets) = assets.as_mut() else {
return;
};
let handle =
assets.generated_sprite(colorselect::WHEEL_KEY, images, colorselect::wheel_pixels);
out.push(UiQuad {
rect,
z_key: eq.z,
texture: Some(handle),
uv: UvRect::FULL,
color: colorselect::wheel_tint(eq.alpha),
clip,
..default()
});
}
// Its brightness strip: one greyscale ramp tinted to the live hue at full value, which is
// exactly `rgb(h, s, v)` at every row.
QuadContent::ColorValue { hue, sat } => {
let Some(assets) = assets.as_mut() else {
return;
};
let handle =
assets.generated_sprite(colorselect::RAMP_KEY, images, colorselect::ramp_pixels);
out.push(UiQuad {
rect,
z_key: eq.z,
texture: Some(handle),
uv: UvRect::FULL,
color: colorselect::ramp_tint(hue, sat, eq.alpha),
clip,
..default()
});
}
QuadContent::Backdrop {
path,
color,
uvs,
tile,
} => {
// A frame Backdrop piece (bg or one of the 8 border pieces). Repeat-sampled when
// `tile` (edges tile past UV 1; a tiled bg wraps) — its own GPU image + cache
// (`sprite_texture_tiled`) since clamp/repeat bake into the `Image`. The four UVs are
// explicit per-corner (the rotated TOP/BOTTOM edges), so they go straight to `UvRect`.
let handle = assets.as_mut().and_then(|a| {
if tile {
a.sprite_texture_tiled(&path, images)
} else {
a.sprite_texture(&path, images)
}
});
// No texture (missing BLP) ⇒ draw nothing rather than a phantom solid quad.
let Some(handle) = handle else { return };
// The 8 border pieces share one atlas, so bilinear at a piece's own edge blends in the
// NEIGHBOURING piece's first column unless the UVs are pulled half a texel inward
// (1402). `tile` is exactly the border-piece predicate here — every one of the 8 sets
// it, and a stretched bg (the only `tile: false` piece) is a whole texture, not a
// slice of one, so it is left alone.
let uvs = match images.get(&handle) {
Some(img) if tile => {
let sz = img.texture_descriptor.size;
benilla_ui::script::inset_atlas_bleed(uvs, sz.width as f32, sz.height as f32)
}
_ => uvs,
};
let mut color = color;
color[3] *= eq.alpha;
out.push(UiQuad {
rect,
z_key: eq.z,
texture: Some(handle),
uv: UvRect::from_corners(uvs),
color,
clip,
..default()
});
}
QuadContent::Text {
text,
color,
justify_h,
justify_v,
font,
font_height,
text_height,
shadow,
outline,
alpha_gradient,
} => {
// No atlas (no client data / Friz Quadrata unreadable — see `ui_text`'s startup
// system) means text simply doesn't render, same graceful-absence posture as a
// missing `WorldAssets`. The rasterization itself (editbox window, ellipsis,
// shadow, links, caret) lives in `text::emit`.
let (Some(atlas), Some(text)) = (font_atlas.as_deref_mut(), text) else {
return;
};
text::emit(
atlas,
&text,
text::TextStyle {
color,
justify_h,
justify_v,
font,
font_height,
text_height,
shadow,
outline,
alpha_gradient,
},
text::TextHost {
z: eq.z,
alpha: eq.alpha,
target: eq.target,
rect,
clip,
ebox: text_ui,
screen_h: h,
scale: s,
font_scale: eq.scale,
caret_pinned,
},
out,
link_spans,
);
}
}
}
/// One pass of the FontString measure round-trip: hand every unmeasured FontString to the font
/// engine and push the answers back. Returns whether anything was measured — the caller's gate for
/// its backstop pass (see the call site for why the order matters).
fn measure_fontstrings(
script: &mut UiScript,
atlas: &mut UiFontAtlas,
s: f32,
ui_cost: &mut super::UiFrameCost,
// The churn column is the recorder's alone ([`super::UiCostWanted`]) — the `[ui-cost]` line
// reports counts, not the strings behind them, and collecting them is not free.
record_texts: bool,
) -> bool {
let requests = script.fontstrings_needing_measure();
// The recorder's churn column: WHICH strings a frame had to re-shape. A steady hover that
// keeps asking is the whole question (`hover_log`), and the answer is a string, not a count —
// so the first few come along by name.
if record_texts {
ui_cost.measured += requests.len();
ui_cost.measured_texts.extend(
requests
.iter()
.take(3)
.map(|r| r.text.chars().take(40).collect::<String>()),
);
}
if requests.is_empty() {
return false;
}
// Every answer goes through the ONE measure body ([`crate::ui_text::measure_request`]) the VM's
// own synchronous measurer calls, against the same shared engine — so a string measured here
// and the same string measured mid-tick are not two computations that agree, they are one.
let measures: Vec<(u32, f32, f32, f32, u64)> = requests
.iter()
.map(|r| {
let (w, h, natural) = crate::ui_text::measure_request(&mut atlas.lock(), s, r);
(r.id, w, h, natural, r.key)
})
.collect();
script.set_measured_text(&measures);
true
}
/// The held-cursor icon quad (CAPTURE-ONLY — see the module doc): a 32×32 icon TOP-LEFT anchored
/// at the mouse (`pos`, y-down logical px — the same space as the extracted quad rects and the
/// hardware cursor's own coordinate origin), `[pos, pos + 32]` — matching the hardware cursor's
/// `(0, 0)` hotspot (decision 0216 §5): the pointer sits at the icon's top-left corner and the
/// icon hangs down-right, the reference look, rather than centering on the mouse. Seated above the
/// whole UI (`z_key = u64::MAX` sorts last → drawn on top). Pure geometry so it's
/// machine-checkable without a live mouse.
fn cursor_icon_quad(pos: Vec2, texture: Handle<Image>) -> UiQuad {
const SIZE: f32 = 32.0;
UiQuad {
rect: Rect::new(pos.x, pos.y, pos.x + SIZE, pos.y + SIZE),
z_key: u64::MAX,
texture: Some(texture),
..default()
}
}
/// The atlas-cell guard's law (decision 1608) — see [`uv_clamp_window`]. Each case is a shape the
/// shipped UI actually draws, and the three that return `None` are the three ways a crop is not a
/// cell.
#[cfg(test)]
mod uv_clamp_tests {
use super::{tiling_axes, uv_clamp_window, UvRect};
/// The bug's own numbers: `POIIcons` is 128², a world-map POI samples cell (7,1), and the
/// window has to stop half a texel (`0.5/128`) inside it — a hair below texel row 16's centre
/// is where the coffin above stopped leaking in.
#[test]
fn a_poi_icons_cell_stops_half_a_texel_inside_itself() {
let uv = UvRect::from_tex_coords([0.875, 1.0, 0.125, 0.25]);
let w = uv_clamp_window(&uv, (128, 128)).expect("an atlas cell is clamped");
let half = 0.5 / 128.0;
assert!((w[0] - (0.875 + half)).abs() < 1e-6, "u_min {}", w[0]);
assert!((w[1] - (0.125 + half)).abs() < 1e-6, "v_min {}", w[1]);
assert!((w[2] - (1.0 - half)).abs() < 1e-6, "u_max {}", w[2]);
assert!((w[3] - (0.25 - half)).abs() < 1e-6, "v_max {}", w[3]);
}
/// The whole texture is already clamped by the sampler — a window here would only cost a
/// batch split.
#[test]
fn the_whole_texture_asks_for_no_window() {
assert!(uv_clamp_window(&UvRect::FULL, (128, 128)).is_none());
}
/// The stance shelf's middle strip past two forms: `SetTexCoord(0, n-2, 0, 1)` tiles along u
/// and spans the whole texture in v — only u wraps. Both axes `Repeat` was the four-form
/// bar's hairline (decision 2000).
#[test]
fn a_one_axis_strip_wraps_that_axis_only() {
assert_eq!(
tiling_axes(&UvRect::from_tex_coords([0.0, 2.0, 0.0, 1.0])),
(true, false)
);
assert_eq!(
tiling_axes(&UvRect::from_tex_coords([0.0, 1.0, 0.0, 3.0])),
(false, true)
);
}
/// Three forms: `SetTexCoord(0, 1, 0, 1)` is the whole texture — nothing tiles, the plain
/// clamped sprite serves it. And a crop inside the texture is a cell, never a tile.
#[test]
fn a_bounded_mapping_tiles_nothing() {
assert_eq!(
tiling_axes(&UvRect::from_tex_coords([0.0, 1.0, 0.0, 1.0])),
(false, false)
);
assert_eq!(tiling_axes(&UvRect::FULL), (false, false));
assert_eq!(
tiling_axes(&UvRect::from_tex_coords([0.453125, 0.875, 0.0, 1.0])),
(false, false)
);
}
/// A mapping past the texture on both axes (a tiled backdrop bg) wraps both — the tiled
/// image's case, unchanged.
#[test]
fn a_two_axis_tile_wraps_both() {
assert_eq!(
tiling_axes(&UvRect::from_tex_coords([0.0, 4.0, 0.0, 2.5])),
(true, true)
);
}
/// `SetTexCoord(0, n, 0, 1)` on an n-slot strip is the reference's TILING idiom (the stance
/// shelf): the repeating axis keeps its exact period, the bounded one still gets its window.
#[test]
fn a_tiling_axis_is_left_alone_while_its_bounded_partner_is_not() {
let uv = UvRect::from_tex_coords([0.0, 3.0, 0.0, 0.5]);
let w = uv_clamp_window(&uv, (64, 64)).expect("the v axis is a bounded crop");
assert!(w[0] > w[2], "u tiles, so its axis must read as OFF: {w:?}");
assert!((w[1] - 0.5 / 64.0).abs() < 1e-6);
assert!((w[3] - (0.5 - 0.5 / 64.0)).abs() < 1e-6);
}
/// A mirrored slice (`left > right` — the PlayerFrame ring) is still one cell: the window is
/// built from the corner EXTENTS, so the flip survives it untouched.
#[test]
fn a_mirrored_slice_is_clamped_by_its_extents() {
let uv = UvRect::from_tex_coords([0.5, 0.25, 0.0, 1.0]);
let w = uv_clamp_window(&uv, (64, 64)).expect("a mirrored cell is still a cell");
assert!((w[0] - (0.25 + 0.5 / 64.0)).abs() < 1e-6);
assert!((w[2] - (0.5 - 0.5 / 64.0)).abs() < 1e-6);
assert!(
w[1] > w[3],
"v spans the whole texture, so it reads as OFF: {w:?}"
);
}
/// A crop thinner than one texel has no interior to inset toward — both bounds pin to its
/// centre, which is the single texel it means (and stays a VALID, enabled range).
#[test]
fn a_sub_texel_crop_pins_to_the_texel_it_names() {
let uv = UvRect::from_tex_coords([0.5, 0.505, 0.0, 1.0]);
let w = uv_clamp_window(&uv, (64, 64)).expect("still a crop");
assert!((w[0] - 0.5025).abs() < 1e-6);
assert!(w[0] <= w[2], "the axis must stay enabled: {w:?}");
assert!((w[2] - 0.5025).abs() < 1e-6);
}
}
#[cfg(test)]
mod cursor_quad_tests {
use super::cursor_icon_quad;
use bevy::prelude::*;
#[test]
fn cursor_icon_quad_is_32px_top_left_anchored_at_the_hotspot() {
let q = cursor_icon_quad(Vec2::new(100.0, 200.0), Handle::default());
// 32×32, top-left anchored on the mouse (the hardware cursor's (0,0) hotspot) — hangs
// down-right: [100..132, 200..232].
assert_eq!(
(q.rect.min.x, q.rect.min.y, q.rect.max.x, q.rect.max.y),
(100.0, 200.0, 132.0, 232.0)
);
assert_eq!(q.rect.width(), 32.0);
assert_eq!(q.rect.height(), 32.0);
// Above every frame, and textured (straight-alpha, not additive).
assert_eq!(q.z_key, u64::MAX);
assert!(q.texture.is_some());
assert!(!q.additive);
}
}
/// The app-side half of the ScrollFrame clip plumb (decision 0112): does a [`UiQuad`] built from a
/// clipped [`QuadContent::Texture`] actually carry `clip` through `drive_script`? Drives the real
/// system in a minimal headless `App` (no `DefaultPlugins` — just the resources `drive_script`
/// reads), rather than re-deriving the y-up→y-down flip by hand: that flip is exactly the seam this
/// test exists to catch a regression in.
#[cfg(test)]
mod clip_plumb_tests {
use bevy::math::Rect;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use benilla_ui::script::UiScript;
use super::{drive_script, UiQuad, UiQuads};
use crate::portrait::PortraitImages;
/// A headless app with exactly the resources/entities `drive_script` reads: a `NonSend` VM
/// carrying a ScrollFrame + scrolled-out child + a colored (pathless) Texture region marker, a
/// 1024×768 primary window (the 768-virtual design height — s = 1, so the WoW-space rects are
/// known by hand; decision 0582), and every other resource
/// left at its default (`WorldAssets`/`UiFontAtlas` absent — no BLP/font pipeline needed to prove
/// the clip carries through a plain colored quad).
fn app_with_scrolled_marker() -> App {
let script = UiScript::new().unwrap();
script
.run(
r#"
local frame = CreateFrame("ScrollFrame", "SF")
frame:SetPoint("TOPLEFT", 0, -100) -- screen top 768 -> frame top 668
frame:SetWidth(300); frame:SetHeight(200) -- frame bottom 300, right 300
local child = CreateFrame("Frame", "Child")
child:SetWidth(300); child:SetHeight(600)
frame:SetScrollChild(child)
local marker = child:CreateTexture(nil, "ARTWORK")
marker:SetTexture(1, 0, 0) -- pathless colored quad: no BLP asset needed
marker:SetAllPoints() -- templateless Lua region: no implicit anchor (1310)
"#,
)
.unwrap();
let mut app = App::new();
app.insert_non_send_resource(script);
app.init_resource::<UiQuads>();
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::ui_models::UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::ui_script::UiFrameCost>();
app.init_resource::<crate::ui_script::UiCostWanted>();
app.init_resource::<Time>();
app.init_resource::<Time<Real>>();
app.init_resource::<crate::ui_script::UiClock>();
// The uiScale dial at its identity Default (1.0) — tests pin the byte-identity base;
// the shipped app inserts the taste default instead (decision 0584).
app.init_resource::<crate::ui_script::UiScaleCvar>();
app.world_mut().spawn((
Window {
resolution: UVec2::new(1024, 768).into(),
..default()
},
PrimaryWindow,
));
app.add_systems(Update, drive_script);
app
}
#[test]
fn a_clipped_texture_quad_carries_its_clip_into_the_uiquad() {
let mut app = app_with_scrolled_marker();
app.update();
let quads = &app.world().resource::<UiQuads>().quads;
let marker: &UiQuad = quads
.iter()
.find(|q| q.color[0] == 1.0 && q.color[1] == 0.0 && q.color[2] == 0.0)
.expect("the colored marker quad extracted");
// WoW space: frame rect [bottom 468, left 0, top 668, right 300]; the quad pass is y-down
// from the top-left, so `y_down = window_height - y_up`: bottom(468)->300, top(668)->100.
assert_eq!(
marker.clip,
Some(Rect::new(0.0, 100.0, 300.0, 300.0)),
"the engine's ScrollFrame clip survives the y-up→y-down flip into UiQuad::clip"
);
}
/// The uiScale dial (decision 0584) through the same plumb, by hand: at dial 0.5 on the same
/// 1024×768 window, s = 0.5 and the VM sees a 2048×1536 virtual screen — the TOPLEFT-anchored
/// frame hangs from virtual top 1536, and every window-px rect halves.
#[test]
fn the_uiscale_dial_scales_the_extracted_rects() {
let mut app = app_with_scrolled_marker();
app.insert_resource(crate::ui_script::UiScaleCvar(0.5));
app.update();
let quads = &app.world().resource::<UiQuads>().quads;
let marker: &UiQuad = quads
.iter()
.find(|q| q.color[0] == 1.0 && q.color[1] == 0.0 && q.color[2] == 0.0)
.expect("the colored marker quad extracted");
// WoW space: frame top = 1536 − 100 = 1436, bottom 1236, right 300. ×0.5 → px y-up
// [618..718, 0..150]; y-down through the 768 window: top 768−718 = 50, bottom 768−618 = 150.
assert_eq!(
marker.clip,
Some(Rect::new(0.0, 50.0, 150.0, 150.0)),
"dial 0.5 halves the px rect and re-hangs the frame from the taller virtual top"
);
}
#[test]
fn an_unclipped_texture_quad_carries_no_clip() {
let script = UiScript::new().unwrap();
script
.run(
r#"
local plain = CreateFrame("Frame", "Plain")
plain:SetPoint("TOPLEFT", 0, 0)
plain:SetWidth(50); plain:SetHeight(50)
local m = plain:CreateTexture(nil, "ARTWORK")
m:SetTexture(0, 1, 0)
m:SetAllPoints() -- templateless Lua region: no implicit anchor (1310)
"#,
)
.unwrap();
let mut app = App::new();
app.insert_non_send_resource(script);
app.init_resource::<UiQuads>();
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::ui_models::UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::ui_script::UiFrameCost>();
app.init_resource::<crate::ui_script::UiCostWanted>();
app.init_resource::<Time>();
app.init_resource::<Time<Real>>();
app.init_resource::<crate::ui_script::UiClock>();
app.init_resource::<crate::ui_script::UiScaleCvar>();
app.world_mut().spawn((
Window {
resolution: UVec2::new(1024, 768).into(),
..default()
},
PrimaryWindow,
));
app.add_systems(Update, drive_script);
app.update();
let quads = &app.world().resource::<UiQuads>().quads;
let marker = quads
.iter()
.find(|q| q.color[0] == 0.0 && q.color[1] == 1.0 && q.color[2] == 0.0)
.expect("the green marker quad extracted");
assert_eq!(marker.clip, None);
}
}
/// The extract gate (decision 0740): a settled frame skips the whole conversion loop, and — the
/// dangerous direction — any extract-visible change must reopen it, INCLUDING paint-only writes
/// that never dirty the layout gate. Uses the same minimal headless app as `clip_plumb_tests`.
/// The alignment merge ([`align_entries`]) — the half of the splice that sees through a shift.
///
/// The cases are built from a REAL extract list, because `FrameHandle` keeps its fields private
/// on purpose: an entry's `z` is a packed [`benilla_ui::order::ZKey`] and hand-forging one would
/// test a fiction rather than the traversal's own keys.
#[cfg(test)]
mod align_tests {
use benilla_ui::script::{ExtractedQuad, QuadContent, UiScript};
use super::align_entries;
/// Four regions across two frames — a z-sorted list with genuine keys to permute.
fn entries() -> Vec<ExtractedQuad> {
let mut script = UiScript::new().unwrap();
script
.run(
r#"
for i = 1, 2 do
local f = CreateFrame("Frame", "F" .. i)
f:SetPoint("TOPLEFT", 0, 0)
f:SetWidth(50); f:SetHeight(50)
for j = 1, 2 do
local t = f:CreateTexture(nil, "ARTWORK")
t:SetTexture(i / 4, j / 4, 0)
t:SetAllPoints()
end
end
"#,
)
.unwrap();
script.resolve();
let list = script.extract();
assert!(
list.len() >= 6,
"two frames and four regions: {}",
list.len()
);
list
}
/// A repaint of one entry: it converts, its old self is dropped, and every other entry keeps
/// its own quads — the shape the splice has always handled, restated on the new machinery.
#[test]
fn a_changed_entry_is_the_only_one_that_converts() {
let was = entries();
let mut now = was.clone();
let at = now
.iter()
.position(|e| matches!(e.content, QuadContent::Texture { .. }))
.unwrap();
let QuadContent::Texture { color, .. } = &mut now[at].content else {
unreachable!()
};
*color = Some([0.0, 0.0, 1.0, 1.0]);
let a = align_entries(&was, &now);
assert_eq!(a.dropped, vec![at], "the repainted entry's old quads go");
for (j, s) in a.source.iter().enumerate() {
assert_eq!(
*s,
(j != at).then_some(j),
"entry {j} reuses its own quads unless it is the one that changed"
);
}
}
/// **The case a positional compare cannot see** (decision 1638): one entry appears in the
/// middle and every entry behind it shifts by one. All of them must keep their quads —
/// before this, index `j` was compared against a stranger and the whole list re-converted.
#[test]
fn an_insertion_shifts_the_tail_and_costs_one_conversion() {
let was = entries();
// The insertion needs a `z` strictly between its neighbours' — the merge reads a sorted
// list — so seat it at the first pair with room. Sibling regions differ by 1 in the key's
// declaration-order field; a frame boundary leaves a wide gap.
let at = (1..was.len())
.find(|&i| was[i].z - was[i - 1].z > 1)
.expect("some adjacent pair has room between its keys");
let mut now = was.clone();
let mut fresh = was[at].clone();
fresh.z = was[at - 1].z + 1;
now.insert(at, fresh);
let a = align_entries(&was, &now);
assert!(a.dropped.is_empty(), "nothing left the list");
assert_eq!(a.source[at], None, "the new entry is the one conversion");
for j in 0..at {
assert_eq!(a.source[j], Some(j), "the head is untouched");
}
for j in at + 1..now.len() {
assert_eq!(
a.source[j],
Some(j - 1),
"entry {j} keeps the quads it had at {}, one place back",
j - 1
);
}
}
/// The mirror: an entry vanishes, nothing converts at all, and the tail keeps its quads from
/// one place forward. The splice must still run — the quads have to leave the list.
#[test]
fn a_deletion_converts_nothing_and_still_has_work() {
let was = entries();
let at = was.len() / 2;
let mut now = was.clone();
now.remove(at);
let a = align_entries(&was, &now);
assert_eq!(
a.dropped,
vec![at],
"the vanished entry's quads are dropped"
);
assert!(
a.source.iter().all(Option::is_some),
"a deletion converts nothing"
);
for j in at..now.len() {
assert_eq!(a.source[j], Some(j + 1), "the tail shifts back one");
}
}
/// Two lists with nothing in common align to nothing kept — the bound above this call is
/// what then sends the frame to the full conversion.
#[test]
fn a_wholly_new_list_keeps_nothing() {
let was = entries();
let now: Vec<ExtractedQuad> = was
.iter()
.cloned()
.map(|mut e| {
e.z += 1;
e
})
.collect();
let a = align_entries(&was, &now);
assert!(a.source.iter().all(Option::is_none));
assert_eq!(a.dropped.len(), was.len());
}
}
#[cfg(test)]
mod extract_gate_tests {
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use benilla_ui::script::UiScript;
use super::{drive_script, UiQuads};
use crate::portrait::PortraitImages;
fn app_with_marker() -> App {
app_from_script(
r#"
local plain = CreateFrame("Frame", "Plain")
plain:SetPoint("TOPLEFT", 0, 0)
plain:SetWidth(50); plain:SetHeight(50)
marker = plain:CreateTexture(nil, "ARTWORK")
marker:SetTexture(1, 0, 0)
marker:SetAllPoints() -- templateless Lua region: no implicit anchor (1310)
"#,
)
}
/// The same headless app around an arbitrary boot script. The splice tests boot a SECOND
/// app straight into a mutated app's final state: its first frame is by construction the
/// full conversion the spliced output must equal.
fn app_from_script(lua: &str) -> App {
let script = UiScript::new().unwrap();
script.run(lua).unwrap();
let mut app = App::new();
app.insert_non_send_resource(script);
app.init_resource::<UiQuads>();
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::ui_models::UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::ui_script::UiFrameCost>();
app.init_resource::<crate::ui_script::UiCostWanted>();
app.init_resource::<Time>();
app.init_resource::<Time<Real>>();
app.init_resource::<crate::ui_script::UiClock>();
app.init_resource::<crate::ui_script::UiScaleCvar>();
app.world_mut().spawn((
Window {
resolution: UVec2::new(1024, 768).into(),
..default()
},
PrimaryWindow,
));
app.add_systems(Update, drive_script);
app
}
#[test]
fn a_settled_frame_skips_and_a_paint_write_reopens_the_gate() {
let mut app = app_with_marker();
app.update(); // frame 1: builds the quads
assert!(app.world().resource::<UiQuads>().dirty, "first frame draws");
app.world_mut().resource_mut::<UiQuads>().dirty = false;
app.update(); // frame 2: identical inputs — the gate must leave the resource alone
assert!(
!app.world().resource::<UiQuads>().dirty,
"a settled frame must not re-mark the quads dirty"
);
// A PAINT-ONLY write: invisible to the layout gate (no anchors/size moved), so only the
// extracted-list compare can reopen extraction. If the gate wrongly ate it, the marker
// would stay red on screen.
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("marker:SetTexture(0, 0, 1)")
.unwrap();
app.update();
let quads = app.world().resource::<UiQuads>();
assert!(quads.dirty, "a paint write must reopen the extract gate");
assert!(
quads
.quads
.iter()
.any(|q| q.color[0] == 0.0 && q.color[2] == 1.0),
"and the produced quads carry the new color"
);
}
#[test]
fn a_layout_write_reopens_the_gate_too() {
let mut app = app_with_marker();
app.update();
app.update();
app.world_mut().resource_mut::<UiQuads>().dirty = false;
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("getglobal('Plain'):SetPoint('TOPLEFT', 40, -40)")
.unwrap();
app.update();
assert!(
app.world().resource::<UiQuads>().dirty,
"a moved frame must re-extract"
);
}
/// **A `<PlayerModel>` pane draws its window's booth, and an unclaimed one draws nothing**
/// (decision 1810).
///
/// The engine verb the stock character sheet needed. `PaperDollFrame.xml` declares a bare
/// `<PlayerModel name="CharacterModelFrame">` with no texture in it at all — our own deleted
/// file put a `<Texture>` there and named the booth with `BenillaSetBoothTexture` — so without
/// this the migrated window's paper doll is an empty rectangle, which no loader error and no
/// missing global would have said a word about.
///
/// Both halves are asserted because the second is what keeps the first honest: the join is a
/// table of frame names ([`crate::portrait::model_pane_booth`]), and a pane no window claims —
/// pfUI's anonymous `CreateFrame("Model")` autocast shine is the corpus case — must draw
/// nothing rather than borrow somebody's bake.
#[test]
fn a_named_model_pane_samples_its_booth_and_an_unclaimed_one_draws_nothing() {
let mut app = app_from_script(
r#"
local doll = CreateFrame("PlayerModel", "CharacterModelFrame")
doll:SetPoint("TOPLEFT", 0, 0)
doll:SetWidth(233); doll:SetHeight(224)
doll:SetUnit("player")
local stray = CreateFrame("PlayerModel", "SomeAddonsModelPane")
stray:SetPoint("TOPLEFT", 300, 0)
stray:SetWidth(233); stray:SetHeight(224)
stray:SetUnit("player")
"#,
);
let bake = app
.world_mut()
.resource_mut::<Assets<Image>>()
.add(Image::default());
app.world_mut().resource_mut::<PortraitImages>().0.insert(
"paperdoll".to_string(),
crate::portrait::PortraitSource::Live(bake.clone()),
);
app.update();
let quads = &app.world().resource::<UiQuads>().quads;
let panes: Vec<_> = quads
.iter()
.filter(|q| q.texture.as_ref() == Some(&bake))
.collect();
assert_eq!(
panes.len(),
1,
"exactly the claimed pane draws the paper doll's bake"
);
assert!(
panes[0].premultiplied,
"a render-target bake carries premultiplied colour"
);
// …and the pane published its aspect, which is what lets the booth render at the shape it
// will be stretched into (decision 1069) and what tells it it is on screen at all.
let aspect = app
.world()
.resource::<crate::portrait::BoothPanes>()
.0
.get("paperdoll")
.copied()
.expect("the pane publishes its aspect");
assert!(
(aspect - 233.0 / 224.0).abs() < 0.01,
"the pane's own rect is the aspect, got {aspect}"
);
}
/// **A booth bake reaches the quad pass flagged PREMULTIPLIED, an ordinary region does not**
/// (decision 1347). This is the wiring half of the paper-doll/dressing-room fix, and it is the
/// half that can silently rot: the shader's `select(a, k, premultiplied)` is only correct if
/// exactly the render-target quads carry the flag. Drop it and every additive effect the pane
/// draws over EMPTY space is multiplied by its own zero alpha again — the R14 pauldrons' fire
/// gone, a weapon glow chopped at the model's silhouette (Goudy, `#bugs` 2026-07-27).
#[test]
fn a_booth_bake_quad_is_flagged_premultiplied_and_a_plain_one_is_not() {
let mut app = app_with_marker();
// The paper doll's own binding — the square booth pane (`BenillaSetBoothTexture`).
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("BenillaSetBoothTexture(marker, 'paperdoll')")
.unwrap();
// The booth publishes a live bake for that slot; without an entry the region draws nothing.
let bake = app
.world_mut()
.resource_mut::<Assets<Image>>()
.add(Image::default());
app.world_mut().resource_mut::<PortraitImages>().0.insert(
"paperdoll".to_string(),
crate::portrait::PortraitSource::Live(bake.clone()),
);
app.update();
let quads = &app.world().resource::<UiQuads>().quads;
let pane = quads
.iter()
.find(|q| q.texture.as_ref() == Some(&bake))
.expect("the booth pane's quad reached the pass");
assert!(
pane.premultiplied,
"a render-target bake carries premultiplied colour — the pass must not re-weight it"
);
assert!(
quads
.iter()
.filter(|q| q.texture.as_ref() != Some(&bake))
.all(|q| !q.premultiplied),
"every straight-alpha region stays unflagged — the flag is the booth's alone"
);
}
/// The per-entry splice, in-place half: a one-entry paint write must ride the splice
/// (`UiFrameCost::spliced == 1` — nonzero is the proof the path FIRED, without which this
/// test is vacuous) and produce byte-identically what the full conversion produces —
/// checked against a fresh app booted straight into the final state.
#[test]
fn a_one_entry_paint_write_splices_and_matches_the_full_conversion() {
let mut app = app_with_marker();
app.world_mut()
.resource_mut::<crate::ui_script::UiCostWanted>()
.0 = true;
app.update();
app.world_mut().resource_mut::<UiQuads>().dirty = false;
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("marker:SetTexture(0, 0, 1)")
.unwrap();
app.update();
assert_eq!(
app.world()
.resource::<crate::ui_script::UiFrameCost>()
.spliced,
1,
"the color write is one splice-simple entry"
);
assert!(
app.world().resource::<UiQuads>().dirty,
"the spliced write re-marks the quads dirty"
);
let mut reference = app_from_script(
r#"
local plain = CreateFrame("Frame", "Plain")
plain:SetPoint("TOPLEFT", 0, 0)
plain:SetWidth(50); plain:SetHeight(50)
marker = plain:CreateTexture(nil, "ARTWORK")
marker:SetTexture(0, 0, 1)
marker:SetAllPoints() -- templateless Lua region: no implicit anchor (1310)
"#,
);
reference.update();
assert!(
app.world().resource::<UiQuads>().quads
== reference.world().resource::<UiQuads>().quads,
"spliced output equals the full conversion of the same model"
);
}
#[test]
fn a_count_changing_write_stitches_and_keeps_the_spans_true() {
let mut app = app_with_marker();
app.world_mut()
.resource_mut::<crate::ui_script::UiCostWanted>()
.0 = true;
app.update();
let before = app.world().resource::<UiQuads>().quads.len();
app.world_mut().resource_mut::<UiQuads>().dirty = false;
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("marker:SetTexture(nil)")
.unwrap();
app.update();
assert_eq!(
app.world()
.resource::<crate::ui_script::UiFrameCost>()
.spliced,
1,
"the clear rides the splice (stitch branch)"
);
let quads = app.world().resource::<UiQuads>();
assert!(quads.dirty, "a vanished quad is a real change");
assert_eq!(
quads.quads.len(),
before - 1,
"the cleared marker's quad is gone from the stitched list"
);
// The follow-up (0 → 1 quads) splices against the RE-DERIVED spans.
app.world_mut().resource_mut::<UiQuads>().dirty = false;
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("marker:SetTexture(0, 1, 0)")
.unwrap();
app.update();
assert_eq!(
app.world()
.resource::<crate::ui_script::UiFrameCost>()
.spliced,
1
);
let mut reference = app_from_script(
r#"
local plain = CreateFrame("Frame", "Plain")
plain:SetPoint("TOPLEFT", 0, 0)
plain:SetWidth(50); plain:SetHeight(50)
marker = plain:CreateTexture(nil, "ARTWORK")
marker:SetTexture(0, 1, 0)
marker:SetAllPoints() -- templateless Lua region: no implicit anchor (1310)
"#,
);
reference.update();
assert!(
app.world().resource::<UiQuads>().quads
== reference.world().resource::<UiQuads>().quads,
"post-stitch splice output equals the full conversion"
);
}
/// **A frame appearing shifts every entry behind it, and the splice sees through that**
/// (decision 1638). Showing a hidden sibling inserts its entries into the MIDDLE of the
/// render list, so every entry after them lands at a new index. Compared index-wise they all
/// looked changed and the frame took the full conversion — which is what 86 % of hover frames
/// were doing. Both directions, and both must equal the full conversion of the same model.
#[test]
fn a_shown_sibling_splices_through_the_shift_and_matches_the_full_conversion() {
// A, B, C in declaration order, so B's entries sort between A's and C's: C is the tail
// that shifts. B starts hidden and contributes nothing.
const BUILD: &str = r#"
local function box(name, x, r, g, b)
local f = CreateFrame("Frame", name)
f:SetPoint("TOPLEFT", x, 0)
f:SetWidth(50); f:SetHeight(50)
local t = f:CreateTexture(nil, "ARTWORK")
t:SetTexture(r, g, b)
t:SetAllPoints()
return f
end
box("A", 0, 1, 0, 0)
hidden = box("B", 60, 0, 1, 0)
box("C", 120, 0, 0, 1)
"#;
let mut app = app_from_script(&format!("{BUILD}\nhidden:Hide()"));
app.world_mut()
.resource_mut::<crate::ui_script::UiCostWanted>()
.0 = true;
app.update();
let without = app.world().resource::<UiQuads>().quads.len();
app.world_mut().resource_mut::<UiQuads>().dirty = false;
// ── Insertion ────────────────────────────────────────────────────────────────────────
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("hidden:Show()")
.unwrap();
app.update();
let cost = app.world().resource::<crate::ui_script::UiFrameCost>();
assert!(
cost.spliced > 0 && cost.dropped == 0,
"B's entries are the only conversions and nothing left the list \
(spliced={}, dropped={})",
cost.spliced,
cost.dropped
);
let quads = app.world().resource::<UiQuads>();
assert!(quads.dirty, "a frame appearing is a real change");
assert!(
quads.quads.len() > without,
"the shown frame's quad actually joined the list ({} -> {})",
without,
quads.quads.len()
);
// The reference must share the app's HISTORY, not just its end state: `Show()` on a
// hidden frame re-stacks it to the tail of its draw bucket — the client's own
// `effective_visible_show 0x76ae10` re-adding it to the level's intrusive list — so B
// draws above C afterwards. (The splice got that right on its own; this reference did
// not, which is how the difference surfaced.)
let mut reference = app_from_script(&format!("{BUILD}\nhidden:Hide()\nhidden:Show()"));
reference.update();
assert!(
app.world().resource::<UiQuads>().quads
== reference.world().resource::<UiQuads>().quads,
"the spliced list equals the full conversion of the same model"
);
// ── Deletion ─────────────────────────────────────────────────────────────────────────
// The mirror, and the reason `dropped` exists: nothing converts at all, so `spliced` is
// zero on a frame that most certainly did splice.
app.world_mut().resource_mut::<UiQuads>().dirty = false;
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("hidden:Hide()")
.unwrap();
app.update();
let cost = app.world().resource::<crate::ui_script::UiFrameCost>();
assert_eq!(cost.spliced, 0, "a pure deletion converts nothing");
assert!(
cost.dropped > 0,
"and it is the drop count that proves it spliced"
);
let quads = app.world().resource::<UiQuads>();
assert!(quads.dirty, "a vanished frame is a real change");
assert_eq!(
quads.quads.len(),
without,
"the list is back to what it was before B appeared"
);
let mut reference = app_from_script(&format!(
"{BUILD}\nhidden:Hide()\nhidden:Show()\nhidden:Hide()"
));
reference.update();
assert!(
app.world().resource::<UiQuads>().quads
== reference.world().resource::<UiQuads>().quads,
"the post-deletion list equals the full conversion of the same model"
);
}
/// A raster-environment change (window resize) must pin the conversion to the FULL path —
/// the splice's held quads were rasterized under the old seam and every one of them is
/// wrong-sized (decision 1342's edge, applied to the splice).
#[test]
fn a_resize_takes_the_full_path_not_the_splice() {
let mut app = app_with_marker();
app.world_mut()
.resource_mut::<crate::ui_script::UiCostWanted>()
.0 = true;
app.update();
let win = app
.world_mut()
.query_filtered::<Entity, With<PrimaryWindow>>()
.single(app.world())
.unwrap();
app.world_mut().get_mut::<Window>(win).unwrap().resolution = UVec2::new(800, 600).into();
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("marker:SetTexture(0, 0, 1)")
.unwrap();
app.update();
assert_eq!(
app.world()
.resource::<crate::ui_script::UiFrameCost>()
.spliced,
0,
"a moved raster environment forbids splicing"
);
}
}