Files
benilla-octo/crates/benilla-app/src/ui_script/extract/mod.rs
T
SamandClaude Fable 5 cd58f90bec a chat link is one token now, and tooltips print true costs
- a chat link edits as one atomic token: the caret can't land inside it, its colors survive, one keypress removes it whole
- spell tooltips print the resolved mana cost through the reference's own table walk, and the "% of base mana" line dies
- the spirit healer's confirm comes back after a cancel and wears its warning icon
- the dressing room's pane is a live animating model, and a tried-on bow lands in the hand

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

1052 lines
51 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::assets::WorldAssets;
use crate::ui_pass::{UiQuad, UiQuads, UvRect};
use crate::ui_text::UiFontAtlas;
mod cooldown;
mod text;
use cooldown::cooldown_quads;
/// `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).
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"))
}
/// Last frame's extract-gate inputs (decision 0740), held together as one `Local` — the gate
/// compares all four 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)>,
portraits: std::collections::HashMap<String, crate::portrait::PortraitSource>,
}
/// 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,
// Gates the held-cursor icon quad (decision 0216 §5): CAPTURE-ONLY, the same presence check
// every other capture-only system uses (`ui_script::capture_ui_active`'s sibling pattern).
capture: Option<Res<crate::capture::CaptureMode>>,
// This frame's `<Minimap>` widget slot, parked for `minimap::emit_minimap` (the UiQuadAppend
// producer that fills the hole with tile/arrow quads — decision 0203 phase 1).
mut minimap_widget: ResMut<crate::minimap::MinimapWidget>,
// Whether the digit-advance feed has run (once per raster environment: on the first frame
// the atlas exists, and again after every seam-scale change — `last_seam` below re-arms it).
mut digits_fed: Local<bool>,
// 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.
mut last_seam: 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, window dims × seam
// scale, the portrait token map) — the glyph atlas bakes once at Startup and 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).
mut prev: Local<GateInputs>,
// This frame's phase split, published for the hover recorder (`hover_log`).
mut ui_cost: ResMut<crate::hover_log::UiFrameCost>,
) {
let Some(mut script) = script else {
return;
};
let Ok(window) = window.single() else {
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`.
if *last_seam != s {
if *last_seam != 0.0 {
script.invalidate_text_measures();
*digits_fed = false;
}
*last_seam = 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 || crate::hover_log::enabled();
let solves_before = cost_on.then(|| script.layout_solves());
// 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();
script.set_screen_size(w / s, if h > 0.0 { h / s } else { 768.0 });
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);
}
{
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) {
script.resolve();
}
}
}
let us_resolve = lap();
let measure_span = bevy::log::info_span!("ui_script: measure").entered();
// The digit-advance feed (the synchronous half of the money layout's metrics): measure
// NumberFontNormal's '0'..'9' once per atlas scale and push them as `BENILLA_DIGIT_W`, so
// `BenillaMoney_Set` can size its coin slots to their numbers *inside* one update — the real
// SmallMoneyFrame's GetTextWidth-mid-update shrink (MoneyFrame.lua l.202-269), which the
// frame-late GetStringWidth round-trip below can't serve. The font pairing comes from the
// registered font object (Fonts.xml stays the single source of truth).
if let Some(atlas) = font_atlas.as_deref_mut() {
if !*digits_fed {
if let Some(number_font) = script.font_object("NumberFontNormal") {
let adv = crate::ui_text::digit_advances(
atlas,
crate::ui_text::FontSpec {
path: number_font.font.as_deref(),
// The one-to-one drawn px (cap inert at NumberFontNormal's 14) × the
// virtual scale; the widths divide back to UI units below.
height: crate::ui_text::drawn_px(number_font.height, None, s),
// The TRUE outline (NumberFontNormal is NORMAL-outlined): it selects the
// baked ring cell; only a THICK outline also biases the step law
// (`outline-bake-tint.md`) — either way the fed digit widths match what
// the render pass actually lays.
outline: number_font.outline,
paint_halo: true,
alpha_gradient: None,
},
);
let adv = adv.map(|a| a / s);
script.set_digit_advances(&adv);
*digits_fed = true;
}
}
}
// 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(
atlas,
&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
paint_halo: true, // measure never paints; irrelevant here
alpha_gradient: None,
},
);
(r.frame, r.index, n, r.key)
})
.collect();
script.set_message_line_rows(&rows);
}
}
drop(measure_span);
let us_measure = lap();
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,
paint_halo: true, // measure never paints
alpha_gradient: None, // alpha never changes metrics
};
let cum: Vec<f32> = crate::ui_text::line_advances(atlas, &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(atlas, &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`/`minimap_widget`/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());
let settled = capture.is_none()
&& prev.dims == Some(dims)
&& 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 = crate::hover_log::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,
skipped: 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;
}
prev.dims = Some(dims);
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();
for eq in extracted {
let Some(r) = eq.rect else { continue };
// 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);
// 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 => continue,
// 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,
});
continue;
}
// The Cooldown widget's pie wipe + finish flash (decision 0137 phase 4) — the
// byte-pinned look of `UI-Cooldown-Indicator.m2`, rebuilt natively (see
// [`cooldown_quads`]).
QuadContent::Cooldown { fraction, flash } => {
cooldown_quads(
rect,
eq.z,
eq.alpha,
fraction,
flash,
clip,
&mut assets,
&mut images,
&mut out,
);
continue;
}
QuadContent::Texture {
path,
color,
additive,
tex_coords,
circular,
portrait_unit,
rotation,
} => {
// 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;
// A **square** binding is a booth pane (`BenillaSetBoothTexture`, decision
// 0208 §5), not a round unit portrait: publish the rect's aspect so the 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. 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.
if !circular && rect.height() > 0.0 {
booths
.panes
.0
.insert(token.clone(), rect.width() / rect.height());
}
let handle = match booths.images.0.get(token) {
Some(PortraitSource::Live(h)) => Some(h.clone()),
Some(PortraitSource::File(p)) => assets
.as_mut()
.and_then(|a| a.sprite_texture(p, &mut images)),
None => None,
};
let Some(handle) = handle else {
continue;
};
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,
clip,
..default()
});
continue;
}
// 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() {
continue;
}
// 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.
let handle = match (path.as_deref(), assets.as_mut()) {
(Some(p), Some(a)) => {
let resolved = if circular {
a.portrait_texture(p, &mut images)
} else {
a.sprite_texture(p, &mut images)
};
if resolved.is_none() {
continue;
}
resolved
}
_ => None,
};
// 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;
// `<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.
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,
};
out.push(UiQuad {
rect,
z_key: eq.z,
texture: handle,
uv,
color,
additive,
clip,
// The engine's SetRotation is counterclockwise-positive; the quad pass spins
// clockwise-on-screen (`UiQuad::rotation`) — negate to convert.
rotation: -rotation,
..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, &mut images)
} else {
a.sprite_texture(&path, &mut images)
}
});
// No texture (missing BLP) ⇒ draw nothing rather than a phantom solid quad.
let Some(handle) = handle else { continue };
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 {
continue;
};
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.as_ref(),
screen_h: h,
scale: s,
font_scale: eq.scale,
caret_pinned: capture.is_some(),
},
&mut out,
&mut link_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,
});
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.
minimap_widget.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 {
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 = crate::hover_log::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,
skipped: false,
};
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 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 crate::hover_log::UiFrameCost,
) -> 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 crate::hover_log::enabled() {
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;
}
let measures: Vec<(u32, f32, f32, f32, u64)> = requests
.iter()
.map(|r| {
// The full raster scale: seam × the owner frame's effective_scale. Measure at the
// exact drawn size and divide the whole product back out, so the frame-LOCAL answer
// times the layout's scale lands on the drawn glyphs to the pixel (integer-stepped
// advances don't commute with scaling — the request's `scale` doc).
let rs = s * r.scale;
let spec = |()| crate::ui_text::FontSpec {
path: r.font.as_deref(),
// The render pass's exact drawn px (two regimes × the full scale) —
// measure == render, and Lua's GetStringWidth/Height echo the DRAWN size
// (0x772890); results divide back to frame-local UI units below.
height: crate::ui_text::drawn_px(r.height, r.text_height, rs),
// The TRUE outline: THICK biases the client's step law (+1px per glyph —
// GlyphStepBase 0x5ca2b0, THICK-only per outline-bake-tint.md) and any outline
// adds the +2r line pitch, so measure must see it.
outline: r.outline,
paint_halo: true, // measure never paints; irrelevant here
alpha_gradient: None, // alpha never changes metrics
};
let wrap = r.wrap_width.map(|w| w * rs);
let (w, h) = crate::ui_text::measure_text(atlas, &r.text, wrap, spec(()));
// …and the NATURAL width, which is what `GetStringWidth` answers with (the reference
// measures its getter's string with no wrap constraint — wow-re
// `fontstring-overflow.md`, "The measurement echo"). A second pass only for the
// regions that actually carry a declared width; for the rest the two are one number.
let natural = if wrap.is_some() {
crate::ui_text::measure_text(atlas, &r.text, None, spec(())).0
} else {
w
};
(r.id, w / rs, h / rs, natural / rs, 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()
}
}
#[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:SetSize(300, 200) -- frame bottom 300, right 300
local child = CreateFrame("Frame", "Child")
child:SetSize(300, 600)
frame:SetScrollChild(child)
local marker = child:CreateTexture(nil, "ARTWORK")
marker:SetTexture(1, 0, 0) -- pathless colored quad: no BLP asset needed
"#,
)
.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::minimap::MinimapWidget>();
app.init_resource::<crate::hover_log::UiFrameCost>();
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:SetSize(50, 50)
local m = plain:CreateTexture(nil, "ARTWORK")
m:SetTexture(0, 1, 0)
"#,
)
.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::minimap::MinimapWidget>();
app.init_resource::<crate::hover_log::UiFrameCost>();
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`.
#[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 {
let script = UiScript::new().unwrap();
script
.run(
r#"
local plain = CreateFrame("Frame", "Plain")
plain:SetPoint("TOPLEFT", 0, 0)
plain:SetSize(50, 50)
marker = plain:CreateTexture(nil, "ARTWORK")
marker:SetTexture(1, 0, 0)
"#,
)
.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::minimap::MinimapWidget>();
app.init_resource::<crate::hover_log::UiFrameCost>();
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"
);
}
}