Files
benilla-octo/crates/benilla-app/src/ui_script/extract/mod.rs
T
gromandClaude Opus 5 d1edd74cce ui: a runaway frame writes its own line, and stops costing the whole bound twice
Two of his sessions froze this morning with nothing in the log. The journal
says what happened: every frame 15,0xx ms, `main_ms` 14,970 of it, GPU 3 ms,
the main thread `state R` with 64 s of CPU in 68 s of wall. That dead-constant
15.02 s is `RUNAWAY_FRAME_SECS` — a Lua loop that never returns, re-entered the
next frame and the next.

The bound did its job and said nothing, because its raise unwinds to whatever
`pcall` caught the call. On this corpus that is always an addon's own guard, so
the trip became nobody's script error: `session errors: 0`, no log line, no
file, no line number. `WOW_UI_RAISES` would have caught it and is `eprintln!`
only, which is no use to someone who is in the game.

So the hook writes its own report before raising, and the host logs it at
`error!` beside the ordinary error drain. Each kept report carries a sixteen
frame traceback, because the innermost line is usually inside a library and the
handler that entered the loop is the name worth having.

And the bound tightens to a second once it has tripped. The first one has to be
far beyond any legitimate frame, which makes it far beyond playable: a loop is
re-entered every frame, so the player gets one frame every fifteen seconds —
indistinguishable from the freeze the bound exists to prevent, and that is
exactly what he got. One trip has already proved the call non-terminating; the
ones after it need only enough rope to say so. A second is still some 125M VM
instructions, two orders past the heaviest handler ever measured here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-02 07:21:48 -07:00

2873 lines
116 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 UI pass: [`tick_script`] runs the VM's frame and [`paint_script`] turns
//! [`UiScript::extract`]'s output into [`UiQuads`]. The held payload's icon is the hardware cursor
//! ([`crate::cursor`]); a capture, which cannot show that cursor, draws [`cursor_icon_quad`].
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`: one untraced `[ui-cost]` line per frame, each phase's μs and the quad counts.
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`: log why the extract gate and the splice missed ([`report_gate_miss`]).
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`: name the first base-lane quad that changed; it pins the full conversion.
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`: also run the full conversion on each spliced frame, and compare.
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>]`: log every quad whose rect covers that point, or comes within `r`
/// px, once per `z` key; logical window px, y-down, as [`convert_entry`]'s `rect`.
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 ([`ui_pick_point`]), deduped by paint key.
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 short, stable name for a log line.
fn content_kind(c: &QuadContent) -> &'static str {
match c {
QuadContent::Frame => "frame",
QuadContent::Minimap { .. } => "minimap",
QuadContent::ModelPane { .. } => "modelpane",
QuadContent::Texture { .. } => "texture",
QuadContent::Line { .. } => "line",
QuadContent::ColorWheel => "colorwheel",
QuadContent::ColorValue { .. } => "colorvalue",
QuadContent::Backdrop { .. } => "backdrop",
QuadContent::Text { .. } => "text",
}
}
/// Which axes run past the texture, the reference's tiling idiom (`SetTexCoord(0, n, 0, 1)`
/// repeats the art n times), and so sample `Repeat`; a bounded axis on `Repeat` would filter its
/// edge against the opposite one. Its tolerance must match [`uv_clamp_window`]'s.
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 [`UiQuad::uv_clamp`] window of an atlas crop, inset half a texel so a magnified cell does
/// not filter in its neighbour. A tiling axis is left alone, as
/// [`benilla_ui::script::inset_atlas_bleed`] does; a whole-texture axis needs no window.
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`
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 pins both bounds to its centre.
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)
}
/// Log why the extract gate did not skip: the input that differed, or the first entry that moved.
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 {
// A full sheet was repacked, moving every UV (`WOW_GLYPH_CACHE=1` shows its occupancy).
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 moved: capture mode never skips.
None => eprintln!("[ui-gate] miss: capture mode (the gate never skips under a capture)"),
}
}
/// Last frame's extract-gate inputs, in one `Local` as the gate compares all or none.
#[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 glyph UVs came from; it moves only on a reset.
generation: Option<u64>,
/// Per-entry prefix ends: entry `i`'s quads are `spans[i-1]..spans[i]` of `UiQuads::quads`
/// (from 0 for `i = 0`), which lets the splice re-convert one entry alone.
spans: Vec<u32>,
/// The stitch's ping-pong buffer: last frame's emptied allocation.
held: Vec<UiQuad>,
}
/// How this frame's entry list lines up with last frame's.
struct Alignment {
/// Per new entry, the old index whose quads it reuses, or `None` to convert.
source: Vec<Option<usize>>,
/// Old entries nothing reuses; the splice cannot undo their side effects.
dropped: Vec<usize>,
}
/// A merge over `z`, a packed [`benilla_ui::order::ZKey`] that an insertion does not shift. Only
/// fully equal pairs reuse quads, so a mis-pairing costs conversions, never correctness: `z` is
/// not proven unique or sorted (a ScrollingMessageFrame's line sorts above its BACKGROUND layer).
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;
}
// Gone this frame.
std::cmp::Ordering::Less => i += 1,
// New this frame.
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 only quads, or its side channels idempotently: the splice
/// keeps last frame's link spans, minimap slot, booth panes and tile requests.
fn splice_simple(eq: &benilla_ui::script::ExtractedQuad) -> bool {
match &eq.content {
QuadContent::Frame
| QuadContent::Backdrop { .. }
| QuadContent::ColorWheel
| QuadContent::ColorValue { .. } => true,
// A file pane writes at most one quad and one idempotent tile request. A **unit** pane
// claims a pooled booth slot, and the claim sweep runs only on the full path — exactly
// why a `portrait_unit` texture below is unspliceable too: an entry whose side channel
// decides which booth renders has to be seen by the pass that rebuilds the whole map.
QuadContent::ModelPane { unit, .. } => unit.is_none(),
// Region text only: a message line's link spans replace the whole set, and a line losing
// its link would leave a stale rect while the tripwire's scratch stays empty.
QuadContent::Text { .. } => matches!(eq.target, benilla_ui::order::ZTarget::Region(_)),
QuadContent::Texture {
portrait_unit: None,
..
} => true,
_ => false,
}
}
/// Re-run the full conversion and log the first quad or span where the spliced list differs; of
/// the side channels only `booths.panes` is written, and re-adding its tokens is idempotent.
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;
};
// The entry owning quad `i`: the first prefix 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)),
);
}
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 for `seam`, so `GetStringWidth` right after `SetText`
/// measures, as the reference answers it inline (`0x79e510` → `0x772890`). Also seated before the
/// manifest loads ([`super::lifecycle::load_ingame_ui_on_world_entry`]), for every `<OnLoad>`.
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,
)));
}
/// The UI pass's first half, before `WorldStage::Input` because the camera reads the UI hover:
/// screen size, `tick`, the measure round-trip, `resolve` and the script errors. The quads are
/// [`paint_script`]'s, after the camera that places the `WorldFrame` nameplates.
pub(super) fn tick_script(
script: Option<NonSendMut<UiScript>>,
window: Query<&Window, With<PrimaryWindow>>,
// Real time: the reference's `GetTime` is the OS tick count, and virtual time clamps each
// delta to 250 ms, so a stall would leave every GetTime-anchored timer running long.
time: Res<Time<Real>>,
mut ui_clock: ResMut<super::UiClock>,
mut font_atlas: Option<ResMut<UiFontAtlas>>,
// The booth seam's DPI, and the pane map cleared when the VM or the window is gone.
mut booths: crate::portrait::BoothBridge,
ui_cost_wanted: Res<super::UiCostWanted>,
// The seam scale the engine's text-metric caches were answered under. Not a `VmMemo`: it is a
// fact about the raster, and a new VM re-seats on `!has_text_measurer()` below.
mut last_seam: Local<f32>,
// The `scale_factor` measures were last answered under; 0 until the first frame.
mut last_dpi: Local<f32>,
ui_scale: Res<super::UiScaleCvar>,
mut ui_cost: ResMut<super::UiFrameCost>,
mut pass: ResMut<super::UiPassState>,
) {
pass.live = false;
let Some(mut script) = script else {
// No VM, no UI sampling a booth pane: a stranded pane would keep the paper-doll camera
// rendering behind a dead UI, and a stranded claim would hold a pooled booth for a
// `FrameHandle` the next VM will reuse for something else.
booths.panes.0.clear();
booths.bake_sizes.0.clear();
booths.positions.0.clear();
booths.unit_panes.release_all();
return;
};
let Ok(window) = window.single() else {
booths.panes.0.clear();
booths.bake_sizes.0.clear();
booths.positions.0.clear();
booths.unit_panes.release_all();
return;
};
let (w, h) = (window.width(), window.height());
// The client's UI space is 768 units tall at every aspect (`f(screenH) = 768`, `0x41ad10`; the
// caret's `H_px/192`, `0x77b8c0`), `768/uiScale` with the dial. The VM lives in it: quads
// scale ×s out, and the mouse and the measures ÷s in.
let s = super::seam_scale(h, ui_scale.0);
// A moved seam scale or DPI stales every text metric the engine caches: integer-stepped
// advances do not rescale, and the size snap moves a string's width by several percent,
// enough for the ellipsis to eat fitting text. A monitor hop moves the DPI alone.
let dpi = window.scale_factor();
// The tile renderer sizes its cells in device pixels off this.
booths.tiles.dpi = dpi;
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;
}
// Re-seat the VM's measurer on the same edge, before the tick so the first update has it.
if let Some(atlas) = font_atlas.as_deref() {
if seam_moved || !script.has_text_measurer() {
seat_text_measurer(&mut script, atlas, s);
}
}
// The phase marks feed the `[ui-cost]` line and the hover recorder (`hover_log`).
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());
// Per frame: both halves' measure passes add to these, and only this zeroes them.
ui_cost.measured = 0;
ui_cost.measured_texts.clear();
// The phases below are last frame's until the paint writes them; see `UiFrameCost::stamped`.
ui_cost.stamped = false;
let mut t_mark = cost_on.then(std::time::Instant::now);
// μs since the previous mark, re-armed; nothing with the meter off.
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 what the interface computed from the old screen size.
if resized {
super::manifest::on_screen_resized(&script);
}
script.tick(time.delta_secs());
}
// The VM's clock anchored at `Time<Real>`'s last update, whose deltas it accumulates; every
// `Instant` to `GetTime` conversion goes through this pair, never `Instant::now()`.
*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 ────────────────────────────────
// A request reads only region data, never a resolved rect, and `resolve` writes no region's
// size, so measuring first lets the frame's one resolve see the answers.
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();
}
// Only a frame that measured can have a fresh request the pass above missed.
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();
// Message lines ask for their wrapped row count at the frame's width, so a long line pushes
// older ones up; no re-resolve, as rows move only the bands, never the anchors.
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,
// The resolved width carries the frame scale; the font height does not.
r.wrap_width * s,
crate::ui_text::FontSpec {
path: r.font.as_deref(),
height: crate::ui_text::drawn_px(r.height, None, s * r.scale),
outline: r.outline, // the outline biases the stepped width
alpha_gradient: None,
},
);
(r.frame, r.index, n, r.key)
})
.collect();
script.set_message_line_rows(&rows);
}
}
drop(measure_span);
let us_measure = lap();
// Script errors reach the Lua handler (`_ERRORMESSAGE`, `BasicControls.xml:16`) before the
// log, so a failing handler lands in this frame's drain.
script.dispatch_script_errors_to_handler();
for err in script.take_errors() {
warn!("ui_script: {err}");
}
// The runaway bound writes its own line, because its raise unwinds into whatever `pcall` the
// loop was called under and usually becomes nobody's error. Without this a frame stuck in Lua
// costs the bound's whole 15 s, every frame, and leaves the log silent — which is exactly what
// two frozen sessions handed back.
for r in script.take_runaway_reports() {
error!("ui_script: {r}");
}
for w in script.take_warnings() {
warn!("ui_script: {w}");
}
for n in script.take_notices() {
info!("ui_script: {n}");
}
// ── The handover ────────────────────────────────────────────────────────────────────────
*pass = super::UiPassState {
live: true,
seam: s,
dpi,
us_tick,
us_resolve,
us_measure,
solves_before: solves_before.unwrap_or(0),
derives_before: derives_before.unwrap_or(0),
};
}
/// The UI pass's second half, after the camera and the plate driver: an incremental measure and
/// `resolve` for what was written since the tick, the walk, and the conversion into [`UiQuads`].
pub(super) fn paint_script(
script: Option<NonSendMut<UiScript>>,
window: Query<&Window, With<PrimaryWindow>>,
mut quads: ResMut<UiQuads>,
world_assets: Option<ResMut<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
mut font_atlas: Option<ResMut<UiFontAtlas>>,
// The booth seam both ways: the bakes regions sample, and the pane aspects published back.
mut booths: crate::portrait::BoothBridge,
// Capture mode (the cursor icon quad) and [`super::UiCostWanted`], tupled to stay inside
// Bevy's 16-parameter system limit.
run: (
Option<Res<crate::run_mode::CaptureMode>>,
Res<super::UiCostWanted>,
),
// The `<Minimap>` widget slot, parked for `minimap::emit_minimap` to fill later in the frame.
mut parked_minimap: ResMut<crate::minimap::MinimapWidget>,
// The extract gate's memory. The conversion is a pure function of the extracted list, the
// editbox text UI, the raster (window size, seam scale, `scale_factor`), the portraits and the
// glyph-sheet generation. A `VmMemo`, since a skip also skips pushes into the VM, so a new
// VM's first frame always converts.
mut prev: Local<crate::ui_script::VmMemo<GateInputs>>,
mut ui_cost: ResMut<super::UiFrameCost>,
pass: Res<super::UiPassState>,
) {
let (capture, ui_cost_wanted) = run;
// Stand down with the tick half, which already cleared the booth panes.
if !pass.live {
return;
}
let Some(mut script) = script else {
return;
};
let prev = prev.get(&script);
let Ok(window) = window.single() else {
return;
};
let (w, h) = (window.width(), window.height());
let (s, dpi) = (pass.seam, pass.dpi);
let (us_tick, us_resolve) = (pass.us_tick, pass.us_resolve);
let (solves_before, derives_before) = (Some(pass.solves_before), Some(pass.derives_before));
let printing = ui_cost_enabled();
let cost_on = printing || ui_cost_wanted.0;
let mut t_mark = cost_on.then(std::time::Instant::now);
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())
};
// ── The second measure and resolve ───────────────────────────────────────────────────────
// What was written since the tick half, above all the nameplate anchors from this frame's
// camera (`vplates::drive_vplates`); a renamed plate needs its measure too.
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();
}
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();
}
}
}
// Folded into the tick half's own measure figure: one frame, one row.
let us_measure = pass.us_measure + lap();
// The glyph sheet's repack counter: a repack moves every cached cell's UV.
let generation = font_atlas.as_deref().map(|a| a.generation);
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;
// Message lines' link spans for the click hit-test; region FontStrings' are not collected.
let mut link_spans: Vec<(
benilla_ui::widget::FrameHandle,
benilla_ui::layout::Rect,
String,
String,
)> = Vec::new();
// The focused editbox's per-byte advances, measured as it draws, for click and scroll.
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(),
// At the drawn size, divided by the seam alone: screen UI units, like the mouse.
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 the draw's row starts and pitch, in UI units.
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 editbox's text UI: its Text quad, scroll window, caret and selection spans,
// and the blink phase (0.5 s, `0x77a790`).
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 ──────────────────────────────────────────────────────
// Equal inputs skip the conversion; the quads, minimap slot and link spans stay as they are.
let dims = (w.to_bits(), h.to_bits(), s.to_bits(), dpi.to_bits());
// A pooled unit-pane booth came free while a pane was waiting for one: take the full path
// once so the claim can move ([`crate::portrait::UnitPaneBooths::take_handover`]). The claim
// sweep runs only there, and a refused pane would otherwise sit empty until something else
// in the UI happened to change.
let pool_handover = booths.unit_panes.take_handover();
let settled = capture.is_none()
&& !pool_handover
&& 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,
stamped: true,
};
}
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;
}
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 ─────────────────────────────────────────────────────────────────
// When only the list moved and the few changed entries write only quads, they re-convert
// alone and stitch into last frame's `UiQuads` at the ranges `prev.spans` holds.
'splice: {
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 pool_handover {
no_splice!("a pooled unit-pane booth came free");
}
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 caret blink is wall-clock state in no entry, the Text arm's one time input.
if text_ui != prev.text_ui {
no_splice!("focused editbox text-ui moved");
}
if booths.images.0 != prev.portraits {
no_splice!("portrait sources moved");
}
// The spans describe last conversion's list; if anything replaced it, convert in full.
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);
// A window opening moves many entries, which the full conversion serves.
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()
);
}
// All-equal belongs to the gate above; reaching here means two comparisons disagree.
if changed.is_empty() && align.dropped.is_empty() {
no_splice!("no entry differs (a comparison razor slipped)");
}
// Departing entries too: the splice cannot un-park a departing minimap slot.
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()
);
}
// The scratch side channels are a tripwire: an entry that writes one takes the full path.
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");
}
// In place: nothing inserted or removed, and each changed entry keeps its quad count.
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: kept runs moved out of last frame's (no `Arc` clones), the
// re-converted entries from the scratch, new spans; `prev.held` keeps the allocation.
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(..);
// Quads the drain has yielded; kept runs strictly increase, so one 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(),
stamped: true,
};
}
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();
// Cleared only on the full-conversion path: a settled frame's map is still true, and
// clearing it would put the body panes' cameras to sleep. The dynamic unit-pane claims are
// swept on the same edge and for the same reason — this is the one path that sees every
// drawn pane, so it is the only one that can tell a pane that stopped drawing from one whose
// entry simply did not change ([`crate::portrait::UnitPaneBooths::sweep_begin`]).
booths.panes.0.clear();
booths.bake_sizes.0.clear();
booths.positions.0.clear();
booths.unit_panes.sweep_begin();
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;
// Every drawn pane has now re-claimed its booth; release the slots nobody asked for.
booths.unit_panes.sweep_end();
// Capture mode only: the held payload's icon at the mouse, for the hardware cursor a capture
// cannot show; every payload kind, as `crate::cursor`'s `payload_icon`.
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 link spans replace last frame's set, so `OnHyperlinkClick` hit-tests what is on screen.
script.set_link_spans(link_spans);
// Park the slot for `minimap::emit_minimap`; `None` when the minimap is hidden.
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 {
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,
stamped: true,
};
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 to its screen quads, plus the side channels some arms write: link spans,
/// the minimap slot, booth pane aspects and tile requests. The full pass and the splice both call
/// this; keep [`splice_simple`] in step with each arm's side effects.
fn convert_entry(
eq: benilla_ui::script::ExtractedQuad,
s: f32,
// The window in logical px: `h` flips y, `w` gives model tiles the screen diagonal.
w: f32,
h: f32,
// The device scale, for the nameplate border resampled to physical pixels.
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: 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, converted like `rect`.
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 {
// A frame draws nothing itself; its regions carry the visuals.
QuadContent::Frame => {}
// The `<Minimap>` content hole, parked for the minimap renderer to fill at this `z`.
QuadContent::Minimap {
zoom,
inside_zoom,
arrow_scale,
} => {
*minimap_slot = Some(crate::minimap::MinimapSlot {
rect,
z: eq.z,
zoom,
inside_zoom,
alpha: eq.alpha,
arrow_scale,
});
}
// A `<Model>`/`<PlayerModel>` pane draws itself, as the stock files declare a bare pane
// with no Texture: a file pane is a model tile, and a unit pane samples a booth's bake
// square — its window's, by the pane's global name
// ([`crate::portrait::model_pane_booth`]), or a pooled dynamic one for an addon's own
// pane ([`crate::portrait::UnitPaneBooths`]). A pane with neither a file nor a unit, and
// one past the pool's cap, draw nothing.
QuadContent::ModelPane {
handle,
name,
model,
skins,
unit,
facing,
model_scale,
position,
own_alpha,
icon,
camera,
light,
fog,
} => {
use crate::portrait::PortraitSource;
// A file pane publishes an idempotent tile request; `ui_models::compose_tiles` draws
// its quad each frame, so a cell packed later needs no re-conversion.
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(),
skins: skins.clone(),
size_px,
// 1 model unit is `1280 · modelScale · layoutScale` FrameXML units
// (`0x76d1a0`).
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 (`0x7b2a50`).
star_px_per_unit: 768.0 * diag * s * dpi,
facing,
position: Vec3::new(position.0, position.1, position.2),
// The perspective root, in model units: translate `pos · layoutScale`,
// rotate `facing`, scale `G48 · (5/3) · modelScale · layoutScale`, where
// `G48 · (5/3)` is the 4:3 renormalizer `√((4/3)²+1)/√(a²+1)`
// (`0x80655c`); the near and far planes do not scale with it.
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 (`0x76d120`).
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) = booths.model_pane_slot(name.as_deref(), handle, unit, facing) else {
return;
};
// `SetPosition`, published before the readiness check as the aspect is below: the
// booth root's translate, in bevy's axes. The order is the widget's — `(depth,
// screen-right, up)` in model space, the camera standing off on the model's `+x` —
// and it is converted here so no booth has to know it, exactly as the file-pane leg
// above hands `root_pos` to `wow_to_bevy`.
//
// Unlike the file pane's, this one *moves* the model: a `<PlayerModel>`'s camera is
// frozen (`0x7acf10`) rather than republished through the root, so the pane zooms and
// pans — Tmog's wheel and right-drag.
//
// **[`SET_POSITION_UNIT`] model units per `SetPosition` unit.** Deliberately not
// the reference's `pos · layoutScale`: that factor belongs to the group the
// reference's root scales with it (`T(pos·layout) · R(facing) ·
// S(G48·(5/3)·modelScale·layout)`) and this booth's root carries only the
// renormalizer, so spending the translate's share alone would move a `scale="4"`
// pane's model four times its own size.
booths.positions.0.insert(
slot.to_string(),
benilla_assets::coords::wow_to_bevy([
position.0 * crate::portrait::SET_POSITION_UNIT,
position.1 * crate::portrait::SET_POSITION_UNIT,
position.2 * crate::portrait::SET_POSITION_UNIT,
]),
);
// The aspect is published before the readiness check: the bake waits on the publish.
if rect.height() > 0.0 {
booths
.panes
.0
.insert(slot.to_string(), rect.width() / rect.height());
// Separately from the aspect, and read by nothing that decides what the pane
// shows: the bake's resolution in physical pixels, as the model tiles measure
// themselves. The rect is in FrameXML units; a bake has to be sized in real ones.
let dpi = booths.tiles.dpi.max(0.01);
let longest = (rect.width().max(rect.height()) * dpi).round().max(1.0);
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
booths.bake_sizes.0.insert(
slot.to_string(),
crate::portrait::pane_bake_size(longest as u32),
);
}
// The bake is premultiplied; the 2D stand-in while a model streams is straight alpha.
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,
mask,
tile,
} => {
// A live unit portrait (`SetPortraitTexture(region, unit)`): the model bake, or the
// reference's 2D TemporaryPortrait while the model streams; no entry yet draws nothing.
if let Some(token) = &portrait_unit {
use crate::portrait::PortraitSource;
// The aspect, published before the readiness check as in the model-pane arm; a
// round slot's row only marks it drawn, which gates the `"targettarget"` cost.
if rect.height() > 0.0 {
booths
.panes
.0
.insert(token.clone(), rect.width() / rect.height());
}
// The bake is premultiplied, the stand-in straight alpha; unflagged, the pass
// would premultiply the bake twice and erase what it draws over empty space.
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 `SetTexCoord`: the character micro button crops
// the player portrait (`MainMenuBarMicroButtons.lua:110,115`).
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],
// `SetPortraitTexture` cuts the inscribed circle, as the reference stamps into
// its 64² bake's alpha; `BenillaSetBoothTexture` samples square.
circular,
premultiplied,
clip,
..default()
});
return;
}
// An unset texture region (no file, no colour, as a cleared icon) draws nothing.
if path.is_none() && color.is_none() {
return;
}
// A path the archives lack draws nothing. The reference keeps the widget's previous
// texture and returns nil to Lua (`CSimpleTexture::SetTexture`, `0x770200`); a path
// here is re-resolved every frame, so the cell is empty. With no `WorldAssets` at all
// (a data-less run) the quad draws untextured.
//
// `SetTexCoord`'s edges map to raw UV corners, so a mirrored slice (`left > right`)
// keeps its flip; 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 past the texture tiles (`BonusActionBarFrame.lua:173`), on that axis alone;
// `SetHorizTile`/`SetVertTile` ask for the same wrap without naming a count, so they
// join it here and the count is computed below, once the art's texel size is known.
let explicit_uv = tex_coords.is_some();
let mut uv = uv;
let wrap = {
let (h, v) = tiling_axes(&uv);
(h || (tile.0 && !explicit_uv), v || (tile.1 && !explicit_uv))
};
let handle = match (path.as_deref(), assets.as_mut()) {
(Some(p), Some(a)) => {
let resolved = if p == benilla_ui::script::nameplate::BORDER_TEXTURE {
// Where the frame system painted the plate, beside the driver's `vpl` line.
if benilla_assets::trace::enabled_for("vpl") {
benilla_assets::trace::line(
"vpl",
&format!(
"paint=({:.1},{:.1})..({:.1},{:.1})",
rect.min.x, rect.min.y, rect.max.x, rect.max.y
),
);
}
// Deviation: the V-plate border is resampled sharp to the quad's physical
// size rather than GPU-magnified, so its 1 px bevel stays crisp; keyed by
// that size, so only a resize re-rasterises it.
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 tabard emblem cell: the reference installs a generated 128×64 or
// 128×32 image, white carrying the emblem BLP's alpha.
a.emblem_mask_texture(blp, images)
} else if circular {
// The circle-masked variant (`SetPortraitToTexture`).
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,
};
// `SetHorizTile`/`SetVertTile`'s repeat count: the region's own span over the art's,
// which is what "tile at its native size" means. Taken here because the texel size is
// only known once the art has loaded. A missing image, a zero-texel axis or an
// explicit `SetTexCoord` leaves the mapping alone.
if (tile.0 || tile.1) && !explicit_uv {
if let Some(sz) = handle
.as_ref()
.and_then(|h| images.get(h))
.map(|img| img.texture_descriptor.size)
{
let repeats = |span: f32, texels: u32| {
if texels > 0 {
span / texels as f32
} else {
1.0
}
};
let u = if tile.0 {
repeats(rect.width(), sz.width)
} else {
1.0
};
let v = if tile.1 {
repeats(rect.height(), sz.height)
} else {
1.0
};
uv = UvRect::from_tex_coords([0.0, u, 0.0, v]);
}
}
// A crop's magnified edge would filter in the neighbouring atlas cell.
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,
// `SetRotation` is counter-clockwise-positive, `UiQuad::rotation` clockwise.
rotation: -rotation,
desaturated,
// `AddMaskTexture`: the mask's own art and rect, converted to screen space the
// same way as `rect`. A mask whose art the archives lack drops the mask, not the
// quad — a missing cut-out shows the whole rectangle, which is the same thing the
// caller sees when it never had a mask to add.
mask: mask.and_then(|m| {
let art = assets.as_mut()?.sprite_texture(&m.path, images)?;
let r = m.rect;
Some(crate::ui_pass::UiQuadMask {
texture: art,
rect: Rect::new(r.left * s, h - r.top * s, r.right * s, h - r.bottom * s),
})
}),
..default()
});
}
// A `Line` region: the quad between two points, `thickness` wide. Drawn through
// `UiQuad::corners`, the explicit-corner path the cooldown wedge already uses, because a
// line is a rotated rectangle and `rect` plus `rotation` cannot express one whose *angle*
// is data rather than a setting.
QuadContent::Line {
path,
color,
additive,
start,
end,
thickness,
} => {
// The y flip, as `rect` above: the two ends and the perpendicular are all in UI space.
let a = Vec2::new(start.0 * s, h - start.1 * s);
let b = Vec2::new(end.0 * s, h - end.1 * s);
let along = b - a;
let len = along.length();
if len <= 0.0001 {
// Both ends in one place: a line of no length, which draws nothing rather than a
// degenerate quad whose normal is undefined.
return;
}
let half = thickness * 0.5 * s;
// The perpendicular, scaled to half the thickness; `(-y, x)` turns the direction a
// quarter turn, which is the offset that gives the line its width.
let n = Vec2::new(-along.y, along.x) / len * half;
let corners = [a + n, b + n, b - n, a - n];
let handle = match (path.as_deref(), assets.as_mut()) {
(Some(p), Some(av)) => {
let resolved = av.sprite_texture(p, images);
if resolved.is_none() {
return;
}
resolved
}
// No art is a solid, which is how every caller in the corpus paints a line
// (`SetColorTexture` then `SetVertexColor`).
_ => None,
};
if handle.is_none() && color.is_none() {
return;
}
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: UvRect::FULL,
color,
additive,
clip,
corners: Some(corners),
..default()
});
}
// The colour picker's hue disc, a generated image drawn at full value ([`colorselect`]).
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: the grey ramp tinted `rgb(h, s, 1)`.
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 backdrop piece, the bg or one of the 8 border pieces, sampled `Repeat` when `tile`;
// its UVs are per-corner, as the TOP and BOTTOM edges are rotated.
let handle = assets.as_mut().and_then(|a| {
if tile {
a.sprite_texture_tiled(&path, images)
} else {
a.sprite_texture(&path, images)
}
});
// A missing BLP draws nothing.
let Some(handle) = handle else { return };
// The 8 border pieces share one atlas, so a `tile` piece is pulled half a texel in on
// its bounded axes; a stretched bg is a whole texture.
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,
world_seat,
} => {
// No atlas (no client data, or an unreadable font) draws no text.
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,
world_seat,
},
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; true when anything was measured.
fn measure_fontstrings(
script: &mut UiScript,
atlas: &mut UiFontAtlas,
s: f32,
ui_cost: &mut super::UiFrameCost,
// The hover recorder's churn column; the `[ui-cost]` line reports only counts.
record_texts: bool,
) -> bool {
let requests = script.fontstrings_needing_measure();
// The first few strings a frame re-shaped, 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;
}
// The VM's synchronous measurer's own body and engine ([`crate::ui_text::measure_request`]).
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 for captures: 32×32 hanging down-right from the mouse, the reference's
/// look with the hardware cursor's `(0, 0)` hotspot, above the whole UI.
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()
}
}
/// [`uv_clamp_window`] and [`tiling_axes`] on shapes the stock UI draws.
#[cfg(test)]
mod uv_clamp_tests {
use super::{tiling_axes, uv_clamp_window, UvRect};
/// `POIIcons` is 128², and a world-map POI samples its cell (7, 1).
#[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]);
}
#[test]
fn the_whole_texture_asks_for_no_window() {
assert!(uv_clamp_window(&UvRect::FULL, (128, 128)).is_none());
}
/// The stance bar's middle strip, `SetTexCoord(0, n-2, 0, 1)` (`BonusActionBarFrame.lua:173`).
#[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)
);
}
#[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)
);
}
#[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)
);
}
#[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);
}
#[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:?}"
);
}
#[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());
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);
assert_eq!(q.z_key, u64::MAX);
assert!(q.texture.is_some());
assert!(!q.additive);
}
}
/// A ScrollFrame clip through the real UI pass systems in a headless `App`, y flip included.
#[cfg(test)]
mod clip_plumb_tests {
use bevy::math::Rect;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use benilla_ui::script::UiScript;
use super::{paint_script, tick_script, UiQuad, UiQuads};
use crate::portrait::PortraitImages;
/// A ScrollFrame with a coloured marker in its scroll child, on a 1024×768 window (`s = 1`).
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
"#,
)
.unwrap();
let mut app = App::new();
app.insert_non_send_resource(script);
app.init_resource::<UiQuads>();
app.init_resource::<crate::ui_script::UiPassState>();
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::portrait::PaneBakeSizes>();
app.init_resource::<crate::portrait::PanePositions>();
app.init_resource::<crate::portrait::UnitPaneBooths>();
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 1.0, the identity.
app.init_resource::<crate::ui_script::UiScaleCvar>();
app.world_mut().spawn((
Window {
resolution: UVec2::new(1024, 768).into(),
..default()
},
PrimaryWindow,
));
// Both halves of the pass: the quads are the paint half's.
app.add_systems(Update, (tick_script, paint_script).chain());
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: bottom 468, left 0, top 668, right 300; y-down through the 768 window, the
// top is 100 and the bottom 300.
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"
);
}
/// At dial 0.5 the VM sees a 2048×1536 screen, 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: top 1536 − 100 = 1436, bottom 1236, right 300; ×0.5 is y-up px 618..718,
// and y-down through the 768 window the top is 50 and the bottom 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"
);
}
/// A mask whose art the archives lack drops the **mask**, not the quad.
///
/// Worth pinning because the other reading is tempting and wrong: a mask that cannot load is
/// zero coverage, and honouring that would erase the art it was meant to shape — a stitched
/// map would go blank wherever a generated mask was missing, which reads as the map's own art
/// failing. Showing the whole rectangle is what the caller sees when it never had a mask.
///
/// Runs with no `WorldAssets` at all, which is the same path a missing file takes: the quad is
/// a pathless solid so it still draws, and the mask asks for art and gets none.
#[test]
fn a_mask_whose_art_is_missing_drops_the_mask_and_keeps_the_quad() {
let script = UiScript::new().unwrap();
script
.run(
r#"
local f = CreateFrame("Frame", "MaskPlumb")
f:SetPoint("TOPLEFT", 0, 0)
f:SetWidth(50); f:SetHeight(50)
local m = f:CreateTexture(nil, "ARTWORK")
m:SetTexture(0, 0, 1)
m:SetAllPoints()
local cut = f:CreateMaskTexture(nil, "ARTWORK")
cut:SetTexture("Interface\\AddOns\\NoSuch\\mask")
cut:SetAllPoints()
m:AddMaskTexture(cut)
"#,
)
.unwrap();
let mut app = App::new();
app.insert_non_send_resource(script);
app.init_resource::<UiQuads>();
app.init_resource::<crate::ui_script::UiPassState>();
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::portrait::PaneBakeSizes>();
app.init_resource::<crate::portrait::PanePositions>();
app.init_resource::<crate::portrait::UnitPaneBooths>();
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, (tick_script, paint_script).chain());
app.update();
let quads = &app.world().resource::<UiQuads>().quads;
let marker: &UiQuad = quads
.iter()
.find(|q| q.color[2] == 1.0 && q.color[0] == 0.0)
.expect("the solid still draws");
assert!(
marker.mask.is_none(),
"a mask with no art is no mask, not an empty one"
);
// And the mask region itself emitted nothing: one quad for the frame slot, one for the
// solid, and none for the cut-out.
assert_eq!(
quads
.iter()
.filter(|q| q.color[2] == 1.0 && q.color[0] == 0.0)
.count(),
1
);
}
#[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
"#,
)
.unwrap();
let mut app = App::new();
app.insert_non_send_resource(script);
app.init_resource::<UiQuads>();
app.init_resource::<crate::ui_script::UiPassState>();
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::portrait::PaneBakeSizes>();
app.init_resource::<crate::portrait::PanePositions>();
app.init_resource::<crate::portrait::UnitPaneBooths>();
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,
));
// Both halves of the pass: the quads are the paint half's.
app.add_systems(Update, (tick_script, paint_script).chain());
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);
}
}
/// [`align_entries`] on a real extract list: `FrameHandle`'s fields are private, and a forged
/// [`benilla_ui::order::ZKey`] would test a fiction.
#[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.
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
}
#[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"
);
}
}
#[test]
fn an_insertion_shifts_the_tail_and_costs_one_conversion() {
let was = entries();
// The new `z` goes strictly between its neighbours', at the first pair with room:
// sibling regions differ by 1, 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
);
}
}
#[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");
}
}
#[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());
}
}
/// A settled frame skips the conversion, and any extract-visible change reopens it, paint-only
/// writes included.
#[cfg(test)]
mod extract_gate_tests {
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use benilla_ui::script::UiScript;
use super::{paint_script, tick_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
"#,
)
}
/// The headless app around a boot script; one booted into another's end state converts in
/// full on its first frame, the result a splice 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::<crate::ui_script::UiPassState>();
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::portrait::PaneBakeSizes>();
app.init_resource::<crate::portrait::PanePositions>();
app.init_resource::<crate::portrait::UnitPaneBooths>();
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,
));
// Both halves of the pass: the quads are the paint half's.
app.add_systems(Update, (tick_script, paint_script).chain());
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
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: only the extracted-list compare can
// reopen extraction.
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"
);
}
/// `PaperDollFrame.xml:204` declares a bare `<PlayerModel name="CharacterModelFrame">`, and a
/// window feeds it a subject and a yaw; an addon's own pane has no window, so `SetUnit` is the
/// whole request — and it used to draw nothing at all, which is the empty 3D portrait box in
/// pfUI. The rule now, in one test because the three halves only mean anything together:
///
/// 1. a stock name still takes its window's booth ([`crate::portrait::model_pane_booth`]),
/// 2. any other pane that names a unit takes a pooled dynamic booth
/// ([`crate::portrait::UnitPaneBooths`]), one slot each, in the order they draw,
/// 3. and the first pane past the pool's cap draws nothing — the same answer
/// `UI_MODEL_CAM_LAYERS` already gives a ninth perspective file pane, and deliberately not
/// a round 2D bust stand-in, which would look plausible and be a different thing.
#[test]
fn a_named_pane_keeps_its_window_booth_while_addon_panes_fill_the_pool_and_the_first_past_the_cap_draws_nothing(
) {
use crate::portrait::UNIT_PANE_SLOTS;
// One more pane than the pool has slots, so the cap is exercised, not just approached.
let panes = UNIT_PANE_SLOTS.len() + 1;
let mut app = app_from_script(&format!(
r#"
local doll = CreateFrame("PlayerModel", "CharacterModelFrame")
doll:SetPoint("TOPLEFT", 0, 0)
doll:SetWidth(233); doll:SetHeight(224)
doll:SetUnit("player")
for i = 1, {panes} do
local p = CreateFrame("PlayerModel", "pfPortraitModelraid" .. i)
p:SetPoint("TOPLEFT", 300, -40 * i)
p:SetWidth(64); p:SetHeight(32)
p:SetUnit("player")
p:SetCamera(0)
end
"#
));
// The pane's token has to name something: the booth is handed a GUID, so a token that
// resolves to nothing claims no slot.
app.world_mut()
.non_send_resource_mut::<UiScript>()
.set_unit(
"player",
Some(benilla_ui::script::UnitState {
guid: 0xdead_beef,
exists: true,
..default()
}),
);
// One distinguishable bake per booth: the paper doll's and the pool's eight.
let bake_for = |app: &mut App, slot: &str| {
let bake = app
.world_mut()
.resource_mut::<Assets<Image>>()
.add(Image::default());
app.world_mut().resource_mut::<PortraitImages>().0.insert(
slot.to_string(),
crate::portrait::PortraitSource::Live(bake.clone()),
);
bake
};
let doll_bake = bake_for(&mut app, "paperdoll");
let pool_bakes: Vec<Handle<Image>> = UNIT_PANE_SLOTS
.iter()
.map(|slot| bake_for(&mut app, slot))
.collect();
app.update();
let drawn = |app: &App, bake: &Handle<Image>| {
app.world()
.resource::<UiQuads>()
.quads
.iter()
.filter(|q| q.texture.as_ref() == Some(bake))
.count()
};
assert_eq!(
drawn(&app, &doll_bake),
1,
"exactly the named pane draws the paper doll's bake"
);
for (i, bake) in pool_bakes.iter().enumerate() {
assert_eq!(
drawn(&app, bake),
1,
"pool slot {i} ({}) must be drawn by exactly one pane",
UNIT_PANE_SLOTS[i]
);
}
// The cap: nine panes asked, eight booths exist, so one pane drew no bake at all.
let pane_quads: usize = std::iter::once(&doll_bake)
.chain(&pool_bakes)
.map(|b| drawn(&app, b))
.sum();
assert_eq!(
pane_quads,
1 + UNIT_PANE_SLOTS.len(),
"the pane past the cap must draw nothing, not borrow a booth"
);
let booths = app.world().resource::<crate::portrait::BoothPanes>();
let aspect = booths
.0
.get("paperdoll")
.copied()
.expect("the named pane publishes its aspect");
assert!(
(aspect - 233.0 / 224.0).abs() < 0.01,
"the pane's own rect is the aspect, got {aspect}"
);
for slot in UNIT_PANE_SLOTS {
let a = booths
.0
.get(slot)
.copied()
.unwrap_or_else(|| panic!("pool slot {slot} publishes its aspect"));
assert!(
(a - 64.0 / 32.0).abs() < 0.01,
"a pooled booth frames at its own pane's aspect, got {a} for {slot}"
);
}
let bake_quad = app
.world()
.resource::<UiQuads>()
.quads
.iter()
.find(|q| q.texture.as_ref() == Some(&pool_bakes[0]))
.expect("slot 0 drew");
assert!(
bake_quad.premultiplied,
"a render-target bake carries premultiplied colour"
);
}
/// `Model:SetPosition` is the pane's model-root translate (`T(pos · layoutScale)`,
/// `0x76d1a0`), and on a `<PlayerModel>` it *moves the model*: the widget's camera is frozen
/// (`0x7acf10`) rather than republished through the root, so unlike a plain `<Model>`'s
/// perspective leg — where it provably cancels, which
/// [`crate::capture::probe_model_camera`]'s `position` leg pins — the pane zooms and pans.
/// The booth path used to drop the call on the floor, which cost Tmog its wheel zoom, its
/// right-drag pan and the per-slot framing of every browse card.
///
/// The axes are the widget's — `SetPosition(depth, right, up)` in model units, the camera
/// standing off on the model's `+x` — so bevy's translate is `(-right, up, -depth)` and a
/// wheel-up (`+depth`) walks the model toward the camera.
#[test]
fn a_unit_pane_publishes_its_set_position_as_its_booth_roots_translate() {
use crate::portrait::{PanePositions, UNIT_PANE_SLOTS};
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")
doll:SetPosition(1, 0, 0)
local p = CreateFrame("PlayerModel", "TmogFramePlayerModel")
p:SetPoint("TOPLEFT", 300, -40)
p:SetWidth(300); p:SetHeight(400)
p:SetUnit("player")
p:SetPosition(2, 0.5, -0.25)
"#,
);
app.world_mut()
.non_send_resource_mut::<UiScript>()
.set_unit(
"player",
Some(benilla_ui::script::UnitState {
guid: 0xdead_beef,
exists: true,
..default()
}),
);
app.update();
let at =
|app: &App, slot: &str| app.world().resource::<PanePositions>().0.get(slot).copied();
// `G48` is irrational at most aspects, so compare to a tolerance, not a bit pattern.
let near = |got: Option<Vec3>, want: Vec3, what: &str| {
let got = got.unwrap_or_else(|| panic!("{what}: nothing published"));
assert!(
got.abs_diff_eq(want, 1e-5),
"{what}: got {got:?}, want {want:?}"
);
};
// `SET_POSITION_UNIT` is 0.22: `(2, 0.5, -0.25)` lands as `(-0.11, -0.055, -0.44)`.
near(
at(&app, UNIT_PANE_SLOTS[0]),
Vec3::new(-0.11, -0.055, -0.44),
"an addon's own pane publishes under the pooled slot it claimed",
);
// A stock name takes its window's booth, and its pane's position with it: the channel is
// keyed by slot precisely so the four window panes are not a second path.
near(
at(&app, "paperdoll"),
Vec3::new(0.0, 0.0, -0.22),
"a named pane publishes under its window's booth",
);
// A fixed number of model units per `SetPosition` unit: the pane's own layout scale does
// **not** multiply it, because this booth's root does not scale by it either
// (`framing::pane_root_scale_at`). Taking the translate's share of the reference's
// `T(pos·layout) · R · S(…·layout)` group while dropping the scale's would move a
// `scale="4"` Tmog card's model four times its own size.
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("TmogFramePlayerModel:SetScale(0.5)")
.unwrap();
app.update();
near(
at(&app, UNIT_PANE_SLOTS[0]),
Vec3::new(-0.11, -0.055, -0.44),
"a scaled pane moves its model by the same model units",
);
// A pane that stops drawing publishes nothing at all, so the booth keeps where it was
// put ([`crate::portrait::pane_position`]) instead of reading an absent entry as 0.
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("TmogFramePlayerModel:Hide()")
.unwrap();
app.update();
assert_eq!(
at(&app, UNIT_PANE_SLOTS[0]),
None,
"an undrawn pane publishes nothing"
);
}
/// The cap is only acceptable if a booth comes back: without a release the first eight panes
/// to draw would own the pool for the session, and pfUI's ninth unit frame would be
/// permanently empty. A pane hiding frees its slot, and the pane that was refused takes it —
/// which needs the extract to run one more full conversion, since the claim sweep runs
/// nowhere else (`UnitPaneBooths::take_handover`); with no UI change to force it, a settled
/// frame would leave the refused pane blank forever.
#[test]
fn a_hidden_unit_pane_frees_its_pooled_booth_for_the_pane_the_cap_refused() {
use crate::portrait::UNIT_PANE_SLOTS;
let filling = UNIT_PANE_SLOTS.len();
let mut app = app_from_script(&format!(
r#"
panes = {{}}
for i = 1, {filling} do
local p = CreateFrame("PlayerModel", "pfPortraitModelraid" .. i)
p:SetPoint("TOPLEFT", 10 * i, 0)
p:SetWidth(64); p:SetHeight(32)
p:SetUnit("player")
panes[i] = p
end
-- The pane past the cap, at an x no filling pane uses.
refused = CreateFrame("PlayerModel", "pfPortraitModelrefused")
refused:SetPoint("TOPLEFT", 500, 0)
refused:SetWidth(64); refused:SetHeight(32)
refused:SetUnit("player")
"#
));
app.world_mut()
.non_send_resource_mut::<UiScript>()
.set_unit(
"player",
Some(benilla_ui::script::UnitState {
guid: 0xdead_beef,
exists: true,
..default()
}),
);
// One shared bake under every slot, so "drew a booth" is a texture match and which slot
// the claim landed in is not part of the property.
let bake = app
.world_mut()
.resource_mut::<Assets<Image>>()
.add(Image::default());
for slot in UNIT_PANE_SLOTS {
app.world_mut().resource_mut::<PortraitImages>().0.insert(
slot.to_string(),
crate::portrait::PortraitSource::Live(bake.clone()),
);
}
let refused_drew = |app: &App| {
app.world()
.resource::<UiQuads>()
.quads
.iter()
.any(|q| q.texture.as_ref() == Some(&bake) && q.rect.min.x == 500.0)
};
app.update();
assert!(
!refused_drew(&app),
"the pool is full, so the ninth pane draws nothing yet"
);
// One filling pane leaves. Its slot frees at the end of that conversion, which is after
// the refused pane already asked, so the handover costs one more conversion — and the
// only thing that changed in the UI is the hide, so nothing else will force one.
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("panes[1]:Hide()")
.unwrap();
app.update();
app.update();
assert!(
refused_drew(&app),
"the freed booth must reach the pane the cap refused"
);
}
/// The shader's `select(a, k, premultiplied)` is right only if exactly the render-target quads
/// carry the flag; unflagged, an additive effect over empty space is lost.
#[test]
fn a_booth_bake_quad_is_flagged_premultiplied_and_a_plain_one_is_not() {
let mut app = app_with_marker();
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"
);
}
/// `spliced == 1` proves the splice fired; the result must equal a fresh app's full conversion.
#[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
"#,
);
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
"#,
);
reference.update();
assert!(
app.world().resource::<UiQuads>().quads
== reference.world().resource::<UiQuads>().quads,
"post-stitch splice output equals the full conversion"
);
}
/// Showing a hidden sibling inserts entries mid-list, and hiding it removes them.
#[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; B starts hidden.
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 comparison app replays the history: `Show()` re-stacks a hidden frame to the tail of
// its draw bucket (the client's show, `0x76ae10`), so B draws above C.
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 ─────────────────────────────────────────────────────────────────────────
// Nothing converts, so `dropped` is what shows the splice ran.
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"
);
}
/// The held quads were rasterized under the old seam, so a resize takes the full path.
#[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"
);
}
}