Backdrop pieces are nine Texture regions on the frame, as in 1.12.1 (#905)

A backdrop was data drawn in the frame's own slot, so `GetRegions` and `GetNumRegions` never saw its pieces, and addons that skin by walking a frame's regions missed them.

In the reference, installing a backdrop creates nine Texture regions on the frame: the background on BACKGROUND, then eight border pieces on BORDER, linked into its region list in that order. A resize re-runs their texcoords and colours, and the colour setters tint the background or the border alone.

The pieces are now real regions, drawn through the region path, so edges draw over another frame's BACKGROUND at the same level. `<Layers>` and `<Backdrop>` load in document order. A region leaving its parent empties any Button, CheckButton, StatusBar, Slider or ColorSelect slot holding it, and a dead layout node now seeds only its dependents.

Tests pin the order, colours, resize, teardown and stock quads.

Fixes #883

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-11 23:10:33 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 9be2802f39
commit 4e0011bdd7
20 changed files with 1277 additions and 382 deletions
@@ -97,7 +97,6 @@ fn content_kind(c: &QuadContent) -> &'static str {
QuadContent::Texture { .. } => "texture",
QuadContent::ColorWheel => "colorwheel",
QuadContent::ColorValue { .. } => "colorvalue",
QuadContent::Backdrop { .. } => "backdrop",
QuadContent::Text { .. } => "text",
}
}
@@ -280,7 +279,6 @@ fn span_bounds(spans: &[u32], i: usize) -> (usize, usize) {
fn splice_simple(eq: &benilla_ui::script::ExtractedQuad) -> bool {
match &eq.content {
QuadContent::Frame
| QuadContent::Backdrop { .. }
// At most one quad and one idempotent tile request.
| QuadContent::ModelPane { .. }
| QuadContent::ColorWheel
@@ -1404,44 +1402,6 @@ fn convert_entry(
..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,
@@ -311,7 +311,7 @@ fn the_ground_dim_draws_over_the_tile_and_clear_of_the_rope() {
let ground = quads
.iter()
.position(|q| match &q.content {
QuadContent::Backdrop { path, .. } => path.contains("UI-DialogBox-Background"),
QuadContent::Texture { path: Some(p), .. } => p.contains("UI-DialogBox-Background"),
_ => false,
})
.expect("the window still wears the 1.14 dialog ground");
@@ -1262,7 +1262,7 @@ fn popup_children_inherit_the_dialog_stratum() {
let backdrop_z = quads
.iter()
.find_map(|q| match &q.content {
QuadContent::Backdrop { path, .. } if path.contains("UI-DialogBox-Background") => {
QuadContent::Texture { path: Some(p), .. } if p.contains("UI-DialogBox-Background") => {
Some(q.z)
}
_ => None,
@@ -41,8 +41,7 @@ fn answer_measures(s: &mut UiScript) {
s.set_measured_text_unwrapped(&answers);
}
/// Every texture path drawn: `<Texture>` regions and the `<Backdrop>` plate, which is its own
/// quad kind (`QuadContent::Backdrop`).
/// Every texture path drawn: `<Texture>` regions, the `<Backdrop>` plate's nine pieces among them.
fn drawn_textures(s: &mut UiScript) -> Vec<String> {
s.resolve();
answer_measures(s);
@@ -51,7 +50,6 @@ fn drawn_textures(s: &mut UiScript) -> Vec<String> {
.into_iter()
.filter_map(|q| match q.content {
benilla_ui::script::QuadContent::Texture { path: Some(p), .. } => Some(p),
benilla_ui::script::QuadContent::Backdrop { path, .. } => Some(path),
_ => None,
})
.collect()
+5 -7
View File
@@ -5,13 +5,11 @@ use crate::framexml::Element;
use super::{abs_value, children_named, color_of, Loader};
impl Loader<'_> {
/// `<Backdrop>` (LoadXML `0x77e6c0`): the table Lua `SetBackdrop` takes, then the two colours.
/// A present `<Color>` defaults a missing channel to black, alpha 1 (`0x6f23d0`), not the
/// white a table `SetBackdrop` leaves.
pub(super) fn apply_backdrop(&mut self, el: &Element, wrapper: &Table, dbg: &str) {
let Some(bd) = children_named(el, "Backdrop").next() else {
return;
};
/// One `<Backdrop>` (LoadXML `0x77e6c0`): the table Lua `SetBackdrop` takes, then the two
/// colours. Each element builds a fresh struct and installs it (`0x769c54`-`0x769c7a`), so a
/// frame's own `<Backdrop>` replaces its template's. A present `<Color>` defaults a missing
/// channel to black, alpha 1 (`0x6f23d0`), not the white a table `SetBackdrop` leaves.
pub(super) fn apply_backdrop(&mut self, bd: &Element, wrapper: &Table, dbg: &str) {
let table = match self.build_backdrop_table(bd) {
Ok(t) => t,
Err(e) => {
+12 -4
View File
@@ -690,12 +690,20 @@ impl Loader<'_> {
// 3 · <Size> and 4 · <Anchors> (the CLayoutFrame geometry base, `0x767800`).
self.apply_size(el, wrapper, dbg_name);
self.apply_anchors(el, wrapper, parent_name, dbg_name);
// 5 · <Layers> regions (`0x769d70`); `$parent` in a region name is this frame.
self.apply_layers(el, wrapper, self_name, dbg_name);
// 5 · <Layers> regions (`0x769d70`) and the <Backdrop> plate (`0x77e6c0`), whose nine
// pieces are regions too, in document order: both are arms of the one child loop
// (`0x769b1c`), so the order they link into `GetRegions` is the file's. A template's
// children come first, as its LoadXML runs first. `$parent` in a region name is this
// frame.
for child in &el.children {
if child.tag.eq_ignore_ascii_case("Layers") {
self.apply_layers(child, wrapper, self_name, dbg_name);
} else if child.tag.eq_ignore_ascii_case("Backdrop") {
self.apply_backdrop(child, wrapper, dbg_name);
}
}
// 5·b · a direct-child `<FontString>`: the widget's own text font (EditBox, message frame).
self.apply_special_fontstrings(el, wrapper, self_name, dbg_name);
// 5a · <Backdrop> plate (LoadXML `0x77e6c0`): the tiled bg + 8-piece border.
self.apply_backdrop(el, wrapper, dbg_name);
// 5a' · <TitleRegion>: the drag handle, through the API's own CreateTitleRegion.
self.apply_title_region(el, wrapper, self_name, dbg_name);
// 5b · per-kind LoadXML extras (EditBox `0x779fb0`), each gated on the element's own tag,
+54 -62
View File
@@ -17,76 +17,68 @@ pub(super) enum FontAttrs {
}
impl Loader<'_> {
/// `<Layers>` (`0x769d70`): each `<Texture>` and `<FontString>`, created on its layer.
/// One `<Layers>` (`0x769d70`): each `<Texture>` and `<FontString>`, created on its layer.
pub(super) fn apply_layers(
&mut self,
el: &Element,
layers: &Element,
wrapper: &Table,
parent_name: &str,
dbg: &str,
) {
for layers in children_named(el, "Layers") {
for layer in children_named(layers, "Layer") {
let level = layer.attr("level").unwrap_or("ARTWORK").to_string();
for region in &layer.children {
// `inherits=` may name a region template, in the frames' one registry
// (`0x6ee500`); a font object's name passes on to `apply_fontstring_font`.
let region = &self.expand_region(region);
let is_texture = region.tag.eq_ignore_ascii_case("Texture");
let is_fontstring = region.tag.eq_ignore_ascii_case("FontString");
if !is_texture && !is_fontstring {
self.report.warnings.push(format!(
"{dbg}: <Layer> child <{}> is not a Texture/FontString; skipped",
region.tag
));
continue;
}
let rname: Option<String> = region
.name()
.map(|raw| framexml::resolve_name(raw, parent_name));
let method = if is_texture {
"CreateTexture"
} else {
"CreateFontString"
};
let region_wrapper: Table =
match wrapper.call_method(method, (rname, Some(level.clone()))) {
Ok(w) => w,
Err(e) => {
self.report.fault(format!("{dbg}: {method}: {e}"));
continue;
}
};
if region.attr_bool("hidden") {
if let Err(e) = region_wrapper.call_method::<()>("Hide", ()) {
self.report.fault(format!("{dbg}: region Hide: {e}"));
for layer in children_named(layers, "Layer") {
let level = layer.attr("level").unwrap_or("ARTWORK").to_string();
for region in &layer.children {
// `inherits=` may name a region template, in the frames' one registry
// (`0x6ee500`); a font object's name passes on to `apply_fontstring_font`.
let region = &self.expand_region(region);
let is_texture = region.tag.eq_ignore_ascii_case("Texture");
let is_fontstring = region.tag.eq_ignore_ascii_case("FontString");
if !is_texture && !is_fontstring {
self.report.warnings.push(format!(
"{dbg}: <Layer> child <{}> is not a Texture/FontString; skipped",
region.tag
));
continue;
}
let rname: Option<String> = region
.name()
.map(|raw| framexml::resolve_name(raw, parent_name));
let method = if is_texture {
"CreateTexture"
} else {
"CreateFontString"
};
let region_wrapper: Table =
match wrapper.call_method(method, (rname, Some(level.clone()))) {
Ok(w) => w,
Err(e) => {
self.report.fault(format!("{dbg}: {method}: {e}"));
continue;
}
};
if region.attr_bool("hidden") {
if let Err(e) = region_wrapper.call_method::<()>("Hide", ()) {
self.report.fault(format!("{dbg}: region Hide: {e}"));
}
if let Some(a) = region
.attr("alpha")
.and_then(|v| v.trim().parse::<f32>().ok())
{
self.call_region(&region_wrapper, "SetAlpha", a, dbg);
}
self.apply_region_layout(
region,
&region_wrapper,
parent_name,
dbg,
FontAttrs::Own,
);
if is_fontstring {
// Before the visual pass, so an explicit `<Color>` beats the font object's.
self.apply_fontstring_font(region, &region_wrapper, dbg);
}
self.apply_region_visual(region, &region_wrapper, is_texture, dbg);
// The implicit creation anchor, run after the region's LoadXML as the
// reference does (`0x7701c0`, `0x771480`); an anchored region is left alone.
if let Err(e) =
crate::script::implicit_creation_anchor_lua(self.lua, &region_wrapper)
{
self.report.fault(format!("{dbg}: implicit anchor: {e}"));
}
}
if let Some(a) = region
.attr("alpha")
.and_then(|v| v.trim().parse::<f32>().ok())
{
self.call_region(&region_wrapper, "SetAlpha", a, dbg);
}
self.apply_region_layout(region, &region_wrapper, parent_name, dbg, FontAttrs::Own);
if is_fontstring {
// Before the visual pass, so an explicit `<Color>` beats the font object's.
self.apply_fontstring_font(region, &region_wrapper, dbg);
}
self.apply_region_visual(region, &region_wrapper, is_texture, dbg);
// The implicit creation anchor, run after the region's LoadXML as the
// reference does (`0x7701c0`, `0x771480`); an anchored region is left alone.
if let Err(e) =
crate::script::implicit_creation_anchor_lua(self.lua, &region_wrapper)
{
self.report.fault(format!("{dbg}: implicit anchor: {e}"));
}
}
}
+4 -2
View File
@@ -12,7 +12,7 @@
//! tail when shown and when a shown frame's strata or level changes, and the later link draws on
//! top. [`crate::widget::WidgetArena`] keeps a counter bumped at those moments, which orders the
//! same within a bucket. The is-region bit is benilla's: the client has no frame drawable, and the
//! frame's own slot (backdrop, scissor) must precede its regions.
//! frame's own slot (a minimap or model pane, scissor) must precede its regions.
//!
//! Deviation: textures in one layer keep link order where the client sorts them by texture handle
//! (`0x7731a0`, comparator `0x7731c0`), because benilla allocates its handles differently; no
@@ -127,6 +127,8 @@ const DECL_SHIFT: u32 = 0;
/// The link-stamp width; the arena renumbers its stamps at the cap.
pub(crate) const INSERTION_BITS: u32 = 18;
const DECL_BITS: u32 = 12;
/// One past the largest declaration seq the key holds; a frame renumbers its regions there.
pub(crate) const DECL_CAP: u32 = 1 << DECL_BITS;
/// Which of a layer batch's arrays an entry drains from, in `0x76fb00`'s order: quads at `+0x10`
/// (a texture appends at `0x7706e0`), a `CGxStringBatch` at `+0x18` (a font string at `0x772e50`)
@@ -283,7 +285,7 @@ pub const fn unpack(raw: u64) -> ZParts {
/// What a [`ZKey`] entry points at.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ZTarget {
/// The frame's own slot (backdrop, scissor).
/// The frame's own slot (a minimap or model pane, scissor).
Frame(FrameHandle),
Region(RegionHandle),
}
+397 -121
View File
@@ -1,8 +1,18 @@
//! The frame backdrop as the 1.12 client builds it: the tiled background and 8-piece border that a
//! `<Backdrop>` element or `SetBackdrop` installs. This module owns the data ([`Backdrop`]) and the
//! geometry ([`pieces`], screen-space quads with per-corner UVs); the app renders them.
//! The frame backdrop as the 1.12 client builds it: a `<Backdrop>` element or `SetBackdrop`
//! installs a spec ([`Backdrop`], the 0x68-byte struct at `frame+0x1ac`) and builds up to nine
//! Texture regions on the frame from it (`0x77e8d0`), the background on BACKGROUND and the eight
//! border pieces on BORDER. They are ordinary regions: `GetRegions` returns them, the region draw
//! path paints them, and every Texture verb works on them. The spec drives them: the colour
//! setters tint them and a resize re-runs their texcoords (`0x77f0c0`). [`pieces`] is the same
//! geometry as screen quads, for a backdrop the app draws itself (the chat bubble).
use crate::layout::Rect;
use std::collections::HashMap;
use crate::layout::{self, Anchor, Point, Rect};
use crate::order::DrawLayer;
use crate::widget::{FrameHandle, RegionHandle, RegionKind};
use super::{Model, RegionData, TexCoords};
/// The default `edgeSize`, logical 32: the constructor writes device 0.025 (`DAT_0081c9b0`).
pub const DEFAULT_EDGE_SIZE: f32 = 32.0;
@@ -21,9 +31,9 @@ pub struct Insets {
/// parser defaults a present but partial `<Color>` or `<BorderColor>` to black.
#[derive(Clone, Debug, PartialEq)]
pub struct Backdrop {
/// The background texture (`+0x24`); none or empty draws no background.
/// The background texture (`+0x24`); none or empty builds no background.
pub bg_file: Option<String>,
/// The 8-slice border texture (`+0x30`); none or empty draws no border.
/// The 8-slice border texture (`+0x30`); none or empty builds no border.
pub edge_file: Option<String>,
/// Tile the background, else stretch it (`+0x40`).
pub tile: bool,
@@ -64,9 +74,9 @@ impl Backdrop {
}
}
/// One drawable piece, the background or one of the 8 border pieces: explicit corners, not a rect,
/// so the bottom-right inset quirk can slant it, and per-corner UVs, since the top and bottom edges
/// are rotated 90° (`0x77f0c0`). Both arrays run `[TL, TR, BR, BL]`, y-up.
/// One drawable piece, the background or one of the 8 border pieces: its corners, axis-aligned,
/// and per-corner UVs, since the top and bottom edges are rotated 90° (`0x77f0c0`). Both arrays
/// run `[TL, TR, BR, BL]`, y-up.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BackdropPiece {
/// Corner positions in screen px.
@@ -82,6 +92,126 @@ pub struct BackdropPiece {
const SLICE: f32 = 0.125;
/// One piece's texcoords, `[TL, TR, BR, BL]`.
type Uvs = [[f32; 2]; 4];
/// The eight border pieces in the struct's order, `+0x04` to `+0x20`, which is their creation
/// order and their atlas slice (`0x77e8d0`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Edge {
Left,
Right,
Top,
Bottom,
TopLeft,
TopRight,
BottomLeft,
BottomRight,
}
const EDGES: [Edge; 8] = [
Edge::Left,
Edge::Right,
Edge::Top,
Edge::Bottom,
Edge::TopLeft,
Edge::TopRight,
Edge::BottomLeft,
Edge::BottomRight,
];
/// A piece's anchors on its frame, `(point, relativePoint, x, y)` in the frame's units, and its
/// `(width, height)`, 0 for an axis the anchors span (`0x77e8d0`'s `SetPoint 0x767c70` and
/// layout-vtable `+0x14`/`+0x18` calls).
type PieceLayout = ([Option<(Point, f32, f32)>; 4], (f32, f32));
/// The background's four anchors. Its BOTTOMRIGHT takes the TOP inset for its y, a bug in the
/// reference (`0x77e9ae` loads `+0x50`, top, not `+0x54`), kept: the resolver reads the bottom
/// edge from BOTTOMLEFT and the right edge from TOPRIGHT first (`0x7671a0`, `0x7671f0`), so the
/// bug shows only to `GetPoint`, never on screen.
fn bg_layout(bd: &Backdrop) -> PieceLayout {
let i = bd.insets;
(
[
Some((Point::TopLeft, i.left, -i.top)),
Some((Point::TopRight, -i.right, -i.top)),
Some((Point::BottomLeft, i.left, i.bottom)),
Some((Point::BottomRight, -i.right, i.top)),
],
(0.0, 0.0),
)
}
/// A border piece's anchors: corners are `edgeSize` squares on their frame corner, edge strips run
/// between them, all flush inside the frame rect.
fn edge_layout(edge: Edge, e: f32) -> PieceLayout {
use Point::*;
let (a, b, size) = match edge {
Edge::Left => ((TopLeft, 0.0, -e), Some((BottomLeft, 0.0, e)), (e, 0.0)),
Edge::Right => ((TopRight, 0.0, -e), Some((BottomRight, 0.0, e)), (e, 0.0)),
Edge::Top => ((TopLeft, e, 0.0), Some((TopRight, -e, 0.0)), (0.0, e)),
Edge::Bottom => ((BottomLeft, e, 0.0), Some((BottomRight, -e, 0.0)), (0.0, e)),
Edge::TopLeft => ((TopLeft, 0.0, 0.0), None, (e, e)),
Edge::TopRight => ((TopRight, 0.0, 0.0), None, (e, e)),
Edge::BottomLeft => ((BottomLeft, 0.0, 0.0), None, (e, e)),
Edge::BottomRight => ((BottomRight, 0.0, 0.0), None, (e, e)),
};
([Some(a), b, None, None], size)
}
/// A corner's texcoords, set once at creation (`0x77e8d0`): its slice, upright.
fn corner_uvs(edge: Edge) -> Uvs {
let i = edge as u8 as f32;
let (u0, u1) = (i * SLICE, (i + 1.0) * SLICE);
[[u0, 0.0], [u1, 0.0], [u1, 1.0], [u0, 1.0]]
}
/// The texcoords `0x77f0c0` runs for a frame `rect` on screen: the tiled background's, `None`
/// when it stretches (no `SetTexCoord` call), and the four edge strips', which tile `run` periods
/// between the corners. The divisors are the stored, unscaled sizes (`0x77f101 fdiv [esi+0x48]`,
/// `0x77f195`), so a scaled frame repeats its art more or less often than at scale 1. All four
/// arrays run `[TL, TR, BR, BL]`.
fn run_uvs(bd: &Backdrop, rect: Rect, scale: f32) -> (Option<Uvs>, [Uvs; 4]) {
let (l, r, b, t) = (rect.left, rect.right, rect.bottom, rect.top);
let bg = (bd.has_bg() && bd.tile).then(|| {
let period = if bd.tile_size != 0.0 {
bd.tile_size
} else {
bd.edge_size
};
let i = bd.insets;
let bg_w = (r - i.right * scale) - (l + i.left * scale);
let bg_h = (t - i.top * scale) - (b + i.bottom * scale);
let (wt, ht) = (bg_w / period, bg_h / period);
[[0.0, 0.0], [wt, 0.0], [wt, ht], [0.0, ht]]
});
// Run lengths clamp at 0 below two edges and never above.
let w_run = ((r - l) / bd.edge_size - 2.0).max(0.0);
let h_run = ((t - b) / bd.edge_size - 2.0).max(0.0);
let s = SLICE;
let edges = [
// LEFT, slice 0, upright.
[[0.0, 0.0], [s, 0.0], [s, h_run], [0.0, h_run]],
// RIGHT, slice 1, upright.
[[s, 0.0], [2.0 * s, 0.0], [2.0 * s, h_run], [s, h_run]],
// TOP, slice 2, rotated: atlas `u` runs down the screen, atlas `v` right to left.
[
[2.0 * s, w_run],
[2.0 * s, 0.0],
[3.0 * s, 0.0],
[3.0 * s, w_run],
],
// BOTTOM, slice 3, rotated as TOP.
[
[3.0 * s, w_run],
[3.0 * s, 0.0],
[4.0 * s, 0.0],
[4.0 * s, w_run],
],
];
(bg, edges)
}
/// Pull a piece's UVs half a texel inward on every axis that does not tile, so bilinear
/// magnification cannot sample the neighbouring slice of the shared border atlas (in
/// `ChatBubble-Backdrop`, a white alpha-0 column: a pale line through the speech bubble). A tiling
@@ -122,134 +252,272 @@ fn aa_corners(x0: f32, x1: f32, y_bottom: f32, y_top: f32) -> [[f32; 2]; 4] {
}
/// The drawable pieces for a frame's resolved rect (y-up), in paint order: the background (layer
/// `BACKGROUND`), then the 8 border pieces (layer `BORDER`). Geometry `0x77e8d0`, UVs `0x77f0c0`.
/// The pieces are textures anchored and sized in the frame's own units, so the layout multiplies
/// their geometry by the frame's effective `scale` (`0x76ac90`), as it does any region's. The
/// texcoord repeats are not: `0x77f0c0` divides the scaled rect by the stored, unscaled
/// `edgeSize` and `tileSize` (`0x77f101 fdiv [esi+0x48]`, `0x77f195`), so a scaled frame's edge
/// and background repeat the art more or less often than at scale 1.
/// `BACKGROUND`), then the 8 border pieces (layer `BORDER`), as the regions `install` builds
/// would resolve and draw. Their geometry is in the frame's own units, so it is multiplied by the
/// frame's effective `scale` (`0x76ac90`), as any region's is.
pub fn pieces(frame: Rect, bd: &Backdrop, scale: f32) -> Vec<BackdropPiece> {
let mut out = Vec::with_capacity(9);
let e = bd.edge_size * scale;
// The texcoord divisor, unscaled.
let e_uv = bd.edge_size;
let (l, r, b, t) = (frame.left, frame.right, frame.bottom, frame.top);
let (bg_uvs, edge_uvs) = run_uvs(bd, frame, scale);
// ── The background piece ──
if bd.has_bg() {
let (il, ir, it, ib) = (
bd.insets.left * scale,
bd.insets.right * scale,
bd.insets.top * scale,
bd.insets.bottom * scale,
);
// The four anchors, y-up. Bottom-right takes the top inset, as the reference does
// (`0x77e9ae` loads `+0x50`, top, not `+0x54`); invisible when the two are equal.
let corners = [
[l + il, t - it],
[r - ir, t - it],
[r - ir, b + it], // BR: the top inset
[l + il, b + ib],
];
// Untiled, no `SetTexCoord` runs and the background stretches over `[0,1]` (`0x77f0c0`).
let uvs = if bd.tile {
let period = if bd.tile_size != 0.0 {
bd.tile_size
} else {
e_uv
};
let bg_w = (r - ir) - (l + il);
let bg_h = (t - it) - (b + ib);
let (wt, ht) = (bg_w / period, bg_h / period);
[[0.0, 0.0], [wt, 0.0], [wt, ht], [0.0, ht]]
} else {
[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]
};
// TOPLEFT, TOPRIGHT and BOTTOMLEFT place it; the BOTTOMRIGHT anchor is never read.
let i = bd.insets;
out.push(BackdropPiece {
corners,
uvs,
corners: aa_corners(
l + i.left * scale,
r - i.right * scale,
b + i.bottom * scale,
t - i.top * scale,
),
// Untiled, no `SetTexCoord` runs and the background stretches over `[0,1]`.
uvs: bg_uvs.unwrap_or([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]),
tile: bd.tile,
is_bg: true,
});
}
// ── The 8 border pieces ──
if bd.has_border() {
// Edge strips tile `run` periods between the corners, 0 below two edges (`0x77f0c0`).
let w_run = ((r - l) / e_uv - 2.0).max(0.0);
let h_run = ((t - b) / e_uv - 2.0).max(0.0);
let mut edge = |x0: f32, x1: f32, y0: f32, y1: f32, uvs: [[f32; 2]; 4]| {
for (k, edge) in EDGES.into_iter().enumerate() {
let corners = match edge {
Edge::Left => aa_corners(l, l + e, b + e, t - e),
Edge::Right => aa_corners(r - e, r, b + e, t - e),
Edge::Top => aa_corners(l + e, r - e, t - e, t),
Edge::Bottom => aa_corners(l + e, r - e, b, b + e),
Edge::TopLeft => aa_corners(l, l + e, t - e, t),
Edge::TopRight => aa_corners(r - e, r, t - e, t),
Edge::BottomLeft => aa_corners(l, l + e, b, b + e),
Edge::BottomRight => aa_corners(r - e, r, b, b + e),
};
out.push(BackdropPiece {
corners: aa_corners(x0, x1, y0, y1),
uvs,
corners,
uvs: edge_uvs.get(k).copied().unwrap_or_else(|| corner_uvs(edge)),
tile: true,
is_bg: false,
});
};
// LEFT, slice 0, upright.
edge(
l,
l + e,
b + e,
t - e,
[[0.0, 0.0], [SLICE, 0.0], [SLICE, h_run], [0.0, h_run]],
);
// RIGHT, slice 1, upright.
edge(
r - e,
r,
b + e,
t - e,
[
[SLICE, 0.0],
[2.0 * SLICE, 0.0],
[2.0 * SLICE, h_run],
[SLICE, h_run],
],
);
// TOP, slice 2, rotated: atlas `u` runs down the screen, atlas `v` right to left.
edge(
l + e,
r - e,
t - e,
t,
[
[2.0 * SLICE, w_run],
[2.0 * SLICE, 0.0],
[3.0 * SLICE, 0.0],
[3.0 * SLICE, w_run],
],
);
// BOTTOM, slice 3, rotated as TOP.
edge(
l + e,
r - e,
b,
b + e,
[
[3.0 * SLICE, w_run],
[3.0 * SLICE, 0.0],
[4.0 * SLICE, 0.0],
[4.0 * SLICE, w_run],
],
);
// The corners, slices 4 to 7, upright.
let corner_uv = |i: f32| {
let u0 = i * SLICE;
let u1 = (i + 1.0) * SLICE;
[[u0, 0.0], [u1, 0.0], [u1, 1.0], [u0, 1.0]]
};
edge(l, l + e, t - e, t, corner_uv(4.0)); // TOPLEFT
edge(r - e, r, t - e, t, corner_uv(5.0)); // TOPRIGHT
edge(l, l + e, b, b + e, corner_uv(6.0)); // BOTTOMLEFT
edge(r - e, r, b, b + e, corner_uv(7.0)); // BOTTOMRIGHT
}
}
out
}
// ── The installed backdrop: the spec and its nine regions ───────────────────────────────────
/// A frame's installed backdrop, the struct's spec and its nine piece slots (`+0x00` the
/// background, `+0x04..+0x20` the edges in `EDGES` order), each `None` where no piece was built.
#[derive(Clone, Debug)]
pub(crate) struct Installed {
pub(crate) spec: Backdrop,
pieces: [Option<RegionHandle>; 9],
/// The frame rect the texcoords last ran for; `ApplyRect 0x76b580` re-runs them only when the
/// size moves past [`layout::size_changed`].
ran_for: Option<Rect>,
}
impl Installed {
fn bg(&self) -> Option<RegionHandle> {
self.pieces[0]
}
fn edges(&self) -> impl Iterator<Item = RegionHandle> + '_ {
self.pieces[1..].iter().flatten().copied()
}
}
/// `CSimpleFrame::SetBackdrop 0x76a5d0`: release the frame's nine pieces and drop its spec
/// (`0x77f4d0`), then, for a spec, build the pieces (`0x77e8d0`) and run their texcoords against
/// the frame's rect if it has one, as Lua `SetBackdrop` does at its end (`0x7776e0`); otherwise
/// the next resolve runs them. Each piece is a fresh Texture linked at the tail of the region
/// list, so a second backdrop's pieces come after any region made in between.
pub(crate) fn install(model: &mut Model, fh: FrameHandle, spec: Option<Backdrop>) {
// One layout touch for the whole swap, the pieces it frees and the ones it builds: the next
// resolve re-derives the graph once either way.
let old = model.backdrops.remove(&fh);
for rh in old.iter().flat_map(|o| o.pieces).flatten() {
super::region::release_region(model, rh);
}
let Some(spec) = spec.filter(|_| model.arena.frame(fh).is_some()) else {
if old.is_some() {
model.touch_layout();
}
return;
};
let owner = model.frame_id(fh);
// `SetTexture 0x770200`: a path that does not load leaves the piece without art.
let loads = |model: &Model, path: &Option<String>| -> Option<String> {
let path = path.as_deref().filter(|p| !p.is_empty())?;
let ok = model.texture_probe.as_ref().is_none_or(|probe| probe(path));
ok.then(|| path.to_string())
};
let bg_art = loads(model, &spec.bg_file);
let edge_art = loads(model, &spec.edge_file);
let mut pieces = [None; 9];
let build = |model: &mut Model,
layer: DrawLayer,
(anchors, size): PieceLayout,
texture: Option<String>,
tex_coords: Option<TexCoords>|
-> Option<RegionHandle> {
// `0x76fc40(frame, layer, 1)`: a Texture on the frame, linked at the tail (`0x76a750`).
let rh = model
.arena
.create_region(fh, RegionKind::Texture, layer, 0)?;
let anchors = anchors
.into_iter()
.flatten()
.map(|(p, x, y)| Anchor::new(p, owner, p, x, y))
.collect();
let size = (size != (0.0, 0.0)).then_some(size);
model.region_data.insert(
rh,
RegionData {
texture,
anchors,
size,
tex_coords,
..RegionData::default()
},
);
Some(rh)
};
if spec.has_bg() {
pieces[0] = build(model, DrawLayer::Background, bg_layout(&spec), bg_art, None);
}
if spec.has_border() {
for (k, edge) in EDGES.into_iter().enumerate() {
let corner = matches!(
edge,
Edge::TopLeft | Edge::TopRight | Edge::BottomLeft | Edge::BottomRight
);
pieces[k + 1] = build(
model,
DrawLayer::Border,
edge_layout(edge, spec.edge_size),
edge_art.clone(),
corner.then(|| TexCoords::Corners(corner_uvs(edge))),
);
}
}
model.backdrops.insert(
fh,
Installed {
spec,
pieces,
ran_for: None,
},
);
model.touch_layout();
if let Some(rect) = model.resolved.get(&fh).copied() {
run_texcoords(model, fh, rect);
}
}
/// `0x77f0c0` for frame `fh` at `rect`, from `install`.
fn run_texcoords(model: &mut Model, fh: FrameHandle, rect: Rect) {
let scale = super::object::eff_scale(model, fh);
let Model {
backdrops,
region_data,
..
} = model;
if let Some(inst) = backdrops.get_mut(&fh) {
rerun(inst, region_data, rect, scale);
}
}
/// `0x77f0c0`: the tiled background's and the edge strips' texcoords for a frame at `rect`, the
/// corners' left as created, then both colours re-applied (`0x77f410`, `0x77f440`).
fn rerun(
inst: &mut Installed,
region_data: &mut HashMap<RegionHandle, RegionData>,
rect: Rect,
scale: f32,
) {
inst.ran_for = Some(rect);
let (bg, edges) = run_uvs(&inst.spec, rect, scale);
if let (Some(rh), Some(uvs)) = (inst.bg(), bg) {
if let Some(d) = region_data.get_mut(&rh) {
d.tex_coords = Some(TexCoords::Corners(uvs));
}
}
for (slot, uvs) in inst.pieces[1..5].iter().zip(edges) {
if let Some(d) = slot.and_then(|rh| region_data.get_mut(&rh)) {
d.tex_coords = Some(TexCoords::Corners(uvs));
}
}
paint(inst, region_data);
}
/// Each piece's single vertex colour (`0x77f750`): the backdrop colour on the background only
/// (`0x77f410`), the border colour on the eight edges only (`0x77f440`).
fn paint(inst: &Installed, region_data: &mut HashMap<RegionHandle, RegionData>) {
if let Some(d) = inst.bg().and_then(|rh| region_data.get_mut(&rh)) {
d.vertex_color = Some(inst.spec.bg_color);
}
for rh in inst.edges() {
if let Some(d) = region_data.get_mut(&rh) {
d.vertex_color = Some(inst.spec.border_color);
}
}
}
/// `SetBackdropColor` (`0x77f410`) or, with `border`, `SetBackdropBorderColor` (`0x77f440`): store
/// the colour in the spec and tint that colour's pieces.
pub(crate) fn set_color(model: &mut Model, fh: FrameHandle, border: bool, color: [f32; 4]) {
let Model {
backdrops,
region_data,
..
} = model;
let Some(inst) = backdrops.get_mut(&fh) else {
return;
};
if border {
inst.spec.border_color = color;
for rh in inst.edges() {
if let Some(d) = region_data.get_mut(&rh) {
d.vertex_color = Some(color);
}
}
} else {
inst.spec.bg_color = color;
if let Some(d) = inst.bg().and_then(|rh| region_data.get_mut(&rh)) {
d.vertex_color = Some(color);
}
}
}
/// After a resolve: re-run the texcoords of every backdrop whose frame rect changed size, as the
/// rect application `0x76b580` calls `0x77f0c0` (at `0x76b61e`); a move alone re-runs nothing.
pub(crate) fn after_resolve(model: &mut Model) {
let Model {
backdrops,
resolved,
arena,
region_data,
..
} = model;
for (fh, inst) in backdrops.iter_mut() {
let Some(&rect) = resolved.get(fh) else {
continue;
};
if inst
.ran_for
.is_some_and(|prev| !layout::size_changed(prev, rect))
{
continue;
}
// `eff_scale`'s guard: a zero scale lays out at 1.
let scale = arena
.frame(*fh)
.map(|f| f.effective_scale)
.filter(|s| s.abs() >= 1e-6)
.unwrap_or(1.0);
rerun(inst, region_data, rect, scale);
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -364,8 +632,11 @@ mod tests {
assert_eq!(br.uvs[2], [1.0, 1.0]);
}
/// The background's BOTTOMRIGHT anchor takes the top inset (`0x77e9ae`), but the resolver
/// reads the bottom edge from BOTTOMLEFT and the right from TOPRIGHT, so the rect is the
/// insets' own, top 7 and bottom 9.
#[test]
fn bg_bottomright_rides_top_inset_bug() {
fn the_bg_bottomright_quirk_never_reaches_the_rect() {
let frame = Rect::new(0.0, 0.0, 100.0, 200.0);
let bd = Backdrop {
insets: Insets {
@@ -377,11 +648,16 @@ mod tests {
..tooltip_backdrop(16.0, 0.0)
};
let bg = pieces(frame, &bd, 1.0)[0];
assert_eq!(bg.corners[3], [3.0, 9.0]);
// BR takes the top inset, 7, not the bottom's 9.
assert_eq!(bg.corners[2], [196.0, 7.0]);
assert_eq!(bg.corners[0], [3.0, 93.0]); // TL
assert_eq!(bg.corners[1], [196.0, 93.0]); // TR
assert_eq!(
bg.corners,
[[3.0, 93.0], [196.0, 93.0], [196.0, 9.0], [3.0, 9.0]]
);
let (anchors, _) = bg_layout(&bd);
assert_eq!(
anchors[3],
Some((Point::BottomRight, -4.0, 7.0)),
"the quirk is kept"
);
}
#[test]
+2 -3
View File
@@ -389,10 +389,9 @@ impl UiScript {
// A ScrollingMessageFrame adds one Text quad per visible ring line, stacked bottom-up
// with each line's fade alpha; the ring lives in its kind state, not in FontStrings.
if let (ZTarget::Frame(fh), Some(fr)) = (target, rect) {
// The Backdrop draws at the frame slot; the lines take the ARTWORK content key,
// where the client's message strings live, over the BACKGROUND (hover box) art.
// The lines take the ARTWORK content key, where the client's message strings
// live, over the BACKGROUND (hover box) art.
let paint = super::layout::FramePaint { alpha, scale };
Self::emit_backdrop(&model, fh, fr, zkey.raw(), paint, clip, &mut out);
Self::emit_message_lines(
&model,
fh,
+38 -84
View File
@@ -1,12 +1,10 @@
use std::collections::HashMap;
use crate::layout::{self, Anchor, LayoutInput, Point, Rect};
use crate::order::ZTarget;
use crate::widget::{FrameHandle, FrameKind, KindState, RegionHandle};
use super::backdrop;
use super::types::RegionData;
use super::{ExtractedQuad, MeasureRequest, Model, QuadContent, UiScript, SCREEN};
use super::{MeasureRequest, Model, UiScript, SCREEN};
/// A frame's `effective_alpha` and `effective_scale`, worn by the quads it emits itself.
#[derive(Clone, Copy)]
@@ -174,8 +172,6 @@ const REGION_TAG: u32 = 0x8000_0000;
pub(crate) struct LayoutScope {
/// Per id: the hash the last converged resolve saw, or [`NO_NODE`]; a mismatch seeds dirty.
last: Vec<u64>,
/// How many ids `last` holds a hash for; a pass matching fewer lost a node: full scope.
last_count: usize,
/// The screen `last` was hashed under, every chain's root, so a move is full scope.
last_screen: Option<Rect>,
/// This pass's hash per id (parallel to `last`); swapped into `last` on convergence.
@@ -214,7 +210,11 @@ struct RegionRow {
impl LayoutScope {
/// Size the per-id arrays for `n` ids and clear all but `last`, ahead of a full derivation.
fn begin_full(&mut self, n: usize) {
self.last.resize(n, NO_NODE);
// `last` only grows: a dead node past the new count keeps its hash, so the scope still
// seeds what anchored to it.
if self.last.len() < n {
self.last.resize(n, NO_NODE);
}
self.now.clear();
self.now.resize(n, NO_NODE);
self.node_of.clear();
@@ -337,7 +337,7 @@ impl LayoutScope {
}
}
/// Mark every node dirty, for what a per-node diff cannot see (a death, a screen move).
/// Mark every node dirty, for what a per-node diff cannot see (no memory, a screen move).
fn dirty_all(&mut self) {
for (id, &n) in self.node_of.iter().enumerate() {
if n != u32::MAX {
@@ -347,14 +347,13 @@ impl LayoutScope {
}
/// Adopt this pass's hashes as the converged memory.
fn commit(&mut self, count: usize, screen: Rect) {
fn commit(&mut self, screen: Rect) {
std::mem::swap(&mut self.last, &mut self.now);
self.last_count = count;
self.last_screen = Some(screen);
}
/// [`Self::commit`] for an incremental pass: only the re-hashed slots. `last_count` and
/// `last_screen` stay, since a structural change always takes the full path.
/// [`Self::commit`] for an incremental pass: only the re-hashed slots. `last_screen` stays,
/// since a structural change always takes the derivation.
fn commit_incremental(&mut self) {
for &(id, h) in &self.staged {
self.last[id as usize] = h;
@@ -364,7 +363,6 @@ impl LayoutScope {
/// Forget the memory, so the next resolve derives the graph in full scope.
pub(crate) fn invalidate(&mut self) {
self.last_count = 0;
self.last_screen = None;
self.last.clear();
// The roster and `node_of` too: `has_node` must not vouch for a node the memory forgot.
@@ -497,7 +495,12 @@ impl UiScript {
/// [`UiScript::resolve`]'s body on a bare `Model`, so a Lua binding can force a fresh resolve,
/// as `UpdateScrollChildRect` must report a range reflecting a same-tick `SetHeight`.
pub(super) fn resolve_layout(model: &mut Model) {
let solves = model.layout_solves;
Self::resolve_layout_inner(model);
// A solve may have resized a frame: its backdrop re-runs its texcoords (`0x76b580`).
if model.layout_solves != solves {
super::backdrop::after_resolve(model);
}
if !model.layout_verify_recheck {
return;
}
@@ -602,7 +605,6 @@ impl UiScript {
// ── The incremental seed ─────────────────────────────────────────────
// Re-hash only the named nodes and seed those whose hash moved. It runs before the gate
// because on this path it is the verdict: an empty seed set means nothing moved.
let mut scope_nodes = 0usize;
let mut full_scope = false;
if let Some(touched) = &touched {
scope.begin_incremental();
@@ -673,23 +675,19 @@ impl UiScript {
// ── The scope: which nodes this solve may touch ────
// A node with unchanged inputs and no moved dependency recomputes to the rect it holds, so
// the solve runs over the dirty closure and feeds other rects in as externals. Full scope
// when there is no memory yet, the screen moved, a node died (fewer nodes match `last`
// than `last_count`), or under verify. The incremental pass seeded its closure above.
// when there is no memory yet, the screen moved, or under verify. The incremental pass
// seeded its closure above.
if touched.is_none() {
let mut matched = 0usize;
for (id, &n) in scope.node_of.iter().enumerate() {
if n == u32::MAX {
continue;
}
scope_nodes += 1;
if scope.last.get(id).copied().unwrap_or(NO_NODE) != NO_NODE {
matched += 1;
}
}
let a_node_died = matched != scope.last_count;
// A node that died past the new id count has no edges recorded (the derivation sized
// them to the count), so only a full scope reaches what anchored to it.
let died_past_end = scope
.last
.iter()
.skip(scope.node_of.len())
.any(|&h| h != NO_NODE);
full_scope = scope.last.is_empty()
|| scope.last_screen != Some(*screen)
|| a_node_died
|| died_past_end
|| gate_skips; // the verify path's re-run: it must reproduce the whole graph
if full_scope {
scope.dirty_all();
@@ -700,6 +698,18 @@ impl UiScript {
scope.dirty[id] = true;
}
}
// A node that died has no hash left to differ, so its id is seeded instead: the
// derivation recorded the edges of whatever still anchors to it, and the closure
// re-resolves those against its missing rect, as a full scope would.
for id in 0..scope.node_of.len() {
if scope.last[id] != NO_NODE
&& scope.node_of[id] == u32::MAX
&& !scope.dirty[id]
{
scope.stack.push(id as u32);
scope.dirty[id] = true;
}
}
scope.close();
}
}
@@ -936,7 +946,7 @@ impl UiScript {
if touched.is_some() {
scope.commit_incremental();
} else {
scope.commit(scope_nodes, *screen);
scope.commit(*screen);
}
// ── Re-arm the ledger ─────────────────────────────────────────────────────────
// The graph now describes a converged model, so writes may name nodes against it.
@@ -1224,62 +1234,6 @@ impl UiScript {
}
out
}
/// Push one [`QuadContent::Backdrop`] per piece of frame `fh`'s backdrop, at the frame slot's
/// `z`: behind its regions, and background before border by the stable sort.
pub(super) fn emit_backdrop(
model: &Model,
fh: FrameHandle,
fr: Rect,
z: u64,
paint: FramePaint,
clip: Option<Rect>,
out: &mut Vec<ExtractedQuad>,
) {
let Some(bd) = model.backdrops.get(&fh) else {
return;
};
let scale = crate::script::object::eff_scale(model, fh);
for piece in backdrop::pieces(fr, bd, scale) {
// The piece's screen bounding box: the render is axis-aligned, so the reference's slant
// under unequal top and bottom insets is not drawn (stock insets are symmetric).
let xs = piece.corners.map(|c| c[0]);
let ys = piece.corners.map(|c| c[1]);
let (left, right) = (
xs.iter().copied().fold(f32::MAX, f32::min),
xs.iter().copied().fold(f32::MIN, f32::max),
);
let (bottom, top) = (
ys.iter().copied().fold(f32::MAX, f32::min),
ys.iter().copied().fold(f32::MIN, f32::max),
);
let path = if piece.is_bg {
bd.bg_file.clone()
} else {
bd.edge_file.clone()
};
let Some(path) = path else { continue };
let color = if piece.is_bg {
bd.bg_color
} else {
bd.border_color
};
out.push(ExtractedQuad {
target: ZTarget::Frame(fh),
z,
rect: Some(Rect::new(bottom, left, top, right)),
alpha: paint.alpha,
content: QuadContent::Backdrop {
path,
color,
uvs: piece.uvs,
tile: piece.tile,
},
clip,
scale: paint.scale,
});
}
}
}
/// The [`MeasureRequest`] region `rh` needs now, if any; shared with the synchronous measure
+3 -2
View File
@@ -155,8 +155,9 @@ pub(crate) struct Model {
pub(crate) link_spans: HashMap<FrameHandle, Vec<(Rect, String, String)>>,
/// Region visuals (texture, colour, text) and layout (anchors, size, justify).
pub(crate) region_data: HashMap<RegionHandle, RegionData>,
/// Each frame's backdrop plate (`<Backdrop>` or `SetBackdrop`, the client's `frame+0x1ac`).
pub(crate) backdrops: HashMap<FrameHandle, backdrop::Backdrop>,
/// Each frame's backdrop plate (`<Backdrop>` or `SetBackdrop`, the client's `frame+0x1ac`):
/// its spec and the nine Texture regions it built.
pub(crate) backdrops: HashMap<FrameHandle, backdrop::Installed>,
/// `CSimpleHTML`'s state: element fonts `+0x350`, link format `+0x360`, content nodes `+0x340`.
pub(crate) simple_html: simplehtml::SimpleHtmlStates,
/// Named `<Font>` objects by lowercased name, as 1.12 matches them with `SStrCmpI` (`0x783870`,
@@ -453,8 +453,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
})?,
)?;
// `SetBackdrop(table|nil)` (`0x7776e0` → `0x76a5d0`) installs or removes the tiled background
// and 8-piece border, both white until tinted; the two colour setters tint the background and
// the border respectively (`0x77f410`/`0x77f440`).
// and 8-piece border, nine Texture regions on the frame (`script::backdrop::install`), both
// white until tinted; the two colour setters tint the background and the border respectively
// (`0x77f410`/`0x77f440`).
m.set(
"SetBackdrop",
lua.create_function(|lua, (this, arg): (Table, Value)| {
@@ -470,14 +471,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
}
};
let mut model = lua.app_data_mut::<Model>().expect("model");
match bd {
Some(b) => {
model.backdrops.insert(h, b);
}
None => {
model.backdrops.remove(&h);
}
}
crate::script::backdrop::install(&mut model, h, bd);
Ok(())
})?,
)?;
@@ -493,7 +487,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// re-enters and would panic on the app-data borrow.
let bd = {
let model = lua.app_data_ref::<Model>().expect("model");
model.backdrops.get(&h).cloned()
model.backdrops.get(&h).map(|i| i.spec.clone())
};
let Some(bd) = bd else {
return Ok(mlua::MultiValue::new());
@@ -535,9 +529,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let color = backdrop_color(lua, args);
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(bd) = model.backdrops.get_mut(&h) {
bd.bg_color = color;
}
crate::script::backdrop::set_color(&mut model, h, false, color);
Ok(())
})?,
)?;
@@ -547,9 +539,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let color = backdrop_color(lua, args);
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(bd) = model.backdrops.get_mut(&h) {
bd.border_color = color;
}
crate::script::backdrop::set_color(&mut model, h, true, color);
Ok(())
})?,
)?;
@@ -558,7 +548,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
match model.backdrops.get(&h) {
match model.backdrops.get(&h).map(|i| &i.spec) {
Some(bd) => Ok((
bd.bg_color[0],
bd.bg_color[1],
@@ -574,7 +564,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
match model.backdrops.get(&h) {
match model.backdrops.get(&h).map(|i| &i.spec) {
Some(bd) => Ok((
bd.border_color[0],
bd.border_color[1],
+7 -2
View File
@@ -448,6 +448,13 @@ pub(super) fn region_type_name(model: &Model, rh: RegionHandle) -> &'static str
/// wrapper raises as stale; the reference frees too, as `CSimpleButton::SetFontString 0x778d20`
/// deletes the label it replaces.
pub(crate) fn free_region(model: &mut Model, rh: RegionHandle) {
release_region(model, rh);
// A removed node takes its edges with it: a structural change, so the full touch.
model.touch_layout();
}
/// [`free_region`] without its layout touch, for a caller that frees several and touches once.
pub(crate) fn release_region(model: &mut Model, rh: RegionHandle) {
model.region_data.remove(&rh);
model.region_resolved.remove(&rh);
if let Some(id) = model.region_to_id.remove(&rh) {
@@ -455,8 +462,6 @@ pub(crate) fn free_region(model: &mut Model, rh: RegionHandle) {
model.region_names.retain(|_, v| *v != id);
}
model.arena.destroy_region(rh);
// A removed node takes its edges with it: a structural change, so the full touch.
model.touch_layout();
}
pub(super) fn region_owner_id(model: &mut Model, rh: RegionHandle) -> u32 {
+597 -5
View File
@@ -1,4 +1,5 @@
//! The backdrop Lua verbs and extract emission (the reference's Backdrop object, `0x77e5f0`).
//! The backdrop Lua verbs, its nine piece regions and their draw (the reference's Backdrop object,
//! `0x77e5f0`, and its pieces, `0x77e8d0`).
use super::common::script;
use crate::script::*;
@@ -28,7 +29,11 @@ fn backdrop_installs_and_extracts_pieces_with_colors() {
.extract()
.into_iter()
.filter_map(|q| match q.content {
QuadContent::Backdrop { path, color, .. } => Some((path, color)),
QuadContent::Texture {
path: Some(path),
color: Some(color),
..
} => Some((path, color)),
_ => None,
})
.collect();
@@ -146,7 +151,7 @@ fn get_backdrop_round_trips_through_set_backdrop() {
assert_eq!(
s.extract()
.iter()
.filter(|q| matches!(q.content, QuadContent::Backdrop { .. }))
.filter(|q| matches!(q.content, QuadContent::Texture { .. }))
.count(),
9
);
@@ -170,7 +175,7 @@ fn set_backdrop_nil_tears_down() {
assert!(s
.extract()
.iter()
.all(|q| !matches!(q.content, QuadContent::Backdrop { .. })));
.all(|q| !matches!(q.content, QuadContent::Texture { .. })));
}
/// The colour setters (`0x777d30`, `0x7780d0`) read r/g/b with a bare `lua_tonumber` (nil is 0.0)
@@ -241,7 +246,7 @@ fn a_scaled_frames_backdrop_scales_with_it() {
let rects: Vec<crate::layout::Rect> = s
.extract()
.into_iter()
.filter(|q| matches!(q.content, QuadContent::Backdrop { .. }))
.filter(|q| matches!(q.content, QuadContent::Texture { .. }))
.map(|q| q.rect.expect("piece rect"))
.collect();
assert_eq!(rects.len(), 9);
@@ -261,3 +266,590 @@ fn a_scaled_frames_backdrop_scales_with_it() {
);
}
}
// ── The pieces are regions (`0x77e8d0`) ──────────────────────────────────────────────────────
/// Installing a backdrop builds nine Textures through the texture constructor and links each into
/// the frame's region list (`0x76fc40` → `0x77fd10` → `0x76a750`): the background on BACKGROUND,
/// then LEFT, RIGHT, TOP, BOTTOM, TOPLEFT, TOPRIGHT, BOTTOMLEFT, BOTTOMRIGHT on BORDER, unnamed,
/// shown, parented to the frame.
#[test]
fn a_backdrop_is_nine_texture_regions_in_the_reference_order() {
let s = script();
s.run(
r#"
local f = CreateFrame("Frame", "NinePlate")
f:SetBackdrop({ bgFile = "Interface\\bg", edgeFile = "Interface\\edge" })
assert(f:GetNumRegions() == 9, "nine regions, got " .. f:GetNumRegions())
local r = { f:GetRegions() }
assert(table.getn(r) == 9, "GetRegions returns the nine")
for i = 1, 9 do
local p = r[i]
assert(p:GetObjectType() == "Texture", "piece " .. i .. " is a Texture")
assert(p:GetName() == nil, "piece " .. i .. " is unnamed")
assert(p:GetParent() == f, "piece " .. i .. " belongs to the frame")
assert(p:IsShown(), "piece " .. i .. " is shown")
end
assert(r[1]:GetTexture() == "Interface\\bg", "the first is the background")
assert(r[1]:GetDrawLayer() == "BACKGROUND", "the background is on BACKGROUND")
for i = 2, 9 do
assert(r[i]:GetTexture() == "Interface\\edge", "piece " .. i .. " is an edge")
assert(r[i]:GetDrawLayer() == "BORDER", "piece " .. i .. " is on BORDER")
end
"#,
)
.unwrap();
}
/// Each piece is gated on its file (`0x77e91f` for the background, the `+0x3c` mask for the edges).
#[test]
fn a_background_alone_is_one_region_and_a_border_alone_eight() {
let s = script();
s.run(
r#"
local f = CreateFrame("Frame")
f:SetBackdrop({ bgFile = "bg" })
assert(f:GetNumRegions() == 1, "bg only: " .. f:GetNumRegions())
f:SetBackdrop({ edgeFile = "edge" })
assert(f:GetNumRegions() == 8, "border only: " .. f:GetNumRegions())
f:SetBackdrop({ bgFile = "", edgeFile = "" })
assert(f:GetNumRegions() == 0, "empty files: " .. f:GetNumRegions())
"#,
)
.unwrap();
}
/// The region list is in link order (`0x76a750` appends), so a texture created after the backdrop
/// is the tenth region; one created before stays first.
#[test]
fn a_region_created_after_the_backdrop_comes_tenth() {
let s = script();
s.run(
r#"
local f = CreateFrame("Frame")
local before = f:CreateTexture(nil, "ARTWORK")
f:SetBackdrop({ bgFile = "bg", edgeFile = "edge" })
local after = f:CreateTexture(nil, "ARTWORK")
assert(f:GetNumRegions() == 11, "11 regions, got " .. f:GetNumRegions())
local r = { f:GetRegions() }
assert(r[1] == before, "the earlier texture stays first")
assert(r[2]:GetTexture() == "bg", "then the background")
assert(r[11] == after, "the later texture comes after the nine pieces")
"#,
)
.unwrap();
}
/// `SetBackdrop(nil)` releases all nine (`0x76a5d0` → `0x77f4d0`), and a second `SetBackdrop`
/// releases the old nine before building new ones, which link at the end of the list.
#[test]
fn set_backdrop_nil_leaves_no_pieces_and_a_second_one_relinks_at_the_end() {
let s = script();
s.run(
r#"
local f = CreateFrame("Frame")
f:SetBackdrop({ bgFile = "bg", edgeFile = "edge" })
f:SetBackdrop(nil)
assert(f:GetNumRegions() == 0, "nil leaves none, got " .. f:GetNumRegions())
f:SetBackdrop({ bgFile = "old", edgeFile = "edge" })
local mine = f:CreateTexture(nil, "ARTWORK")
f:SetBackdrop({ bgFile = "new", edgeFile = "edge" })
assert(f:GetNumRegions() == 10, "nine pieces and one texture, got " .. f:GetNumRegions())
local r = { f:GetRegions() }
assert(r[1] == mine, "the texture made between the two is now first")
assert(r[2]:GetTexture() == "new", "the new background follows it")
"#,
)
.unwrap();
}
/// The colours are each piece's vertex colour (`0x77f750`): `SetBackdropColor` writes the
/// background only (`0x77f410`), `SetBackdropBorderColor` the eight edges only (`0x77f440`).
#[test]
fn the_border_colour_tints_the_eight_edges_and_never_the_background() {
let s = script();
s.run(
r#"
local f = CreateFrame("Frame")
f:SetBackdrop({ bgFile = "bg", edgeFile = "edge" })
f:SetBackdropBorderColor(1, 0, 0, 0.6)
local r = { f:GetRegions() }
local function is(p, cr, cg, cb, ca, what)
local pr, pg, pb, pa = p:GetVertexColor()
assert(math.abs(pr - cr) < 0.01 and math.abs(pg - cg) < 0.01
and math.abs(pb - cb) < 0.01 and math.abs(pa - ca) < 0.01,
what .. ": " .. pr .. "," .. pg .. "," .. pb .. "," .. pa)
end
is(r[1], 1, 1, 1, 1, "the background keeps white")
for i = 2, 9 do is(r[i], 1, 0, 0, 0.6, "edge " .. i) end
f:SetBackdropColor(0, 0, 1)
is(r[1], 0, 0, 1, 1, "the background takes the backdrop colour")
for i = 2, 9 do is(r[i], 1, 0, 0, 0.6, "edge " .. i .. " after SetBackdropColor") end
"#,
)
.unwrap();
}
/// A piece is an ordinary texture: an addon may hide it or swap its art, and that is what draws.
#[test]
fn an_addon_can_hide_or_retexture_a_piece() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local f = CreateFrame("Frame")
f:SetPoint("CENTER", 0, 0)
f:SetWidth(100); f:SetHeight(100)
f:SetBackdrop({ bgFile = "bg", edgeFile = "edge" })
local r = { f:GetRegions() }
r[1]:SetTexture("mine")
r[2]:Hide()
"#,
)
.unwrap();
s.resolve();
let paths: Vec<String> = s
.extract()
.into_iter()
.filter_map(|q| match q.content {
QuadContent::Texture { path: Some(p), .. } => Some(p),
_ => None,
})
.collect();
assert_eq!(
paths.len(),
8,
"the hidden LEFT edge draws nothing: {paths:?}"
);
assert_eq!(paths[0], "mine", "the background draws its new art");
}
/// The edges tile along their run, `dim / edgeSize - 2` (`0x77f0c0`), and a resize re-runs the
/// texcoords (`0x76b580`), which `GetTexCoord` reads back in `SetTexCoord`'s UL, LL, UR, LR order.
#[test]
fn a_resize_re_runs_the_edge_texcoords() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
f = CreateFrame("Frame")
f:SetPoint("CENTER", 0, 0)
f:SetWidth(200); f:SetHeight(100)
f:SetBackdrop({ bgFile = "bg", edgeFile = "edge", edgeSize = 16 })
"#,
)
.unwrap();
s.resolve();
let left = "local r = { f:GetRegions() } return r[2]:GetTexCoord()";
let uv = s
.eval::<(f32, f32, f32, f32, f32, f32, f32, f32)>(left)
.unwrap();
// 100 / 16 - 2 = 4.25 periods down the LEFT edge, slice 0.
assert_eq!(uv, (0.0, 0.0, 0.0, 4.25, 0.125, 0.0, 0.125, 4.25));
s.run("f:SetHeight(64)").unwrap();
s.resolve();
let uv = s
.eval::<(f32, f32, f32, f32, f32, f32, f32, f32)>(left)
.unwrap();
assert_eq!(uv, (0.0, 0.0, 0.0, 2.0, 0.125, 0.0, 0.125, 2.0));
}
/// `<Backdrop>` is one more child of the frame's LoadXML loop (`0x769b1c`), handled in document
/// order with `<Layers>` (`0x769c38`, `0x769cdb`): a backdrop before the layers links its pieces
/// first, one after them links them after.
#[test]
fn xml_backdrop_and_layers_link_in_document_order() {
let s = script();
let doc = crate::framexml::parse(
r#"<Ui>
<Frame name="BackdropFirst">
<Backdrop bgFile="bg" edgeFile="edge"/>
<Layers><Layer level="ARTWORK"><Texture name="$parentArt"/></Layer></Layers>
</Frame>
<Frame name="LayersFirst">
<Layers><Layer level="ARTWORK"><Texture name="$parentArt"/></Layer></Layers>
<Backdrop bgFile="bg" edgeFile="edge"/>
</Frame>
</Ui>"#,
)
.expect("valid FrameXML");
let report = crate::loader::load(&s, &doc, &|_| None);
assert!(report.errors.is_empty(), "{:?}", report.errors);
s.run(
r#"
local a = { BackdropFirst:GetRegions() }
assert(table.getn(a) == 10, "ten regions")
assert(a[1]:GetTexture() == "bg" and a[10] == BackdropFirstArt, "pieces, then the art")
local b = { LayersFirst:GetRegions() }
assert(b[1] == LayersFirstArt and b[2]:GetTexture() == "bg", "the art, then the pieces")
"#,
)
.unwrap();
}
/// The regression check on the draw: each piece's quad (rect, texcoords, colour and order) is the
/// one the backdrop geometry (`pieces`, `0x77e8d0` and `0x77f0c0`) gives for the frame, on the
/// stock plates' shapes, at scale 1 and below, after a resize too.
#[test]
fn the_piece_quads_are_the_backdrop_geometry() {
use crate::layout::Rect;
// The tooltip, the dialog box (`UI-DialogBox-Border`, asymmetric 11/12 insets), an untiled
// stretch, and a top inset over the bottom one.
let shapes = [
"{ bgFile = 'bg', edgeFile = 'edge', tile = true, tileSize = 16, edgeSize = 16, \
insets = { left = 5, right = 5, top = 5, bottom = 5 } }",
"{ bgFile = 'bg', edgeFile = 'edge', tile = true, tileSize = 32, edgeSize = 32, \
insets = { left = 11, right = 12, top = 12, bottom = 11 } }",
"{ bgFile = 'bg', edgeFile = 'edge', tile = false, edgeSize = 16, \
insets = { left = 4, right = 4, top = 4, bottom = 4 } }",
];
for (i, shape) in shapes.iter().enumerate() {
for scale in [1.0f32, 0.75] {
let mut s = script();
s.set_screen_size(1024.0, 768.0);
s.run(&format!(
r#"
f = CreateFrame("Frame")
f:SetScale({scale})
f:SetPoint("TOPLEFT", nil, "TOPLEFT", 100, -100)
f:SetWidth(301); f:SetHeight(157)
f:SetAlpha(0.5)
f:SetBackdrop({shape})
f:SetBackdropColor(0.1, 0.2, 0.3, 0.9)
f:SetBackdropBorderColor(0.4, 0.5, 0.6)
"#
))
.unwrap();
for (w, h) in [(301.0f32, 157.0f32), (90.0, 40.0)] {
s.run(&format!("f:SetWidth({w}); f:SetHeight({h})"))
.unwrap();
s.resolve();
// The one frame with a backdrop.
let (fr, eff) = {
let model = s.model_ref();
let fh = *model.backdrops.keys().next().unwrap();
let fr = model.resolved[&fh];
(fr, crate::script::object::eff_scale(&model, fh))
};
let quads: Vec<_> = s
.extract()
.into_iter()
.filter_map(|q| match q.content {
QuadContent::Texture {
path: Some(path),
color,
tex_coords,
..
} => Some((path, q.rect.unwrap(), color, tex_coords, q.alpha)),
_ => None,
})
.collect();
let spec = {
let mut b: Backdrop = s
.eval::<mlua::Table>("return f:GetBackdrop()")
.map(|t| Backdrop {
bg_file: t.get("bgFile").ok(),
edge_file: t.get("edgeFile").ok(),
tile: t.get::<Option<f32>>("tile").ok().flatten().is_some(),
tile_size: t.get("tileSize").unwrap(),
edge_size: t.get("edgeSize").unwrap(),
insets: {
let ins: mlua::Table = t.get("insets").unwrap();
Insets {
left: ins.get("left").unwrap(),
right: ins.get("right").unwrap(),
top: ins.get("top").unwrap(),
bottom: ins.get("bottom").unwrap(),
}
},
..Default::default()
})
.unwrap();
let c = s
.eval::<(f32, f32, f32, f32)>("return f:GetBackdropColor()")
.unwrap();
b.bg_color = [c.0, c.1, c.2, c.3];
let c = s
.eval::<(f32, f32, f32, f32)>("return f:GetBackdropBorderColor()")
.unwrap();
b.border_color = [c.0, c.1, c.2, c.3];
b
};
let want = pieces(fr, &spec, eff);
let ctx = format!("shape {i} scale {scale} size {w}x{h}");
assert_eq!(quads.len(), want.len(), "{ctx}");
for (k, (q, p)) in quads.iter().zip(&want).enumerate() {
let (path, rect, color, tc, alpha) = q;
let xs = p.corners.map(|c| c[0]);
let ys = p.corners.map(|c| c[1]);
let bb = Rect::new(
ys.iter().copied().fold(f32::MAX, f32::min),
xs.iter().copied().fold(f32::MAX, f32::min),
ys.iter().copied().fold(f32::MIN, f32::max),
xs.iter().copied().fold(f32::MIN, f32::max),
);
let close = |a: f32, b: f32| (a - b).abs() < 1e-3;
// The draw normalizes a rect, so an edge shorter than its two corners, whose
// anchors cross, draws as the same box either way.
let (lo_y, hi_y) = (rect.bottom.min(rect.top), rect.bottom.max(rect.top));
let (lo_x, hi_x) = (rect.left.min(rect.right), rect.left.max(rect.right));
assert!(
close(lo_x, bb.left)
&& close(hi_x, bb.right)
&& close(hi_y, bb.top)
&& close(lo_y, bb.bottom),
"{ctx} piece {k}: rect {rect:?} want {bb:?}"
);
let want_path = if p.is_bg { "bg" } else { "edge" };
assert_eq!(path, want_path, "{ctx} piece {k}");
let want_color = if p.is_bg {
spec.bg_color
} else {
spec.border_color
};
assert_eq!(*color, Some(want_color), "{ctx} piece {k} colour");
assert_eq!(*alpha, 0.5, "{ctx} piece {k}: the frame's alpha");
// An untiled background keeps the whole texture (no `SetTexCoord`).
let uvs = match tc {
Some(TexCoords::Corners(c)) => *c,
None => [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]],
Some(other) => panic!("{ctx} piece {k}: {other:?}"),
};
for (a, b) in uvs.iter().zip(p.uvs.iter()) {
assert!(
close(a[0], b[0]) && close(a[1], b[1]),
"{ctx} piece {k}: uvs {uvs:?} want {:?}",
p.uvs
);
}
}
}
}
}
}
/// Each `SetBackdrop` links nine fresh regions, so a frame re-set on every show runs its link
/// positions past the draw key's field; they renumber in link order and the draw keeps it.
#[test]
fn a_backdrop_set_many_times_keeps_its_frames_draw_order() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
f = CreateFrame("Frame")
f:SetPoint("CENTER", 0, 0)
f:SetWidth(100); f:SetHeight(100)
local first = f:CreateTexture(nil, "ARTWORK")
first:SetAllPoints(f); first:SetTexture("first")
for i = 1, 600 do f:SetBackdrop({ bgFile = "bg", edgeFile = "edge" }) end
local last = f:CreateTexture(nil, "ARTWORK")
last:SetAllPoints(f); last:SetTexture("last")
local r = { f:GetRegions() }
assert(table.getn(r) == 11 and r[1] == first and r[11] == last, "link order kept")
"#,
)
.unwrap();
s.resolve();
let paths: Vec<String> = s
.extract()
.into_iter()
.filter_map(|q| match q.content {
QuadContent::Texture { path: Some(p), .. } => Some(p),
_ => None,
})
.collect();
let mut want = vec!["bg".to_string()];
want.extend(std::iter::repeat_n("edge".to_string(), 8));
want.extend(["first".to_string(), "last".to_string()]);
assert_eq!(paths, want);
}
/// A piece a Button adopts into a slot is still the backdrop's (`+0x00..+0x20`), and its teardown
/// deletes it; the region-removed hook (`0x778cb0`) empties the slot, so the next
/// `SetNormalTexture("path")` builds a fresh texture that draws, not a write into the dead one.
#[test]
fn a_button_slot_holding_a_piece_lets_go_when_the_backdrop_does() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
b = CreateFrame("Button")
b:SetPoint("CENTER", 0, 0)
b:SetWidth(100); b:SetHeight(40)
b:SetBackdrop({ bgFile = "bg", edgeFile = "edge" })
local piece = ({ b:GetRegions() })[1]
b:SetNormalTexture(piece)
assert(b:GetNormalTexture() == piece, "the slot holds the piece")
b:SetBackdrop(nil)
assert(b:GetNormalTexture() == nil, "the slot let go of the freed piece")
b:SetNormalTexture("fresh")
assert(b:GetNormalTexture():GetTexture() == "fresh", "a new texture answers")
"#,
)
.unwrap();
s.resolve();
let drawn = s
.extract()
.into_iter()
.any(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == "fresh"));
assert!(drawn, "the fresh normal texture draws");
}
/// The border is on BORDER (`0x77e8d0`), and the draw layer outranks the frame within a level
/// (`0x765920`): a backdrop's edge draws over a later-linked frame's BACKGROUND texture at the
/// same strata and level, where the frame's own slot drew it under.
#[test]
fn a_border_draws_over_another_frames_background_at_its_level() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local a = CreateFrame("Frame")
a:SetPoint("CENTER", 0, 0)
a:SetWidth(100); a:SetHeight(100)
a:SetBackdrop({ edgeFile = "edge" })
local b = CreateFrame("Frame")
b:SetAllPoints(a)
local t = b:CreateTexture(nil, "BACKGROUND")
t:SetAllPoints(b); t:SetTexture("under")
assert(a:GetFrameLevel() == b:GetFrameLevel(), "one level")
"#,
)
.unwrap();
s.resolve();
let paths: Vec<String> = s
.extract()
.into_iter()
.filter_map(|q| match q.content {
QuadContent::Texture { path: Some(p), .. } => Some(p),
_ => None,
})
.collect();
let under = paths.iter().position(|p| p == "under").expect("drawn");
let first_edge = paths.iter().position(|p| p == "edge").expect("drawn");
assert!(first_edge > under, "the border draws over it: {paths:?}");
}
/// `0x77f0c0` re-applies both colours after the texcoords (`0x77f3e9`, `0x77f3f4`), so a resize
/// repaints a piece an addon tinted itself.
#[test]
fn a_resize_repaints_a_piece_over_its_own_vertex_colour() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
f = CreateFrame("Frame")
f:SetPoint("CENTER", 0, 0)
f:SetWidth(100); f:SetHeight(100)
f:SetBackdrop({ bgFile = "bg", edgeFile = "edge" })
f:SetBackdropBorderColor(1, 0, 0)
"#,
)
.unwrap();
s.resolve();
let left = "local r = { f:GetRegions() } return r[2]:GetVertexColor()";
s.run("local r = { f:GetRegions() } r[2]:SetVertexColor(0, 1, 0)")
.unwrap();
assert_eq!(
s.eval::<(f32, f32, f32, f32)>(left).unwrap(),
(0.0, 1.0, 0.0, 1.0)
);
s.run("f:SetWidth(120)").unwrap();
s.resolve();
assert_eq!(
s.eval::<(f32, f32, f32, f32)>(left).unwrap(),
(1.0, 0.0, 0.0, 1.0),
"the resize put the border colour back"
);
}
/// The background's BOTTOMRIGHT anchor carries the top inset as its y (`0x77e9ae` loads `+0x50`),
/// which `GetPoint` shows though the drawn rect never does.
#[test]
fn the_bg_bottomright_anchor_answers_the_top_inset() {
let s = script();
s.run(
r#"
local f = CreateFrame("Frame")
f:SetBackdrop({ bgFile = "bg", insets = { left = 3, right = 4, top = 7, bottom = 9 } })
local bg = f:GetRegions()
local found
for i = 1, bg:GetNumPoints() do
local point, rel, relPoint, x, y = bg:GetPoint(i)
if point == "BOTTOMRIGHT" then
assert(rel == f and relPoint == "BOTTOMRIGHT", "on the frame's corner")
assert(x == -4 and y == 7, "x " .. x .. ", y " .. y .. ": the top inset")
found = true
elseif point == "BOTTOMLEFT" then
assert(x == 3 and y == 9, "the bottom-left takes the bottom inset")
end
end
assert(found, "a BOTTOMRIGHT anchor")
"#,
)
.unwrap();
}
/// A teardown kills the pieces' layout nodes; whatever anchored to one re-resolves against the
/// missing rect in the dirty-scope solve, exactly as a from-scratch solve of the same model does.
#[test]
fn a_teardown_re_resolves_what_anchored_to_a_piece() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
f = CreateFrame("Frame")
f:SetPoint("CENTER", 0, 0)
f:SetWidth(100); f:SetHeight(100)
f:SetBackdrop({ bgFile = "bg", edgeFile = "edge" })
local g = CreateFrame("Frame")
dep = g:CreateTexture(nil, "ARTWORK")
dep:SetTexture("dep")
dep:SetWidth(10); dep:SetHeight(10)
dep:SetPoint("TOPLEFT", ({ f:GetRegions() })[1], "BOTTOMLEFT", 0, 0)
"#,
)
.unwrap();
s.resolve();
let drawn = |s: &UiScript| {
s.extract().into_iter().any(|q| {
q.rect.is_some()
&& matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == "dep")
})
};
assert!(drawn(&s), "anchored to the live background");
s.run("f:SetBackdrop(nil)").unwrap();
s.resolve();
let incremental = {
let m = s.model_ref();
(m.resolved.clone(), m.region_resolved.clone())
};
{
let mut m = s.model_mut();
m.layout_scope.invalidate();
m.touch_layout();
}
s.resolve();
let full = {
let m = s.model_ref();
(m.resolved.clone(), m.region_resolved.clone())
};
assert!(
incremental == full,
"the dirty-scope solve matches a full one"
);
eprintln!(
"DBG {:?}",
s.eval::<(Option<f32>, Option<f32>, i64)>(
"return dep:GetLeft(), dep:GetTop(), dep:GetNumPoints()"
)
);
eprintln!(
"DBG2 {:?}",
s.eval::<(Option<String>, Option<String>)>(
"local p, r = dep:GetPoint(1) return p, r and r:GetObjectType()"
)
);
assert!(!drawn(&s), "its anchor target is gone");
}
-11
View File
@@ -129,17 +129,6 @@ pub enum QuadContent {
/// `0..=1`; the widget's value moves only its marker, never the strip.
sat: f32,
},
/// One `Backdrop` piece, the tiled background or one of the 8 border pieces, drawn in the
/// frame's slot behind its regions; per-corner UVs, since the top and bottom edges turn 90°.
Backdrop {
path: String,
/// The `SetBackdropColor` or `SetBackdropBorderColor` tint, pre-alpha.
color: [f32; 4],
/// `[TL, TR, BR, BL]`, screen order.
uvs: [[f32; 2]; 4],
/// Sample with repeat (wrap) addressing.
tile: bool,
},
/// A `FontString` region with its resolved font; the app measures and draws it.
Text {
text: Option<String>,
+48
View File
@@ -124,6 +124,34 @@ pub enum KindState {
}
impl KindState {
/// The owner's region-removed hook, frame vtable `+0x2c`, which `0x77fd10` runs on the old
/// parent whenever a region leaves it, freed, reparented or detached (`0x77fd55`): a kind
/// that keeps a region in a slot drops it there, so no slot names a dead region. Button
/// `0x778cb0`, CheckButton `0x785230`, StatusBar `0x783110`, Slider `0x789790` and ColorSelect
/// `0x78b530` override it; every other kind keeps the base unlink `0x76a7f0` alone.
pub fn region_removed(&mut self, rh: RegionHandle) {
let clear = |slot: &mut Option<RegionHandle>| {
if *slot == Some(rh) {
*slot = None;
}
};
match self {
KindState::Button(bs) => bs.region_removed(rh),
// The bar texture, `+0x324`.
KindState::StatusBar(sb) => clear(&mut sb.bar),
// The thumb, `+0x328`.
KindState::Slider(sl) => clear(&mut sl.thumb),
// The four art slots, `+0x318` to `+0x324`.
KindState::ColorSelect(cs) => {
clear(&mut cs.wheel);
clear(&mut cs.wheel_thumb);
clear(&mut cs.value_strip);
clear(&mut cs.value_thumb);
}
_ => {}
}
}
/// The display lines of either message-frame class, which share the [`MessageLine`] record.
pub fn message_lines(&self) -> Option<&VecDeque<MessageLine>> {
match self {
@@ -890,6 +918,26 @@ impl ButtonState {
}
}
/// The region-removed hook: a CheckButton drops its checked pair (`0x785230`, `+0x4e0` and
/// `+0x4e4`), then every button its label (`+0x338`), its three state slots (`+0x4b8` to
/// `+0x4c0`), the shown one (`+0x4c4`) and its highlight (`+0x4c8`) (`0x778cb0`).
fn region_removed(&mut self, rh: RegionHandle) {
for slot in [
&mut self.checked_tex,
&mut self.disabled_checked,
&mut self.text,
&mut self.disabled,
&mut self.normal,
&mut self.pushed,
&mut self.shown,
&mut self.highlight,
] {
if *slot == Some(rh) {
*slot = None;
}
}
}
/// Whether a region of this button draws: a state texture only when shown, the highlight
/// while hovered or locked, the checked pair by their rule, anything else always.
pub fn region_visible(&self, rh: RegionHandle, hovered: bool) -> bool {
+27 -9
View File
@@ -671,12 +671,7 @@ impl WidgetArena {
draw_layer: DrawLayer,
sub_level: i8,
) -> Option<RegionHandle> {
let decl_seq = {
let f = self.frame_mut(owner)?;
let d = f.next_decl;
f.next_decl += 1;
d
};
let decl_seq = self.take_decl(owner)?;
let (index, generation) = self.regions.insert(Region {
kind,
owner,
@@ -693,15 +688,38 @@ impl WidgetArena {
Some(handle)
}
/// Free one region, as only `CSimpleHTML::SetText` does in the client, pool-freeing the last
/// parse's blocks (`0x78adc6`). The caller drops the region's script-model state too, and never
/// frees a region some state holds by handle.
/// The next declaration seq on `owner`, the region's link position. At the draw key's cap the
/// frame's regions renumber from 0 in link order (`regions` ascends by seq), so a frame that
/// rebuilds regions for ever, a backdrop set on every show, keeps its order and never
/// outgrows the key.
pub(crate) fn take_decl(&mut self, owner: FrameHandle) -> Option<u32> {
if self.frame(owner)?.next_decl >= crate::order::DECL_CAP {
let regions = self.frame(owner)?.regions.clone();
let mut next = 0;
for rh in regions {
if let Some(r) = self.region_mut(rh) {
r.decl_seq = next;
next += 1;
}
}
self.frame_mut(owner)?.next_decl = next;
}
let f = self.frame_mut(owner)?;
let d = f.next_decl;
f.next_decl += 1;
Some(d)
}
/// Free one region, as `CSimpleHTML::SetText` pool-frees the last parse's blocks (`0x78adc6`)
/// and a backdrop's teardown its nine pieces (`0x77f4d0`). The caller drops the region's
/// script-model state too, and never frees a region some state holds by handle.
pub fn destroy_region(&mut self, h: RegionHandle) -> bool {
let Some(region) = self.region(h) else {
return false;
};
let owner = region.owner;
if let Some(f) = self.frame_mut(owner) {
f.kind_state.region_removed(h);
f.regions.retain(|&r| r != h);
if f.title_region == Some(h) {
f.title_region = None;
+8 -6
View File
@@ -345,6 +345,13 @@ impl WidgetArena {
None if was_detached => return false,
_ => {}
}
// Leaving its parent runs the parent's region-removed hook (`0x77fd55`); a detached
// region already left.
if !was_detached {
if let Some(of) = self.frame_mut(old_owner) {
of.kind_state.region_removed(rh);
}
}
let Some(new_owner) = new_owner else {
if let Some(r) = self.region_mut(rh) {
r.detached = true;
@@ -354,12 +361,7 @@ impl WidgetArena {
if let Some(of) = self.frame_mut(old_owner) {
of.regions.retain(|&r| r != rh);
}
let decl_seq = {
let f = self.frame_mut(new_owner).expect("live new owner");
let d = f.next_decl;
f.next_decl += 1;
d
};
let decl_seq = self.take_decl(new_owner).expect("live new owner");
if let Some(r) = self.region_mut(rh) {
r.owner = new_owner;
r.decl_seq = decl_seq;
+63
View File
@@ -324,3 +324,66 @@ fn the_tooltip_registry_tracks_live_gametooltips() {
a.destroy(loose);
assert!(a.tooltip_kinds().is_empty());
}
// ── The region-removed hook (frame vtable `+0x2c`) ───────────────────────────────────────────
/// Freeing, reparenting or detaching a region runs its parent's hook, and each kind that keeps a
/// region in a slot lets go of it: StatusBar `0x783110`, Slider `0x789790`, ColorSelect
/// `0x78b530`, CheckButton `0x785230` over Button `0x778cb0`.
#[test]
fn a_region_leaving_its_parent_empties_the_kind_slot_that_held_it() {
let mut a = arena();
let other = a.create(FrameKind::Frame, None, None);
let bar = a.create(FrameKind::StatusBar, None, None);
let slider = a.create(FrameKind::Slider, None, None);
let wheel = a.create(FrameKind::ColorSelect, None, None);
let check = a.create(FrameKind::CheckButton, None, None);
let tex = |a: &mut WidgetArena, f| {
a.create_region(f, RegionKind::Texture, DrawLayer::Artwork, 0)
.unwrap()
};
let (b, t, w, c, n) = (
tex(&mut a, bar),
tex(&mut a, slider),
tex(&mut a, wheel),
tex(&mut a, check),
tex(&mut a, check),
);
match &mut a.frame_mut(bar).unwrap().kind_state {
KindState::StatusBar(s) => s.bar = Some(b),
_ => unreachable!(),
}
match &mut a.frame_mut(slider).unwrap().kind_state {
KindState::Slider(s) => s.thumb = Some(t),
_ => unreachable!(),
}
match &mut a.frame_mut(wheel).unwrap().kind_state {
KindState::ColorSelect(s) => s.value_thumb = Some(w),
_ => unreachable!(),
}
match &mut a.frame_mut(check).unwrap().kind_state {
KindState::Button(s) => {
s.checked_tex = Some(c);
s.set_state_slot(ButtonVisualState::Normal, Some(n));
}
_ => unreachable!(),
}
// Freed, reparented, detached, freed, and the shown normal texture freed.
assert!(a.destroy_region(b));
assert!(a.set_region_owner(t, Some(other)));
assert!(a.set_region_owner(w, None));
assert!(a.destroy_region(c));
assert!(a.destroy_region(n));
assert!(
matches!(&a.frame(bar).unwrap().kind_state, KindState::StatusBar(s) if s.bar.is_none())
);
assert!(
matches!(&a.frame(slider).unwrap().kind_state, KindState::Slider(s) if s.thumb.is_none())
);
assert!(matches!(&a.frame(wheel).unwrap().kind_state,
KindState::ColorSelect(s) if s.value_thumb.is_none()));
let KindState::Button(s) = &a.frame(check).unwrap().kind_state else {
unreachable!()
};
assert_eq!((s.checked_tex, s.normal), (None, None));
}