Files
benilla-octo/crates/benilla-app/src/glue/backdrop.rs
T
SamandClaude Fable 5 bdef9cd790 benilla-world: the engine becomes its own crate
- the world engine splits from the game into a benilla-world crate — 112 files, and the wall is ratcheted to zero names of the game inside the engine
- the engine's public doorway is counted and gated at 32 items, and the render materials, shaders and asset cache move down to benilla-assets
- the game's files leave the engine's asset root — game:// is its own space — and a spike binary boots the engine with no game attached
- weather rides along: snowfall emission is denominated in the reference's frame cap, and a long teleport retires the stranded precipitation field instead of leaving it behind

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:13:44 +07:00

303 lines
13 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.
//! WoW `Backdrop`s, at the geometry the client itself uses (wow-re `backdrop-mechanism.md`,
//! byte-verified; decision 0543): the `edgeFile` strip split into its eight upright pieces
//! ([`split_backdrop_edges`] — the un-rotation law), the eight-piece border rig
//! ([`backdrop_border`]) seated to its frame's laid-out size by [`fit_backdrop_borders`], and the
//! tiled backdrop bg ([`tiled_bg_node`]). Split out of [`super::art`] (which loads the pieces and
//! owns every other glue texture): this file is the backdrop *mechanism*, art is the *inventory*.
use bevy::prelude::*;
use benilla_assets::{sprite_image, sprite_image_tiled, WorldAssets};
/// `UI-Tooltip-Background`'s native size (the tile-period → `stretch_value` conversion).
const TOOLTIP_BG_NATIVE: f32 = 64.0;
// ── WoW backdrops ────────────────────────────────────────────────────────────────────────────────
/// One `Backdrop` `edgeFile`'s eight pieces, split out upright and ready to draw.
#[derive(Clone)]
pub(crate) struct BackdropEdges {
pub(crate) left: Handle<Image>,
pub(crate) right: Handle<Image>,
pub(crate) top: Handle<Image>,
pub(crate) bottom: Handle<Image>,
pub(crate) tl: Handle<Image>,
pub(crate) tr: Handle<Image>,
pub(crate) bl: Handle<Image>,
pub(crate) br: Handle<Image>,
}
/// Split a WoW `edgeFile` strip into its eight upright `e`×`e` pieces, in the returned order
/// LEFT, RIGHT, TOP, BOTTOM, TOPLEFT, TOPRIGHT, BOTTOMLEFT, BOTTOMRIGHT — the strip's own order
/// (wow-re `backdrop-mechanism.md` §3, byte-verified from the client's UV constants; independently
/// confirmed here by reading the shapes out of `Glue-Tooltip-Border`'s pixels).
///
/// TOP and BOTTOM are stored **rotated 90°** in the strip — the client maps atlas-u to screen-Y
/// and atlas-v to screen-X reversed — so they are un-rotated here and every piece draws with plain
/// upright UVs: the TOP cell reads texel `(2e+y, e−1−x)`, the BOTTOM cell `(3e+y, e−1−x)`.
pub(crate) fn split_backdrop_edges(e: usize, strip: &[u8]) -> [Vec<u8>; 8] {
let src = |x: usize, y: usize| {
let i = (y * 8 * e + x) * 4;
&strip[i..i + 4]
};
let mut cells = std::array::from_fn(|_| vec![0u8; e * e * 4]);
for y in 0..e {
for x in 0..e {
let mut put = |cell: usize, px: &[u8]| {
let i = (y * e + x) * 4;
cells[cell][i..i + 4].copy_from_slice(px);
};
put(0, src(x, y)); // LEFT ← slice 0, upright
put(1, src(e + x, y)); // RIGHT ← slice 1, upright
put(2, src(2 * e + y, e - 1 - x)); // TOP ← slice 2, un-rotated
put(3, src(3 * e + y, e - 1 - x)); // BOTTOM ← slice 3, un-rotated
put(4, src(4 * e + x, y)); // TOPLEFT
put(5, src(5 * e + x, y)); // TOPRIGHT
put(6, src(6 * e + x, y)); // BOTTOMLEFT
put(7, src(7 * e + x, y)); // BOTTOMRIGHT
}
}
cells
}
/// Load one edge file and split it ([`split_backdrop_edges`]); the cell size is the strip's height.
pub(super) fn backdrop_edges(
assets: &mut WorldAssets,
path: &str,
images: &mut Assets<Image>,
) -> Option<BackdropEdges> {
let (w, h, rgba) = assets.decode_rgba(path)?;
let e = h as usize;
if e == 0 || w as usize != 8 * e {
warn!("glue art: {path} is {w}x{h}, not an 8-cell edge strip");
return None;
}
let [left, right, top, bottom, tl, tr, bl, br] = split_backdrop_edges(e, &rgba);
// The four runs tile, so they must WRAP; the corners map [0,1] exactly once, so they clamp
// (a repeat-sampled corner bleeds its opposite edge in under linear filtering).
let mut run = |px: Vec<u8>| images.add(sprite_image_tiled(h, h, px));
let (left, right, top, bottom) = (run(left), run(right), run(top), run(bottom));
let mut corner = |px: Vec<u8>| images.add(sprite_image(h, h, px));
Some(BackdropEdges {
left,
right,
top,
bottom,
tl: corner(tl),
tr: corner(tr),
bl: corner(bl),
br: corner(br),
})
}
/// A `Backdrop`'s border, at the geometry the client itself uses (wow-re `backdrop-mechanism.md`
/// §2, byte-verified): the border sits **inside** the frame rect, flush with its edges — corner
/// squares of exactly `e`×`e` at the four corners, edge strips spanning between them and **tiling**
/// at period `e` (the run math `side/e − 2` has no upper clamp, so edges never stretch). Eight
/// pieces on a full-bleed rig child of the frame node, one per authored piece — the client's own
/// eight `CSimpleTexture`s — seated to the frame's **laid-out** size by [`fit_backdrop_borders`],
/// so content-sized frames and window rescales stay correct without a spawn-time size.
///
/// Deliberately **not** Bevy's `TextureSlicer`: its corner scale is
/// `min(render_size / texture_size, max_corner_scale)`, so feeding it a 3·`e`-tall nine-patch atlas
/// silently shrank the entire border on any frame shorter than 3·`e`. A 37-tall edit box drew its
/// 16-unit border at 12.3 — pulling the art clear of the authored `BackgroundInsets`, which are
/// cut so the fill butts against each edge's bright line, and leaving the scene showing bare
/// between frame and fill (decision 0543).
pub(crate) fn backdrop_border(
b: &mut ChildSpawnerCommands,
edges: &BackdropEdges,
e: f32,
color: Color,
) {
let mut rig = b.spawn((
BackdropRig {
e,
fitted: (Vec2::ZERO, 0.0),
},
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
right: Val::Px(0.0),
top: Val::Px(0.0),
bottom: Val::Px(0.0),
..default()
},
));
rig.with_children(|rig| {
for (img, kind) in [
(&edges.tl, BackdropPiece::Tl),
(&edges.tr, BackdropPiece::Tr),
(&edges.bl, BackdropPiece::Bl),
(&edges.br, BackdropPiece::Br),
(&edges.left, BackdropPiece::Left),
(&edges.right, BackdropPiece::Right),
(&edges.top, BackdropPiece::Top),
(&edges.bottom, BackdropPiece::Bottom),
] {
rig.spawn((
kind,
ImageNode {
image: img.clone(),
color,
..default()
},
// Zero-size until the first fit — nothing shows on the pre-layout frame.
Node {
position_type: PositionType::Absolute,
width: Val::Px(0.0),
height: Val::Px(0.0),
..default()
},
));
}
});
}
/// The full-bleed child a [`backdrop_border`] hangs its pieces on: its `ComputedNode` reads the
/// frame's laid-out size, which is what the pieces are fitted to.
#[derive(Component)]
pub(crate) struct BackdropRig {
/// The authored `edgeSize`.
e: f32,
/// The (physical size, screen scale) last fitted — skip settled frames.
fitted: (Vec2, f32),
}
/// Which of the eight authored pieces a rig child draws.
#[derive(Component, Clone, Copy)]
pub(crate) enum BackdropPiece {
Tl,
Tr,
Bl,
Br,
Left,
Right,
Top,
Bottom,
}
/// Seat every [`backdrop_border`]'s eight pieces to its frame's **laid-out** size, re-fitting
/// whenever the frame or the glue scale changes — so a content-sized frame (the dialogs, which the
/// ref itself resizes to fit their text) and a window resize both keep a correct border, where a
/// spawn-time bake could only guess.
///
/// Every shared edge is ONE integer of physical pixels, and every piece is placed by
/// left/top/width/height off that same integer grid — never by a `right`/`bottom` anchor. Bevy
/// rounds a node's position and its size to physical pixels *independently*, so two pieces meeting
/// at a fractional coordinate round apart and the seam shows: a 1 px transparent slit at one end
/// (bare scene through the frame) and a 1 px overlap at the other, where the semi-transparent edge
/// art composites twice and reads visibly darker. Snapping the shared numbers first makes that
/// rounding a no-op. Only the frame's OUTER edges keep the fractional remainder, where there is no
/// neighbour to disagree with.
pub(crate) fn fit_backdrop_borders(
window: Query<&Window, With<bevy::window::PrimaryWindow>>,
mut rigs: Query<(&mut BackdropRig, &ComputedNode, &Children)>,
mut pieces: Query<(&BackdropPiece, &mut Node, &mut ImageNode)>,
) {
let s = super::screen_scale(window.single().ok());
for (mut rig, computed, children) in &mut rigs {
let size = computed.size();
if computed.is_empty() || rig.fitted == (size, s) {
continue;
}
rig.fitted = (size, s);
let inv = computed.inverse_scale_factor;
let edge = (rig.e * s / inv).round().max(1.0);
let (bw, bh) = (size.x.round(), size.y.round());
// The client clamps each run to zero when the frame is too small for its two corners
// (`side/e − 2`, floored at 0) — the corners then simply overlap, as they do there.
let run_w = (bw - 2.0 * edge).max(0.0);
let run_h = (bh - 2.0 * edge).max(0.0);
// A run tiles along its own axis and maps 1:1 across its `e`-thick side; the period
// follows the snapped edge so it matches the drawn corner exactly.
let run = |along_x: bool| NodeImageMode::Tiled {
tile_x: along_x,
tile_y: !along_x,
stretch_value: edge * inv / rig.e,
};
for &child in children {
let Ok((kind, mut node, mut img)) = pieces.get_mut(child) else {
continue;
};
let (x, y, w, h, mode) = match kind {
BackdropPiece::Tl => (0.0, 0.0, edge, edge, NodeImageMode::Auto),
BackdropPiece::Tr => (bw - edge, 0.0, edge, edge, NodeImageMode::Auto),
BackdropPiece::Bl => (0.0, bh - edge, edge, edge, NodeImageMode::Auto),
BackdropPiece::Br => (bw - edge, bh - edge, edge, edge, NodeImageMode::Auto),
BackdropPiece::Left => (0.0, edge, edge, run_h, run(false)),
BackdropPiece::Right => (bw - edge, edge, edge, run_h, run(false)),
BackdropPiece::Top => (edge, 0.0, run_w, edge, run(true)),
BackdropPiece::Bottom => (edge, bh - edge, run_w, edge, run(true)),
};
// Physical → logical for `Val::Px`; ×scale-factor at layout restores the integers.
node.left = Val::Px(x * inv);
node.top = Val::Px(y * inv);
node.width = Val::Px(w * inv);
node.height = Val::Px(h * inv);
img.image_mode = mode;
}
}
}
/// The tiled backdrop bg (`UI-Tooltip-Background`) behind a bordered box, at the authored
/// `tileSize` period, faction-tinted by the caller (the texture's own alpha rides along).
pub(crate) fn tiled_bg_node(sheet: Handle<Image>, period: f32, s: f32, color: Color) -> ImageNode {
ImageNode {
image: sheet,
color,
image_mode: NodeImageMode::Tiled {
tile_x: true,
tile_y: true,
stretch_value: period * s / TOOLTIP_BG_NATIVE,
},
..default()
}
}
#[cfg(test)]
mod tests {
use super::*;
// The split against the backdrop UV law (wow-re `backdrop-mechanism.md` §3): an e=2 strip with
// pixel (x,y) = (x, y) in the r/g channels, so every copied texel is assertable by coordinates.
#[test]
fn backdrop_split_follows_the_edgefile_law() {
let e = 2usize;
let (w, h) = (8 * e, e);
let mut strip = vec![0u8; w * h * 4];
for y in 0..h {
for x in 0..w {
let i = (y * w + x) * 4;
strip[i] = x as u8;
strip[i + 1] = y as u8;
strip[i + 2] = 0xAB;
strip[i + 3] = 0xFF;
}
}
let cells = split_backdrop_edges(e, &strip);
let at = |cell: usize, x: usize, y: usize| {
let i = (y * e + x) * 4;
(cells[cell][i], cells[cell][i + 1])
};
// The four upright pieces are direct copies of their slice.
assert_eq!(at(0, 0, 0), (0, 0)); // LEFT ← slice 0 (x 0..2)
assert_eq!(at(0, 1, 1), (1, 1));
assert_eq!(at(1, 0, 0), (2, 0)); // RIGHT ← slice 1 (x 2..4)
assert_eq!(at(4, 0, 0), (8, 0)); // TOPLEFT ← slice 4 (x 8..10)
assert_eq!(at(4, 1, 1), (9, 1));
assert_eq!(at(5, 0, 0), (10, 0)); // TOPRIGHT ← slice 5
assert_eq!(at(6, 0, 0), (12, 0)); // BOTTOMLEFT ← slice 6
assert_eq!(at(7, 0, 0), (14, 0)); // BOTTOMRIGHT ← slice 7
// TOP ← slice 2 (x 4..6), UN-ROTATED: dst(x,y) = src(2e+y, e-1-x).
assert_eq!(at(2, 0, 0), (4, 1)); // dst(0,0) ← src(4, 1)
assert_eq!(at(2, 1, 0), (4, 0)); // dst(1,0) ← src(4, 0)
assert_eq!(at(2, 0, 1), (5, 1)); // dst(0,1) ← src(5, 1)
// BOTTOM ← slice 3 (x 6..8), same un-rotation.
assert_eq!(at(3, 0, 0), (6, 1));
assert_eq!(at(3, 1, 1), (7, 0));
// Every piece is exactly one e×e cell — nothing is stretched or padded.
assert!(cells.iter().all(|c| c.len() == e * e * 4));
}
}