diff --git a/crates/benilla-app/examples/bake_zone_land.rs b/crates/benilla-app/examples/bake_zone_land.rs
new file mode 100644
index 00000000..d6b8c399
--- /dev/null
+++ b/crates/benilla-app/examples/bake_zone_land.rs
@@ -0,0 +1,675 @@
+//! `bake_zone_land`: one land mask per map, cut from the client's own exploration art.
+//!
+//! ```text
+//! cargo run -q -p benilla-app --example bake_zone_land -- [--out
] [--dry-run]
+//! [--sheet ] [--dump-rects ]
+//! ```
+//!
+//! The map addon (`OpenMapClassic`) stitches every zone's 1.12 sheet onto one canvas, and a sheet
+//! is a **rectangle** while a zone is not: Westfall's sheet paints sea, parchment and a slice of
+//! Duskwood around the land. So each sheet is masked down to its own ground by a per-zone alpha
+//! mask, `Media/Textures/MapZoneDilate/.tga`. There are **forty** of those, hand-traced
+//! upstream for stock Classic's forty outdoor zones. This realm's `WorldMapArea.dbc` has 175 rows
+//! — 124 maps of its own (Alah'Thalas, Gilneas, Tel'Abim, Hyjal, the Timbermaw tunnels…) — and a
+//! map with no mask falls back to a plain rectangular feather. That fallback is what grom sees:
+//! "it would be really nice if the darkened areas were not just massive squares on the map, and
+//! should fit into the actual edges of the terrian".
+//!
+//! The shape of the ground is already in the install, in a form that covers every map the realm
+//! has, because the realm had to author it to ship the zone at all: `WorldMapOverlay.dbc`'s art.
+//! An overlay is one piece the game reveals as you explore, and it is cut to the ground — that is
+//! why a half-explored zone shows a ragged coastline rather than a half-rectangle. The **union of
+//! a map's overlay alpha** is therefore the land, measured rather than traced, and it needs no
+//! per-zone handwork: a realm update is a re-run of this tool.
+//!
+//! What it does, per map with at least one overlay:
+//!
+//! 1. Composites every overlay's alpha into a 1002×668 buffer — the detail sheet's own pixel
+//! extent (`compat/classic_api/uimap.rs`'s `LAYER_WIDTH`/`LAYER_HEIGHT`) — at the overlay's
+//! `offsetX`/`offsetY`, tile by tile, exactly as the window stacks them (see
+//! [`rasterize_overlays`]).
+//! 2. Raises any bare patch the overlay art completely surrounds, which is a seam between two
+//! abutting pieces and not sea ([`fill_enclosed_gaps`]).
+//! 3. Area-downscales that to 256×256, the size the shipped masks are.
+//! 4. Softens it the way `scripts/soften-zone-masks.py` softens the hand-made ones, and for the
+//! same reason: a binary mask ends every sheet on a crisp line against its neighbour, so
+//! nothing blends. Same constants (`DILATE`, `BLUR`), and **without** that script's
+//! `RECT_MARGIN` — the rect inset exists to hide a 1.12 sheet's painted parchment border, and
+//! an overlay-derived mask has no border to hide.
+//!
+//! The report names the maps with **no** overlay at all, which are the ones that still need the
+//! rectangular fallback, and flags any mask whose coverage is so high the mask is a square again.
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::path::PathBuf;
+
+use benilla_ui::script::uimap::{self, UiMapNode};
+use benilla_ui::script::{WorldMapContinentView, WorldMapZoneView};
+
+/// The detail sheet's pixel extent, which every overlay's offset is relative to: the same pair
+/// `compat/classic_api/uimap.rs` publishes as `layerWidth`/`layerHeight`, and the reciprocals
+/// `script/worldmap.rs`'s `OVERLAY_RECIP_X`/`_Y` scale a hit rect by.
+const LAYER_WIDTH: usize = 1002;
+const LAYER_HEIGHT: usize = 668;
+
+/// An overlay's art is cut into 256×256 pieces (`WorldMapFrame.lua:115-158`).
+const TILE: u32 = 256;
+
+/// The shipped masks' size, so a baked one drops in beside a hand-made one.
+const MASK: usize = 256;
+
+/// Outward dilate, then ramp, both in mask pixels — `scripts/soften-zone-masks.py`'s own
+/// constants, unchanged. That script's docstring is the argument for the pair: the dilate pushes
+/// coverage out so the ramp starts at the old boundary instead of eating into the zone, and a
+/// symmetric blur would take both neighbours to 50% at a shared border and show the sea-grey
+/// underlay through as a bright seam.
+const DILATE: usize = 2;
+const BLUR: f32 = 3.0;
+
+/// Coverage above this and the mask keeps essentially the whole sheet, i.e. it is the rectangle
+/// again and masking with it buys nothing. Reported rather than suppressed: a map whose overlays
+/// genuinely tile their sheet is a fact about the data, and silently shipping a square is how the
+/// complaint happened in the first place.
+const SQUARE_COVERAGE: f32 = 0.95;
+
+fn main() {
+ let args: Vec = std::env::args().skip(1).collect();
+ let dry_run = args.iter().any(|a| a == "--dry-run");
+ // `--sheet `: the 1002×668 union before the downscale, one greyscale PNG per map. The
+ // diagnostic for the one failure that looks like success — an edge tile composited with its
+ // padding smears every overlay out to the 256 grid, which at mask size is a plausible-looking
+ // blob and at sheet size is unmistakably a lattice.
+ let sheet_dir: Option = args
+ .iter()
+ .position(|a| a == "--sheet")
+ .and_then(|i| args.get(i + 1))
+ .map(PathBuf::from);
+ // `--dump-rects `: where every map's art sits on the stitched world sheet, as
+ // `uiMapIDleftrighttopbottom` normalized on `WORLD_UI_MAP_ID`. The masks
+ // this example bakes are each in their own map's rect; anything that reasons about two maps at
+ // once — `scripts/clip-zone-masks-to-coast.py` composites all of them into one world-space land
+ // buffer — needs the placement too, and this is the same graph the map window is driven by, so
+ // the file cannot drift from what the client draws.
+ let dump_rects: Option = args
+ .iter()
+ .position(|a| a == "--dump-rects")
+ .and_then(|i| args.get(i + 1))
+ .map(PathBuf::from);
+ let out_dir: PathBuf = args
+ .iter()
+ .position(|a| a == "--out")
+ .and_then(|i| args.get(i + 1))
+ .map(PathBuf::from)
+ .unwrap_or_else(|| {
+ PathBuf::from("benilla-config/AddOns/OpenMapClassic/Media/Textures/MapZoneLand")
+ });
+
+ let Some(data) = benilla_formats::wow_data() else {
+ eprintln!("no install");
+ std::process::exit(1);
+ };
+ println!("data: {}", data.display());
+ let mut chain = benilla_formats::open_chain(&data).expect("chain opens");
+ let areas = benilla_formats::load_area_table_catalog(&mut chain).expect("AreaTable.dbc");
+ let maps =
+ benilla_assets::MapCatalogRes(benilla_formats::load_map_catalog(&mut chain).expect("Map"));
+ // The same catalog the map window itself is fed, so the masks are keyed to the maps this
+ // client has rather than to a list transcribed from somewhere else.
+ let (continents, ui_data) =
+ benilla_app::ui_world_map::build_catalog(&mut chain, &areas, &maps).expect("the catalog");
+ let direct_areas = ui_data.direct_area_views();
+ let graph = uimap::build_graph(&continents, &direct_areas);
+
+ println!(
+ "catalog: {} continents, {} zones, {} instance maps, {} graph nodes",
+ continents.len(),
+ continents.iter().map(|c| c.zones.len()).sum::(),
+ direct_areas.len(),
+ graph.len(),
+ );
+
+ if let Some(path) = &dump_rects {
+ dump_world_rects(&graph, path);
+ }
+
+ // The maps worth a mask: a node with a `WorldMapArea` row of its own, which is every zone and
+ // every orphan. The world sheet and the continents have no row in the catalog's zone lists —
+ // their art is a different stitch and the addon does not mask it — so they are counted, not
+ // listed as gaps.
+ let mut rows: Vec<(&UiMapNode, &WorldMapZoneView)> = Vec::new();
+ let mut no_row = 0usize;
+ for node in &graph {
+ match zone_view(&continents, &direct_areas, node) {
+ Some(view) => rows.push((node, view)),
+ None => no_row += 1,
+ }
+ }
+ println!(
+ "{} map rows to bake from; {} node(s) carry no row of their own (the world sheet and the \
+ continents)",
+ rows.len(),
+ no_row
+ );
+
+ // A mask is named by uiMapID, so two rows minting the same id would overwrite each other.
+ let mut seen: BTreeMap = BTreeMap::new();
+ let mut collisions: Vec = Vec::new();
+ for (node, _) in &rows {
+ if let Some(first) = seen.insert(node.ui_map_id, node.name.as_str()) {
+ collisions.push(format!("{} ({first} and {})", node.ui_map_id, node.name));
+ }
+ }
+ if !collisions.is_empty() {
+ println!(
+ "\nWARNING: {} uiMapID collision(s) — the later row's mask overwrites the earlier: {}",
+ collisions.len(),
+ collisions.join(", ")
+ );
+ }
+
+ let mut baked: Vec = Vec::new();
+ let mut empty: Vec<(u32, String)> = Vec::new();
+ let mut missing_tiles: BTreeSet = BTreeSet::new();
+ for (node, view) in &rows {
+ if view.overlays.is_empty() {
+ empty.push((node.ui_map_id, node.name.clone()));
+ continue;
+ }
+ let (mut sheet, tiles, missed) = rasterize_overlays(&mut chain, view, &mut missing_tiles);
+ let filled = fill_enclosed_gaps(&mut sheet);
+ if let Some(dir) = &sheet_dir {
+ std::fs::create_dir_all(dir).expect("the sheet directory");
+ let bytes: Vec = sheet
+ .iter()
+ .map(|v| (v * 255.0).round().clamp(0.0, 255.0) as u8)
+ .collect();
+ image::GrayImage::from_raw(LAYER_WIDTH as u32, LAYER_HEIGHT as u32, bytes)
+ .expect("the sheet is 1002x668")
+ .save(dir.join(format!("{}-sheet.png", node.ui_map_id)))
+ .expect("write the sheet");
+ }
+ if tiles == missed {
+ // Overlay rows with no art on this install: the map is as maskless as one with no
+ // rows at all, and the difference matters to whoever fixes it, so say which it is.
+ empty.push((
+ node.ui_map_id,
+ format!(
+ "{} [{} overlay row(s), no art]",
+ node.name,
+ view.overlays.len()
+ ),
+ ));
+ continue;
+ }
+ let mask = soften(&downscale(&sheet, LAYER_WIDTH, LAYER_HEIGHT, MASK, MASK));
+ let coverage = mask.iter().sum::() / (MASK * MASK) as f32;
+ baked.push(Baked {
+ ui_map_id: node.ui_map_id,
+ name: node.name.clone(),
+ map_file: view.map_file.clone(),
+ overlays: view.overlays.len(),
+ tiles,
+ missed,
+ filled,
+ coverage,
+ mask,
+ });
+ }
+ baked.sort_by_key(|b| b.ui_map_id);
+ empty.sort();
+
+ if !dry_run {
+ std::fs::create_dir_all(&out_dir).expect("the output directory");
+ for b in &baked {
+ let rgba: Vec = b
+ .mask
+ .iter()
+ .flat_map(|&a| [0, 0, 0, (a * 255.0).round().clamp(0.0, 255.0) as u8])
+ .collect();
+ let bytes = benilla_formats::rgba_to_tga(MASK as u32, MASK as u32, &rgba)
+ .expect("a 256x256 mask encodes");
+ std::fs::write(out_dir.join(format!("{}.tga", b.ui_map_id)), bytes).expect("write");
+ }
+ }
+
+ println!("\n-- baked --");
+ println!(
+ "{:>8} {:<28} {:<24} {:>8} {:>7} {:>7} {:>8} note",
+ "uiMapID", "name", "art folder", "overlays", "tiles", "seam px", "coverage"
+ );
+ for b in &baked {
+ let note = if b.coverage > SQUARE_COVERAGE {
+ "SQUARE: the overlays tile the whole sheet"
+ } else if b.missed > 0 {
+ "partial: some tiles are not in the chain"
+ } else {
+ ""
+ };
+ println!(
+ "{:>8} {:<28} {:<24} {:>8} {:>7} {:>7} {:>8.3} {note}",
+ b.ui_map_id,
+ trim(&b.name, 28),
+ trim(&b.map_file, 24),
+ b.overlays,
+ if b.missed > 0 {
+ format!("{}-{}", b.tiles, b.missed)
+ } else {
+ b.tiles.to_string()
+ },
+ b.filled,
+ b.coverage,
+ );
+ }
+
+ println!("\n-- no overlay art: these still need the rectangular fallback --");
+ for (id, name) in &empty {
+ println!("{id:>8} {name}");
+ }
+
+ let squares: Vec<&Baked> = baked
+ .iter()
+ .filter(|b| b.coverage > SQUARE_COVERAGE)
+ .collect();
+ println!(
+ "\n{} mask(s) baked, {} map(s) with no overlay art, {} above {:.2} coverage{}",
+ baked.len(),
+ empty.len(),
+ squares.len(),
+ SQUARE_COVERAGE,
+ if squares.is_empty() {
+ String::new()
+ } else {
+ format!(
+ " ({})",
+ squares
+ .iter()
+ .map(|b| format!("{} {}", b.ui_map_id, b.name))
+ .collect::>()
+ .join(", ")
+ )
+ }
+ );
+ if !missing_tiles.is_empty() {
+ println!(
+ "{} overlay tile(s) named by the DBC are not in the chain, e.g. {}",
+ missing_tiles.len(),
+ missing_tiles
+ .iter()
+ .take(4)
+ .cloned()
+ .collect::>()
+ .join(", ")
+ );
+ }
+ if dry_run {
+ println!(
+ "--dry-run: nothing written (would have written to {})",
+ out_dir.display()
+ );
+ } else {
+ println!("wrote {} mask(s) to {}", baked.len(), out_dir.display());
+ }
+}
+
+/// Write every placed map's rect on the world sheet, and report the sheet's scale in yards.
+///
+/// One row per node that `UiMapNode::placed_on_world` accepts, tab-separated:
+/// `uiMapID left right top bottom`, normalized on `WORLD_UI_MAP_ID` with left/top the small end.
+/// A continent is included as well as its zones — it is a real rect on the sheet, and a consumer
+/// that wants only zones has the zone ids already.
+///
+/// The yards line exists because a world unit is otherwise dimensionless. Each row carries both
+/// its yard rect (`loc_rect`, from `WorldMapArea.dbc`) and its world rect, so their ratio is
+/// yards per world unit, measured once per map rather than assumed; it differs between the two
+/// continents because each is scaled onto the sheet by its own art rect.
+fn dump_world_rects(graph: &[UiMapNode], path: &std::path::Path) {
+ let mut lines = String::new();
+ let mut rows = 0usize;
+ // Yards per world unit, per continent, from every map whose placement and yard rect are both
+ // real. `loc_rect` runs left > right and top > bottom (WoW's axes decrease), so the spans are
+ // taken that way round.
+ let mut scales: BTreeMap<(u32, char), Vec> = BTreeMap::new();
+ for node in graph {
+ if !node.placed_on_world() {
+ continue;
+ }
+ let (l, r, t, b) = node.world_rect;
+ lines.push_str(&format!(
+ "{}\t{l:.8}\t{r:.8}\t{t:.8}\t{b:.8}\n",
+ node.ui_map_id
+ ));
+ rows += 1;
+ let (ll, lr, lt, lb) = node.loc_rect;
+ if let Some(cont) = node.continent_map_id {
+ if ll - lr > 0.0 && lt - lb > 0.0 {
+ scales
+ .entry((cont, 'u'))
+ .or_default()
+ .push((ll - lr) / (r - l));
+ scales
+ .entry((cont, 'v'))
+ .or_default()
+ .push((lt - lb) / (b - t));
+ }
+ }
+ }
+ if let Some(dir) = path.parent() {
+ if !dir.as_os_str().is_empty() {
+ std::fs::create_dir_all(dir).expect("the dump directory");
+ }
+ }
+ std::fs::write(path, lines).expect("write the rects");
+ println!("\n-- world rects --");
+ println!("{rows} placed map(s) written to {}", path.display());
+ for ((cont, axis), mut v) in scales {
+ v.sort_by(|a, b| a.partial_cmp(b).unwrap());
+ println!(
+ "Map.dbc {cont} {axis}: {:.0} yards per world unit (median of {} map(s), \
+ {:.0}..{:.0})",
+ v[v.len() / 2],
+ v.len(),
+ v[0],
+ v[v.len() - 1],
+ );
+ }
+}
+
+/// One finished mask and the measurements behind it.
+struct Baked {
+ ui_map_id: u32,
+ name: String,
+ map_file: String,
+ overlays: usize,
+ tiles: usize,
+ missed: usize,
+ /// Sheet pixels [`fill_enclosed_gaps`] raised: seams between abutting overlay pieces.
+ filled: usize,
+ /// Mean coverage over the 256×256, 0..1.
+ coverage: f32,
+ /// Row-major 256×256 coverage, 0..1.
+ mask: Vec,
+}
+
+fn trim(s: &str, n: usize) -> String {
+ if s.chars().count() <= n {
+ return s.to_string();
+ }
+ s.chars().take(n - 1).chain(std::iter::once('…')).collect()
+}
+
+/// The catalog row behind a graph node, joined the way `compat/classic_api/uimap.rs`'s `views`
+/// joins it: a zone by its 1-based `(continent, zone)` dropdown pair, an orphan by its
+/// `WorldMapArea` row id. `None` for the world sheet and the continents, which have neither.
+fn zone_view<'a>(
+ continents: &'a [WorldMapContinentView],
+ direct_areas: &'a [(u32, WorldMapZoneView)],
+ node: &UiMapNode,
+) -> Option<&'a WorldMapZoneView> {
+ if let Some(id) = node.direct_area {
+ return direct_areas
+ .iter()
+ .find(|(row_id, _)| *row_id == id)
+ .map(|(_, v)| v);
+ }
+ let (ci, zi) = node.selection?;
+ let cont = continents.get(ci.checked_sub(1)? as usize)?;
+ cont.zones.get(zi.checked_sub(1)? as usize)
+}
+
+/// The union of a map's overlay alpha over the 1002×668 detail sheet, as `(sheet, tiles, missed)`
+/// with coverage in 0..1.
+///
+/// **Alpha-over, `c ← c + a(1 − c)`, not `max`.** Both are unions and both keep overlapping land
+/// at full coverage rather than brightening past it, but this one is the arithmetic the window
+/// itself performs: `WorldMapFrame.lua` creates one `WorldMapOverlay` texture per piece on
+/// `WorldMapDetailFrame` in the ARTWORK layer at the default `BLEND` mode, so a fully explored
+/// map's visible reveal is `1 − Π(1 − aᵢ)` per pixel and nothing else.
+///
+/// Measured, the two barely differ on this data — Elwynn's sheet is 43.7% fully opaque under
+/// `max` and 44.2% under this, mean coverage 0.544 against 0.546 — because 1.12's pieces are laid
+/// edge to edge and hardly overlap at all. It is written this way anyway: where they *do* overlap
+/// the window adds their feathers and `max` does not, so `max` would under-read exactly the seams
+/// the art overlaps to hide, and a realm that authors its own overlays with real overlap would
+/// get a striped mask out of this tool with no sign that anything was wrong.
+fn rasterize_overlays(
+ chain: &mut benilla_formats::Chain,
+ view: &WorldMapZoneView,
+ missing: &mut BTreeSet,
+) -> (Vec, usize, usize) {
+ let mut sheet = vec![0f32; LAYER_WIDTH * LAYER_HEIGHT];
+ let mut tiles = 0usize;
+ let mut missed = 0usize;
+ for o in &view.overlays {
+ // The reference's own tile count and numbering: `ceil(w/256) * ceil(h/256)` files named
+ // ``, row-major from 1 (`WorldMapFrame.lua:115-158`, and
+ // `compat/classic_api/uimap.rs`'s `fileDataIDs` walk).
+ let (cols, rows) = (o.width.div_ceil(TILE), o.height.div_ceil(TILE));
+ for n in 1..=(cols * rows) {
+ tiles += 1;
+ let (col, row) = ((n - 1) % cols, (n - 1) / cols);
+ // The art is BLP on this install; the engine's own lookup tries `.tga` first
+ // (`0x835248`) and no shipped overlay is one.
+ let path = format!("{}{n}.blp", o.texture);
+ let Ok((tw, th, rgba)) = benilla_formats::read_texture_rgba(chain, &path) else {
+ missed += 1;
+ missing.insert(path);
+ continue;
+ };
+ // **The crop is the whole point.** An edge tile is padded out to 256 with whatever the
+ // authoring tool left there, and on this install that padding is opaque: composited
+ // whole, every overlay smears out to its tile grid and the mask becomes a lattice of
+ // squares — the defect this tool exists to remove, re-created one step later.
+ let keep_w = (o.width - col * TILE).min(TILE).min(tw) as usize;
+ let keep_h = (o.height - row * TILE).min(TILE).min(th) as usize;
+ for y in 0..keep_h {
+ let dy = o.offset_y as usize + (row * TILE) as usize + y;
+ if dy >= LAYER_HEIGHT {
+ break;
+ }
+ for x in 0..keep_w {
+ let dx = o.offset_x as usize + (col * TILE) as usize + x;
+ if dx >= LAYER_WIDTH {
+ break;
+ }
+ let a = rgba[(y * tw as usize + x) * 4 + 3] as f32 / 255.0;
+ let d = &mut sheet[dy * LAYER_WIDTH + dx];
+ *d += a * (1.0 - *d);
+ }
+ }
+ }
+ }
+ (sheet, tiles, missed)
+}
+
+/// Coverage below which a sheet pixel counts as bare for [`fill_enclosed_gaps`].
+const BARE: f32 = 0.5;
+
+/// Raise every bare region that does **not** reach the sheet's border to full coverage, and answer
+/// how many pixels that was.
+///
+/// 1.12's overlay pieces are laid edge to edge, each feathered into its own rect, so where two
+/// abut their ramps cross well below opaque and the union has a hairline gap along the join. Those
+/// gaps are not sea — they are a seam between two paintings of the same ground — and left in they
+/// come out of the downscale as dark specks scattered through the middle of a zone, which is a new
+/// defect of the same family as the one this tool removes.
+///
+/// A flood from the border rather than a morphological close, because a close is a guess about how
+/// wide a seam is and this is not: anything the overlay art completely surrounds is ground the
+/// overlay art covers. Measured on the three stock zones below, it is a small correction — 76 px of
+/// Elwynn's 669,336, 1,424 of Tanaris', 10,165 of Westfall's — and all of it is speckle.
+fn fill_enclosed_gaps(sheet: &mut [f32]) -> usize {
+ let mut outside = vec![false; sheet.len()];
+ let mut stack: Vec = Vec::new();
+ let push = |i: usize, outside: &mut Vec, stack: &mut Vec| {
+ if sheet[i] < BARE && !outside[i] {
+ outside[i] = true;
+ stack.push(i);
+ }
+ };
+ for x in 0..LAYER_WIDTH {
+ push(x, &mut outside, &mut stack);
+ push(
+ (LAYER_HEIGHT - 1) * LAYER_WIDTH + x,
+ &mut outside,
+ &mut stack,
+ );
+ }
+ for y in 0..LAYER_HEIGHT {
+ push(y * LAYER_WIDTH, &mut outside, &mut stack);
+ push(y * LAYER_WIDTH + LAYER_WIDTH - 1, &mut outside, &mut stack);
+ }
+ while let Some(i) = stack.pop() {
+ let (x, y) = (i % LAYER_WIDTH, i / LAYER_WIDTH);
+ // Four-connected, so a one-pixel diagonal chink does not count as a way out to sea.
+ if x > 0 {
+ push(i - 1, &mut outside, &mut stack);
+ }
+ if x + 1 < LAYER_WIDTH {
+ push(i + 1, &mut outside, &mut stack);
+ }
+ if y > 0 {
+ push(i - LAYER_WIDTH, &mut outside, &mut stack);
+ }
+ if y + 1 < LAYER_HEIGHT {
+ push(i + LAYER_WIDTH, &mut outside, &mut stack);
+ }
+ }
+ let mut filled = 0usize;
+ for (i, v) in sheet.iter_mut().enumerate() {
+ if *v < BARE && !outside[i] {
+ *v = 1.0;
+ filled += 1;
+ }
+ }
+ filled
+}
+
+/// For each destination index, the source indices it covers and how much of each — one axis of a
+/// box/area resample, so a 3.91-source-pixel-wide destination pixel weighs its two partial ends
+/// by the fraction they contribute instead of rounding one of them away.
+fn axis_weights(src: usize, dst: usize) -> Vec> {
+ let scale = src as f64 / dst as f64;
+ (0..dst)
+ .map(|d| {
+ let (a, b) = (d as f64 * scale, (d + 1) as f64 * scale);
+ let first = a.floor() as usize;
+ let last = (b.ceil() as usize).clamp(first + 1, src);
+ (first..last)
+ .map(|s| {
+ let lo = (s as f64).max(a);
+ let hi = ((s + 1) as f64).min(b);
+ (s, (hi - lo).max(0.0) as f32)
+ })
+ .collect()
+ })
+ .collect()
+}
+
+/// Area-average `src` down to `dw`×`dh`.
+fn downscale(src: &[f32], sw: usize, sh: usize, dw: usize, dh: usize) -> Vec {
+ let xw = axis_weights(sw, dw);
+ let yw = axis_weights(sh, dh);
+ let mut out = vec![0f32; dw * dh];
+ for (dy, ys) in yw.iter().enumerate() {
+ for (dx, xs) in xw.iter().enumerate() {
+ let mut acc = 0f32;
+ let mut sum = 0f32;
+ for &(sy, wy) in ys {
+ for &(sx, wx) in xs {
+ let w = wy * wx;
+ acc += src[sy * sw + sx] * w;
+ sum += w;
+ }
+ }
+ out[dy * dw + dx] = if sum > 0.0 { acc / sum } else { 0.0 };
+ }
+ }
+ out
+}
+
+/// Square max filter of radius `r`, the dilate: `ImageFilter.MaxFilter(2r+1)`'s shape.
+fn dilate(src: &[f32], w: usize, h: usize, r: usize) -> Vec {
+ if r == 0 {
+ return src.to_vec();
+ }
+ // Separable, because max is: the max over a square is the max of the per-row maxima.
+ let mut mid = vec![0f32; w * h];
+ for y in 0..h {
+ for x in 0..w {
+ let lo = x.saturating_sub(r);
+ let hi = (x + r + 1).min(w);
+ mid[y * w + x] = src[y * w + lo..y * w + hi]
+ .iter()
+ .copied()
+ .fold(0.0, f32::max);
+ }
+ }
+ let mut out = vec![0f32; w * h];
+ for y in 0..h {
+ let lo = y.saturating_sub(r);
+ let hi = (y + r + 1).min(h);
+ for x in 0..w {
+ out[y * w + x] = (lo..hi).map(|k| mid[k * w + x]).fold(0.0, f32::max);
+ }
+ }
+ out
+}
+
+/// Separable gaussian of standard deviation `sigma`, edges clamped.
+///
+/// Clamped rather than zero-padded on purpose: an overlay that runs to the sheet's own border is
+/// land that continues past it, and zero padding would fade that border out — putting a soft line
+/// along a coast the art draws hard.
+fn blur(src: &[f32], w: usize, h: usize, sigma: f32) -> Vec {
+ if sigma <= 0.0 {
+ return src.to_vec();
+ }
+ let r = (sigma * 3.0).ceil() as usize;
+ let k: Vec = (0..=2 * r)
+ .map(|i| {
+ let d = i as f32 - r as f32;
+ (-(d * d) / (2.0 * sigma * sigma)).exp()
+ })
+ .collect();
+ let norm: f32 = k.iter().sum();
+ let k: Vec = k.iter().map(|v| v / norm).collect();
+
+ let mut mid = vec![0f32; w * h];
+ for y in 0..h {
+ for x in 0..w {
+ let mut acc = 0f32;
+ for (i, &kw) in k.iter().enumerate() {
+ let sx = (x + i).saturating_sub(r).min(w - 1);
+ acc += src[y * w + sx] * kw;
+ }
+ mid[y * w + x] = acc;
+ }
+ }
+ let mut out = vec![0f32; w * h];
+ for y in 0..h {
+ for x in 0..w {
+ let mut acc = 0f32;
+ for (i, &kw) in k.iter().enumerate() {
+ let sy = (y + i).saturating_sub(r).min(h - 1);
+ acc += mid[sy * w + x] * kw;
+ }
+ out[y * w + x] = acc;
+ }
+ }
+ out
+}
+
+/// Dilate, then ramp — `scripts/soften-zone-masks.py`'s `soften` minus its `RECT_MARGIN` inset.
+///
+/// The inset is dropped because it is not a softening at all: it holds coverage at zero a fixed
+/// fraction in from the sheet's border to hide the torn parchment frame a 1.12 zone sheet paints
+/// around its own rect. An overlay has no frame — it is a cut-out of ground — so an inset here
+/// would only throw away real coast, and on a map whose overlays run to the sheet edge it would
+/// throw away the edge of the zone.
+fn soften(coverage: &[f32]) -> Vec {
+ let d = dilate(coverage, MASK, MASK, DILATE);
+ blur(&d, MASK, MASK, BLUR)
+ .iter()
+ .map(|v| v.clamp(0.0, 1.0))
+ .collect()
+}
diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs
index a390905b..24131403 100644
--- a/crates/benilla-app/src/cvars.rs
+++ b/crates/benilla-app/src/cvars.rs
@@ -141,6 +141,15 @@ pub(crate) fn registered_pairs() -> impl Iterator = registered_pairs().collect();
+ let mut app = cvar_app();
+ for name in [
+ "worldMapShowPlayerCoords",
+ "worldMapShowCursorCoords",
+ "mapFade",
+ ] {
+ assert!(
+ !table.contains_key(name),
+ "{name} names no 1.12 or realm CVar — see this test's note before adding a row"
+ );
+ assert_eq!(
+ apply(&mut app, name, "0"),
+ SetOutcome::Unknown,
+ "{name} must stay unknown, so OpenMapClassic's pcall'd write is dropped as the \
+ reference's raise drops it"
+ );
+ assert_eq!(res::(&app).get(name), None, "…and reads back as nil");
+ }
+ }
+
/// A host write, its latch committed at once, and its observers run before this returns.
fn apply(app: &mut App, name: &str, value: &str) -> SetOutcome {
let world = app.world_mut();
diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs
index 8078cdcc..7bec825a 100644
--- a/crates/benilla-app/src/lib.rs
+++ b/crates/benilla-app/src/lib.rs
@@ -211,7 +211,9 @@ mod ui_trade;
mod ui_tradeskill;
mod ui_trainer;
mod ui_unit;
-mod ui_world_map;
+/// Public for the `bake_zone_land` example, which reads the same catalog the map window is
+/// fed from so the masks it bakes are keyed to the maps this realm actually has.
+pub mod ui_world_map;
mod video;
mod vplates;
mod weapon_trail;
diff --git a/crates/benilla-app/src/ui_text/engine/gpu.rs b/crates/benilla-app/src/ui_text/engine/gpu.rs
index 237668f0..48c08dbf 100644
--- a/crates/benilla-app/src/ui_text/engine/gpu.rs
+++ b/crates/benilla-app/src/ui_text/engine/gpu.rs
@@ -98,7 +98,11 @@ fn publish_sheet(
e.reset_pending = false;
e.generation += 1;
e.stats.resets += 1;
- e.chars.clear();
+ // The cells only: `chars` holds pen metrics and the shaper's raster keys, nothing
+ // about the sheet, so clearing it made the frame after a reset re-*shape* every
+ // string on screen as well as re-rasterize it — a `Buffer` and a
+ // `shape_until_scroll` per character, the expensive half. It is bounded by the sizes
+ // and characters a session actually asks for ([`MAX_PPEM`] of them at most per face).
e.cells.clear();
e.sheet.reset();
}
diff --git a/crates/benilla-app/src/ui_text/engine/mod.rs b/crates/benilla-app/src/ui_text/engine/mod.rs
index 3f87e323..62ac6098 100644
--- a/crates/benilla-app/src/ui_text/engine/mod.rs
+++ b/crates/benilla-app/src/ui_text/engine/mod.rs
@@ -10,6 +10,10 @@
//! names (`UNIT_NAME_FONT`, em 32, string flag `0x80`), because the 32 px cap is a raster-memory
//! budget that blurs big text on a modern display ([`super::FONTSTRING_EM_CAP`]).
//!
+//! Deviation: a raster size past [`EXACT_PPEM`] is quantized to a [`RASTER_LADDER`] rung and the
+//! quad scales the cell ([`raster_ppem`] says why), where the reference needs no ladder: its 32 px
+//! cap means it only ever holds one size per face.
+//!
//! Deviation: the reference flushes every glyph cache on a window resize (`0x5c2b50` →
//! `0x5ca6f0`); this keeps the old cells, because nameplate meshes bake UVs and a flush would
//! rebuild them every resize frame.
@@ -45,6 +49,59 @@ const MIN_PPEM: u16 = 2;
/// reference's 32 px cap is applied in logical units, to UI text only ([`super::fontstring_em`]).
const MAX_PPEM: u16 = 256;
+/// At or below this device size a glyph is rasterized at the exact size it draws at; above it,
+/// [`raster_ppem`] quantizes the raster. 64 device px is a 34-unit font on a 1440p screen, over
+/// the reference's own 32-unit one-to-one cap ([`super::FONTSTRING_EM_CAP`]), so nothing the
+/// interface draws at one-to-one is quantized on any screen up to ~1440 px tall.
+const EXACT_PPEM: u16 = 64;
+
+/// The rungs a raster size above [`EXACT_PPEM`] snaps to: third-octave steps (×2^⅓ ≈ 1.26) from
+/// [`EXACT_PPEM`] to [`MAX_PPEM`], so the whole band holds six raster sizes instead of 192.
+const RASTER_LADDER: [u16; 7] = [EXACT_PPEM, 81, 102, 128, 161, 203, MAX_PPEM];
+
+/// The size a glyph is rasterized at for a request of `ppem`: `ppem` itself at display sizes and
+/// below, else the nearest [`RASTER_LADDER`] rung, the quad scaling the cell by `ppem / rung`
+/// ([`TextEngine::cell`]).
+///
+/// Why: a 251 px glyph and a 257 px glyph are the same picture, and a canvas addon asks for a new
+/// size every frame of a zoom — `MapCanvas.lua`'s `WorldMapCanvas` scales its child, so every
+/// `FontString` under `WorldMapButton` gets `round(size · seam · canvasScale)` device px, a fresh
+/// one per easing frame. Each one took its own cells, a 15-letter string at ~200 px fills a
+/// quarter of the sheet, and the sheet is append-only ([`super::pack`]), so a glide exhausted it
+/// every ~20 frames: grom's 2026-11-04 session logged **27** `glyph sheet full` resets in four
+/// minutes, each dropping every cached glyph and re-rasterizing the whole interface next frame.
+/// Quantizing the *raster* alone is what keeps this invisible: the metrics stay keyed by the true
+/// `ppem` ([`TextEngine::chars`]), so no advance, pitch or measured extent moves by a texel.
+///
+/// Deviation from the reference, which does not need this: it caps every raster at 32 px
+/// (`0x5ca030` → `[CGxFont+0x24c]`) and magnifies, so a zoom never asked it for a new size at all.
+/// benilla rasterizes at the true size instead, because that cap is a raster-memory budget that
+/// blurs big text on a modern display; the ladder is that budget, re-drawn at modern sizes.
+fn raster_ppem(ppem: u16) -> u16 {
+ if ppem <= EXACT_PPEM {
+ return ppem;
+ }
+ let p = u32::from(ppem);
+ for rungs in RASTER_LADDER.windows(2) {
+ // The geometric midpoint of the two rungs, as an integer ratio test so no float rounding
+ // decides a rung: `p <= sqrt(lo·hi)` ⇔ `p² <= lo·hi`, and `p ≤ 256` keeps `p²` in `u32`.
+ if p * p <= u32::from(rungs[0]) * u32::from(rungs[1]) {
+ return rungs[0];
+ }
+ }
+ MAX_PPEM
+}
+
+/// A cell holding more than this fraction of the sheet's texels gets a line in the log naming the
+/// string that asked for it ([`TextEngine::note_loud_cell`]). 1/1024 is a 64×64 cell, which the
+/// client faces reach somewhere past 90 device px: one 13-glyph string there is over a hundredth
+/// of the sheet per raster rung, and a dozen regions' worth is the whole cache.
+///
+/// Tuned to fire rather than to stay quiet. A screen tall enough reaches it with the stock zone
+/// banner (`ZoneTextFont`, 32 units after the cap) and will say so once per face; that is the
+/// price of a log that answers "what asked for a 257 px glyph?" without a second session.
+const LOUD_CELL_SHARE: u64 = 1024;
+
// ── The cache's own types ──
struct Face {
@@ -59,7 +116,8 @@ struct Face {
ascent_ratio: f32,
}
-/// `(face, glyph, ppem, outline radius)`: one bitmap per exact device-pixel size.
+/// `(face, glyph, raster ppem, outline radius)`: one bitmap per rasterized device-pixel size,
+/// which is the requested size at display sizes and a [`RASTER_LADDER`] rung above them.
type GlyphKey = (fontdb::ID, u16, u16, u8);
/// One rasterized cell, all in physical px; the layout divides by [`TextEngine::dpi`].
@@ -127,8 +185,11 @@ pub(crate) struct TextEngine {
dpi: f32,
/// `(face, ppem, char)` to pen metrics; never touches the GPU, so the VM's measurer fills it.
chars: HashMap<(usize, u16, char), CharCell>,
- /// `(face, glyph, ppem, radius)` to cell; `None` for no ink or no room, so a miss is paid once.
+ /// [`GlyphKey`] to cell; `None` for no ink or no room, so a miss is paid once.
cells: HashMap>,
+ /// `(face, raster ppem, radius)` already reported by [`LOUD_CELL_SHARE`], so a display-size
+ /// string names itself once and not once per letter per frame.
+ loud_cells: HashSet<(fontdb::ID, u16, u8)>,
sheet: Sheet,
/// Bumped on a [reset][Sheet::reset], the only event that moves a UV, so it is what a
/// cross-frame holder of glyph UVs ([`crate::nameplates`]) watches.
@@ -204,6 +265,7 @@ impl TextEngine {
dpi,
chars: HashMap::new(),
cells: HashMap::new(),
+ loud_cells: HashSet::new(),
sheet: Sheet::new(images),
generation: 0,
reset_pending: false,
@@ -320,7 +382,7 @@ impl TextEngine {
/// `AllGlyphsCached` (`0x5c9fa0`) is not this: it gates geometry invalidation after eviction.
pub(super) fn ensure_str(&mut self, face: usize, ppem: u16, radius: u8, text: &str) {
for ch in text.chars() {
- self.ensure_char(face, ppem, Some(radius), ch);
+ self.ensure_char(face, ppem, Some(radius), ch, text);
}
}
@@ -330,7 +392,7 @@ impl TextEngine {
/// the step law reads only the advance, so skipping the bitmap changes no width.
pub(super) fn ensure_metrics(&mut self, face: usize, ppem: u16, text: &str) {
for ch in text.chars() {
- self.ensure_char(face, ppem, None, ch);
+ self.ensure_char(face, ppem, None, ch, text);
}
}
@@ -344,7 +406,14 @@ impl TextEngine {
/// One character: shape it alone into its [`CharCell`] and rasterize its glyphs at `radius`.
/// Alone, so the width sum holds each face's own advance with no neighbour term (kerning is
/// dropped, per the `ui_text` module doc) and one table answers a measure and a draw.
- fn ensure_char(&mut self, face: usize, ppem: u16, radius: Option, ch: char) {
+ ///
+ /// `owner` is the whole string `ch` came out of, carried for the diagnostics below and read
+ /// nowhere else. A character alone does not say where it came from: two of these warnings are
+ /// about a character that should not be in a 1.12 string at all, and the string names the
+ /// producer where `U+FFFD` on its own named nothing for a whole session's log; the third
+ /// ([`LOUD_CELL_SHARE`]) is about a *size*, and there the string is the only thing that says
+ /// which region asked.
+ fn ensure_char(&mut self, face: usize, ppem: u16, radius: Option, ch: char, owner: &str) {
if let Some(known) = self.chars.get(&(face, ppem, ch)) {
let Some(radius) = radius else { return };
// The pen metrics are cached; the cells for this radius may not be.
@@ -353,7 +422,7 @@ impl TextEngine {
return;
};
for g in &glyphs {
- self.ensure_cell(id, ppem, radius, g);
+ self.ensure_cell(id, ppem, radius, g, ch, owner);
}
return;
}
@@ -423,17 +492,21 @@ impl TextEngine {
.map_or_else(|| format!("{got:?}"), |i| i.post_script_name.clone());
let want = self.faces.get(face).map_or("?", |f| f.path.as_str());
warn!(
- "ui_text: '{want}' (weight {}, style {style:?}) has no glyph for {ch:?} — the \
- shaper fell back to {got_name}, so this character draws and measures in \
- another face",
- weight.0
+ "ui_text: '{want}' (weight {}, style {style:?}) has no glyph for {ch:?} in \
+ {} — the shaper fell back to {got_name}, so this character draws and \
+ measures in another face",
+ weight.0,
+ owner_context(owner, ch)
);
}
}
if glyphs.is_empty() {
// No face shaped it: it draws and measures nothing, and warns once.
if self.complained.insert(ch) {
- warn!("ui_text: no glyph for {ch:?} in any registered face");
+ warn!(
+ "ui_text: no glyph for {ch:?} in any registered face, in {}",
+ owner_context(owner, ch)
+ );
}
// Cache the miss as an empty cell, or every frame's pre-warm shapes it again.
self.chars.insert(
@@ -449,25 +522,43 @@ impl TextEngine {
if let Some(radius) = radius {
for g in &glyphs {
let g = *g;
- self.ensure_cell(face_id, ppem, radius, &g);
+ self.ensure_cell(face_id, ppem, radius, &g, ch, owner);
}
}
self.chars
.insert((face, ppem, ch), CharCell { glyphs, floor_sum });
}
- /// Rasterize and pack one glyph's cell, unless it is already there.
- fn ensure_cell(&mut self, face_id: fontdb::ID, ppem: u16, radius: u8, g: &GlyphRef) {
- let key: GlyphKey = (face_id, g.glyph_id, ppem, radius);
+ /// Rasterize and pack one glyph's cell at [`raster_ppem`]`(ppem)`, unless it is already there.
+ /// `ch` and `owner` only name the producer in the [`LOUD_CELL_SHARE`] line.
+ fn ensure_cell(
+ &mut self,
+ face_id: fontdb::ID,
+ ppem: u16,
+ radius: u8,
+ g: &GlyphRef,
+ ch: char,
+ owner: &str,
+ ) {
+ let raster_px = raster_ppem(ppem);
+ let key: GlyphKey = (face_id, g.glyph_id, raster_px, radius);
if self.cells.contains_key(&key) {
return;
}
+ // `g.key` is the zero-phase raster key the shaper handed back at the *requested* size; a
+ // quantized raster asks swash for the same glyph of the same face at the rung's size
+ // instead, every other dimension of the key untouched. At a size the ladder does not
+ // touch the key is passed through, so the exact path is bit-for-bit what it always was.
+ let mut raster_key = g.key;
+ if raster_px != ppem {
+ raster_key.font_size_bits = f32::from(raster_px).to_bits();
+ }
let raster = {
let Self {
swash, font_system, ..
} = self;
swash
- .get_image(font_system, g.key)
+ .get_image(font_system, raster_key)
.as_ref()
.map(|i| (i.placement, i.data.clone()))
};
@@ -483,7 +574,7 @@ impl TextEngine {
}
self.stats.cells_rasterized += 1;
// An outlined cell grows by `pad` each side and its bearings move out; the advance stays.
- let (uv, cw, ch, bx, bt) = if radius == 0 {
+ let (uv, cell_w, cell_h, bx, bt) = if radius == 0 {
(
self.sheet.alloc(w, h, &Cell::Coverage(&cov)),
w,
@@ -501,14 +592,15 @@ impl TextEngine {
placement.top as f32 + pad as f32,
)
};
+ self.note_loud_cell(face_id, raster_px, radius, cell_w, cell_h, ppem, ch, owner);
match uv {
Some(uv) => {
self.cells.insert(
key,
Some(GlyphInfo {
uv,
- px_w: cw as f32,
- px_h: ch as f32,
+ px_w: cell_w as f32,
+ px_h: cell_h as f32,
bearing_x: bx,
bearing_top: bt,
}),
@@ -518,12 +610,66 @@ impl TextEngine {
}
}
+ /// One letter taking [`LOUD_CELL_SHARE`] of the whole glyph sheet, named with the string that
+ /// asked for it, once per `(face, raster size, radius)`.
+ ///
+ /// Why: the sheet filling says only *that* it filled. A whole session's log said it 27 times
+ /// and named nothing, and finding the producer meant reasoning backwards from a canvas scale
+ /// in another subsystem's trace to an addon's source. The string is what identifies it:
+ /// `"35.1, 63.9"` is Atlas-CFM's map cursor readout (`CFMAtlas/AtlasUI.lua:609`) and nothing
+ /// else, where "a 128 px cell" names every FontString in the interface at once.
+ #[expect(
+ clippy::too_many_arguments,
+ reason = "a diagnostic, and every term is in the line"
+ )]
+ fn note_loud_cell(
+ &mut self,
+ face_id: fontdb::ID,
+ raster_px: u16,
+ radius: u8,
+ cell_w: u32,
+ cell_h: u32,
+ ppem: u16,
+ ch: char,
+ owner: &str,
+ ) {
+ let (_, total) = self.sheet.occupancy();
+ let texels = u64::from(cell_w) * u64::from(cell_h);
+ if texels * LOUD_CELL_SHARE < total || !self.loud_cells.insert((face_id, raster_px, radius))
+ {
+ return;
+ }
+ let path = self
+ .faces
+ .iter()
+ .find(|f| f.id == face_id)
+ .map_or("?", |f| f.path.as_str());
+ let drawn = if raster_px == ppem {
+ String::new()
+ } else {
+ format!(", drawn at {ppem} px")
+ };
+ warn!(
+ "ui_text: a {raster_px} px cell for {ch:?} in '{path}' (outline radius {radius}) is \
+ {cell_w}×{cell_h} texels, 1/{} of the glyph sheet{drawn} — asked for by {}. \
+ Display-size text: a FontString inside a zoomed canvas, a SetTextHeight, or a large \
+ screen.",
+ total / texels.max(1),
+ owner_context(owner, ch)
+ );
+ }
+
/// The sheet could not fit a cell: record it empty, so the glyph is missing for at most one
/// frame, and ask for a reset at the frame boundary, not here, where glyphs already pushed this
/// frame hold UVs into the sheet. Deviation: the reference evicts the least recently used glyph
/// and repacks its hole (`0x5cad2b`, over `[CGxFont+0x64..0x6c]`); this drops every cell,
/// because a shelf allocator cannot reclaim an interior cell and the sheet is far larger than
/// the reference's 256×256 pages.
+ ///
+ /// It stays a `warn!`, and should stay rare: what used to fill the sheet was one zooming
+ /// canvas minting a new raster size per frame, which [`raster_ppem`] now folds into six, and
+ /// the line above it names any single cell big enough to matter ([`LOUD_CELL_SHARE`]). A reset
+ /// still costs the next frame every glyph on screen, so it is not an `info!`.
fn note_exhausted(&mut self, key: GlyphKey) {
self.cells.insert(key, None);
if !self.reset_pending {
@@ -542,7 +688,15 @@ impl TextEngine {
self.chars.get(&(face, ppem, ch))
}
- /// One glyph's cell; `None` for a zero-ink glyph, and for one frame after the sheet fills.
+ /// One glyph's cell, in the requested size's device px; `None` for a zero-ink glyph, and for
+ /// one frame after the sheet fills.
+ ///
+ /// Above [`EXACT_PPEM`] the cell was rasterized at a [`RASTER_LADDER`] rung, so every term of
+ /// the returned [`GlyphInfo`] — extent and both bearings — scales by `ppem / rung` here, and
+ /// no caller learns that the bitmap is another size: the quad lands on the same device pixels
+ /// and samples a cell at most a third of an octave off. The pen step is untouched, because it
+ /// comes off the shaper at the true `ppem` ([`CharCell`]), which is what keeps a measure, a
+ /// wrap and a drawn line in agreement through a zoom.
pub(super) fn cell(
&self,
face: usize,
@@ -551,10 +705,23 @@ impl TextEngine {
glyph_id: u16,
) -> Option {
let id = self.faces.get(face)?.id;
- self.cells
- .get(&(id, glyph_id, ppem, radius))
+ let raster_px = raster_ppem(ppem);
+ let info = self
+ .cells
+ .get(&(id, glyph_id, raster_px, radius))
.copied()
- .flatten()
+ .flatten()?;
+ if raster_px == ppem {
+ return Some(info);
+ }
+ let s = f32::from(ppem) / f32::from(raster_px);
+ Some(GlyphInfo {
+ uv: info.uv,
+ px_w: info.px_w * s,
+ px_h: info.px_h * s,
+ bearing_x: info.bearing_x * s,
+ bearing_top: info.bearing_top * s,
+ })
}
/// The one texture every glyph quad samples.
@@ -596,6 +763,35 @@ impl UiFontAtlas {
}
}
+/// How much of the owning string a glyph diagnostic prints, in characters.
+const OWNER_CONTEXT_CHARS: usize = 80;
+
+/// The string a warned-about character sits in, as a diagnostic phrase.
+///
+/// Why it exists: a session logged `'Fonts\FRIZQT__.TTF' … has no glyph for '\u{fffd}'` and that
+/// was the whole line. `U+FFFD` is *minted*, never typed — something decoded bytes that were not
+/// UTF-8 — and the character alone names neither the decoder nor the string, so the hunt had
+/// nowhere to start. Ruled out by hand, so the next log does not repeat the work: the realm's own
+/// FrameXML chain (297 `.lua`/`.xml`/`.toc` winners), all 155 `DBFilesClient` string blocks,
+/// every installed addon's sources and this player's whole `benilla-config` are each valid UTF-8
+/// end to end, and nothing on disk holds the `EF BF BD` bytes. So the producer builds its string
+/// at runtime — a `strsub` through the middle of a multi-byte sequence, a `\ddd` escape, or one of
+/// the engine's own lossy decodes (`protocol::wire::read_cstring`,
+/// `binding_abi::text_arg`) — and the string is the thing that identifies it.
+///
+/// `escape_debug` so a control byte or a `|c` colour code cannot forge log structure, and the
+/// character's own byte offset so a long string still says *where*.
+fn owner_context(owner: &str, ch: char) -> String {
+ let at = owner.find(ch).map(|b| b.to_string());
+ let head: String = owner.chars().take(OWNER_CONTEXT_CHARS).collect();
+ let elided = if head.len() < owner.len() { "…" } else { "" };
+ format!(
+ "\"{}{elided}\" (byte {})",
+ head.escape_debug(),
+ at.as_deref().unwrap_or("?")
+ )
+}
+
/// Read one font path by the rule every by-path addon asset follows, chain first
/// ([`benilla_assets::read_chain_or_loose`]).
fn read_font_bytes(source: &FontSource, path: &str) -> Option> {
@@ -644,6 +840,7 @@ pub(super) fn test_engine(dpi: f32) -> Option {
dpi,
chars: HashMap::new(),
cells: HashMap::new(),
+ loud_cells: HashSet::new(),
sheet: Sheet::new(&Assets::::default()),
generation: 0,
reset_pending: false,
@@ -825,6 +1022,227 @@ mod ppem_tests {
}
}
+ /// A glyph warning has to carry the string, because the character cannot name its producer.
+ ///
+ /// The line that prompted this said only `has no glyph for '\u{fffd}'`. A replacement
+ /// character is minted by a lossy decode and the hunt for the decoder came up empty against
+ /// every file on disk (see [`owner_context`]), so the string it sits in is the next fact the
+ /// log can carry — escaped, so a `|c` colour code or a control byte cannot forge log
+ /// structure, and with the byte offset so a long string still says where.
+ #[test]
+ fn a_glyph_warning_carries_the_string_the_character_came_out_of() {
+ let ctx = owner_context("Level 60 J\u{fffd}ger", '\u{fffd}');
+ assert!(
+ ctx.contains("Level 60 J"),
+ "the string is what identifies the producer: {ctx}"
+ );
+ assert!(
+ ctx.contains("(byte 10)"),
+ "…and the offset says where in it: {ctx}"
+ );
+
+ // Escaped: a tab, a newline or a `|` sequence must not break the line into two.
+ let escaped = owner_context("a\tb\nc\u{fffd}", '\u{fffd}');
+ assert!(
+ !escaped.contains('\n') && !escaped.contains('\t'),
+ "a region's text is addon-controlled and must not forge log structure: {escaped}"
+ );
+
+ // Long strings are cut, and say so.
+ let long: String = std::iter::repeat_n('x', 200).chain(['\u{fffd}']).collect();
+ let cut = owner_context(&long, '\u{fffd}');
+ assert!(cut.contains('…'), "an elision marks the cut: {cut}");
+ assert!(
+ cut.len() < 140,
+ "a whole quest description per warning is the noise this replaced: {}",
+ cut.len()
+ );
+ assert!(
+ cut.contains("(byte 200)"),
+ "the offset is the real string's, not the excerpt's: {cut}"
+ );
+ }
+
+ /// The ladder's own arithmetic: exact where it matters, few rungs where it does not, and
+ /// never more than an eighth off the size it stands in for.
+ #[test]
+ fn the_raster_ladder_is_exact_below_display_sizes_and_coarse_above_them() {
+ for ppem in MIN_PPEM..=EXACT_PPEM {
+ assert_eq!(
+ raster_ppem(ppem),
+ ppem,
+ "{ppem} px is under the display-size floor and must be its own raster"
+ );
+ }
+ let band: HashSet = (EXACT_PPEM + 1..=MAX_PPEM).map(raster_ppem).collect();
+ assert!(
+ band.len() <= RASTER_LADDER.len(),
+ "the {} sizes above {EXACT_PPEM} px must fold into the ladder's rungs, not {band:?}",
+ MAX_PPEM - EXACT_PPEM
+ );
+ assert!(
+ band.iter().all(|r| RASTER_LADDER.contains(r)),
+ "every raster above the floor is a rung: {band:?}"
+ );
+ // What a canvas zoom actually walks: a scale glide of 1× to 4.8× on grom's 2560×1440
+ // screen is `round(32 · 1440/768 · 0.9 · s)`, which is 193 distinct device sizes.
+ let glide: HashSet = (100u16..=480)
+ .map(|hundredths| {
+ let px = 32.0 * (1440.0 / 768.0) * 0.9 * f32::from(hundredths) / 100.0;
+ (px.round() as u16).clamp(MIN_PPEM, MAX_PPEM)
+ })
+ .collect();
+ assert!(
+ glide.len() > 100,
+ "a glide is supposed to walk many sizes, else this proves nothing: {}",
+ glide.len()
+ );
+ // Only the display-size half is folded: below the floor the glide's sizes are their own
+ // rasters, and a 60 px cell is a thousandth of the sheet, so there is nothing to save.
+ let big: HashSet = glide.iter().copied().filter(|&p| p > EXACT_PPEM).collect();
+ let rasters: HashSet = big.iter().copied().map(raster_ppem).collect();
+ assert!(
+ big.len() > 100 && rasters.len() <= RASTER_LADDER.len(),
+ "{} display-size device sizes over one glide must share at most {} rasters, not {}",
+ big.len(),
+ RASTER_LADDER.len(),
+ rasters.len()
+ );
+ // The magnification or minification a quad pays for the rung it lands on.
+ for ppem in MIN_PPEM..=MAX_PPEM {
+ let r = f64::from(raster_ppem(ppem));
+ let p = f64::from(ppem);
+ let ratio = if p > r { p / r } else { r / p };
+ assert!(
+ ratio <= 1.13,
+ "{ppem} px draws off a {r} px cell, a {ratio:.3}× resample — the rungs are a \
+ third of an octave apart, so half a step is the most a request can be off"
+ );
+ }
+ }
+
+ /// Two sizes a third of an octave apart share one cell, and the quad is still the size the
+ /// request asked for: the whole bargain of [`raster_ppem`].
+ #[test]
+ fn a_quantized_cell_is_shared_and_still_draws_at_the_requested_size() {
+ let Some(mut e) = engine_or_skip() else {
+ return;
+ };
+ let face = e.face_for(None);
+ // 203 is a rung; 190 and 215 sit either side of it and are not.
+ for ppem in [190u16, 203, 215] {
+ e.ensure_str(face, ppem, 0, "M");
+ }
+ let gid = |e: &TextEngine, ppem: u16| {
+ e.char_cell(face, ppem, 'M').expect("shaped").glyphs[0].glyph_id
+ };
+ let (g190, g215) = (gid(&e, 190), gid(&e, 215));
+ let a = e.cell(face, 190, 0, g190).expect("a cell at 190");
+ let b = e.cell(face, 215, 0, g215).expect("a cell at 215");
+ let rung = e.cell(face, 203, 0, gid(&e, 203)).expect("a cell at 203");
+ assert_eq!(a.uv, rung.uv, "190 px draws off the 203 px cell");
+ assert_eq!(b.uv, rung.uv, "…and so does 215 px: one bitmap, not three");
+
+ // The quad is the requested size, not the rung's: extent and both bearings scale.
+ let s = 190.0 / 203.0;
+ assert!(
+ (a.px_h - rung.px_h * s).abs() < 1e-3 && (a.px_w - rung.px_w * s).abs() < 1e-3,
+ "the quad scales the cell: {a:?} against {rung:?} × {s}"
+ );
+ assert!(
+ (a.bearing_top - rung.bearing_top * s).abs() < 1e-3,
+ "…bearings with it, or the ink sits off the baseline: {a:?}"
+ );
+ assert!(
+ a.px_h < rung.px_h && rung.px_h < b.px_h,
+ "and the three sizes still draw at three heights: {} {} {}",
+ a.px_h,
+ rung.px_h,
+ b.px_h
+ );
+
+ // The pen is untouched: it comes off the shaper at the true size, so a measure, a wrap
+ // and a drawn line still agree at every frame of a glide.
+ let step_at = |ppem: u16| e.char_cell(face, ppem, 'M').expect("shaped").floor_sum;
+ assert!(
+ step_at(190) < step_at(203) && step_at(203) < step_at(215),
+ "quantizing the raster must not quantize the advance: {} {} {}",
+ step_at(190),
+ step_at(203),
+ step_at(215)
+ );
+ }
+
+ /// The defect: a canvas zoom used to fill the sheet every ~20 frames, and each reset dropped
+ /// every cached glyph in the interface. grom's 2026-11-04 session logged 27 of them in four
+ /// minutes, clustered on the frames after each `world map art: … scale 3.68` line.
+ #[test]
+ fn a_whole_canvas_zoom_glide_no_longer_fills_the_glyph_sheet() {
+ let Some(mut e) = engine_or_skip() else {
+ return;
+ };
+ let face = e.face_for(None);
+ // A hovered zone name at `WorldMapTextFont`'s one-to-one size (102 units capped to 32,
+ // `super::super::fontstring_em`) through a 1× to 4.8× canvas scale, THICK as the font
+ // object declares it.
+ for hundredths in 100u16..=480 {
+ let px = 32.0 * (1440.0 / 768.0) * 0.9 * f32::from(hundredths) / 100.0;
+ let ppem = (px.round() as u16).clamp(MIN_PPEM, MAX_PPEM);
+ e.ensure_str(face, ppem, 2, "Dun Morogh");
+ assert!(
+ !e.reset_pending,
+ "the sheet filled at {ppem} px, {} cells in — a glide must not cost the \
+ interface its whole glyph cache",
+ e.cells.len()
+ );
+ }
+ let (used, total) = e.sheet.occupancy();
+ assert!(
+ used * 2 < total,
+ "a zone name through a whole glide should leave the sheet half empty: {used}/{total}"
+ );
+ }
+
+ /// A single letter taking a 256th of the sheet names itself in the log; ordinary text does
+ /// not. The session this came from said `glyph sheet full` 27 times and named nothing.
+ #[test]
+ fn a_display_size_cell_is_reported_once_and_ordinary_text_is_not() {
+ let Some(mut e) = engine_or_skip() else {
+ return;
+ };
+ let face = e.face_for(None);
+ e.ensure_str(face, 14, 2, "Dun Morogh");
+ // `ZoneTextFont` at one-to-one on grom's 2560×1440 screen: 32 units capped, × 1440/768,
+ // × a 0.9 uiScale. The stock interface's largest text must not be mistaken for a defect.
+ e.ensure_str(face, 54, 2, "Dun Morogh");
+ assert!(
+ e.loud_cells.is_empty(),
+ "chat-size text and the stock zone banner must be silent: {:?}",
+ e.loud_cells
+ );
+ // Atlas-CFM's map cursor readout at the canvas scale a zoom glide reaches.
+ e.ensure_str(face, 128, 0, "35.1, 63.9");
+ assert_eq!(
+ e.loud_cells.len(),
+ 1,
+ "one line per (face, raster size, radius), not one per letter: {:?}",
+ e.loud_cells
+ );
+ e.ensure_str(face, 135, 0, "41.7, 12.0");
+ assert_eq!(
+ e.loud_cells.len(),
+ 1,
+ "135 px lands on the same rung, so it is the same report"
+ );
+ e.ensure_str(face, 203, 2, "Dun Morogh");
+ assert_eq!(
+ e.loud_cells.len(),
+ 2,
+ "another rung with another radius is another report: {:?}",
+ e.loud_cells
+ );
+ }
+
#[test]
fn a_logical_height_becomes_whole_device_pixels() {
let Some(mut e) = engine_or_skip() else {
diff --git a/crates/benilla-app/src/ui_text/layout/mod.rs b/crates/benilla-app/src/ui_text/layout/mod.rs
index dd269f48..0e24cba4 100644
--- a/crates/benilla-app/src/ui_text/layout/mod.rs
+++ b/crates/benilla-app/src/ui_text/layout/mod.rs
@@ -631,8 +631,11 @@ mod measure_fits_render {
eprintln!("skipping: no install / font chain");
return;
};
- for label in LABELS {
- let s = spec(16.0 * ERA);
+ // Two regimes: an interface size, and one past the raster ladder's floor
+ // ([`super::super::engine`]), where the cell is a rung's bitmap scaled to the request.
+ // The pen never reads the cell, so the ink must still fit the measure there.
+ for (label, height) in LABELS.iter().flat_map(|l| [(l, 16.0 * ERA), (l, 60.0)]) {
+ let s = spec(height);
let want = measure_text(&mut e, label, None, s).0 - 1.0; // less the headroom pixel
let quads = layout_text_quads(
&mut e,
@@ -650,9 +653,9 @@ mod measure_fits_render {
let ink = quads.iter().map(|q| q.rect.max.x).fold(0.0f32, f32::max);
assert!(
ink <= want.ceil() + 1e-3,
- "{label:?}: ink reaches {ink} but the measure promised {want}"
+ "{label:?} at {height}: ink reaches {ink} but the measure promised {want}"
);
- assert!(!quads.is_empty(), "{label:?} drew nothing");
+ assert!(!quads.is_empty(), "{label:?} at {height} drew nothing");
}
}
diff --git a/crates/benilla-app/src/ui_world_map.rs b/crates/benilla-app/src/ui_world_map.rs
index 29eda793..539831c3 100644
--- a/crates/benilla-app/src/ui_world_map.rs
+++ b/crates/benilla-app/src/ui_world_map.rs
@@ -30,7 +30,7 @@ use benilla_world::world_map::CurrentMap;
/// The app's copy of the pushed catalog's projection data (rects, world-sheet constants), in the
/// engine's order: the indices must agree.
#[derive(Resource)]
-pub(crate) struct WorldMapUiData {
+pub struct WorldMapUiData {
continents: Vec,
/// The instance maps no continent owns, `0x4a5d00`'s third array in `WorldMapArea.dbc` file
/// order, unsorted: what the `-2` direct-area selection resolves against.
@@ -182,7 +182,7 @@ fn map_row_view(
/// The catalog off the patch chain, the pure half of [`seed_world_map_catalog`]; `None` when a
/// DBC fails to load, which disables the map window.
-pub(crate) fn build_catalog(
+pub fn build_catalog(
chain: &mut benilla_formats::Chain,
areas: &benilla_formats::AreaTableCatalog,
maps: &MapCatalogRes,
@@ -264,7 +264,23 @@ pub(crate) fn build_catalog(
.collect(),
Err(e) => {
// The client tolerates a missing bitmap: hover and click are inert.
- warn!("world map: no zone bitmap for {}: {e:#}", cont.name);
+ //
+ // A row with no placement of its own is not a continent anyone can look at, and
+ // the reference ships no bitmap for it either — `WorldMapArea` row 694 `World`
+ // is the one on this realm, an all-zero `loc` rect, and `Interface\WorldMap\`
+ // holds exactly `Azeroth.zmp`, `Kalimdor.zmp` and `Expansion01.zmp` across the
+ // whole 112,523-file chain. So that miss is the data agreeing with itself, not a
+ // gap, and warning about it every boot buries the misses that are real.
+ let placed = cont.loc_left != cont.loc_right && cont.loc_top != cont.loc_bottom;
+ if placed {
+ warn!("world map: no zone bitmap for {}: {e:#}", cont.name);
+ } else {
+ debug!(
+ "world map: {} has no rectangle of its own and no zone bitmap, as the \
+ reference has none for it: hover and click there are inert",
+ cont.name
+ );
+ }
Vec::new()
}
};
@@ -356,6 +372,16 @@ pub(crate) struct WorldMapCatalog(pub(crate) Vec);
/// `GetMapZones` at file scope (Astrolabe builds its zone table there), and the reference holds
/// this DBC data from load.
impl WorldMapUiData {
+ /// The orphan rows as the VM is handed them: `(WorldMapArea row id, view)` pairs, the shape
+ /// [`benilla_ui::script::uimap::build_graph`] takes as its `direct_areas` argument.
+ ///
+ /// A method rather than a public field because the entry carries the projection data beside
+ /// the view and only the view crosses into the VM; `seed_world_map_catalog` below is the
+ /// in-crate caller, so the push and any outside caller cannot build a different list.
+ pub fn direct_area_views(&self) -> Vec<(u32, WorldMapZoneView)> {
+ self.direct.iter().map(|d| (d.id, d.view.clone())).collect()
+ }
+
/// The zone a world point falls in on `map_id`, as `(AreaTable id, mapX, mapY)` with the two
/// fractions in **percent** — the shape ClassicAPI's `C_Map.GetAreaTriggerInfo` answers and
/// pfQuest feeds straight into `pfMap:AddNode` (`database.lua:805-806`), whose coordinates are
@@ -396,12 +422,7 @@ pub(crate) fn seed_world_map_catalog(world: &mut World, script: &mut UiScript) {
script.set_world_map_catalog(catalog.0.clone());
// The instance maps, at the same edge: the reference fills both containers in one walk.
if let Some(data) = world.get_resource::() {
- script.set_world_map_direct_areas(
- data.direct
- .iter()
- .map(|d| (d.id, d.view.clone()))
- .collect::>(),
- );
+ script.set_world_map_direct_areas(data.direct_area_views());
}
}
@@ -1915,6 +1936,52 @@ mod direct_area_tests {
Some(build_catalog(&mut chain, &areas, &maps).expect("the real catalog"))
}
+ /// The `World` row is a continent by the one test the reference uses (`areaId == 0`), but it
+ /// has no rectangle and no `.zmp`, and the reference ships none for it either. So the zone
+ /// grid being empty there is the data agreeing with itself, and `build_catalog` says so at
+ /// `debug` rather than warning about it on every boot — see the `Err` arm above.
+ #[test]
+ fn the_unplaced_world_row_is_a_continent_with_no_rectangle_to_hover() {
+ let Some((views, _)) = real_catalog() else {
+ return;
+ };
+ // By art folder, not `name`: `name` is `Map.dbc`'s label, and this row names map 0, so
+ // it is labelled "Eastern Kingdoms" exactly like the real one beside it.
+ let unplaced: Vec<(&str, &str)> = views
+ .iter()
+ .filter(|c| c.zone_grid.is_empty())
+ .map(|c| (c.map_file.as_str(), c.name.as_str()))
+ .collect();
+ assert_eq!(
+ unplaced,
+ vec![("World", "Eastern Kingdoms")],
+ "exactly one continent row has no zone bitmap, and it is the one with no rectangle"
+ );
+ let degenerate: Vec<&str> = views
+ .iter()
+ .filter(|c| {
+ let (l, r, t, b) = c.loc_rect;
+ l == r || t == b
+ })
+ .map(|c| c.map_file.as_str())
+ .collect();
+ assert_eq!(
+ degenerate,
+ vec!["World"],
+ "and it is the same row: no rectangle is why there is no bitmap"
+ );
+ // Measured off this install: the `areaID == 0` rows are `13 Kalimdor` (map 1),
+ // `14 Azeroth` (map 0) and `694 World` (map 0, all-zero rect). Nothing the player sees
+ // carries the third — `uimap::build_graph` drops it by art folder, and the realm's own
+ // `Overrides.lua` pins `CONTINENTS_LENGTH = 2` — so this row reaching the catalog and
+ // getting an empty grid is the end of it, not a dropdown with Eastern Kingdoms twice.
+ assert_eq!(
+ views.len(),
+ 3,
+ "the catalog keeps all three; the graph above it does not"
+ );
+ }
+
#[test]
fn the_real_dbc_carries_exactly_the_three_battleground_orphans() {
let Some((views, data)) = real_catalog() else {
@@ -1981,12 +2048,7 @@ mod direct_area_tests {
let mut script = UiScript::new().expect("a bare engine");
script.set_world_map_catalog(views);
- script.set_world_map_direct_areas(
- data.direct
- .iter()
- .map(|d| (d.id, d.view.clone()))
- .collect::>(),
- );
+ script.set_world_map_direct_areas(data.direct_area_views());
// The engine's world-enter sync (`0x4947ac`).
let (c, z) = player.zone.unwrap_or((0, 0));
script.sync_world_map_to_player_zone(c, z, player.direct);
diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs
index fce583c8..61c21975 100644
--- a/crates/benilla-formats/src/lib.rs
+++ b/crates/benilla-formats/src/lib.rs
@@ -15,7 +15,7 @@ mod chain;
pub use chain::{Chain, ChainEntry};
/// Loose `.tga` art, as addon folders ship it.
mod tga;
-pub use tga::tga_to_rgba;
+pub use tga::{rgba_to_tga, tga_to_rgba};
/// Where the WoW install is; [`Chain`] opens it.
mod install;
pub use install::{addon_corpus, addon_corpus_candidates, candidates, skipped, wow_data};
diff --git a/crates/benilla-formats/src/tga.rs b/crates/benilla-formats/src/tga.rs
index 5e649934..70724c2f 100644
--- a/crates/benilla-formats/src/tga.rs
+++ b/crates/benilla-formats/src/tga.rs
@@ -177,6 +177,53 @@ pub fn tga_to_rgba(bytes: &[u8]) -> Result<(u32, u32, Vec)> {
Ok((width, height, rgba))
}
+/// The 26-byte TGA 2.0 footer: two zero offsets (no extension area, no developer directory) and
+/// the signature. Nothing in this client reads it — [`tga_to_rgba`] stops at the pixels and the
+/// reference's `0x5a3a30` never seeks the tail — but the masks already shipped in
+/// `OpenMapClassic/Media/Textures` carry it, because Pillow writes it (`scripts/soften-zone-masks.py`),
+/// so writing it keeps a baked mask and a softened one the same shape of file.
+const FOOTER: &[u8; 26] = b"\x00\x00\x00\x00\x00\x00\x00\x00TRUEVISION-XFILE.\x00";
+
+/// Encode RGBA8 `pixels`, rows **top-down** like [`tga_to_rgba`] returns them, as an uncompressed
+/// 32-bit TGA.
+///
+/// The byte layout is the one the shipped zone masks have, measured off
+/// `MapZoneDilate/1429.tga`: `imageType` 2, no colour map, `bpp` 32, descriptor `0x08` — eight
+/// alpha bits and TGA's default **bottom-left** origin, so the rows go out reversed. That is what
+/// Pillow writes, and the masks this sits beside are Pillow's output, so a re-bake drops into the
+/// same folder without the addon seeing two different files.
+pub fn rgba_to_tga(width: u32, height: u32, pixels: &[u8]) -> Result> {
+ if width == 0 || height == 0 {
+ bail!("TGA: cannot write a {width}x{height} image");
+ }
+ if width > 0xffff || height > 0xffff {
+ bail!("TGA: {width}x{height} does not fit the 16-bit header fields");
+ }
+ let need = (width as usize) * (height as usize) * 4;
+ if pixels.len() != need {
+ bail!(
+ "TGA: {width}x{height} wants {need} RGBA bytes, got {}",
+ pixels.len()
+ );
+ }
+
+ let mut out = Vec::with_capacity(18 + need + FOOTER.len());
+ out.extend_from_slice(&[0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
+ out.extend_from_slice(&(width as u16).to_le_bytes());
+ out.extend_from_slice(&(height as u16).to_le_bytes());
+ out.push(32);
+ out.push(0x08);
+ for y in (0..height as usize).rev() {
+ for x in 0..width as usize {
+ let s = (y * width as usize + x) * 4;
+ // BGRA, the order the decoder above swizzles back.
+ out.extend_from_slice(&[pixels[s + 2], pixels[s + 1], pixels[s], pixels[s + 3]]);
+ }
+ }
+ out.extend_from_slice(FOOTER);
+ Ok(out)
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -304,4 +351,36 @@ mod tests {
assert!(tga_to_rgba(&tga(2, 2, 2, 16, 0, &[0; 32])).is_err()); // 16-bit
assert!(tga_to_rgba(&tga(2, 2, 2, 32, 0, &[0; 4])).is_err()); // truncated pixels
}
+
+ /// The writer's bytes are the shape the shipped masks have, and the decoder above reads back
+ /// exactly what was written — the pair that lets a baked mask be trusted without opening it in
+ /// another program.
+ #[test]
+ fn written_pixels_come_back_unchanged_through_the_decoder() {
+ // Asymmetric in both axes and in alpha, so a flipped row, a flipped column or a dropped
+ // alpha all fail rather than one of the three.
+ let w = 3u32;
+ let h = 2u32;
+ let src: Vec = vec![
+ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, //
+ 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24,
+ ];
+ let file = rgba_to_tga(w, h, &src).unwrap();
+ // Header: uncompressed true-colour, 32-bit, eight alpha bits and a bottom-left origin.
+ assert_eq!(file[2], 2);
+ assert_eq!(file[16], 32);
+ assert_eq!(file[17], 0x08);
+ assert_eq!(file.len(), 18 + (w * h * 4) as usize + 26);
+ assert_eq!(&file[file.len() - 18..], b"TRUEVISION-XFILE.\x00");
+
+ let (gw, gh, back) = tga_to_rgba(&file).unwrap();
+ assert_eq!((gw, gh), (w, h));
+ assert_eq!(back, src);
+ }
+
+ #[test]
+ fn a_pixel_count_that_does_not_match_the_header_is_refused() {
+ assert!(rgba_to_tga(2, 2, &[0u8; 12]).is_err());
+ assert!(rgba_to_tga(0, 4, &[]).is_err());
+ }
}
diff --git a/crates/benilla-ui/src/loader/mod.rs b/crates/benilla-ui/src/loader/mod.rs
index 3318a076..110fdf04 100644
--- a/crates/benilla-ui/src/loader/mod.rs
+++ b/crates/benilla-ui/src/loader/mod.rs
@@ -82,6 +82,37 @@ pub fn load_into(
loader.report
}
+/// Whether a frame created as `kind` reads everything a template declared as `` carries.
+///
+/// The merge itself never drops anything: `framexml`'s `merge` keeps the template's whole attribute
+/// list and every child and lets only the instance's `tag` win, so the question is purely which of
+/// `decorate`'s steps run. The geometry, layers, backdrop, title region, nested frames and scripts
+/// are kind-blind; the per-kind steps (`apply_button`, `apply_slider`, `apply_statusbar`,
+/// `apply_editbox`, `apply_colorselect`, `apply_messageframe`, `apply_simplehtml`,
+/// `apply_minimap`) are each gated on the merged element's tag, which *is* `kind`. So a kind whose
+/// `IsObjectType` chain already contains the template's tag runs every step that tag would have
+/// run, and nothing is lost — which is also how the reference behaves, its `LoadXML` dispatching
+/// on the created object's own class rather than on the XML tag.
+///
+/// This is what the `''-only parts … do not apply` warning is for, and why it is directional:
+/// a `Button` wearing a `` template is a superset and silent, while a `Frame` wearing a
+/// `