diff --git a/crates/benilla-app/src/capture/probe_book.rs b/crates/benilla-app/src/capture/probe_book.rs index 94ddc955..c216d8a0 100644 --- a/crates/benilla-app/src/capture/probe_book.rs +++ b/crates/benilla-app/src/capture/probe_book.rs @@ -190,6 +190,22 @@ fn report_render(script: &UiScript) -> u32 { } else { info!("PROBE_BOOK: (render) PASS — no block is cut off with \"...\""); } + // **Does the page FIT?** The director's own comparison against 1.12.1 (08-15) was that ours + // ran off the bottom where the reference's ends inside the window. The scroll range answers it + // exactly and without an eye: it is the content's overhang past the viewport, so this page — + // which the reference shows whole — must come out at zero. + let range = script + .eval::("return ItemTextScrollFrame:GetVerticalScrollRange()") + .unwrap_or(-1.0); + if range == 0.0 { + info!("PROBE_BOOK: (render) PASS — the page fits the window (scroll range 0)"); + } else { + fails += 1; + error!( + "PROBE_BOOK: (render) FAIL — the page overhangs its window by {range} units; the \ + reference shows this one whole" + ); + } fails } diff --git a/crates/benilla-app/src/chat_bubble.rs b/crates/benilla-app/src/chat_bubble.rs index 70da8e50..413338e0 100644 --- a/crates/benilla-app/src/chat_bubble.rs +++ b/crates/benilla-app/src/chat_bubble.rs @@ -448,33 +448,29 @@ fn draw_bubble( let border = border_px(viewport, basis); let margin = gx_px(MARGIN, basis); - // Shape at the nearest baked atlas size, then rescale to the exact window-derived em — - // the plate-text recipe (`crate::vplates::plate_text`). + // The exact window-derived em, laid out AT that em. This used to shape at the nearest baked + // ladder size and rescale the finished quads by `px/shaped` around the box centre — a bubble's + // size is derived from the viewport and so lands on nothing round, which meant every bubble on + // screen was a resampled bitmap. Since decision 1342 the raster follows the request. let px = text_px(TEXT_H, basis); - let shaped = atlas.snap_size(px); - if shaped <= 0.0 { + if px <= 0.0 { return; } - let s = px / shaped; + let mut e = atlas.lock(); let spec = FontSpec { - path: None, // NAMEPLATE_FONT — Friz Quadrata, the atlas default - height: Some(shaped), + path: None, // NAMEPLATE_FONT — Friz Quadrata, the engine's default face + height: Some(px), outline: Outline::None, - paint_halo: true, alpha_gradient: None, }; // The wrap law (`0x4b1600` tail): single-line width > 0.2 gx → hard-cap (forces wrap); - // else max(measured, 2·border-unit). All in shaped space, rescaled after. - let cap_s = gx_px(WRAP_W, basis) / s; - let floor_s = (2.0 * border) / s; - let (line_w, _) = measure_text(&atlas.metrics, &b.text, None, spec); - let box_w_s = if line_w > cap_s { - cap_s - } else { - line_w.max(floor_s) - }; - let (_, box_h_s) = measure_text(&atlas.metrics, &b.text, Some(box_w_s), spec); - let (text_w, text_h) = ((box_w_s * s).ceil(), (box_h_s * s).ceil()); + // else max(measured, 2·border-unit). + let cap = gx_px(WRAP_W, basis); + let floor = 2.0 * border; + let (line_w, _) = measure_text(&mut e, &b.text, None, spec); + let box_w = if line_w > cap { cap } else { line_w.max(floor) }; + let (_, box_h) = measure_text(&mut e, &b.text, Some(box_w), spec); + let (text_w, text_h) = (box_w.ceil(), box_h.ceil()); // The auto-fit body: the frame hugs the text layout ± the flat margin; BOTTOM-center on // the seat, growing upward. Snapped to whole logical px (the plate divergence — a @@ -538,13 +534,13 @@ fn draw_bubble( color: [1.0, 1.0, 1.0, alpha], ..default() }); - // The text: centered in the margin box, wrapped at the shaped-space cap, rescaled around - // the box center to the exact em (uniform, so wrap points survive the rescale). + // The text: centered in the margin box, wrapped at the cap. No rescale pass — the glyphs came + // out of the cache at this em. let center = Vec2::new(cx, (frame.min.y + frame.max.y) * 0.5); let mut text_quads = layout_text_quads( - atlas, + &mut e, &b.text, - Rect::from_center_size(center, Vec2::new(box_w_s, box_h_s)), + Rect::from_center_size(center, Vec2::new(box_w, box_h)), [b.color[0], b.color[1], b.color[2], alpha], Justify { h: JustifyH::Center, @@ -553,14 +549,7 @@ fn draw_bubble( Z_TEXT, spec, ); - for q in text_quads.iter_mut() { - q.rect = Rect::new( - center.x + (q.rect.min.x - center.x) * s, - center.y + (q.rect.min.y - center.y) * s, - center.x + (q.rect.max.x - center.x) * s, - center.y + (q.rect.max.y - center.y) * s, - ); - } + drop(e); if trace { eprintln!( "bubble-trace: viewport={viewport:?} frame=({:.1},{:.1})..({:.1},{:.1}) border={border:.1} alpha={alpha:.2} text={:?}", diff --git a/crates/benilla-app/src/combat_text/mod.rs b/crates/benilla-app/src/combat_text/mod.rs index 2f8d8226..3b72ff82 100644 --- a/crates/benilla-app/src/combat_text/mod.rs +++ b/crates/benilla-app/src/combat_text/mod.rs @@ -33,7 +33,13 @@ //! **The render geometry** (wow-re `worldtext-geometry-law.md`, §5-verified, 9af65294 — zero free //! parameters; the anchor-seat half corrected here, see the seating comment in //! [`float_combat_text`]): the rise is added to **world z before projection**; the block is -//! **h-centered with its bottom at** the projected point (rising above it), clamped on-screen; +//! **h-centered with its bottom at** the projected point (rising above it), clamped on-screen — +//! but only once the projector has ACCEPTED the point at all: a number whose anchor lands behind +//! the camera or outside the viewport is **destroyed and its slot released** +//! (`6c7d9d` → `6c6dda` → `0x6c86a0`; wow-re `object-layer/scratch/nameplate-offscreen-cull.md`, +//! §5-VERIFIED 2026-08-15), never clamped to a border and never claiming a bucket-1 rect. Ours +//! clamped, which put another unit's numbers on the screen edge — the worldtext half of 1341's +//! phantom plates, and the half that record's §7 wrongly called byte-attested; //! the on-screen size is //! **constant with unit distance** (the depth counter-scale exists only in the *nameplate* path) //! and composes as `px = round_half_away(v × √(W²+H²))` — `v` the category's interpolated scale @@ -261,6 +267,11 @@ pub(crate) fn float_combat_text( bucket.clear(); let mut order: Vec = (0..texts.0.len()).collect(); order.sort_by_key(|&i| (texts.0[i].anchor, std::cmp::Reverse(texts.0[i].slot))); + // A number whose anchor fails the projector's accept verdict is **destroyed, and its slot + // released** (`6c7d9d` → `6c6dda` → `0x6c86a0`) — not skipped for a frame, and never clamped + // to a border (wow-re `object-layer/scratch/nameplate-offscreen-cull.md`, §5-VERIFIED + // 2026-08-15). Collected here and reaped after the walk, since the walk holds the list. + let mut culled: Vec = Vec::new(); for i in order { let t = &texts.0[i]; let cat = &CATEGORIES[t.category as usize]; @@ -269,18 +280,21 @@ pub(crate) fn float_combat_text( // The rise enters world z BEFORE the projection (`6c7d29 fadd` into `+0x1c`, projected at // `6c7d96`) — no post-projection y term exists. let world = t.pos + Vec3::Y * (cat.rise * life); - let Ok(screen) = cam.world_to_viewport(&cam_tf, world) else { - continue; // behind the camera + let Some(screen) = crate::ui_pass::project_overlay(cam, &cam_tf, world, viewport) else { + culled.push(i); // behind the camera or off the viewport — the ref destroys it + continue; }; // The size law: constant with distance (no depth term anywhere in the worldtext path). let size_value = scale_value(t.category, life); let target_px = text_px(size_value, viewport); - // Shape at the atlas size nearest the target (crisp bitmaps), then scale the quad rects - // about the anchor point for the exact size — the crit pop animates through sizes the - // atlas never baked. - let shaped_px = atlas.snap_size(target_px); + // Laid out AT the target size. The crit pop animates through a continuum of sizes, which + // under the old fixed ladder meant shaping at the nearest rung and rescaling every quad + // about the anchor — a resampled bitmap for the whole animation. Now the pop steps through + // whole device-pixel sizes and each one is rasterized crisp (decision 1342). The real + // client stretched a ≤32 px raster here; rendering the true size is the recorded upgrade. + let mut e = atlas.lock(); let mut glyphs = layout_text_quads( - atlas, + &mut e, &t.text, Rect::from_center_size(screen, Vec2::ZERO), argb(t.color), @@ -290,21 +304,16 @@ pub(crate) fn float_combat_text( }, Z_WORLD_TEXT, FontSpec { - path: None, // DAMAGE_TEXT_FONT = Friz Quadrata (the atlas default), no outline - height: Some(shaped_px), + path: None, // DAMAGE_TEXT_FONT = Friz Quadrata (the default face), no outline + height: Some(target_px), outline: Outline::None, - paint_halo: true, alpha_gradient: None, }, ); - let ratio = target_px / shaped_px; + drop(e); let (alpha_text, alpha_shadow) = fade_alpha(cat, elapsed_ms); let mut bounds: Option = None; for q in &mut glyphs { - q.rect = Rect { - min: screen + (q.rect.min - screen) * ratio, - max: screen + (q.rect.max - screen) * ratio, - }; // REPLACE, never multiply: the fade byte IS the rendered alpha (module doc, the // ALPHA + SHADOW law) — the packed color's alpha byte (row 4's 0x80) never draws. q.color[3] = f32::from(alpha_text) / 255.0; @@ -365,6 +374,29 @@ pub(crate) fn float_combat_text( } quads.overlays.append(&mut glyphs); } + // The destroy half of the cull above. Releasing the SLOT is the point, not just skipping the + // draw: the per-unit array is 4 deep and a spawn past it is a hard drop, so a number aging out + // of sight behind you would otherwise hold a slot a visible number could have had. + drop_indices(&mut texts.0, culled); +} + +/// Remove `idx` (in any order, no duplicates) from `v`, keeping the rest in order. +/// +/// Its own function because the walk that decides *which* numbers to destroy runs over a sorted +/// index permutation while the list is borrowed, so the removal has to happen afterwards by index +/// — and index bookkeeping under removal is exactly the kind of thing that is quietly wrong for a +/// month. Tested below. +fn drop_indices(v: &mut Vec, mut idx: Vec) { + if idx.is_empty() { + return; + } + idx.sort_unstable(); + let mut i = 0; + v.retain(|_| { + let keep = idx.binary_search(&i).is_err(); + i += 1; + keep + }); } /// The MELEE number/word producer: consumes [`SwingImpact`] (the swing clip's impact keyframe — @@ -479,4 +511,20 @@ mod tests { let gold = texts.0.iter().find(|t| t.anchor == other).unwrap(); assert_eq!(gold.color, COLOR_SPELL_GOLD); } + + /// The destroy half of the off-screen cull (1344): the walk collects indices out of order, + /// against the *pre-removal* list, so the reap has to take them all at once and keep the rest + /// in order. Slots are freed by this and by nothing else. + #[test] + fn drop_indices_takes_exactly_those() { + let mut v = vec!["a", "b", "c", "d", "e"]; + drop_indices(&mut v, vec![3, 0, 1]); // unsorted, as the seat walk produces them + assert_eq!(v, ["c", "e"]); + drop_indices(&mut v, vec![]); + assert_eq!(v, ["c", "e"], "no culls, no change"); + drop_indices(&mut v, vec![1]); + assert_eq!(v, ["c"]); + drop_indices(&mut v, vec![0]); + assert!(v.is_empty()); + } } diff --git a/crates/benilla-app/src/loading_screen.rs b/crates/benilla-app/src/loading_screen.rs index a7d83b98..51a1efd0 100644 --- a/crates/benilla-app/src/loading_screen.rs +++ b/crates/benilla-app/src/loading_screen.rs @@ -118,6 +118,15 @@ impl LoadingScreen { self.active } + /// An active cover, for tests that drive covered-frame accounting (the entry-load deferral). + #[cfg(test)] + pub(crate) fn test_covering() -> Self { + Self { + active: true, + ..Self::default() + } + } + /// Raise (or re-arm) the screen for a fresh load. `awaiting_snap` marks a raise whose /// destination snap is still in flight; `map` is the destination when the edge knows it. fn raise(&mut self, reason: &str, awaiting_snap: bool, map: Option, now: f32) { @@ -164,34 +173,13 @@ fn setup_loading_screen( world_assets: Option>, mut images: ResMut>, ) { - let Some(mut assets) = world_assets else { - return; - }; - - // Catalog (LoadingScreenID → BLP path). - match load_loading_screens(&mut assets.chain.lock_recover()) { - Ok(c) => { - info!("LoadingScreens.dbc: {} screens catalogued", c.len()); - commands.insert_resource(LoadingScreenCatalogRes(c)); - } - Err(e) => { - error!("LoadingScreens.dbc unavailable, loading screen disabled: {e:#}"); - return; - } - } - - // The two layers 1.12 actually draws (sRGB clamp sprites — UI art, not tiling world art). - let mut tex = |path: &str| assets.sprite_texture(path, &mut images); - let Some(border) = tex("Interface\\Glues\\LoadingBar\\Loading-BarBorder.blp") else { - error!("loading bar textures missing, loading screen disabled"); - return; - }; - let fill = tex("Interface\\Glues\\LoadingBar\\Loading-BarFill.blp").unwrap_or_default(); - // The spawned ImageNodes below own these handles (the root is never despawned, only hidden), so - // the textures stay resident without a separate holder resource. - - // Root: fullscreen black — this IS the pillarbox letterbox. Flex-centres the 4:3 content area. - commands + // Root: fullscreen black — this IS the pillarbox letterbox, and it spawns UNCONDITIONALLY. + // Every `covering()` consumer (the world camera stays active to warm pipelines, the warm + // pass, the settle hold, and now the audio hold) behaves as if covered whenever + // `LoadingScreen::active` is true — so a missing catalog or bar texture must degrade to a + // plain black cover, never to "no cover node at all" while the whole client pretends one is + // up (the naked-world failure this used to be). Flex-centres the 4:3 content area. + let root = commands .spawn(( LoadingRoot, Node { @@ -207,73 +195,101 @@ fn setup_loading_screen( GlobalZIndex(1000), Visibility::Hidden, )) - .with_children(|root| { - // The whole loading screen renders in one 4:3 area fit to viewport height, centred, with - // the root's black showing as pillarbox bars L/R (the reference's behaviour — the glue/load - // screen is authored 4:3; on a wider window it letterboxes). Width = height·4/3 in vh. - // The backdrop + bar are children, so their verified fractions are relative to THIS area. - // Sibling paint order = spawn order: backdrop → fill → border (border on top). - root.spawn(Node { - width: Val::Vh(100.0 * BACKDROP_ASPECT), - height: Val::Vh(100.0), - position_type: PositionType::Relative, - ..default() - }) - .with_children(|area| { - // Backdrop art: the square BLP stretched to fill the 4:3 area (mild widen — what the - // reference does). Starts black (default-white texture tinted) until the art resolves. - area.spawn(( - LoadingBackdrop, - ImageNode { - color: Color::BLACK, - image_mode: NodeImageMode::Stretch, - ..default() - }, - Node { - position_type: PositionType::Absolute, - width: Val::Percent(100.0), - height: Val::Percent(100.0), - ..default() - }, - )); - // Fill — left-anchored; width = progress·FILL_MAX_WIDTH (set each frame). y from the - // BOTTOM (verified by screenshot + binary). The fill art is a horizontally-uniform - // gradient, so width-scaling reads as a left→right reveal. - area.spawn(( - LoadingBarFill, - Node { - position_type: PositionType::Absolute, - left: Val::Percent(FILL_LEFT * 100.0), - bottom: Val::Percent(FILL_BOTTOM * 100.0), - width: Val::Percent(0.0), // set each frame - height: Val::Percent(FILL_HEIGHT * 100.0), - ..default() - }, - ImageNode { - image: fill, - image_mode: NodeImageMode::Stretch, - ..default() - }, - )); - // Border frame, on top. - area.spawn(( - LoadingBarBorder, - Node { - position_type: PositionType::Absolute, - left: Val::Percent(BORDER_LEFT * 100.0), - bottom: Val::Percent(BORDER_BOTTOM * 100.0), - width: Val::Percent(BORDER_WIDTH * 100.0), - height: Val::Percent(BORDER_HEIGHT * 100.0), - ..default() - }, - ImageNode { - image: border, - image_mode: NodeImageMode::Stretch, - ..default() - }, - )); - }); + .id(); + + let Some(mut assets) = world_assets else { + return; + }; + + // Catalog (LoadingScreenID → BLP path). + match load_loading_screens(&mut assets.chain.lock_recover()) { + Ok(c) => { + info!("LoadingScreens.dbc: {} screens catalogued", c.len()); + commands.insert_resource(LoadingScreenCatalogRes(c)); + } + Err(e) => { + error!("LoadingScreens.dbc unavailable — plain black cover, no art: {e:#}"); + return; + } + } + + // The two layers 1.12 actually draws (sRGB clamp sprites — UI art, not tiling world art). + let mut tex = |path: &str| assets.sprite_texture(path, &mut images); + let Some(border) = tex("Interface\\Glues\\LoadingBar\\Loading-BarBorder.blp") else { + error!("loading bar textures missing — plain black cover, no art"); + return; + }; + let fill = tex("Interface\\Glues\\LoadingBar\\Loading-BarFill.blp").unwrap_or_default(); + // The spawned ImageNodes below own these handles (the root is never despawned, only hidden), so + // the textures stay resident without a separate holder resource. + + commands.entity(root).with_children(|root| { + // The whole loading screen renders in one 4:3 area fit to viewport height, centred, with + // the root's black showing as pillarbox bars L/R (the reference's behaviour — the glue/load + // screen is authored 4:3; on a wider window it letterboxes). Width = height·4/3 in vh. + // The backdrop + bar are children, so their verified fractions are relative to THIS area. + // Sibling paint order = spawn order: backdrop → fill → border (border on top). + root.spawn(Node { + width: Val::Vh(100.0 * BACKDROP_ASPECT), + height: Val::Vh(100.0), + position_type: PositionType::Relative, + ..default() + }) + .with_children(|area| { + // Backdrop art: the square BLP stretched to fill the 4:3 area (mild widen — what the + // reference does). Starts black (default-white texture tinted) until the art resolves. + area.spawn(( + LoadingBackdrop, + ImageNode { + color: Color::BLACK, + image_mode: NodeImageMode::Stretch, + ..default() + }, + Node { + position_type: PositionType::Absolute, + width: Val::Percent(100.0), + height: Val::Percent(100.0), + ..default() + }, + )); + // Fill — left-anchored; width = progress·FILL_MAX_WIDTH (set each frame). y from the + // BOTTOM (verified by screenshot + binary). The fill art is a horizontally-uniform + // gradient, so width-scaling reads as a left→right reveal. + area.spawn(( + LoadingBarFill, + Node { + position_type: PositionType::Absolute, + left: Val::Percent(FILL_LEFT * 100.0), + bottom: Val::Percent(FILL_BOTTOM * 100.0), + width: Val::Percent(0.0), // set each frame + height: Val::Percent(FILL_HEIGHT * 100.0), + ..default() + }, + ImageNode { + image: fill, + image_mode: NodeImageMode::Stretch, + ..default() + }, + )); + // Border frame, on top. + area.spawn(( + LoadingBarBorder, + Node { + position_type: PositionType::Absolute, + left: Val::Percent(BORDER_LEFT * 100.0), + bottom: Val::Percent(BORDER_BOTTOM * 100.0), + width: Val::Percent(BORDER_WIDTH * 100.0), + height: Val::Percent(BORDER_HEIGHT * 100.0), + ..default() + }, + ImageNode { + image: border, + image_mode: NodeImageMode::Stretch, + ..default() + }, + )); }); + }); } /// Per-frame: observe the lifecycle edges, run the trigger/clear state machine, resolve backdrop @@ -303,6 +319,11 @@ fn drive_loading_screen( // The 0837 warm pass: the cover holds until the menagerie's pipelines have compiled — the // point of the cover is that NOTHING first-sight-compiles after it lifts. warm: Res, + // The deferred world-entry UI load (0962's frame accounting, applied to 1051's burst): + // armed at the entry edge, run behind this cover a few frames in. While it is still + // pending the reveal would show a world with no interface — the reference's reveal always + // has the UI up, because its UI load happens inside its blocking world load. + entry_ui_pending: Option>, // The lifecycle edges (decisions 0737/0738), all observed from what the net bridge already // publishes — this module owns the whole state machine, nothing else is instrumented for it. edges: ( @@ -425,6 +446,7 @@ fn drive_loading_screen( && !screen.awaiting_snap && !player_settling && warm.satisfied() + && entry_ui_pending.is_none() { screen.ready_frames += 1; if screen.ready_frames >= CLEAR_AFTER_READY_FRAMES { @@ -445,7 +467,7 @@ fn drive_loading_screen( screen.last_wait_log = now; info!( "loading screen: waiting {:.1}s — {}/{} resident, {} placements pending, \ - {} colliders pending, awaiting_snap={}, settling={}, warm={}", + {} colliders pending, awaiting_snap={}, settling={}, warm={}, ui_pending={}", now - screen.active_since, progress.ready, progress.total, @@ -454,6 +476,7 @@ fn drive_loading_screen( screen.awaiting_snap, player_settling, warm.satisfied(), + entry_ui_pending.is_some(), ); } } diff --git a/crates/benilla-app/src/nameplates.rs b/crates/benilla-app/src/nameplates.rs index 067e2160..1b982d9c 100644 --- a/crates/benilla-app/src/nameplates.rs +++ b/crates/benilla-app/src/nameplates.rs @@ -163,20 +163,26 @@ impl NamePaint { /// atlas exists), one mesh per distinct line stack (every "Young Wolf" shares one mesh), and the /// live per-unit plate entities. /// -/// **Every field here is atlas-derived and dies with the bake it was built from** ([`baked_from`]). -/// The meshes carry glyph-cell UVs in their vertex data ([`build_name_mesh`]) and the materials -/// bind the atlas image by handle — both are readings of ONE `UiFontAtlas`, and a re-bake -/// (`ui_text::atlas`'s `BakeEnv` edge, decision 1296) replaces that atlas with a new image and a -/// new cell layout. Keeping either across the edge draws new-bake UVs through the old-bake -/// texture: the garbage-glyph unit names the maximised window produced (B272, decision 1339). +/// **The meshes are glyph-cell UVs on the GPU, and they die with the cell layout they were built +/// from** ([`baked_from`]). This is the one place in the codebase that persists glyph UVs across +/// frames ([`build_name_mesh`] bakes them into `ATTRIBUTE_UV_0`), which makes it the one place that +/// has to watch [`UiFontAtlas::generation`]. +/// +/// The **materials** used to be on that list and no longer are: they bind the glyph sheet by +/// handle, and since decision 1342 that texture is written in place and its handle never changes. +/// It was the old size ladder's re-bake — a whole new image asset per window resize — that made a +/// bound handle go stale, and drawing new-bake UVs through the old-bake texture is what turned unit +/// names into fragments of other letters (B272, decision 1339). There is no successor texture now, +/// so there is nothing for a material to go stale against; only the UVs can move, and only when the +/// sheet fills and resets. #[derive(Resource, Default)] pub(crate) struct Nameplates { materials: HashMap>, meshes: HashMap, Handle>, /// unit → (plate entity, the (lines, color) it was built with). live: bevy::ecs::entity::EntityHashMap<(Entity, Vec, NamePaint)>, - /// The `UiFontAtlas::generation` every cache above was built from — `None` before the first - /// build. [`drop_stale_atlas_caches`] empties all three when it moves. + /// The `UiFontAtlas::generation` the meshes' UVs were built from — `None` before the first + /// build. [`drop_stale_glyph_caches`] empties them when it moves. baked_from: Option, } @@ -213,8 +219,15 @@ const ROCK_MEAN_RATE: f32 = 1.5; /// mean to it instead of easing through seconds of misplaced name. const ROCK_SNAP: f32 = 1.0; -/// The shaped-glyph source size (px) for the mesh bake — a baked atlas size (crisp cells); only -/// the cell UVs and the relative geometry survive the normalization to pitch units. +/// The source size (logical px) the name glyphs are rasterized at for the mesh bake. Only the cell +/// UVs and the *relative* geometry survive the normalization to pitch units, so this is purely a +/// resolution dial: the world transform scales the block to its real size, and a bigger source +/// means crisper letters when a name is close. +/// +/// The real client's unit-name font is pinned at a 32-px raster and magnifies the cells for every +/// plate (`UNIT_NAME_FONT`, size `0.99f` with string flag `0x80` — wow-re `system/font`, so its +/// world text was always a stretched bitmap). Rendering the source larger is the same recorded +/// crispness divergence world text already takes at the [`crate::ui_text::FONTSTRING_EM_CAP`]. const BAKE_PX: f32 = 36.0; /// Build one name-block mesh in **name-local pitch units**: x centered on 0 (exact centering for @@ -239,11 +252,17 @@ fn build_name_mesh(atlas: &mut UiFontAtlas, lines: &[String]) -> Mesh { // asc/(asc+|desc|))` load_param the 2-D path seats with) — needed to hang each line FROM its // baseline. Invariant to the ratio's value: `line_top` shifts with it but the baseline stays // pinned to the byte grid, so this only has to AGREE with `layout_text_quads`, not be exact. - let baseline_frac = ((f64::from(BAKE_PX) * f64::from(atlas.ascent_ratio(None)) + 0.5).floor() - / f64::from(BAKE_PX)) as f32; + let mut e = atlas.lock(); + // The size the glyphs are ACTUALLY laid out at: [`BAKE_PX`] rounded to whole device pixels and + // back ([`TextEngine::ppem`]). Normalizing by the requested 36 instead would be off by up to + // half a device pixel per unit of pitch on a fractional-DPI display — small, but it is exactly + // the kind of "close enough" that the size ladder was made of. + let src = e.drawn_size(BAKE_PX); + let baseline_frac = + ((f64::from(src) * f64::from(e.ascent_ratio(None)) + 0.5).floor() / f64::from(src)) as f32; for (i, line) in lines.iter().enumerate() { let glyphs = layout_text_quads( - atlas, + &mut e, line, Rect::from_center_size(Vec2::ZERO, Vec2::ZERO), [1.0, 1.0, 1.0, 1.0], // the material tints; glyph alpha rides the texture @@ -253,10 +272,9 @@ fn build_name_mesh(atlas: &mut UiFontAtlas, lines: &[String]) -> Mesh { }, 0, FontSpec { - path: None, // UNIT_NAME_FONT = Friz Quadrata, the atlas default + path: None, // UNIT_NAME_FONT = Friz Quadrata, the default face height: Some(BAKE_PX), outline: Outline::None, - paint_halo: true, alpha_gradient: None, }, ); @@ -271,10 +289,10 @@ fn build_name_mesh(atlas: &mut UiFontAtlas, lines: &[String]) -> Mesh { // of a cell below the cell top, so the cell top maps to n − i + baseline_frac. let line_top = n - i as f32 + baseline_frac; for q in &glyphs { - let x0 = (q.rect.min.x - cx) / BAKE_PX; - let x1 = (q.rect.max.x - cx) / BAKE_PX; - let y0 = line_top - q.rect.min.y / BAKE_PX; // px top → local (higher) - let y1 = line_top - q.rect.max.y / BAKE_PX; // px bottom → local (lower) + let x0 = (q.rect.min.x - cx) / src; + let x1 = (q.rect.max.x - cx) / src; + let y0 = line_top - q.rect.min.y / src; // px top → local (higher) + let y1 = line_top - q.rect.max.y / src; // px bottom → local (lower) let base = positions.len() as u32; positions.extend([[x0, y0, 0.0], [x1, y0, 0.0], [x1, y1, 0.0], [x0, y1, 0.0]]); let [tl, tr, br, bl] = q.uv.corners; @@ -283,6 +301,7 @@ fn build_name_mesh(atlas: &mut UiFontAtlas, lines: &[String]) -> Mesh { indices.extend([base, base + 2, base + 1, base, base + 3, base + 2]); } } + drop(e); let mut mesh = Mesh::new( PrimitiveTopology::TriangleList, RenderAssetUsages::default(), @@ -348,8 +367,8 @@ pub(crate) fn drive_nameplates( let (Ok(cam_tf), Some(atlas)) = (camera.single(), atlas.as_mut()) else { return; }; - // A re-baked atlas invalidates every cache below it (see [`Nameplates`]). - drop_stale_atlas_caches(&mut plates, atlas.generation, &mut commands); + // A glyph-sheet reset moves every cached UV (see [`Nameplates`]). + drop_stale_glyph_caches(&mut plates, atlas.generation, &mut commands); // The palette materials, once (they need the atlas image). if plates.materials.is_empty() { let image = atlas.image(); @@ -669,27 +688,30 @@ impl Plugin for NameplatesPlugin { } } -/// Empty every atlas-derived cache when the bake under it moves — the staleness edge [`Nameplates`] +/// Empty the UV-bearing caches when the glyph sheet resets — the staleness edge [`Nameplates`] /// describes. /// -/// A re-bake hands us a different image and a different cell layout, so the meshes' baked UVs and -/// the materials' bound texture both stop meaning what they meant. Dropping the maps alone is not -/// enough: the live plate entities still hold their old `Mesh3d`/`MeshMaterial3d` handles, so they -/// are despawned too and rebuilt from the new atlas on this same frame's walk below (which -/// re-enters every visible unit). That one-frame rebuild is the whole cost of a re-bake here. +/// A reset repacks the sheet from empty, so every UV baked into a mesh stops pointing at its own +/// letter. Dropping the mesh map alone is not enough: the live plate entities still hold their old +/// `Mesh3d` handles, so they are despawned too and rebuilt on this same frame's walk below (which +/// re-enters every visible unit). That one-frame rebuild is the whole cost of a reset here. +/// +/// The **materials survive**, because the sheet's texture handle does (decision 1342). Under the +/// old size ladder this edge fired on every window resize and had to drop them too; now it fires +/// only when the sheet fills, which the occupancy instrument (`WOW_GLYPH_CACHE=1`) exists to keep +/// honest. /// /// The **first** build is not an edge — `baked_from: None` seeds to the live generation with /// nothing to drop. -fn drop_stale_atlas_caches(plates: &mut Nameplates, generation: u64, commands: &mut Commands) { +fn drop_stale_glyph_caches(plates: &mut Nameplates, generation: u64, commands: &mut Commands) { if plates.baked_from == Some(generation) { return; } if plates.baked_from.is_some() { debug!( - "nameplates: atlas re-baked (generation {generation}) — dropping {} meshes, {} \ - materials and {} live plates", + "nameplates: glyph sheet reset (generation {generation}) — dropping {} meshes and {} \ + live plates", plates.meshes.len(), - plates.materials.len(), plates.live.len(), ); for (plate, _, _) in plates.live.values() { @@ -699,15 +721,14 @@ fn drop_stale_atlas_caches(plates: &mut Nameplates, generation: u64, commands: & } plates.live.clear(); plates.meshes.clear(); - plates.materials.clear(); } plates.baked_from = Some(generation); } /// Drop the per-line-stack mesh dedup on a cross-map transition (`world_map::MapChange` — see its /// doc): it grows with every distinct name ever seen and the old map's population never comes -/// back. The palette `materials` stay — they are keyed by the atlas bake, not the map -/// ([`drop_stale_atlas_caches`] owns that edge). +/// back. The palette `materials` stay — they bind the one stable glyph sheet and outlive both this +/// edge and [`drop_stale_glyph_caches`]'s. fn evict_name_meshes( mut changes: MessageReader, mut plates: ResMut, @@ -815,19 +836,19 @@ mod tests { plates } - /// **A re-bake empties every atlas-derived cache, live plates included** (B272, decision 1339). + /// **A sheet reset empties every UV-bearing cache, live plates included** (B272, decision + /// 1339; narrowed by 1342). /// - /// The meshes carry glyph-cell UVs in vertex data and the materials bind the atlas image by - /// handle. A re-bake replaces the atlas with a new image whose HEIGHT is a function of the - /// seam-dependent size ladder, so every normalized V from the old bake addresses a different - /// row of the new one. Keeping the material while rebuilding meshes is what drew new-bake UVs - /// through the old-bake texture — unit names as fragments of other letters. + /// The meshes carry glyph-cell UVs in vertex data, and a reset repacks the sheet from empty, so + /// every one of them stops pointing at its own letter. Dropping the map is not enough on its + /// own: the live plate entities still hold the old `Mesh3d`, so they must be despawned to be + /// rebuilt. That is the assertion a map-clear alone would pass and the screen would not. /// - /// Dropping the maps is not enough on its own: the live plate entities still hold the old - /// `Mesh3d`/`MeshMaterial3d`, so they must be despawned to be rebuilt. That is the assertion - /// the map-clear alone would pass and the screen would not. + /// The materials must **survive** — they bind a texture whose handle never changes. Dropping + /// them was right when a re-bake published a new image every window resize; keeping them now is + /// the difference between rebuilding a handful of meshes and rebuilding the whole palette. #[test] - fn a_rebake_drops_the_meshes_the_materials_and_the_live_plates() { + fn a_sheet_reset_drops_the_meshes_and_the_live_plates_but_not_the_materials() { let mut world = World::new(); let unit = world.spawn_empty().id(); let plate = world.spawn_empty().id(); @@ -836,29 +857,29 @@ mod tests { let mut queue = bevy::ecs::world::CommandQueue::default(); { let mut commands = Commands::new(&mut queue, &world); - drop_stale_atlas_caches(&mut plates, 8, &mut commands); + drop_stale_glyph_caches(&mut plates, 8, &mut commands); } queue.apply(&mut world); assert!(plates.meshes.is_empty(), "stale UVs must not survive"); - assert!( - plates.materials.is_empty(), - "the material binds the OLD atlas image — the half that made it garbage rather than \ - merely soft" + assert_eq!( + plates.materials.len(), + 1, + "the material binds a texture whose handle never moves — dropping it is pure churn" ); assert!(plates.live.is_empty()); assert!( world.get_entity(plate).is_err(), - "a live plate holds the old mesh+material handles: clearing the maps around it leaves \ - the wrong cells on screen" + "a live plate holds the old mesh handle: clearing the map around it leaves the wrong \ + cells on screen" ); assert_eq!(plates.baked_from, Some(8)); } - /// The FIRST build is not an edge — there is nothing baked from a previous atlas to drop, and + /// The FIRST build is not an edge — there is nothing from a previous cell layout to drop, and /// treating it as one would despawn the plates the same frame they were spawned. #[test] - fn the_first_bake_seeds_without_dropping_anything() { + fn the_first_build_seeds_without_dropping_anything() { let mut world = World::new(); let unit = world.spawn_empty().id(); let plate = world.spawn_empty().id(); @@ -867,7 +888,7 @@ mod tests { let mut queue = bevy::ecs::world::CommandQueue::default(); { let mut commands = Commands::new(&mut queue, &world); - drop_stale_atlas_caches(&mut plates, 0, &mut commands); + drop_stale_glyph_caches(&mut plates, 0, &mut commands); } queue.apply(&mut world); @@ -877,9 +898,10 @@ mod tests { assert_eq!(plates.baked_from, Some(0)); } - /// The steady frame — every frame that is not a re-bake — costs nothing and keeps everything. + /// The steady frame — every frame that is not a reset, which is very nearly all of them — + /// costs nothing and keeps everything. #[test] - fn an_unmoved_bake_is_a_no_op() { + fn an_unmoved_generation_is_a_no_op() { let mut world = World::new(); let unit = world.spawn_empty().id(); let plate = world.spawn_empty().id(); @@ -888,7 +910,7 @@ mod tests { let mut queue = bevy::ecs::world::CommandQueue::default(); { let mut commands = Commands::new(&mut queue, &world); - drop_stale_atlas_caches(&mut plates, 3, &mut commands); + drop_stale_glyph_caches(&mut plates, 3, &mut commands); } queue.apply(&mut world); diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index 661f61b0..21530225 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -138,10 +138,29 @@ impl Plugin for NetPlugin { .in_set(WorldStage::Net), ) // Not part of the movement chain above: one send on the world-enter message. - .add_systems(Update, send_query_time.in_set(WorldStage::Net)); + .add_systems(Update, send_query_time.in_set(WorldStage::Net)) + .add_systems(Update, population_pulse.in_set(WorldStage::Net)); } } +/// The population pulse (`WOW_MOVE_TRACE_TAGS=pop`): once a second, one trace line with the count +/// of net entities resident in the object index. The "did the world arrive" instrument, born as +/// decision 1340's retest: a teleport's visibility refresh (the release-time position report) +/// reads as the count climbing within a couple of seconds of the `sett` release, while the ~20 s +/// lazy-relocation pop-in the report guards against reads as the count sitting still until the +/// server's own timer fires. Free when the tag is off. +fn population_pulse(index: Res, time: Res