From 65520a4a586ac6831a94ca38ee6d98d55db3bc02 Mon Sep 17 00:00:00 2001 From: grom Date: Wed, 4 Nov 2026 21:16:20 -0700 Subject: [PATCH] The map's darkening was a grid of grey boxes, and a 12 px string was emptying the glyph sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grom, after a night's play: "make the map look a lot nicer, 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 if that makes sense, also I shouldn't see this dark squares on the fully zoomed out world map, also make it so the world map can't move out of the frame." **The darkening.** OpenMapClassic washed a never-visited zone through its land mask, and those masks are Voronoi-dilated by construction — "so that the forty of them tile each continent with no gap" — so for any zone that is not on a coast the mask *is* the rectangle. Measured at zoom 2.5 over the Eastern Kingdoms: a grid of flat grey boxes. Nothing could have been softened into a fix; it was the wrong mask. It was also drawn at full strength from every zoom, and at the whole-world view the zones' own art is not drawn at all, so the only thing on the world map was the fog's rectangles. A land mask for every map now comes from the client's own data. `bake_zone_land` unions a map's `WorldMapOverlay` alpha — the pieces the game reveals as you explore, cut to the shape of the ground — into one 256×256 mask; `overlay-land-index.py` writes the list the addon reads, because Lua cannot test for a file and a *missing* mask is dropped by the engine so the whole rectangle draws, which is the defect itself. 53 maps have overlays (the 40 stock zones, Hyjal, Alterac Valley and 11 of the realm's own); 76 have none and keep a wide feather. `clip-zone-masks-to-coast.py` then takes the open sea off the dilated sheet masks — the same dilation was painting parchment out on the ocean — by closing the continent's own land by 500 yards (above the 466-yard inradius of the widest enclosed gap, so seams fill and the sea does not) and keeping a 300-yard coastal margin. Twelve landlocked zones lose 0.0%; Darkshore loses 53%, all of it water. `tga.rs` gains the writer that needed, `script::uimap` and `ui_world_map::build_catalog` become public so an example can reach the uiMapID graph, and `WorldMapUiData` grows the accessor so the VM push and an outside caller cannot build different lists. **The glyph sheet.** His four-minute session reset it 27 times, each dropping every cached glyph. The producer is Atlas-CFM's cursor coordinate readout: a 12 px FontString on `WorldMapDetailFrame`, rewritten every frame, and the canvas zooms by scaling that frame — 140 device px a digit at a canvas scale of 6.9. Nothing about the addon is wrong, so the fix is ours: `raster_ppem` rasterizes exactly up to 64 device px and snaps to a third-octave ladder above it, with `cell()` scaling the cached bitmap and metrics left keyed on the true ppem, so advances and `GetStringWidth` are bit-for-bit unchanged. Same 1,200-frame reproduction: 3 resets and 6,623 rasters before, **0 and 1,424** after, with occupancy climbing once to 30% and stopping. A reset no longer clears the metric cache either, and `note_loud_cell` names the string that asked for an outsized cell. **Four more from the same log.** `SetOwner`'s unknown-anchor line named a forwarding wrapper rather than the addon that chose the anchor (two `debugstack` frames now); the nine reference anchor names are written down with the binary evidence, because `ANCHOR_TOP` is not one of them. The `CreateFrame(kind, …, inherits=)` warning fired in both directions and only one drops content — `kind_subsumes` makes it directional, and `type_chain` moves out of `install` so `IsObjectType` and the loader read one table. A string that is not UTF-8 now names the Lua line that cut it, and both glyph warnings quote the string the character came out of. The three world-map CVars stay unregistered, with the argument written down beside `REGISTERED` so it is not re-litigated from the warning. **`World.zmp`.** The realm's `WorldMapArea` carries a third `areaID == 0` row, `694 World`, with an all-zero rect; the reference ships no bitmap for it and neither the graph nor the realm's own `Overrides.lua` lets it reach a player. The miss is a `debug!` line now, and the `warn!` is kept for a continent that *has* a rectangle and is missing its bitmap. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-app/examples/bake_zone_land.rs | 675 ++++++++++++++++++ crates/benilla-app/src/cvars.rs | 54 ++ crates/benilla-app/src/lib.rs | 4 +- crates/benilla-app/src/ui_text/engine/gpu.rs | 6 +- crates/benilla-app/src/ui_text/engine/mod.rs | 464 +++++++++++- crates/benilla-app/src/ui_text/layout/mod.rs | 11 +- crates/benilla-app/src/ui_world_map.rs | 92 ++- crates/benilla-formats/src/lib.rs | 2 +- crates/benilla-formats/src/tga.rs | 79 ++ crates/benilla-ui/src/loader/mod.rs | 33 +- crates/benilla-ui/src/script/binding_abi.rs | 61 +- crates/benilla-ui/src/script/mod.rs | 42 +- crates/benilla-ui/src/script/model.rs | 10 + crates/benilla-ui/src/script/object.rs | 2 +- .../src/script/object/frame_state.rs | 64 +- .../src/script/tests/create_frame_template.rs | 103 +++ crates/benilla-ui/src/script/tests/regions.rs | 78 ++ crates/benilla-ui/src/script/tooltip/verbs.rs | 131 +++- docs/ADDON_COMPAT.md | 317 ++++++++ scripts/clip-zone-masks-to-coast.py | 515 +++++++++++++ scripts/overlay-land-index.py | 75 ++ 21 files changed, 2715 insertions(+), 103 deletions(-) create mode 100644 crates/benilla-app/examples/bake_zone_land.rs create mode 100755 scripts/clip-zone-masks-to-coast.py create mode 100755 scripts/overlay-land-index.py 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 +/// ` + + + DropDownLikeLoadedAs = self:GetName() + + + "#, + ); + + // 1 · the empty one, both kinds the session logged. + s.run( + r#"BtnBare = CreateFrame("Button", "BtnBare", nil, "BareFrameTemplate") + SliderBare = CreateFrame("Slider", "SliderBare", nil, "BareFrameTemplate")"#, + ) + .unwrap(); + // The frames are real and keep the kind asked for, backdrop methods and all. + assert_eq!( + s.eval::<(String, String)>("return BtnBare:GetObjectType(), SliderBare:GetObjectType()") + .unwrap(), + ("Button".to_string(), "Slider".to_string()) + ); + assert!( + s.eval::("return BtnBare.SetBackdrop ~= nil and BtnBare.GetBackdrop ~= nil") + .unwrap(), + "the whole reason an addon inherits BackdropTemplate, and in 1.12 it is a plain method" + ); + assert!( + s.take_warnings().is_empty(), + "an empty template on a Button or a Slider cannot drop anything" + ); + + // 2 · a populated `` template on a Button: every part of it arrived. + s.run(r#"IGW_RankLike = CreateFrame("Button", "IGW_RankLike", nil, "DropDownLikeTemplate")"#) + .unwrap(); + assert_eq!( + s.eval::<(f32, f32)>("return IGW_RankLike:GetWidth(), IGW_RankLike:GetHeight()") + .unwrap(), + (128.0, 32.0), + "" + ); + assert!( + s.eval::(r#"return getglobal("IGW_RankLikeLeft") ~= nil"#) + .unwrap(), + ", named against the caller — UIDropDownMenu_* reads these by name" + ); + assert!( + s.eval::(r#"return getglobal("IGW_RankLikeButton") ~= nil"#) + .unwrap(), + ", which is the dropdown's own open button" + ); + assert_eq!( + s.eval::("return DropDownLikeLoadedAs").unwrap(), + "IGW_RankLike", + ", with the instance's name" + ); + assert!( + s.eval::("return IGW_RankLike:IsMouseEnabled()") + .unwrap(), + "the template's attributes merged too" + ); + assert!(s.take_errors().is_empty()); + assert!( + s.take_warnings().is_empty(), + "a Button has Frame in its IsObjectType chain, so nothing was skipped to warn about" + ); +} + /// `` builds its one child through the `` path (`0x6ee280`) and stores it as /// the scroll child, which gives the frame its range. #[test] diff --git a/crates/benilla-ui/src/script/tests/regions.rs b/crates/benilla-ui/src/script/tests/regions.rs index 64411765..f1d952bb 100644 --- a/crates/benilla-ui/src/script/tests/regions.rs +++ b/crates/benilla-ui/src/script/tests/regions.rs @@ -1874,3 +1874,81 @@ fn the_later_era_texture_dials_round_trip() { "one axis at a time" ); } + +/// A Lua string is bytes, so `SetText` takes whatever it is handed — and names the line that cut +/// a multi-byte character in half. +/// +/// A session's log carried `'Fonts\FRIZQT__.TTF' … has no glyph for '\u{fffd}'` and nothing else: +/// `U+FFFD` is minted by a lossy decode, never typed, and the character alone named neither the +/// string nor the producer. The decode is [`crate::script::binding_abi::text_arg`]'s, the only +/// one on the Lua side, so that is where the caller is named. The text still goes through — the +/// reference draws the broken bytes rather than refusing the call — and the warning is host-only, +/// because the producer is an addon's own byte slicing, not the player's to fix. +#[test] +fn text_that_is_not_utf8_still_sets_and_names_the_lua_line_that_cut_it() { + let mut s = script(); + s.run( + r#"Holder = CreateFrame("Frame", "ByteHolder") + Label = Holder:CreateFontString("ByteLabel", "ARTWORK") + -- "Jäger" in UTF-8, truncated through the middle of the ä (C3 A4). + function CutAName() Label:SetText(strsub("J\195\164ger", 1, 2)) end + CutAName()"#, + ) + .unwrap(); + // The call goes through — the reference draws the broken bytes rather than refusing — but the + // region keeps a Rust `String`, so the lone 0xC3 is already the replacement character by the + // time `GetText` can answer. That is the deviation the warning exists to make visible: 1.12 + // round-trips the bytes it was handed, and until `RegionData::text` is bytes this cannot. + assert_eq!( + s.eval::("return Label:GetText()").unwrap(), + "J\u{fffd}", + "the text still sets, lossily — and 'lossily' is the fact being reported" + ); + let warnings = s.take_warnings().join("\n"); + assert!( + warnings.contains("not UTF-8 at byte 1 (C3)"), + "the offset and the offending byte must be in the line: {warnings}" + ); + assert!( + warnings.contains("CutAName"), + "and the Lua line that produced it, which is the whole point: {warnings}" + ); + assert!( + !s.diagnostics() + .into_iter() + .any(|d| d.message.contains("not UTF-8")), + "host-only: `/errors` is for faults in the player's own interface" + ); + + // Valid UTF-8 says nothing, so the check cannot start charging the path every SetText takes. + s.run(r#"Label:SetText("J\195\164ger")"#).unwrap(); + assert!( + !s.take_warnings().join("\n").contains("not UTF-8"), + "a whole multi-byte character is ordinary text" + ); + + // One fault, many subjects: fifty cut names through one loop are ONE line, because the dedup + // key is the call site and the bad byte, not the quoted string — which the message carries + // precisely so it can vary. (Nine of the fifty are cut; `i >= 10` lands on a byte boundary.) + s.run( + r#"function CutMany() + for i = 1, 50 do Label:SetText(strsub("Name" .. i .. "\195\164x", 1, 6)) end + end + CutMany()"#, + ) + .unwrap(); + let repeats: Vec = s + .take_warnings() + .into_iter() + .filter(|w| w.contains("not UTF-8")) + .collect(); + assert_eq!( + repeats.len(), + 1, + "nine cut strings through one site are one line, not nine: {repeats:#?}" + ); + assert!( + repeats[0].contains("CutMany") && repeats[0].contains("\"Name1"), + "and it names the new producer, with one of its subjects: {repeats:#?}" + ); +} diff --git a/crates/benilla-ui/src/script/tooltip/verbs.rs b/crates/benilla-ui/src/script/tooltip/verbs.rs index f834dcb7..b1628390 100644 --- a/crates/benilla-ui/src/script/tooltip/verbs.rs +++ b/crates/benilla-ui/src/script/tooltip/verbs.rs @@ -4,37 +4,12 @@ use mlua::{Lua, Table, Value, Variadic}; use crate::layout::{Anchor, Point}; use crate::script::binding_abi::optional_string; +use crate::script::caller_site; use crate::script::object::{frame_handle_of, frame_wrapper}; use crate::script::region::region_handle_of; use crate::script::Model; use crate::widget::{KindState, TooltipAnchor, TOOLTIP_LINE_GAP, TOOLTIP_PAD}; -/// The Lua line that called the binding we are inside, as `debugstack` renders it, for a -/// diagnostic that has to name its own caller. -/// -/// `debugstack(2)` skips `debugstack` itself and this binding's frame, so its first line is the -/// call site. Only that line is kept — a whole traceback per warning would bury the log, and the -/// call site is the fact being hunted. An empty string reads as "unknown" rather than pretending. -/// -/// Paid only on a diagnostic path. `debugstack` names frames from `lua_getinfo` alone (the -/// reference's own walk, `0x703760`) and searches no table — unlike the traceback mlua used to -/// build, which walked every global and is why `third_party/mlua-sys` is vendored. -fn caller_site(lua: &Lua) -> String { - let Ok(f) = lua.globals().get::("debugstack") else { - return String::new(); - }; - let Ok(stack) = f.call::>(2) else { - return String::new(); - }; - stack - .unwrap_or_default() - .lines() - .next() - .unwrap_or("") - .trim() - .to_string() -} - use super::{ append_line, bool_arg, clear_content, fire_cleared, full_alpha, hide_tooltip, now, parse_line_color, parse_line_tail, set_shown, text_of, tip_mut, with_tip, write_cell, @@ -92,6 +67,19 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { // Resolved once: the placement and `GetAnchorType` both read this value. The // warning on an unknown name is ours; the reference is silent, and Lua sees no // difference. + // + // These nine are the **whole** set: the realm's own `WoW.exe` string pool + // holds exactly `ANCHOR_BOTTOMLEFT`, `ANCHOR_BOTTOMRIGHT`, `ANCHOR_CURSOR`, + // `ANCHOR_LEFT`, `ANCHOR_NONE`, `ANCHOR_PRESERVE`, `ANCHOR_RIGHT`, + // `ANCHOR_TOPLEFT` and `ANCHOR_TOPRIGHT`, which is also the mode space the + // core's own numbering fills (0..5 place a point, 6 cursor, 7 none, 8 + // preserve). There is no `ANCHOR_TOP` and no `ANCHOR_BOTTOM` — those arrive + // in a later expansion — so a 1.12 client answers both with mode 0, and so + // does this one. Five installed addons pass `ANCHOR_TOP` anyway (pfUI's + // `modules/mapreveal.lua:44`, DoiteAuras, SuperCleveRoidMacros, + // improvedguildwindow_oc, OpenMapClassic); their tooltips land on the owner's + // left on the reference too. Adding a tenth name here would be a deviation, + // not a fix. let anchor = match anchor .as_deref() .map(str::to_ascii_uppercase) @@ -603,4 +591,95 @@ mod tests { "ANCHOR_BOTTOMRIGHT is a real 1.12 anchor" ); } + + /// The nine the reference knows, and the two it does not. + /// + /// The realm's own `WoW.exe` string pool holds exactly nine `ANCHOR_*` names, which is also + /// the mode space `SetOwner`'s core fills (`0x52fe90`: 0..5 place a point, 6 cursor, 7 none, + /// 8 preserve). `ANCHOR_TOP` and `ANCHOR_BOTTOM` are **later-expansion** names and are + /// absent, so the reference falls each of them to mode 0 with no error — and five installed + /// addons pass `ANCHOR_TOP` regardless (pfUI `modules/mapreveal.lua:44`, DoiteAuras, + /// SuperCleveRoidMacros, improvedguildwindow_oc, OpenMapClassic). A session's warning named + /// one of them, through Atlas-CFM's tooltip wrapper. + /// + /// So this is not a gap to close: it welds the set shut both ways. A name dropped from the + /// nine would silently place every tooltip on the owner's left; a tenth added would place a + /// tooltip where no 1.12 client places one. + #[test] + fn the_nine_reference_anchor_names_are_accepted_and_anchor_top_is_not_one_of_them() { + let mut s = UiScript::new().unwrap(); + s.run( + r#"T = CreateFrame("GameTooltip", "AnchorSetTip", UIParent) + Host = CreateFrame("Frame", "AnchorSetHost", UIParent)"#, + ) + .unwrap(); + for name in [ + "ANCHOR_LEFT", + "ANCHOR_RIGHT", + "ANCHOR_TOPLEFT", + "ANCHOR_TOPRIGHT", + "ANCHOR_BOTTOMLEFT", + "ANCHOR_BOTTOMRIGHT", + "ANCHOR_CURSOR", + "ANCHOR_NONE", + "ANCHOR_PRESERVE", + ] { + s.run(&format!(r#"T:SetOwner(Host, "{name}")"#)).unwrap(); + assert_eq!( + s.eval::("return T:GetAnchorType()").unwrap(), + name, + "{name} is one of the reference's nine and must round-trip" + ); + assert!( + !s.take_warnings().join("\n").contains("unknown anchor"), + "{name} is in the realm's own string pool" + ); + } + // The two later-expansion names, and a bare point name: mode 0, which `GetAnchorType` + // reports as ANCHOR_LEFT, exactly as the reference's un-matched local does. + for absent in ["ANCHOR_TOP", "ANCHOR_BOTTOM", "BOTTOMRIGHT"] { + s.run(&format!(r#"T:SetOwner(Host, "{absent}")"#)).unwrap(); + assert_eq!( + s.eval::("return T:GetAnchorType()").unwrap(), + "ANCHOR_LEFT", + "{absent} names no 1.12 anchor, so the mode stays 0" + ); + assert!( + s.take_warnings() + .join("\n") + .contains(&format!("unknown anchor '{absent}'")), + "…and our own diagnostic says which name was ignored" + ); + } + } + + /// A pass-through wrapper must not be the last word on who passed the anchor. + /// + /// Atlas-CFM wraps every clearing tooltip method, `"SetOwner"` among them + /// (`CFMLoot\Core\Tooltip.lua:347`, list at `:403`), with a forwarder whose body is + /// `return originalMethod(self, arg1, …)`. One `debugstack` frame named that forwarder and + /// stopped, so a session's warning identified the addon that *relays* the call rather than + /// the one that chose `ANCHOR_TOP`. `caller_site`'s two frames carry the real caller too. + #[test] + fn an_unknown_anchor_through_a_forwarding_wrapper_still_names_the_real_caller() { + let mut s = UiScript::new().unwrap(); + s.run( + r#"T = CreateFrame("GameTooltip", "WrappedTip", UIParent) + Host = CreateFrame("Frame", "WrappedHost", UIParent) + local original = T.SetOwner + T.SetOwner = function(self, a, b) return original(self, a, b) end + function TheRealCaller() T:SetOwner(Host, "ANCHOR_TOP") end + TheRealCaller()"#, + ) + .unwrap(); + let warnings = s.take_warnings().join("\n"); + assert!( + warnings.contains("unknown anchor 'ANCHOR_TOP'"), + "the anchor must still be reported: {warnings}" + ); + assert!( + warnings.contains("TheRealCaller"), + "the frame past the forwarder is the fact being hunted: {warnings}" + ); + } } diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index 4356d261..a7455694 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -3231,6 +3231,65 @@ With that in, `initializationErrors` is **0**, the window is `shown=0` at login, map, and a real `--drag` pans it. The settings window opens with its real tabs — Audio, General, Voice Packs, Profiles — and clicking them switches pages with no raise. +### The same trap again, one library down: `CallbackHandler-1.0`'s own `Fire` + +A session logged `...ills\lib\CallbackHandler-1.0\CallbackHandler-1.0.lua:113: attempt to perform +arithmetic on local 'argc' (a boolean value)`, and the shape is the one above with a different +library. `WidgetBase.Fire` is the *widget* side; `registry:Fire` is the *callback registry* side, +and only the first was shifted. + +Four copies of `CallbackHandler-1.0` are installed. Three are Ace3v (MINOR 6: +MasterTradeSkills, SoftSpoken, UnitXP_SP3_Addon) with `registry:Fire(eventname, argc, a1…a10)`; +OpenMapClassic's is modern (MINOR 8) with `registry:Fire(eventname, ...)`. `LibStub.minors` reads +2 for every library, so the first copy to load wins outright, and alphabetical load order makes +that MasterTradeSkills'. OpenMapClassic's `API.lua:27` then fires modern-style through an Ace3v +registry: + +```lua +local function Fire(...) if events then events:Fire(...) end end +... +mod.OnStitchedChanged = function(on) + on = on and true or false + if on ~= wasStitched then Fire("OpenMap_StitchedChanged", on) end -- API.lua:162 +end +``` + +`on` lands in `argc`, and `Dispatchers[argc+1]` at `:113` raises on a boolean. **Not a verb of +ours**: no engine call is anywhere in that chain, and the reference client raises here too, off the +same realm `LibStub`. The blast radius is OpenMap's whole published callback surface, not one +event: `OpenMap_ViewChanged` passes a 0..1 fraction where the count belongs, so `Dispatchers[u+1]` +is nil and that one raises on the *call* instead of the add, and the two dungeon-map events pass a +table. No addon can follow OpenMap's map at all — which is the feature `API.lua`'s header +advertises to Questie, TomTom and the gatherers. + +**Fixed caller-side, in `API.lua`, and the choice is the interesting part.** The callee-side edit +is tempting — shift a non-numeric `argc` down and count what arrived, once, and all three Ace3v +copies are covered. But those three copies live in *MasterTradeSkills*, *SoftSpoken* and +*UnitXP_SP3_Addon*: third-party addons in the player's own folder, which he updates. A fix there is +a fix that disappears, and patching a stranger's vendored library is the opposite of the thing this +whole document is for. `OpenMapClassic` is ours. + +The objection to caller-side was that a caller cannot know which registry it got. It can: ask. + +```lua +local probe = {} +local reg = CBH:New(probe, "RegisterCallback", "UnregisterCallback", "UnregisterAllCallbacks") +local seen +probe.RegisterCallback(probe, "OpenMapAbiProbe", function(_, v) seen = v end) +reg:Fire("OpenMapAbiProbe", 7) +fireTakesArgc = seen ~= 7 +``` + +A *number* is fired so neither ABI can raise on the probe itself: Ace3v reads 7 as a count and +hands the handler seven nils, the modern one hands it the 7. Probed rather than read off `MINOR` +because the minor number is the thing that has already lied — `LibStub.minors` says 2 for +everything. Then `Fire` adds `select("#", ...)` or does not. + +Measured in a capture that registers a real callback and then moves the view: **5 `OpenMap_ViewChanged` +callbacks delivered, `0.700, 0.460, 12.00`**, where before the fix the same probe got none and the +log carried the raise. The one remaining `argc` line in that capture's log is the probe's own +answer, `argcAbi=true`. + ### Two more defects it was hiding Opening all three registered options tables (`MasterTradeSkills`, `OpenMapClassic`, @@ -3774,3 +3833,261 @@ ruins — not the city. That is true of this install and not only of retail: `te the player's own chain holds `Azeroth` and `Kalimdor` and a folder per dungeon, and no folder for any city (measured across 112,411 files). There is no interior minimap to show, so the two underground cities keep their plan whatever the new setting says. + +## "Darken unexplored areas" drew a grid of grey boxes, because the mask it used is dilated on purpose + +> "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 if that makes sense, also I shouldn't see this +> dark squares on the fully zoomed out world map" + +Two separate faults with one symptom, and the second one is the kind that is only obvious once you +stop looking at the code and look at the picture. + +### The shade had two paths and the cheap one was a rectangle + +`Stitch.lua`'s `Shade` darkened a zone two ways. A zone the player had been to got **one darkened +copy of each unexplored piece's art**, which carries that piece's own alpha and therefore the shape +of the ground. A zone the player had found *none* of got `Shade.Texture`: a single +`SetColorTexture` over the whole 1002×668 art rectangle, cut down by the zone's land mask. + +The land masks are `MapZoneDilate` — forty hand-made files whose own purpose, stated in their +generator, is to be **Voronoi-dilated "so that the forty of them tile each continent with no +gap"**. For a zone that is not on a coast, a mask that tiles the continent *is* the zone's +rectangle. So the cheap path painted a grey box, by construction, for every inland zone. + +Measured at zoom 2.5 over the Eastern Kingdoms with nothing explored: a grid of flat grey +rectangles with two or three coastal zones the only honest shapes among them. + +### …and the expensive path was covering something this fork does not have + +The flat path is **gone**, and so is the copy layer behind it. The copies exist upstream for a +stated reason: they are drawn over the **baked tiles** (`ns.MapZoneArt`, one pre-rendered texture +per world tile standing in for the live layers at mid zoom), and a baked tile is many pieces in +one, so it cannot be tinted piece by piece. This fork has no baked tiles — `Data/ZoneArt.lua` is +three lines, *"Forever MapZones bake removed. Baked path disabled."*, and its `colorMatch = -1` +means `Baked.use` can never become true. The copies were covering nothing. + +So the darkening is now one line in `GradeEntry`: multiply an unexplored piece's **own** texture by +`Shade.DIM`. That is the same picture — a texture multiplied by `DIM` is a texture multiplied by +`DIM`, whether it is the art or a copy of the art over it — and it is a *better* one in a way the +copies had to work for: a zone's base art carries no piece (`PlaceBase` passes `false`) and the +overlays only ever cover land, so the sea a 1.12 sheet paints stays bright by construction, where +the copies needed a land mask to achieve the same thing. + +That removed `Shade.Texture`, `Shade.Zone`, `Shade.Pieces`, `Shade.Colour`, `Shade.Near`, +`Shade.Show`, the per-zone shade cull and the `Shade.root` layer — about 120 lines down to four +lines of state and one function. `shadeRoot` is kept, empty, only so every layer above it keeps the +frame level it had. + +Quads a frame at zoom 5 with half the overlays explored: `show` 4,729 · `dim` **4,675** · `hide` +4,550. Darkening a map now costs *fewer* quads than not darkening it, because the colour-match twin +over a darkened piece is hidden rather than drawn. Before the change `dim` was 5,124 against +`show`'s 4,674 — 450 extra quads, every one of them masked. + +Frame times, measured once the machine was actually idle — which is the point worth keeping: +`show` **5.20 ms** · `dim` **5.14** · `hide` **4.88** (240 frames each, same scene, zoom 5). +Darkening is free now. Every earlier reading that night shared the machine with a release build and +was worthless: the same `dim` scene read 8.68 ms, then 8.94, then **26.05** under load average 8. +Quad counts were load-independent and said the same thing throughout, which is why they are the +numbers above. A frame-time A/B taken while something else is compiling is not a measurement. + +*What is given up, stated plainly.* The flat shade sat on `shadeRoot`, **over** the close-up +minimap terrain; the piece copies sat under it, and the tint is the art itself, so neither darkens +the minimap. At a high zoom inside a zone you have explored none of, the minimap terrain therefore +reads at full brightness with the darkened sheet showing around it. That was already true of +upstream's behaviour for any zone you had partly explored — the flat path only ever ran for +"none of it" — and buying it back means a rectangle, which is the thing this whole section is +about. Left as it is, deliberately. + +### A fog that was the only thing on the whole-world view + +The darkening was drawn at full strength "from every zoom". At the whole-world view the *zones' own +art is not drawn at all* — `DETAIL_FADE` starts at zoom 3 — so on the world map there was Blizzard's +clean painted world with nothing on it but the fog's rectangles. + +It now rides `detailNow`, the very ramp the art it darkens rides. Measured on the current build: +the whole-world view with "Darken unexplored areas" **on** and with it **off** differ by **0 +pixels**. The same comparison before the change differed by 84,818. + +Riding the art's own ramp is also what bounds the cost. `Shade.vis` is false out there, and while +it is false nothing is *built*: at the far zoom the cull box is a whole continent, so the old code +was free to lay out the piece copies for forty zones at once. + +### The cities had to be switched off, not faded + +A city's own sheet is darkened by **multiplying its art** (`cities.Dim`), not by a shade laid over +it, so it cannot be faded out with everything else — a vertex colour has no alpha to ramp. Left +alone, an unvisited city stayed a small hard black rectangle on the world view after the rest of +the fog had gone. It now reads `Shade.vis` too, and `S.SetShadeZoom` re-runs `cities.Dim()` when +that crosses. That is also the `black-rectangle dungeon insets in Darken mode` from an earlier +report: half of the remaining pixels at the world view were cities, and the count went 2,894 → 1,385 +→ 0 as the two were fixed. + +## A land mask for every map, generated from the exploration overlays + +The hand-made mask set has **40** files. This realm's `WorldMapArea.dbc` has **175 rows**. Every +map with no mask was drawn through a plain edge feather — `MapFeather` is opaque from 7.8% in, so +over a zone-sized sheet that is a rectangle with a thin soft rim — and shaded the same way. + +`cargo run -p benilla-app --example bake_zone_land` writes a mask per map from the client's own +data: the union of that map's `WorldMapOverlay` alpha, composited into the 1002×668 sheet at each +overlay's `offsetX/offsetY` and downscaled to 256×256. The overlays are the pieces the game reveals +as you explore, cut to the shape of the ground, so their union is the map's land and nothing else — +which is exactly what "should fit into the actual edges of the terrain" asks for, and it comes from +the player's own install rather than from a table somebody traced. + +**53 maps have overlays**: the 40 stock outdoor zones, Alterac Valley, Hyjal, and 11 of the realm's +own — Gillijim's Isle, Lapidis Isle, Icepoint Rock, Tel'Abim, Gilneas, Thalassian Highlands, +Blackstone Island, Balor, Northwind, Grim Reaches, Moonwhisper Coast. `scripts/overlay-land-index.py` +turns the baked folder into `Data/OverlayLand.lua`, because Lua cannot ask whether a texture file +exists — and a mask whose art is missing is *dropped* by the engine, so the whole rectangle draws, +which is the very defect this set removes. + +Two things the baker had to get right, both recorded in its own source: the reveal is alpha-over +rather than `max`, because `WorldMapFrame.lua` puts one texture per piece on the detail frame at +the default blend; and bare pixels the overlay art completely *encloses* are flood-filled, because +the pieces are feathered into their own rects and where two abut their ramps cross below opaque — +Westfall's raw union had 10,165 pinholes through the middle of the zone. + +Where the two sets disagree they disagree in the expected direction. Against the hand-made masks, +84–93% of the generated coverage falls inside theirs, while only 50–76% of theirs falls inside the +generated — that gap *is* the dilation, filling sea to tile the continent. So the two are used for +different jobs: a zone's **sheet** still prefers the dilated mask, because a sheet has to reach its +neighbour; the **shade** prefers the generated one, because a shade should stop at the coast. + +**76 maps have no `WorldMapOverlay` rows at all** — the six cities, two battlegrounds, every +instance, and about twenty of the realm's own outdoor pockets, Alah'Thalas among them. No source in +the client can give those a coastline, so they keep the feather fallback, now `FEATHER_WIDE` (13.7%) +rather than `FEATHER` (7.8%): a sheet laid down rather than a box cut out. That is the half of +"edges of maps are the same" that can be answered without new art. + +## Zone sheets were painting parchment on the open sea + +The same dilation, one layer up. A zone's **sheet** is cut by `MapZoneSoft`, and that mask is +dilated so the forty of them tile a continent with no gap — which is right on land, where the +dilation is what closes the seam to the neighbour, and wrong past the coast, where there is no +neighbour and nothing underneath. So a zone painted its blank parchment on the ocean. Measured at +zoom 6 west of the Eastern Kingdoms: pale rectangles floating in the Great Sea at world +(0.725, 0.483) and (0.748, 0.407), both inside the rects of stock zones that *have* a mask. + +`scripts/clip-zone-masks-to-coast.py` writes `MapZoneCoast`: `MapZoneSoft` intersected with the +continent's own land — the union of every `MapOverlayLand` mask — **morphologically closed by 500 +yards**, then grown by a **300-yard coastal margin** with a 150-yard feather. + +Three numbers, each measured rather than chosen: + +- **500 yards** because the close has to fill the gaps *between* zones without filling the ocean, + and a close by R removes exactly the background a disk of radius R cannot sit in — so R must + exceed the widest enclosed gap's **inradius**, not its width. Those are 466 yards on Kalimdor + and 267 on the Eastern Kingdoms; after the close, zero enclosed gap pixels remain on either. +- **300 yards** because a 1.12 sheet deliberately paints the hatched shallows off its own coast + and those should stay. 10.4% of everything the forty sheets paint sits more than 600 yards past + any land; at a 600-yard margin a straight rect edge is still plainly visible off the Wetlands, + at 300 the kept water follows the coastline everywhere. +- The buffer is sized in **yards, not world units**, because the sheet is 44,533 yards across a + world-u and 29,689 across a world-v — exactly 1.5:1, the detail frame's 1002:668. A circular + dilate in world units would have been an ellipse on the map. + +Twelve genuinely landlocked stock zones lose **0.0%** — that is the guard the script refuses to +ship without. Darkshore loses 53%, Teldrassil 46%, Stranglethorn 39%, Durotar 27%; all of it is +water. + +## The map could be dragged out of its own window + +> "make it so the world map can't move out of the frame like it is now, still pan and zoom when +> zoomed, but zoomed out should fit and stay in the frame" + +`WorldMap.lua`'s `Extents` gives the view `EDGE_SLACK = 0.2` of room past the world's edge, so the +wheel can zoom about a point near the edge without the pan fighting it. The slack is a fifth of a +**world** width, and it does not shrink with the zoom: at zoom 30 the view is a thirtieth of the +world, so a fifth of a world is six screenfuls of empty sea. Measured before the change — the view +driven to the world's top-left corner at zoom 6 sat there, and the whole window was open water with +a strip of the world's top edge along the bottom. + +`mod.db.clampView`, on by default, drops the slack to zero and makes `Range` return the centre +outright on whichever axis the world is smaller than the view, at every zoom rather than +interpolating out of it — that second half is what "zoomed out should fit and stay in the frame" +asks for, because interpolating let the world slide about inside its own letterbox. Driving the +view at the world's corner now answers: + +``` +zoom 1 → u 0.5000 v 0.5000 (locked centred: the world fits) +zoom 2 → u 0.2500 v 0.2555 (half a view from the edge) +zoom 6 → u 0.0833 v 0.0852 +zoom 20 → u 0.0250 v 0.0255 +``` + +i.e. the map's edge meets the window's edge and stops, at every zoom. The cost is that the wheel can +no longer zoom exactly about a point hard against the map's edge, which is what upstream bought with +the slack; "Keep the map in the window" in the options turns it back off. + +## The `World.zmp` warning was the data agreeing with itself + +Every boot logged `world map: no zone bitmap for World: reading Interface\WorldMap\World.zmp: file +not in patch chain`. The realm's `WorldMapArea.dbc` carries three `areaID == 0` rows — `13 Kalimdor` +(map 1), `14 Azeroth` (map 0) and `694 World` (map 0, **all-zero rect**) — and the reference's own +catalog walk selects continents by exactly that column, so the third reaches our catalog too. + +It has no rectangle, so there is nothing to hover; and the reference ships no bitmap for it either — +`Interface\WorldMap\` holds exactly `Azeroth.zmp`, `Kalimdor.zmp` and `Expansion01.zmp` across the +whole 112,523-file chain. Nothing a player sees carries the row: `uimap::build_graph` drops it by +art folder and the realm's own `Overrides.lua` pins `CONTINENTS_LENGTH = 2`. So the miss is now a +`debug!` line with the reasoning in it, and the `warn!` is kept for a continent that *does* have a +rectangle and *is* missing its bitmap — which is the case worth seeing. + +## A 12 px FontString inside a zoomed canvas emptied the glyph sheet 27 times in four minutes + +His session logged `ui_text: glyph sheet full (…/4194304 texels, N cells) — resetting at the frame +boundary` **27 times in four minutes**, N running from 460 to 2,537. Every one of those drops the +whole glyph cache and the next frame re-rasterizes every string on screen. That is a hitch, 27 +times, and it is a large part of "we need the whole thing running smoother". + +**The producer is `AtlasCFMWorldMapCursorText`** — Atlas-CFM's world-map cursor coordinate +readout, a Friz Quadrata **12 px** FontString created directly on `WorldMapDetailFrame` +(`CFMAtlas/AtlasUI.lua:561`) and rewritten from an `OnUpdate` on that same frame every frame the +cursor is over the map. It is on in his saved variables (`AtlasCursorCoords = true`). + +Nothing about that is wrong. `MapCanvas.lua` reparents the detail frame into the canvas and the +canvas zooms by **scaling that child**, so the FontString's effective scale is the zoom: 12 px × +his 1.6875 seam × a canvas scale of 6.9 is **140 device px per digit**, and the string is different +every frame, so a glide minted a fresh set of ~13 display-size cells per frame. The evidence is the +correlation: every one of the 27 resets opens within ~2 s of a `world map art: … scale N` line with +N > 1, and there is not one while the canvas sits at its resting 0.90. + +**The engine was what turned it into a hitch**, so that is where it is fixed. `raster_ppem()` now +rasterizes at exactly the requested size up to **64 device px** and snaps to a third-octave ladder +(`64, 81, 102, 128, 161, 203, 256`) above it, with `cell()` scaling the cached cell's extent and +bearings by `ppem / rung` so the quad lands on the same device pixels. **Metrics are untouched** — +`chars` stays keyed by the true ppem, so every advance, wrap and `GetStringWidth` is bit-for-bit +what it was. 192 device sizes over one glide become 7 rasters. 64 is chosen so nothing the stock +interface draws at 1:1 is quantized: the largest is `ZoneTextFont`/`WorldMapTextFont` at 32 units +after the reference's own cap, 54 device px on his screen. + +Same reproduction both sides — his saved variables, all his addons, 1,200 frames, the canvas glided +between min and max zoom every 90: + +| | `glyph sheet full` | cells rasterized | sheet occupancy | +|---|---|---|---| +| before | 3 | 6,623 | sawtooth 0 → 95% → reset | +| after | **0** | **1,424** | climbs once to 30.2% and stops | + +Saturation is the real result: the cache reaches a bounded steady state instead of sawtoothing. + +Two things came with it. A sheet reset no longer clears the **metric** cache — `chars` holds pen +metrics and shaper keys and nothing about the sheet, so clearing it made the frame after a reset +re-*shape* every string as well as re-rasterize it, which is the expensive half. And a new +`note_loud_cell` line names the offender once per `(face, rung, radius)`, with the string that +asked for it: + +``` +ui_text: a 128 px cell for '7' in 'Fonts\FRIZQT__.TTF' (outline radius 0) is 65×91 texels, +1/709 of the glyph sheet, drawn at 115 px — asked for by "70.1, 11.5" (byte 0). +``` + +`"70.1, 11.5"` is Atlas-CFM's readout and nothing else — two lines for a 1,200-frame run. The next +session that does this will say so itself instead of needing to be correlated out of timestamps. + +Quality was checked rather than assumed: four THICKOUTLINE rows at the ladder's worst points +(0.889× minify, 1.118× magnify), same scene, both binaries. A control band with no rig text is +**pixel-identical**, the tight bounding boxes of the 72 px and 90 px rows are identical to the +pixel, and the text bands differ by 4.6–6.0% RMSE — edge antialiasing, invisible at 1:1. diff --git a/scripts/clip-zone-masks-to-coast.py b/scripts/clip-zone-masks-to-coast.py new file mode 100755 index 00000000..2a6642ea --- /dev/null +++ b/scripts/clip-zone-masks-to-coast.py @@ -0,0 +1,515 @@ +#!/usr/bin/env python3 +"""Clip OpenMapClassic's per-zone sheet masks to the continent's own coastline. + +grom, at mid zoom out over the Great Sea west of the Eastern Kingdoms: pale parchment +**rectangles float on the open ocean**. Measured from a capture at world point (0.68, 0.42), zoom +6: the pale blocks sit at world points (0.725, 0.483) and (0.748, 0.407), and +`OpenMapClassic.MapStitch.Rect(id)` puts both inside the rects of uiMapIDs 1426 (Dun Morogh) and +1437 (Wetlands) -- two stock zones, each with a hand-made mask. + +So the cause is the **dilation**. `Media/Textures/MapZoneDilate/.tga` is Voronoi-dilated, "so +that the forty of them tile each continent with no gap", and a Voronoi cell does not stop at the +water: a coastal zone's cell runs out to sea until it meets the next cell, which out there is +never. The mask is opaque over open ocean, so the zone's sheet paints its blank parchment there. +`MapZoneSoft` is that same set softened and inherits it exactly. + +On land the dilation is *right* and must be kept -- it is the only thing closing the seam between +two neighbouring sheets. It is wrong only past the coast, where there is no neighbour to meet. So +this intersects each soft mask with the continent's own land, and the three constants below are +the three ways that intersection can be got wrong. + +What the land is: `MapOverlayLand/.tga`, the union of each map's `WorldMapOverlay` alpha -- +the pieces the game reveals as you explore, cut to the ground (`examples/bake_zone_land.rs`). +Composited into one world-space buffer at each map's rect on the stitched sheet, it is the shape +of Azeroth, measured rather than traced. + +Three constants, each settling one trade-off: + +* `CLOSE_YARDS`, the morphological close. The overlay union has gaps along every zone border -- + two sheets' art abutting, not sea -- and intersecting with it raw would re-cut every seam the + dilation exists to hide. A close (dilate R, erode R) removes any background a disk of radius R + cannot sit in, so it fills the seams and leaves the ocean. R is set from the data: the widest + fully-enclosed gap in the union, per continent, which `--dry-run` measures and then re-measures + after the close to show it is gone. +* `MARGIN_YARDS`, the coastal keep. A 1.12 zone sheet deliberately paints the water off its own + coast -- Blizzard's hatching, the islets, the ships -- and clipping exactly at the overlay's + edge would shave all of it off. So the closed land is grown by this much before the intersect. + Coastal water yes, open ocean no. +* `FEATHER_YARDS`, the ramp at the outer edge of that margin. A hard cut would put a straight line + across open water wherever the margin ends inside a sheet's rect -- the defect's own shape, one + step further out. This is the same argument `soften-zone-masks.py` makes for its `BLUR`. + +Reads `MapZoneSoft` and `MapOverlayLand`, writes `MapZoneCoast`; both sources are left alone so +this is re-runnable and the three sets can be compared. 256x256 uncompressed 32-bit TGA out, RGB +black, alpha the coverage, exactly as `soften-zone-masks.py` writes and the shipped masks are. + +The world rects come from the client's own uiMap graph, dumped by + + cargo run -q -p benilla-app --example bake_zone_land -- --dry-run --dump-rects + +which is the same graph the map window is driven by, so the placement here cannot drift from what +the addon draws. `--rects` takes that file. +""" +import argparse +import re +import sys +from pathlib import Path + +import numpy as np +from PIL import Image + +# Yards per world unit on the stitched sheet, measured by `--dump-rects` over every placed map: +# 44533 across u and 29689 down v on both continents, to the yard, on all 76 of them. The sheet is +# therefore *anisotropic in world units* and isotropic in yards -- 1002:668 is exactly 1.5, and so +# is 44533:29689 -- which is why the land buffer below is sized in yards and not in world units: a +# circular dilate in world units would be an ellipse on the map. +YARDS_PER_U = 44533.0 +YARDS_PER_V = 29689.0 + +# The land buffer's resolution, as the width in pixels; the height follows from the yard extent so +# a pixel is square in yards. 4096 across the 60100-yard span is 15.1 yards a pixel, finer than +# the 256x256 source masks are over any zone (~19 yards for Dun Morogh), so the composite does not +# throw away mask detail. +BUFFER_WIDTH = 4096 + +# Pad the world extent by this much on every side, in world units, so a dilate near the outermost +# map's edge is not clipped by the buffer border. +EXTENT_PAD = 0.02 + +# Below this the composited coverage is background. The sources are softened (dilate 2, blur 3), +# and a gaussian's half level sits on the step it blurred, so 0.5 recovers the dilated boundary. +LAND_LEVEL = 0.5 + +# The close radius. A gap is closed when R exceeds its *inradius* -- the largest disk that fits +# inside it -- because closing leaves exactly the background a disk of radius R can sit in. The +# report measures that over every fully-enclosed gap in the union: 467 yards on Kalimdor (a 933 +# yard gap) and 267 on the Eastern Kingdoms (534 yards). 500 is just above the larger, and the +# report then re-measures the gaps after the close to show there are none left. +# +# The guard that this is not *too* large is the other end of the report: a zone with no coastline +# -- Elwynn, Badlands, Loch Modan, Duskwood -- must lose ~nothing, because a radius big enough to +# bridge open water is a radius that has already swallowed the gaps around a landlocked zone. Too +# small and the seams between neighbouring sheets re-open; too large and the coastline grows back +# out to sea and nothing has been fixed. +# +# What it buys, measured with the coastal margin set to zero so the close is the only thing acting: +# with no close the landlocked zones lose 2.7--35.8% of their coverage to the seams (Redridge +# 35.8, Alterac 18.5, Loch Modan 17.8, Elwynn 14.2); at 500 that is 0.0--8.0%, and the 300-yard +# margin takes the rest of it to 0.0 for every one of them. +CLOSE_YARDS = 500.0 + +# The coastal keep, and the ramp past it. 300 yards is about 45 pixels of a 1.12 zone sheet (one +# is 1002 px over 4900--7000 yards on these rects): the width of the hatched shallows and the +# islets Blizzard paints off a coast, and far short of the 1000--1500 yards of blank parchment the +# Voronoi cells were reaching into -- measured, 10.4% of everything the forty sheets paint sits +# more than 600 yards past any land, and 2.9% sits more than 1200. Looked at, 300 is where the +# kept water stops being a rectangle and starts being a coast; at 600 a straight rect edge is +# still visible off the Wetlands and Dun Morogh, which is the defect itself. The feather is half +# again, so the sheet's own water fades out instead of ending on a line of its own. +MARGIN_YARDS = 300.0 +FEATHER_YARDS = 150.0 + +# Names for the report only, read from the addon's own data file, which carries the zone name in a +# comment beside each uiMapID. +NAMES_LUA = "Data/Levels.lua" + +# Coverage loss above which a zone with no coastline is a failure rather than a result: the close +# has eaten the inter-zone gaps instead of the ocean. See `CLOSE_YARDS`. +INLAND_LOSS_LIMIT = 0.03 + +# The zones used as that guard: stock zones with no coastline, so every pixel of their dilated +# mask is over their own ground or a neighbour's and nothing here may touch them. Arathi, +# Stonetalon, Hillsbrad, Dun Morogh and Thousand Needles read as inland and are deliberately not +# on the list -- each reaches water somewhere, and each does lose a slice. **Moonglade** is off it +# for the same reason and it is the one that is not obvious: its rect's north edge is past +# Kalimdor's own north coast (looked at: `--dump-png`'s land buffer, Moonglade's rect sits on the +# shoreline north of Felwood), so it loses 9% and that loss is the fix working, not failing. +INLAND_GUARD = (1416, 1418, 1422, 1427, 1428, 1429, 1430, 1431, 1432, 1433, 1449, 1452) + +# grom's own two measurements of the defect, from a capture at world point (0.68, 0.42), zoom 6: +# the middle of each pale parchment block floating on the Great Sea. The report answers what the +# clip does at exactly those two points, so a run that does not close the reported symptom says so +# rather than being judged on an average. +DEFECT_PROBES = ((0.725, 0.483), (0.748, 0.407)) + + +def read_rects(path: Path) -> dict[int, tuple[float, float, float, float]]: + """`uiMapIDleftrighttopbottom`, normalized on uiMapID 947.""" + out = {} + for line in path.read_text().splitlines(): + if not line.strip() or line.startswith("#"): + continue + parts = line.split("\t") + out[int(parts[0])] = tuple(float(v) for v in parts[1:5]) + return out + + +def read_names(path: Path) -> dict[int, str]: + out = {} + if not path.is_file(): + return out + for m in re.finditer(r"\[(\d+)\]\s*=\s*\{[^}]*\}\s*,\s*--\s*(.+)", path.read_text()): + out[int(m.group(1))] = m.group(2).strip() + return out + + +def read_alpha(path: Path) -> np.ndarray: + """A mask's alpha as 0..1 floats, row 0 at the top.""" + im = Image.open(path).convert("RGBA") + return np.asarray(im.split()[-1], dtype=np.float32) / 255.0 + + +def sample(mask: np.ndarray, u: np.ndarray, v: np.ndarray) -> np.ndarray: + """Bilinear sample `mask` at normalized coordinates `u` (across) and `v` (down), clamped.""" + h, w = mask.shape + x = np.clip(u * w - 0.5, 0.0, w - 1.0) + y = np.clip(v * h - 0.5, 0.0, h - 1.0) + x0 = np.floor(x).astype(np.int32) + y0 = np.floor(y).astype(np.int32) + x1 = np.minimum(x0 + 1, w - 1) + y1 = np.minimum(y0 + 1, h - 1) + fx = (x - x0)[None, :] + fy = (y - y0)[:, None] + top = mask[np.ix_(y0, x0)] * (1 - fx) + mask[np.ix_(y0, x1)] * fx + bot = mask[np.ix_(y1, x0)] * (1 - fx) + mask[np.ix_(y1, x1)] * fx + return top * (1 - fy) + bot * fy + + +class World: + """The world-space land buffer: a pixel grid over the stitched sheet, square in yards.""" + + def __init__(self, rects: dict[int, tuple[float, float, float, float]], width: int): + # The extent is every map's rect, not the unit square: the continents hang off both ends + # of the world sheet on this realm's data (Kalimdor's left edge is u -0.187, the Eastern + # Kingdoms' right edge is 1.163), so a buffer cut to 0..1 would clip real land. + us = [r[0] for r in rects.values()] + [r[1] for r in rects.values()] + vs = [r[2] for r in rects.values()] + [r[3] for r in rects.values()] + self.u0, self.u1 = min(us) - EXTENT_PAD, max(us) + EXTENT_PAD + self.v0, self.v1 = min(vs) - EXTENT_PAD, max(vs) + EXTENT_PAD + self.w = width + self.ypp = (self.u1 - self.u0) * YARDS_PER_U / width # yards per pixel, both axes + self.h = max(1, int(round((self.v1 - self.v0) * YARDS_PER_V / self.ypp))) + self.buf = np.zeros((self.h, self.w), dtype=np.float32) + + def cols(self, u0: float, u1: float) -> tuple[int, int]: + a = int(np.floor((u0 - self.u0) / (self.u1 - self.u0) * self.w)) + b = int(np.ceil((u1 - self.u0) / (self.u1 - self.u0) * self.w)) + return max(0, a), min(self.w, b) + + def rows(self, v0: float, v1: float) -> tuple[int, int]: + a = int(np.floor((v0 - self.v0) / (self.v1 - self.v0) * self.h)) + b = int(np.ceil((v1 - self.v0) / (self.v1 - self.v0) * self.h)) + return max(0, a), min(self.h, b) + + def u_of(self, cols: np.ndarray) -> np.ndarray: + return self.u0 + (cols + 0.5) / self.w * (self.u1 - self.u0) + + def v_of(self, rows: np.ndarray) -> np.ndarray: + return self.v0 + (rows + 0.5) / self.h * (self.v1 - self.v0) + + def paste_max(self, rect: tuple[float, float, float, float], mask: np.ndarray) -> None: + left, right, top, bottom = rect + c0, c1 = self.cols(left, right) + r0, r1 = self.rows(top, bottom) + if c1 <= c0 or r1 <= r0: + return + u = (self.u_of(np.arange(c0, c1)) - left) / (right - left) + v = (self.v_of(np.arange(r0, r1)) - top) / (bottom - top) + # `cols`/`rows` round outwards, so the first and last pixel of the span can sit just past + # the rect. `sample` clamps there rather than wrapping, which would smear the mask's edge + # row one pixel out all the way round; these zero it instead. + inside = (u >= 0) & (u <= 1) + inside_v = (v >= 0) & (v <= 1) + patch = sample(mask, u, v) * inside[None, :] * inside_v[:, None] + np.maximum(self.buf[r0:r1, c0:c1], patch, out=self.buf[r0:r1, c0:c1]) + + def read(self, rect: tuple[float, float, float, float], field: np.ndarray, n: int) -> np.ndarray: + """Sample `field` over `rect` into an n x n grid -- the inverse of `paste_max`.""" + left, right, top, bottom = rect + u = left + (np.arange(n) + 0.5) / n * (right - left) + v = top + (np.arange(n) + 0.5) / n * (bottom - top) + return sample(field, (u - self.u0) / (self.u1 - self.u0), (v - self.v0) / (self.v1 - self.v0)) + + +def edt(fg: np.ndarray, cap: float) -> np.ndarray: + """Euclidean distance in pixels from every pixel to the nearest `True`, exact up to `cap`. + + Felzenszwalb--Huttenlocher's decomposition: an exact per-column distance first, then the exact + combination along each row. The row pass is restricted to offsets within `cap`, which costs + nothing in correctness for the answers we use -- if the true distance is at most `cap` then the + nearest pixel is within `cap` columns -- and turns an O(w) inner loop into a fixed one. + """ + h, w = fg.shape + inf = np.float32(cap + 1.0) + g = np.where(fg, np.float32(0.0), inf) + for y in range(1, h): + np.minimum(g[y], g[y - 1] + 1.0, out=g[y]) + for y in range(h - 2, -1, -1): + np.minimum(g[y], g[y + 1] + 1.0, out=g[y]) + np.minimum(g, inf, out=g) + best = g * g + tmp = np.empty_like(best) + for dx in range(1, int(np.ceil(cap)) + 1): + d2 = np.float32(dx * dx) + for src, dst, pad in ( + (np.s_[:, dx:], np.s_[:, : w - dx], np.s_[:, w - dx :]), + (np.s_[:, : w - dx], np.s_[:, dx:], np.s_[:, :dx]), + ): + tmp[dst] = g[src] + tmp[pad] = inf + np.multiply(tmp, tmp, out=tmp) + np.add(tmp, d2, out=tmp) + np.minimum(best, tmp, out=best) + return np.sqrt(best) + + +def border_reach(mask: np.ndarray) -> np.ndarray: + """Which `True` pixels of `mask` are 4-connected to the buffer border -- i.e. are open sea. + + Run-wise rather than pixel-wise: within one row, every pixel of a maximal run of `mask` is + reachable from every other, so a whole run is settled at once. Alternating a row pass with a + column pass converges in a handful of iterations where a pixel-at-a-time flood would take + thousands. + """ + seed = np.zeros_like(mask) + seed[0] |= mask[0] + seed[-1] |= mask[-1] + seed[:, 0] |= mask[:, 0] + seed[:, -1] |= mask[:, -1] + + def rows_pass(m: np.ndarray, s: np.ndarray) -> np.ndarray: + starts = m.copy() + starts[:, 1:] &= ~m[:, :-1] + ids = np.cumsum(starts.ravel()) + flat_m = m.ravel() + hit = np.bincount(ids[flat_m], weights=s.ravel()[flat_m], minlength=ids[-1] + 1) > 0 + return (hit[ids].reshape(m.shape)) & m + + for _ in range(64): + before = int(seed.sum()) + seed = rows_pass(mask, seed) + seed = np.ascontiguousarray( + rows_pass(np.ascontiguousarray(mask.T), np.ascontiguousarray(seed.T)).T + ) + if int(seed.sum()) == before: + break + return seed + + +def report_gaps( + world: "World", + rects: dict[int, tuple[float, float, float, float]], + land: np.ndarray, + dist_to_land: np.ndarray, + cap: float, +) -> np.ndarray: + """Print the widest fully-enclosed gap per continent, and answer which pixels those are.""" + bg = ~land + holes = bg & ~border_reach(bg) + for label, lo, hi in continent_bands(rects): + band = np.zeros_like(holes) + c0, c1 = world.cols(lo, hi) + band[:, c0:c1] = True + sel = holes & band + if not sel.any(): + print(f" {label}: no enclosed gap at all") + continue + widest = float(dist_to_land[sel].max()) + at_cap = " (AT THE MEASUREMENT CAP -- raise --gap-cap-yards)" if widest >= cap else "" + print( + f" {label}: {int(sel.sum())} enclosed gap px, widest " + f"{2 * widest * world.ypp:.0f} yards across (inradius {widest * world.ypp:.0f} " + f"yards / {widest:.1f} px){at_cap}" + ) + return holes + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("textures", type=Path, help="OpenMapClassic/Media/Textures") + ap.add_argument("--rects", type=Path, required=True, help="--dump-rects output") + ap.add_argument("--out", type=Path, default=None, help="default: /MapZoneCoast") + ap.add_argument("--soft-dir", default="MapZoneSoft") + ap.add_argument("--land-dir", default="MapOverlayLand") + ap.add_argument("--close-yards", type=float, default=CLOSE_YARDS) + ap.add_argument("--margin-yards", type=float, default=MARGIN_YARDS) + ap.add_argument("--feather-yards", type=float, default=FEATHER_YARDS) + ap.add_argument("--width", type=int, default=BUFFER_WIDTH) + # How far out the gap measurement can see. Only the measurement: an enclosed gap wider than + # this reads as exactly this wide, and the report says so rather than quietly under-reporting. + ap.add_argument("--gap-cap-yards", type=float, default=4000.0) + ap.add_argument("--dry-run", action="store_true", help="measure and report, write nothing") + ap.add_argument("--dump-png", type=Path, default=None, help="land buffer and before/after PNGs") + args = ap.parse_args() + + soft_dir = args.textures / args.soft_dir + land_dir = args.textures / args.land_dir + out_dir = args.out or (args.textures / "MapZoneCoast") + for d in (soft_dir, land_dir): + if not d.is_dir(): + print(f"not a directory: {d}", file=sys.stderr) + return 2 + rects = read_rects(args.rects) + names = read_names(args.textures.parent.parent / NAMES_LUA) + + # -- the land buffer ------------------------------------------------------------------- + world = World(rects, args.width) + print( + f"world buffer {world.w}x{world.h} over u {world.u0:.4f}..{world.u1:.4f}, " + f"v {world.v0:.4f}..{world.v1:.4f} -- {world.ypp:.2f} yards a pixel" + ) + land_ids, unplaced = [], [] + for path in sorted(land_dir.glob("*.tga"), key=lambda p: int(p.stem)): + mid = int(path.stem) + if mid not in rects: + unplaced.append(mid) + continue + world.paste_max(rects[mid], read_alpha(path)) + land_ids.append(mid) + print( + f"composited {len(land_ids)} land mask(s); {len(unplaced)} have no rect on the sheet " + f"({', '.join(str(m) for m in unplaced) or 'none'})" + ) + + binary = world.buf >= LAND_LEVEL + raw_cover = float(binary.mean()) + + # -- what the close has to fill -------------------------------------------------------- + # A gap between two zones is background the overlay art completely surrounds; open sea is + # background that reaches the buffer's border. Closing by radius R removes exactly the + # background a disk of radius R cannot sit inside, so the number that sets R is the largest + # radius of a disk that fits in an enclosed gap. + gap_cap = max(args.gap_cap_yards, args.close_yards * 1.5) / world.ypp + dist_to_land = edt(binary, gap_cap) + print(f"\nland {raw_cover * 100:.2f}% of the buffer") + holes = report_gaps(world, rects, binary, dist_to_land, gap_cap) + print(f"close radius R = {args.close_yards:.0f} yards ({args.close_yards / world.ypp:.1f} px)") + + # -- close, then the coastal margin ---------------------------------------------------- + r_px = args.close_yards / world.ypp + dilated = dist_to_land <= r_px + closed = edt(~dilated, r_px * 1.5) > r_px + print( + f"close raised coverage {raw_cover * 100:.2f}% -> {float(closed.mean()) * 100:.2f}% " + f"of the buffer; after it:" + ) + # The same measurement again, which is the only proof the radius was big enough: a seam that + # survives the close is a seam that will be cut out of two neighbouring sheets. + report_gaps(world, rects, closed, edt(closed, gap_cap), gap_cap) + # The margin and its ramp come out of one distance field: full coverage out to `margin`, then a + # smoothstep to nothing by `margin + feather`. + out_dist = edt(closed, (args.margin_yards + args.feather_yards) / world.ypp * 1.2) + m_px = args.margin_yards / world.ypp + f_px = max(args.feather_yards / world.ypp, 1e-6) + t = np.clip(1.0 - (out_dist - m_px) / f_px, 0.0, 1.0) + keep = (t * t * (3.0 - 2.0 * t)).astype(np.float32) + print( + f"coastal margin {args.margin_yards:.0f} yards + {args.feather_yards:.0f} yard feather " + f"({args.margin_yards / YARDS_PER_U:.4f} of the sheet's width, " + f"{args.margin_yards / YARDS_PER_V:.4f} of its height) " + f"-> keep {float((keep > 0.5).mean()) * 100:.2f}% of the buffer" + ) + + # -- the reported symptom, at the reported spots ---------------------------------------- + masked = {int(p.stem) for p in soft_dir.glob("*.tga")} + print("\nat grom's two measured points:") + for u, v in DEFECT_PROBES: + col = int((u - world.u0) / (world.u1 - world.u0) * world.w) + row = int((v - world.v0) / (world.v1 - world.v0) * world.h) + here = [ + f"{m} {names.get(m, '?')}" + for m, (left, right, top, bottom) in sorted(rects.items()) + if m in masked and left <= u <= right and top <= v <= bottom + ] + print( + f" ({u}, {v}): {out_dist[row, col] * world.ypp:>5.0f} yards past the closed land, " + f"keep {keep[row, col]:.3f} -- in the rect of {', '.join(here) or 'no masked map'}" + ) + + # -- the masks ------------------------------------------------------------------------- + rows = [] + for path in sorted(soft_dir.glob("*.tga"), key=lambda p: int(p.stem)): + mid = int(path.stem) + if mid not in rects: + print(f" {mid}: no rect on the sheet, skipped", file=sys.stderr) + continue + before = read_alpha(path) + after = (before * world.read(rects[mid], keep, before.shape[0])).astype(np.float32) + rows.append((mid, before, after)) + + print(f"\n{'uiMapID':>8} {'zone':<24} {'before':>8} {'after':>8} {'removed':>8}") + worst = [] + for mid, before, after in rows: + b, a = float(before.mean()), float(after.mean()) + frac = 0.0 if b <= 0 else 1.0 - a / b + worst.append((frac, mid)) + print(f"{mid:>8} {names.get(mid, '?'):<24} {b:>8.4f} {a:>8.4f} {frac * 100:>7.1f}%") + + bad = [(f, m) for f, m in worst if m in INLAND_GUARD and f > INLAND_LOSS_LIMIT] + worst.sort(reverse=True) + print( + "\nlost most: " + + ", ".join(f"{names.get(m, m)} {f * 100:.0f}%" for f, m in worst[:6]) + ) + print( + "lost least: " + + ", ".join(f"{names.get(m, m)} {f * 100:.1f}%" for f, m in worst[-4:][::-1]) + ) + + if args.dump_png: + args.dump_png.mkdir(parents=True, exist_ok=True) + png(args.dump_png / "land-raw.png", world.buf) + png(args.dump_png / "land-closed.png", closed.astype(np.float32)) + png(args.dump_png / "land-keep.png", keep) + png(args.dump_png / "land-gaps.png", holes.astype(np.float32)) + for mid, before, after in rows: + png(args.dump_png / f"{mid}-before.png", before) + png(args.dump_png / f"{mid}-after.png", after) + print(f"PNGs in {args.dump_png}") + + # The guard is checked before anything is written, not after: a run that fails it has eaten + # the inter-zone gaps and its masks would cut seams back into the sheets, so it ships nothing. + if bad: + print( + "\nFAILED: inland zone(s) lost more than " + f"{INLAND_LOSS_LIMIT * 100:.0f}% -- " + + ", ".join(f"{names.get(m, m)} {f * 100:.1f}%" for f, m in bad) + + ". The close radius is too small and is eating the inter-zone gaps, not the ocean." + " Nothing written.", + file=sys.stderr, + ) + return 1 + if args.dry_run: + print(f"\n--dry-run: nothing written (would have written to {out_dir})") + return 0 + out_dir.mkdir(parents=True, exist_ok=True) + for mid, _, after in rows: + rgba = np.zeros(after.shape + (4,), dtype=np.uint8) + rgba[:, :, 3] = np.round(np.clip(after, 0.0, 1.0) * 255.0).astype(np.uint8) + Image.fromarray(rgba, "RGBA").save(out_dir / f"{mid}.tga") + print(f"\nwrote {len(rows)} mask(s) to {out_dir}") + return 0 + + +def continent_bands(rects: dict[int, tuple[float, float, float, float]]) -> list: + """The u span each continent owns alone, for splitting a measurement between the two. + + The two continent rects overlap in the middle of the sheet -- Kalimdor's reaches to u 0.640, + the Eastern Kingdoms' starts at 0.372 -- and the overlap is all open water, so the band each + owns outright is the honest place to measure its gaps. + """ + out = [] + kal, ek = rects.get(1414), rects.get(1415) + if kal and ek: + out.append(("Kalimdor", kal[0], ek[0])) + out.append(("Eastern Kingdoms", kal[1], ek[1])) + return out + + +def png(path: Path, field: np.ndarray) -> None: + data = np.round(np.clip(field, 0.0, 1.0) * 255.0).astype(np.uint8) + Image.fromarray(data, "L").save(path) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/overlay-land-index.py b/scripts/overlay-land-index.py new file mode 100755 index 00000000..102ad075 --- /dev/null +++ b/scripts/overlay-land-index.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Write OpenMapClassic's `Data/OverlayLand.lua` from the baked `MapOverlayLand` masks. + +`cargo run -p benilla-app --example bake_zone_land` writes one 256x256 alpha mask per map, the +union of that map's own `WorldMapOverlay` art -- the shape of the ground it paints. Lua cannot ask +whether a texture file exists (a missing one draws as a white quad, or, as a *mask*, is silently +dropped and the whole rectangle shows), so the addon needs a table of which ids were baked. That +is all this writes. + +Run it after the baker, any time the mask set changes: + + python3 scripts/overlay-land-index.py + +`--dir` points at another mask folder, `--out` at another destination. +""" +import argparse +import re +import sys +from pathlib import Path + +ADDON = Path(__file__).resolve().parent.parent / "benilla-config/AddOns/OpenMapClassic" + +HEAD = """-------------------------------------------------------------------------------- +-- Which maps have a generated land mask (Media/Textures/MapOverlayLand/.tga). +-- +-- GENERATED -- do not edit. `cargo run -p benilla-app --example bake_zone_land` writes the masks, +-- `scripts/overlay-land-index.py` writes this list. +-- +-- Each mask is the union of that map's own `WorldMapOverlay` alpha: the pieces the game reveals +-- as you explore, cut to the shape of the ground, so the union is the map's land and nothing +-- else. `Map/Stitch.lua` cuts the unexplored darkening to it, and falls back to it for the art of +-- any map the hand-made `MapZoneSoft` set has no file for -- this realm has 175 `WorldMapArea` +-- rows against that set's 40. +-- +-- A file list, not a file test, because Lua has no way to ask whether a texture exists: a mask +-- whose art is missing is dropped by the engine and the whole rectangle draws, which is the +-- "massive squares" this set exists to remove. +-------------------------------------------------------------------------------- + +local _, ns = ... + +ns.MapOverlayLand = { +""" + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--dir", default=str(ADDON / "Media/Textures/MapOverlayLand")) + ap.add_argument("--out", default=str(ADDON / "Data/OverlayLand.lua")) + args = ap.parse_args() + + folder = Path(args.dir) + if not folder.is_dir(): + print(f"overlay-land-index: no mask folder at {folder}", file=sys.stderr) + return 1 + ids = sorted( + int(m.group(1)) + for p in folder.iterdir() + if (m := re.fullmatch(r"(\d+)\.tga", p.name, re.IGNORECASE)) + ) + if not ids: + print(f"overlay-land-index: {folder} holds no .tga masks", file=sys.stderr) + return 1 + + body = [] + for i in range(0, len(ids), 8): + body.append("\t" + " ".join(f"[{n}] = true," for n in ids[i : i + 8])) + text = HEAD + "\n".join(body) + "\n}\n" + Path(args.out).write_text(text, encoding="utf-8") + print(f"overlay-land-index: {len(ids)} masks -> {args.out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())