glyphs at their true size — the ladder that scrambled text is gone

- text rasterizes every glyph at its exact on-screen size, on demand — the fixed size ladder and its rescale-after-snap are gone, the machinery that left names scrambled and lines off their baseline after a window resize
- the worldport ack now rides the load's completion the way the real client sends it, and the near-teleport echo stands alone as the whole handshake
- a floating combat number whose anchor leaves the screen is destroyed rather than dragged to the border, and a trailing line break ends its line instead of opening a new one
- entering the world, the loading cover provably presents before the UI builds behind it, holds the world's audio while it is up, and fails closed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-16 17:45:34 +07:00
co-authored by Claude Fable 5
parent dfa369127c
commit 77b54ab3cc
31 changed files with 3718 additions and 2925 deletions
@@ -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::<f64>("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
}
+20 -31
View File
@@ -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={:?}",
+64 -16
View File
@@ -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<usize> = (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<usize> = 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<Rect> = 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<T>(v: &mut Vec<T>, mut idx: Vec<usize>) {
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());
}
}
+118 -95
View File
@@ -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<u32>, now: f32) {
@@ -164,34 +173,13 @@ fn setup_loading_screen(
world_assets: Option<ResMut<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
) {
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<crate::pipe_warm::WarmPass>,
// 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<Res<crate::ui_script::PendingEntryUiLoad>>,
// 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(),
);
}
}
+79 -57
View File
@@ -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<NamePaint, Handle<StandardMaterial>>,
meshes: HashMap<Vec<String>, Handle<Mesh>>,
/// unit → (plate entity, the (lines, color) it was built with).
live: bevy::ecs::entity::EntityHashMap<(Entity, Vec<String>, 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<u64>,
}
@@ -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<benilla_world::world_map::MapChange>,
mut plates: ResMut<Nameplates>,
@@ -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);
+21 -1
View File
@@ -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<GuidIndex>, time: Res<Time>, mut last: Local<f32>) {
if !benilla_assets::trace::enabled_for("pop") {
return;
}
let now = time.elapsed_secs();
if now - *last < 1.0 {
return;
}
*last = now;
benilla_assets::trace::line("pop", &format!("net entities={}", index.0.len()));
}
// ── ECS state: components ────────────────────────────────────────────────────────────────────────
/// The server guid of a streamed entity. The one stable identity for a unit/player/GameObject.
@@ -698,6 +717,7 @@ pub(crate) static CAST_TRACE: std::sync::LazyLock<bool> =
/// A message the ECS sends to the server through the write thread. Carries the player's pose so the
/// writer thread needs no shared state.
#[derive(Debug)]
pub(crate) enum ClientCommand {
/// A self-movement packet: the [`MoveKind`] opcode + the live CMovement `flags` + our pose, plus the
/// airborne clock (`fall_time`, ms) and ballistic launch tail (`jump`) while jumping/falling. The
+8
View File
@@ -612,6 +612,14 @@ pub(crate) struct Player {
/// it drops the ride + spline without mirroring the stale flight pose over the snap (the
/// 4-yd-hover + full-6s-settle landing bug, decision 0501) and owes no `CMSG_MOVE_SPLINE_DONE`.
pub(super) ride_abort: bool,
/// **A `MSG_MOVE_WORLDPORT_ACK` is owed, payable at the settle release** (decision 1340).
/// The real client sends the worldport ack as the LAST act of its blocking destination load
/// (wow-re: `0x401bc0` sends `0xDC` at `0x401cae` only after `0x66fbe0`'s load returns) — our
/// async re-expression of "after the load" is the release. Safe to defer: vmangos has no load
/// timeout, drops every packet we'd send meanwhile (the player is out-of-world for the whole
/// window), and force-acks at logout. Set by the non-riding worldport snap; a riding crossing
/// (0455) never settles and acks immediately, as before.
pub(crate) owes_worldport_ack: bool,
/// `Time::elapsed_secs` when we last sent a heartbeat.
pub(super) last_heartbeat: f32,
/// `Time::elapsed_secs` when the current airborne phase (jump or step-off) began, else `None` on the
+35 -31
View File
@@ -21,7 +21,7 @@ use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
use crate::creature_anim::{move_flags, wrap_pi};
use crate::net::{
ClientCommand, ClientControlMessage, Embodied, Guid, MoveKind, MoveModeMessage, NetCommands,
ClientCommand, ClientControlMessage, Embodied, Guid, MoveModeMessage, NetCommands,
SelfMoveMessage, SpeedChangeMessage, TeleportMessage, WorldportMessage,
};
use crate::transport::Transport;
@@ -266,17 +266,25 @@ pub(super) fn apply_server_moves(
// loaded map. If terrain setup never ran (no `./WoW/`), inserting is harmless.
commands.insert_resource(CurrentMap(w.map_id));
if w.needs_ack {
let _ = net_cmds.0.send(ClientCommand::WorldportAck);
info!(
"worldport: mapId {} @ {:?} ({}, acked)",
w.map_id,
w.position,
if riding {
"riding, boat-local pose"
} else {
"world pose"
}
);
if riding {
// A riding crossing never settles (the deck is the support, 0455) — ack now,
// exactly as before 1340.
let _ = net_cmds.0.send(ClientCommand::WorldportAck);
info!(
"worldport: mapId {} @ {:?} (riding, boat-local pose, acked)",
w.map_id, w.position
);
} else {
// The ack rides the settle release (decision 1340): the real client sends
// MSG_MOVE_WORLDPORT_ACK only after its blocking destination load completes, and
// vmangos keeps us out-of-world — dropping everything we'd send — until the ack
// lands, with no load timeout. See [`Player::owes_worldport_ack`].
player.owes_worldport_ack = true;
info!(
"worldport: mapId {} @ {:?} (world pose, ack deferred to release)",
w.map_id, w.position
);
}
} else {
info!(
"worldport: initial login on mapId {} @ {:?}",
@@ -304,29 +312,20 @@ pub(super) fn apply_server_moves(
// and drops the ride instead of mirroring the stale flight pose back over this snap
// (decision 0501 — the 4-yd hover + full-6s settle at every taxi landing).
player.ride_abort = true;
// The echo goes now, and it is the WHOLE near-teleport handshake (decision 1340). The
// real client echoes on the very next movement tick (wow-re: the 0xC7 drain applies the
// snap, then `0x60e0a0` sends guid+counter+time) and sends nothing else — no
// `MSG_MOVE_STOP` exists anywhere in its chain. vmangos holds us at the OLD position
// until the echo lands; processing it runs the full relocation + visibility refresh
// (`ExecuteTeleportNear` → `TeleportPositionRelocation`), and the destination's units
// stream within ~1 s of this send — measured with the `pop` pulse, four legs, no
// stall. The invented echo-time Stop this arm used to send served nothing; if a
// silent-player pop-in ever resurfaces, `WOW_MOVE_TRACE_TAGS=pop` decides in one run.
let _ = net_cmds.0.send(ClientCommand::TeleportAck {
guid: t.guid,
counter: t.counter,
});
// After the ack, report our settled position. vmangos refreshes a STATIONARY player's
// surrounding object visibility only on its lazy relocation timer (~20s observed), but forces
// an immediate refresh on any received movement packet. Without this, the NPCs/GameObjects at
// the destination don't appear for ~20s after a teleport (yet a fresh login is instant, because
// that does a full world-enter) — the real client reports its position, so they show at once.
let _ = net_cmds.0.send(ClientCommand::Move {
kind: MoveKind::Stop,
flags: 0,
pos: t.position,
orientation: t.orientation,
pitch: 0.0, // a Stop clears the flags → not swimming → no pitch tail
fall_time: 0,
jump: None,
transport: None, // flags 0 → no transport tail
});
info!(
"teleport: snapped to {:?}, acked + reported position",
t.position
);
info!("teleport: snapped to {:?}, acked", t.position);
}
// A bare server-authored move for our own mover (decision 0725) — no handshake, no ack. Drained
// after the teleport arms deliberately: a teleport in the same frame is the larger edge (it
@@ -609,6 +608,11 @@ pub(super) fn release_on_session_end(
player.airborne_since = None;
player.wedged = false;
player.wedge_still = 0;
// The arrival debt dies with the session (decision 1340): a logout mid-settle must not
// carry a worldport ack into the NEXT login's settle release — the server would reject
// an ack from a player who is in world (vmangos ProcessPackets, STATUS_TRANSFER), and
// the old session's transfer was already force-acked server-side at logout.
player.owes_worldport_ack = false;
}
}
@@ -106,6 +106,7 @@ pub(super) fn release_post_snap_hold(
progress: Option<Res<WorldLoadProgress>>,
time: Res<Time>,
mut last_counters: Local<Option<[usize; 4]>>,
net_cmds: Option<Res<crate::net::NetCommands>>,
) {
let Some(p) = progress else { return };
// **The facts must be about the ground under our own feet** (decision 1336, B263 round 3).
@@ -151,6 +152,17 @@ pub(super) fn release_post_snap_hold(
} else if now >= player.settle_deadline {
player.end_settle(false, now);
}
// **Pay the worldport ack the moment the hold ends** (decision 1340) — on either end, the
// resident release or the stall timeout (a dead stream must still complete the transfer, or
// the server holds us out-of-world until logout). This is the real client's post-load `0xDC`,
// re-expressed: its blocking load's "done" is our release.
if !player.settling && player.owes_worldport_ack {
if let Some(net) = net_cmds.as_deref() {
player.owes_worldport_ack = false;
let _ = net.0.send(crate::net::ClientCommand::WorldportAck);
info!("worldport: ack sent at settle release");
}
}
}
#[cfg(test)]
@@ -334,4 +346,77 @@ mod tests {
step(&mut app, 0.05, |p| p.focus_tile = Some(own_tile()));
assert!(!settling(&mut app), "matching residency must still release");
}
/// Decision 1340: the arrival debts — the deferred worldport ack and the near-teleport
/// position report — are paid on the frame the hold ends, and exactly once. The ack is the
/// real client's post-load `0xDC`, so it must ride the release, never the snap — and go
/// out exactly once.
#[test]
fn the_resident_release_pays_the_worldport_ack_once() {
let mut app = app();
let (tx, rx) = crossbeam_channel::unbounded();
app.insert_resource(crate::net::NetCommands(tx));
{
let mut player = app.world_mut().resource_mut::<Player>();
player.world_stale = false;
player.owes_worldport_ack = true;
}
// Still streaming: settling holds, nothing is sent.
step(&mut app, 0.1, |p| {
p.total = 2000;
p.ready = 1500;
p.colliders_pending = 5;
p.scene_ready = false;
});
assert!(settling(&mut app));
assert!(
rx.try_recv().is_err(),
"the ack went out before the release"
);
// Residency lands: the hold ends and the ack goes out.
step(&mut app, 0.1, |p| {
p.ready = 2000;
p.colliders_pending = 0;
p.scene_ready = true;
});
assert!(!settling(&mut app));
let sent: Vec<crate::net::ClientCommand> = rx.try_iter().collect();
assert!(
matches!(sent[..], [crate::net::ClientCommand::WorldportAck]),
"exactly one worldport ack at the release, got {sent:?}"
);
// Paid once: further quiet frames send nothing more.
step(&mut app, 0.1, |_| {});
assert!(rx.try_recv().is_err(), "the ack was paid twice");
}
/// The stall timeout pays the ack too: a dead stream must still complete the transfer —
/// vmangos keeps an unacked far teleport out-of-world (dropping every packet) until logout,
/// so a release with no ack would strand the session, silently.
#[test]
fn a_timeout_release_still_pays_the_ack() {
let mut app = app();
let (tx, rx) = crossbeam_channel::unbounded();
app.insert_resource(crate::net::NetCommands(tx));
{
let mut player = app.world_mut().resource_mut::<Player>();
player.world_stale = false;
player.owes_worldport_ack = true;
}
// Frozen counters, scene never presentable: first frame baselines, then the budget runs.
for _ in 0..=(SETTLE_TIMEOUT + 2.0) as usize {
step(&mut app, 1.0, |p| {
p.total = 2000;
p.ready = 500;
p.colliders_pending = 300;
p.scene_ready = false;
});
}
assert!(!settling(&mut app), "the stall backstop never fired");
let sent: Vec<crate::net::ClientCommand> = rx.try_iter().collect();
assert!(
matches!(sent[..], [crate::net::ClientCommand::WorldportAck]),
"the timeout release must still ack the transfer, got {sent:?}"
);
}
}
+12
View File
@@ -154,6 +154,14 @@ pub(super) fn play_kit_ext(
source: Option<Entity>,
force_loop: bool,
) -> Result<()> {
// The cover's audio hold ([`SoundConfig::world_hold`]): while the loading screen is up, no
// new sound starts — checked before anything allocates, so the per-frame retry drivers (the
// creature body-loop, the liquid loops) cost one bool while held and start clean at the
// reveal. A dropped one-shot under an opaque cover is a sound the reference never played:
// its world load blocks, and the events these sounds voice happen unheard.
if config.world_hold {
return Ok(());
}
let kit = match kit_ref {
KitRef::Id(id) => kits.catalog.get(id),
KitRef::Name(name) => kits.catalog.by_name(name),
@@ -351,6 +359,10 @@ pub(crate) fn play_file(
path: &str,
category: SoundCategory,
) -> Result<()> {
// The cover's audio hold — same gate as [`play_kit_ext`], same reason.
if config.world_hold {
return Ok(());
}
let data = kits.sfx(assets, path)?;
let data = data.volume(mixer::amp_to_db(config.category_amp(category)));
let mixer = out.mixer.as_mut().context("no audio device")?;
+31
View File
@@ -107,6 +107,17 @@ pub(crate) struct SoundConfig {
/// persists — to hear what the DBC data asks for. (The reference's route is
/// `/console SoundReverb 1`; benilla has no `ConsoleExec` yet.)
pub reverb: bool,
/// **The loading cover's audio half** — NOT a CVar, a per-frame live bit fed from
/// [`crate::loading_screen::LoadingScreen::covering`] by [`feed_world_hold`]. While the cover
/// is up, no new sound *starts* ([`kit::play_kit_ext`]/[`kit::play_file`] return early) and
/// the zone beds hard-stop and stay down (`zone::zone_audio`'s hold arm) — the reference
/// blocks on its world load, so nothing world-side is audible under its loading screen, and
/// an async client has to build that observable explicitly (0737's argument, in audio).
/// Sounds already ringing when the cover rises play out — the reference's sound engine keeps
/// running through the load edge too, which is what lets the glue theme's 2 s fade tail
/// (1109) ride under the entry cover exactly as the real client's does: its amp is applied
/// once at start, and a playing stream never passes back through the kit starters.
pub world_hold: bool,
}
impl SoundConfig {
@@ -136,10 +147,27 @@ impl Default for SoundConfig {
music_enabled: true,
ambience_enabled: true,
reverb: false,
world_hold: false,
}
}
}
/// Copy the loading cover's state into [`SoundConfig::world_hold`], once per frame, in
/// `PreUpdate` — before every trigger system, so the whole frame reads one answer. The one-frame
/// lag off the raise (the raise happens in the previous frame's `Present`) is inherent and
/// harmless: the raise frame is the snap frame, and the leaks this closes — units streaming in,
/// footsteps, the fall grunt, the zone beds — all fire on later frames. The lag also keeps the
/// enter-world button's own click audible: it plays on the raise frame, before the bit flips.
fn feed_world_hold(
mut config: ResMut<SoundConfig>,
screen: Res<crate::loading_screen::LoadingScreen>,
) {
let hold = screen.covering();
if config.world_hold != hold {
config.world_hold = hold;
}
}
/// The backend output. `mixer` is `None` when no audio device exists (headless/CI) or
/// `$WOW_NOSOUND` is set — every consumer tolerates silence. A **non-Send** resource: the
/// backend's device stream is not `Send` on every platform, so all audio systems run on the
@@ -224,6 +252,9 @@ impl Plugin for SoundPlugin {
.init_resource::<SoundConfig>()
.init_resource::<AudioListener>()
.add_systems(Startup, hal_overload::setup)
// The cover's audio hold (see [`SoundConfig::world_hold`]): fed in PreUpdate so every
// trigger system this frame — whatever stage it runs in — reads one answer.
.add_systems(PreUpdate, feed_world_hold)
.add_systems(
Update,
(
+28 -1
View File
@@ -280,6 +280,17 @@ fn zone_audio(
let phase = phase(&clock);
let zone = &mut *zone;
// The cover's audio hold ([`SoundConfig::world_hold`]): the raise kills the world
// soundscape — the reference's loading screen sits over a torn-down world, so a departure
// zone's bed playing under the destination's cover is a sound it cannot make. Resetting
// `area`/`interior` with the beds is what makes the reveal a fresh zone entry: the first
// uncovered frame re-resolves the destination and starts its music/ambience through the
// ordinary change block below, exactly like walking into the zone.
if config.world_hold {
stop_world_soundscape(zone, "loading cover");
return;
}
// ---- react to an area or interior change (the WMO row overrides the terrain chain where
// its FKs are nonzero — sound::interior, decision 0076) ----
let inside = interior.0;
@@ -630,6 +641,13 @@ fn server_sounds(
if msgs.is_empty() {
return;
}
// Under the cover a push is dropped, not deferred (the hold, [`SoundConfig::world_hold`]):
// the reference's blocking load hears none of these, and the recurring pushes (the Darkmoon
// Faire emitter re-pushes every 5 s) re-arrive on their own after the reveal.
if config.world_hold {
msgs.clear();
return;
}
let (Some(mut kits), Some(assets)) = (kits, assets) else {
return;
};
@@ -682,6 +700,15 @@ fn server_sounds(
/// counterparts are process-global statics, not per-session state (the 6 s cold-start and the
/// intro throttle survive a relog).
fn leave_world(mut zone: NonSendMut<ZoneAudio>) {
stop_world_soundscape(&mut zone, "left world");
}
/// Stop the beds and reset the scheduler to cold — shared by the two edges where the world's
/// audio dies: leaving the world ([`leave_world`]) and the loading cover rising over it
/// ([`zone_audio`]'s hold arm, decision 1345 — the cover kills the world soundscape exactly
/// as the reference's blocking load does). Idempotent: with the handles already taken, every
/// line is a no-op and nothing logs.
fn stop_world_soundscape(zone: &mut ZoneAudio, reason: &str) {
// The teardown declick — fast enough to read as a cut, long enough not to pop.
const WORLD_TEARDOWN_FADE_MS: u64 = 250;
let had = zone.music.is_some() || zone.ambience.is_some();
@@ -705,7 +732,7 @@ fn leave_world(mut zone: NonSendMut<ZoneAudio>) {
zone.interior = None;
zone.was_underwater = false;
if had {
info!("sound: world soundscape stopped (left world)");
info!("sound: world soundscape stopped ({reason})");
}
}
+61
View File
@@ -350,6 +350,44 @@ impl Material2d for UiQuadMaterial {
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct UiQuadAppend;
/// Project a world point for a world-anchored overlay, **with the reference projector's own accept
/// verdict** — `None` means "this one does not draw", and the caller must honour it.
///
/// `0x483ee0` returns a boolean (wow-re `object-layer/scratch/nameplate-offscreen-cull.md`,
/// §5-VERIFIED 2026-08-15). It rejects a point behind the near plane (`483f7a`) **and** a point
/// outside the viewport — the four tests at `484075`/`484086`/`484096`/`4840a6`, against the
/// **WorldFrame**'s own region (`[0xb4b2bc]`, mirrored ÷G44/÷G48 at `0x483970`), so the accept
/// region is exactly the viewport, inclusive. Both plate and worldtext callers **destroy** the
/// thing they were about to place when it comes back false.
///
/// **The trap this exists to remove:** on the viewport rejections the projector has *already
/// written* the out-param (`484065`/`484067`), so a caller that ignores the verdict reads a
/// perfectly plausible off-screen coordinate and sails on — which is exactly how benilla ended up
/// clamping the plates of units nobody can see onto the screen border (1341). Returning `Option`
/// makes the verdict unignorable at the call site.
///
/// The chat bubble deliberately does **not** call this: the reference's bubble seat is the one
/// caller of the three that ignores the boolean, so a bubble simply slides off the edge with its
/// speaker. Bevy's own `Err` covers only the behind-camera half, which is why the viewport test
/// has to be here rather than left to it.
pub(crate) fn project_overlay(
cam: &Camera,
cam_tf: &GlobalTransform,
world: Vec3,
viewport: Vec2,
) -> Option<Vec2> {
// Bevy's `Err` is the near-plane half (`483f7a`); `accepts` is the viewport half.
let p = cam.world_to_viewport(cam_tf, world).ok()?;
accepts(p, viewport).then_some(p)
}
/// The accept REGION: exactly the viewport, **inclusive**. `[0xb4b2bc]` is the WorldFrame, and its
/// region rect is mirrored ÷G44/÷G48 into the compare fields (`0x483970`), so the aspect factors
/// cancel exactly and the test is against the raw screen box.
fn accepts(p: Vec2, viewport: Vec2) -> bool {
(0.0..=viewport.x).contains(&p.x) && (0.0..=viewport.y).contains(&p.y)
}
/// Owns the player-UI camera + the per-frame quad→mesh rebuild. See the module doc for the
/// camera-order arbitration and the batching/ordering approach.
pub(crate) struct PlayerUiPlugin;
@@ -986,6 +1024,29 @@ mod tests {
app
}
/// The projector's accept region is **exactly the viewport, inclusive** — the WorldFrame's own
/// region mirrored ÷G44/÷G48 (`0x483970`), so the aspect factors cancel. A point on the edge
/// draws (the seat's clamp, not this, is what keeps the rect fully inside); a point outside it
/// does not, however plausible the coordinate the projector already wrote looks. The last row
/// is the measured phantom that bought this: a unit two thirds of a screen below the bottom,
/// whose plate used to be dragged up onto the border (1341).
#[test]
fn the_accept_region_is_the_viewport_inclusive() {
let vp = Vec2::new(1440.0, 810.0);
assert!(accepts(Vec2::new(720.0, 405.0), vp));
assert!(accepts(Vec2::ZERO, vp), "the corner is in view");
assert!(accepts(vp, vp), "so is the far corner");
for out in [
Vec2::new(-0.5, 405.0),
Vec2::new(1440.5, 405.0),
Vec2::new(720.0, -0.5),
Vec2::new(720.0, 810.5),
Vec2::new(1409.0, 1332.0),
] {
assert!(!accepts(out, vp), "{out:?} is not on screen");
}
}
fn batches(app: &mut App) -> usize {
app.world_mut()
.query_filtered::<(), With<UiQuadBatch>>()
+47 -45
View File
@@ -44,19 +44,19 @@ fn report_gate_miss(
now: &[benilla_ui::script::ExtractedQuad],
prev: &[benilla_ui::script::ExtractedQuad],
dims_eq: bool,
bake_eq: bool,
generation_eq: bool,
text_ui_eq: bool,
portraits_eq: bool,
) {
if !dims_eq {
eprintln!("[ui-gate] miss: window/scale changed");
eprintln!("[ui-gate] miss: window size / seam scale / DPI changed");
return;
}
if !bake_eq {
// Named, because it is the one miss whose cause is a QUARTER SECOND behind the input that
// provoked it (`REBAKE_SETTLE`): a resize that has already settled, re-converting again
// when the atlas it was waiting for finally lands (decision 1339).
eprintln!("[ui-gate] miss: glyph atlas re-baked");
if !generation_eq {
// Named, because it is the one miss with no visible cause at all: the glyph sheet filled
// and was repacked from empty, so every held quad's UV moved. It should be very rare —
// `WOW_GLYPH_CACHE=1` reports the occupancy that led here (decision 1342).
eprintln!("[ui-gate] miss: the glyph sheet reset");
return;
}
if !text_ui_eq {
@@ -102,11 +102,11 @@ fn report_gate_miss(
pub(super) struct GateInputs {
extracted: Vec<benilla_ui::script::ExtractedQuad>,
text_ui: Option<benilla_ui::script::EditBoxTextUi>,
dims: Option<(u32, u32, u32)>,
dims: Option<(u32, u32, u32, u32)>,
portraits: std::collections::HashMap<String, crate::portrait::PortraitSource>,
/// The `UiFontAtlas::generation` the held quads' glyph cells were rasterized from — the term
/// that stopped being constant at decision 1296 (see the gate's own note below, decision 1339).
bake: Option<u64>,
/// The `UiFontAtlas::generation` the held quads' glyph UVs came from — see the gate's own
/// note below. Moves only when the glyph sheet resets (decisions 1339, 1342).
generation: Option<u64>,
}
/// Per frame: screen size → `tick` (OnUpdate) → `resolve` → `extract` → [`UiQuads`]. Script errors
@@ -153,27 +153,29 @@ pub(super) fn drive_script(
// VM has no cached measures to invalidate and no measurer at all, and the re-seat below already
// catches that on its own test (`!script.has_text_measurer()`).
mut last_seam: Local<f32>,
// The atlas bake this pass last seated a measurer from (decision 1296) — the other half of the
// same staleness edge. `None` until an atlas exists, which is also a real edge to catch.
mut last_bake: Local<Option<u64>>,
// The `scale_factor` this pass last answered measures under — the other half of the same
// staleness edge (decision 1342). `0.0` until the first frame, which is also a real edge.
mut last_dpi: Local<f32>,
// The uiScale dial folded into the seam scale (decision 0584).
ui_scale: Res<super::UiScaleCvar>,
// ── The extract gate's memory (decision 0740): last frame's conversion inputs ─────────────
// The conversion loop below is a pure function of (extracted, text_ui, window dims × seam
// scale, the portrait token map, THE ATLAS BAKE) — the sprite caches are monotone
// path→handle, so equal inputs reproduce the same `UiQuads` the diff would then discard.
// Capture mode never skips (the harness wants exact per-frame output, including the
// The conversion loop below is a pure function of (extracted, text_ui, the RASTER
// ENVIRONMENT, the portrait token map, the glyph-sheet generation) — the sprite caches are
// monotone path→handle, so equal inputs reproduce the same `UiQuads` the diff would then
// discard. Capture mode never skips (the harness wants exact per-frame output, including the
// cursor-icon quad's live mouse position).
//
// The bake term is decision 1339's correction: this list read "the glyph atlas bakes once at
// Startup", which decision 1296 falsified — the atlas now re-bakes whenever the raster
// environment moves, and every glyph quad the conversion emits carries that bake's cell UVs
// and image handle. The two are DEBOUNCED apart by design (`REBAKE_SETTLE` = 0.25 s), which is
// exactly what made the omission bite: the seam moves the instant the window resizes, the UI
// re-converts and settles well inside a quarter second, and *then* the new atlas arrives to a
// gate whose other four inputs all match — so the conversion is skipped and the screen keeps
// the old bake's resampled cells indefinitely. 1296's re-bake landed and nothing on screen
// used it, which is the shape of the reports that survived it.
// The raster environment is window size × seam scale × **`scale_factor`**, and that last term
// is decision 1342's correction. A monitor hop at an unchanged window size moves nothing else
// in this list, but it changes the integer device-pixel size every glyph rasterizes at
// (`TextEngine::ppem`) — so the quads held from last frame are the wrong size and the gate
// must miss. (1339 caught the same hole through an atlas-bake counter, which was the right
// fix for a design where the atlas re-baked; there is nothing to re-bake now, so the term that
// survives is the one that actually moved.)
//
// The generation term is the glyph sheet RESETTING — the one event that moves a cached cell's
// UV, which happens when the sheet fills and is repacked from empty. Held quads carry the old
// UVs and would draw letters as fragments of other letters.
//
// A [`crate::ui_script::VmMemo`] because a skip is not only a quad-conversion skip: it also skips
// `set_link_spans` and the minimap-slot / booth-pane refills, which are pushes INTO the VM. The
@@ -208,18 +210,20 @@ pub(super) fn drive_script(
// eats fitting text: the director's "Contr..." rows, reproduced by `WOW_RESIZE`). Declare the
// staleness at the seam and let every round-trip re-answer under the new `s`.
//
// A **re-bake** is the same staleness by the other route: the atlas now follows the seam
// (decision 1296), and a fresh bake replaces the whole step table — so a cached metric answered
// off the old table is stale even when `s` itself did not move (a pure DPI hop, a monitor
// change). Watch the generation beside the seam and treat either edge identically.
// **The DPI is the other half of the same edge** (decision 1342). A logical height becomes an
// integer DEVICE-pixel raster size, so a monitor hop at an unchanged window size leaves `s`
// exactly where it was and still moves every measured width. (1296/1339 caught this through
// the atlas's bake generation, which followed the same two terms; with nothing to re-bake, the
// honest thing to watch is the term itself.)
let dpi = window.scale_factor();
let generation = font_atlas.as_deref().map(|a| a.generation);
let seam_moved = *last_seam != s || *last_bake != generation;
let seam_moved = *last_seam != s || *last_dpi != dpi;
if seam_moved {
if *last_seam != 0.0 {
script.invalidate_text_measures();
}
*last_seam = s;
*last_bake = generation;
*last_dpi = dpi;
}
// …and re-seat the VM's own font engine at the same edge, for the same reason: an
// [`crate::ui_text::AtlasMeasurer`] answers only for the seam it was built under. This is what
@@ -233,7 +237,7 @@ pub(super) fn drive_script(
if let Some(atlas) = font_atlas.as_deref() {
if seam_moved || !script.has_text_measurer() {
script.set_text_measurer(Box::new(crate::ui_text::AtlasMeasurer::new(
atlas.metrics(),
atlas.engine(),
s,
)));
}
@@ -323,7 +327,7 @@ pub(super) fn drive_script(
.iter()
.map(|r| {
let n = crate::ui_text::measure_wrapped_rows(
atlas,
&mut atlas.lock(),
&r.text,
// wrap_width is the RESOLVED frame width (scale already in it) — ×s only;
// the font height is frame-local — ×s × the frame's scale, the drawn size.
@@ -333,7 +337,6 @@ pub(super) fn drive_script(
// The band's own drawn px (one-to-one regime; inert ≤ 32).
height: crate::ui_text::drawn_px(r.height, None, s * r.scale),
outline: r.outline, // step-law width bias, as in the measures above
paint_halo: true, // measure never paints; irrelevant here
alpha_gradient: None,
},
);
@@ -386,10 +389,9 @@ pub(super) fn drive_script(
// request's `scale` doc).
height: crate::ui_text::drawn_px(req.height, None, s * req.scale),
outline: req.outline,
paint_halo: true, // measure never paints
alpha_gradient: None, // alpha never changes metrics
};
let cum: Vec<f32> = crate::ui_text::line_advances(atlas, &req.text, spec)
let cum: Vec<f32> = crate::ui_text::line_advances(&mut atlas.lock(), &req.text, spec)
.iter()
.map(|a| a / s)
.collect();
@@ -398,7 +400,7 @@ pub(super) fn drive_script(
// Advances/pitch return in UI units (÷s): the engine's click→index and row math
// compare them against the ÷s mouse feed.
let (rows, cell_h) = match req.wrap_width {
Some(w) => crate::ui_text::line_rows(atlas, &req.text, w * s, spec),
Some(w) => crate::ui_text::line_rows(&mut atlas.lock(), &req.text, w * s, spec),
None => (vec![0], 0.0),
};
script.set_editbox_advances(req.id, req.key, cum, rows, cell_h / s);
@@ -419,10 +421,10 @@ pub(super) fn drive_script(
// LBRS pin that was every single settled frame, ~0.3 ms/frame of glyph re-rasterization for
// an identical `Vec`. On a skip, `quads`/`minimap_widget`/the engine's link spans all keep
// last frame's values, which the equal inputs prove are this frame's values too.
let dims = (w.to_bits(), h.to_bits(), s.to_bits());
let dims = (w.to_bits(), h.to_bits(), s.to_bits(), dpi.to_bits());
let settled = capture.is_none()
&& prev.dims == Some(dims)
&& prev.bake == generation
&& prev.generation == generation
&& text_ui == prev.text_ui
&& booths.images.0 == prev.portraits
&& extracted == prev.extracted;
@@ -465,13 +467,13 @@ pub(super) fn drive_script(
&extracted,
&prev.extracted,
prev.dims == Some(dims),
prev.bake == generation,
prev.generation == generation,
text_ui == prev.text_ui,
booths.images.0 == prev.portraits,
);
}
prev.dims = Some(dims);
prev.bake = generation;
prev.generation = generation;
prev.text_ui = text_ui.clone();
prev.portraits = booths.images.0.clone();
prev.extracted = extracted.clone();
@@ -836,12 +838,12 @@ fn measure_fontstrings(
return false;
}
// Every answer goes through the ONE measure body ([`crate::ui_text::measure_request`]) the VM's
// own synchronous measurer calls, over the same `Arc`-shared table — so a string measured here
// own synchronous measurer calls, against the same shared engine — so a string measured here
// and the same string measured mid-tick are not two computations that agree, they are one.
let measures: Vec<(u32, f32, f32, f32, u64)> = requests
.iter()
.map(|r| {
let (w, h, natural) = crate::ui_text::measure_request(&atlas.metrics, s, r);
let (w, h, natural) = crate::ui_text::measure_request(&mut atlas.lock(), s, r);
(r.id, w, h, natural, r.key)
})
.collect();
@@ -79,7 +79,6 @@ pub(super) fn emit(
host.scale * host.font_scale,
),
outline: style.outline,
paint_halo: true,
alpha_gradient: style.alpha_gradient,
};
// The focused edit box's text draws WINDOWED (`0x77da80`): the scroll window's substring,
@@ -121,7 +120,7 @@ pub(super) fn emit(
if let Some(ui) = ebox {
let drawn = &text[ui.display_from.min(text.len())..];
let (x0, top, cell_h) =
crate::ui_text::line_origin(atlas, drawn, host.rect, draw_justify, spec);
crate::ui_text::line_origin(&mut atlas.lock(), drawn, host.rect, draw_justify, spec);
ebox_geom = Some((x0, top, cell_h));
text_clip = Some(host.clip.map_or(host.rect, |c| c.intersect(host.rect)));
if !ui.multi_line {
@@ -190,7 +189,7 @@ pub(super) fn emit(
// (`ink=`). They diverge by the width of the markup when the advance table is measured over
// the raw buffer — the caret-out-in-space report (decision 1075) as a pair of numbers.
let ebox_geom = ebox.map(|ui| {
let ink = crate::ui_text::measure_text(&atlas.metrics, draw_text, None, spec).0;
let ink = crate::ui_text::measure_text(&mut atlas.lock(), draw_text, None, spec).0;
format!(" caret={:.1} ink={ink:.1}", ui.caret_x * host.scale)
});
info!(
@@ -209,9 +208,9 @@ pub(super) fn emit(
// the stable z-sort keeps it behind the glyph pass. Offset is WoW y-up (`y="-1"` = down) →
// y-down screen dy = −y. Markup color codes inside the text tint the shadow run too (v1
// corner, invisible for solid-color strings). The shadow is a single flat offset copy —
// never itself outlined (an outlined shadow would be a muddy black blob), so it suppresses
// the halo PAINT only: it keeps the font's true outline for the step law, laying out
// identically to its fill (a shadow with different steps would smear under long strings).
// never itself outlined — it lays out identically to its fill (a shadow with different steps
// would smear under long strings) and redraws the same composite cells in the shadow color,
// where the ring's black is indistinguishable from the shadow's.
if let (Some(sh), Some((dx, dy))) = (style.shadow, shadow_delta) {
// WHOLE pixels (`shadow_offset_px`, computed above): the shadow is a rigid copy of the
// fill, so its displacement must be an integer in the rect's own space — see that fn for
@@ -222,12 +221,9 @@ pub(super) fn emit(
draw_rect.max.x + dx,
draw_rect.max.y + dy,
);
let shadow_spec = crate::ui_text::FontSpec {
paint_halo: false,
..spec
};
let shadow_spec = crate::ui_text::FontSpec { ..spec };
let mut sq = layout_text_quads(
atlas,
&mut atlas.lock(),
draw_text,
srect,
sh.color,
@@ -257,7 +253,7 @@ pub(super) fn emit(
// for the engine's click hit-test (y-down → y-up flip).
let mut spans = Vec::new();
let g = layout_text_quads_links(
atlas,
&mut atlas.lock(),
text,
host.rect,
base_color,
@@ -286,7 +282,7 @@ pub(super) fn emit(
g
} else {
layout_text_quads(
atlas,
&mut atlas.lock(),
draw_text,
draw_rect,
base_color,
+89 -3
View File
@@ -101,7 +101,82 @@ fn ui_wanted(world: &World) -> bool {
|| std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1")
}
/// `OnEnter(InWorld)`: materialize the in-game UI for **this** session.
/// The world-entry UI load, armed at `OnEnter(InWorld)` and run by [`run_pending_entry_load`]
/// once the loading cover is actually ON THE GLASS.
///
/// This is 0962's frame accounting, second offender: `OnEnter(InWorld)` fires on exactly the
/// frame whose render would first present the cover (the raise frame still renders the glue
/// above it), so a synchronous burst there holds the *previous* present — the frozen character
/// screen — for its whole duration. 0962 taught the menagerie to wait for
/// `WARM_COVER_PRESENT_FRAMES`; 1051 then moved the FrameXML load onto the same unprotected
/// frame, and by 1290+addons the burst had grown to ~0.5 s (measured live: raise 48.317 →
/// reference sourcing + 55 files + addons + saved vars + `PLAYER_LOGIN` → 48.83) — the
/// director's "frozen char for 1 sec" at every Enter World.
///
/// The counter counts covered+in-world frames, exactly like the warm pass: renders are serial,
/// so at [`ENTRY_LOAD_COVER_FRAMES`] the two intermediate frames' renders — which draw the
/// cover — have committed their presents before this frame's stall begins. The loading screen
/// folds this resource's existence into its clear condition, so a reveal can never precede the
/// UI it is supposed to reveal.
#[derive(Resource, Default)]
pub(crate) struct PendingEntryUiLoad {
covered_frames: u32,
}
/// Covered+in-world frames before the entry load runs — the same present proxy, and the same
/// value, as `pipe_warm::WARM_COVER_PRESENT_FRAMES` (0962's argument, restated there).
const ENTRY_LOAD_COVER_FRAMES: u32 = 3;
/// `OnEnter(InWorld)`: arm the deferred entry load. The load itself runs a few frames later —
/// see [`PendingEntryUiLoad`].
pub(crate) fn arm_entry_ui_load(mut commands: Commands) {
commands.insert_resource(PendingEntryUiLoad::default());
}
/// `PreUpdate` (chained after [`run_pending_reload`] — same exclusive slot, and a reload must
/// not interleave an armed entry load): run the armed entry load once the cover has presented.
///
/// No cover at all — a capture booting straight `InWorld`, or the screen's assets missing —
/// means there is no glass to protect and nothing watching the previous present: load
/// immediately. Leaving the world first (an instant disconnect) drops the latch unrun;
/// [`end_ui_session`] treats that as "the in-game UI never existed" and skips the shutdown
/// writes, so a UI-less VM can never clobber the saved-variables files with its emptiness.
pub(crate) fn run_pending_entry_load(world: &mut World) {
if world.get_resource::<PendingEntryUiLoad>().is_none() {
return;
}
let in_world = *world
.resource::<State<crate::char_select::ClientState>>()
.get()
== crate::char_select::ClientState::InWorld;
if !in_world {
// Left the world before the load ran — nothing to build a UI for.
world.remove_resource::<PendingEntryUiLoad>();
return;
}
let covering = world
.get_resource::<crate::loading_screen::LoadingScreen>()
.is_some_and(|s| s.covering());
if covering {
let mut pending = world.resource_mut::<PendingEntryUiLoad>();
pending.covered_frames += 1;
if pending.covered_frames < ENTRY_LOAD_COVER_FRAMES {
return;
}
}
world.remove_resource::<PendingEntryUiLoad>();
let start = std::time::Instant::now();
load_ingame_ui_on_world_entry(world);
// The standing instrument for this burst: the one number that says whether the cover is
// still hiding it, on every entry, in every log.
info!(
"ui_script: in-game UI up in {:.0} ms (behind the cover: {})",
start.elapsed().as_secs_f32() * 1000.0,
covering
);
}
/// Materialize the in-game UI for **this** session.
///
/// **Once per world entry, not once per process** (decision 1290). The reference builds the whole
/// in-game UI at `CGGameUI::Initialize 0x48fbf0` and destroys it again at `0x490bd0` on the way
@@ -293,11 +368,17 @@ pub(crate) fn shutdown_ui_state(script: &mut UiScript, identity: Option<&(String
/// Exclusive rather than a `NonSendMut` system because it both drops and installs a `NonSend`, and
/// because the shutdown writes must be ordered against each other — see [`shutdown_ui_state`].
pub(crate) fn end_ui_session(world: &mut World) {
// An armed-but-unrun entry load ([`PendingEntryUiLoad`]) means this session never built an
// in-game UI: there are no globals to save and no addon state to write, and running the
// shutdown tail against the boot VM would overwrite the real files with that emptiness.
let ui_never_loaded = world.remove_resource::<PendingEntryUiLoad>().is_some();
let identity = world
.get_resource::<AddOnIdentity>()
.and_then(|id| id.0.clone());
if let Some(mut script) = world.get_non_send_resource_mut::<UiScript>() {
shutdown_ui_state(&mut script, identity.as_ref());
if !ui_never_loaded {
if let Some(mut script) = world.get_non_send_resource_mut::<UiScript>() {
shutdown_ui_state(&mut script, identity.as_ref());
}
}
// The CVar bridge (decision 1291): the dying VM's table folds into the persist state — after
// the shutdown events above (a `PLAYER_LOGOUT` handler may `SetCVar`, and in the reference
@@ -386,11 +467,16 @@ pub(crate) fn run_pending_reload(world: &mut World) {
pub(crate) fn shutdown_on_exit(
script: Option<NonSendMut<UiScript>>,
id: Res<AddOnIdentity>,
pending_entry: Option<Res<PendingEntryUiLoad>>,
mut exits: MessageReader<AppExit>,
) {
if exits.read().next().is_none() {
return;
}
// Same guard as [`end_ui_session`]: a quit inside the entry-load window has no UI to save.
if pending_entry.is_some() {
return;
}
if let Some(mut script) = script {
shutdown_ui_state(&mut script, id.0.as_ref());
}
+18 -5
View File
@@ -264,10 +264,14 @@ impl Plugin for UiScriptPlugin {
// cast-fail display (0427) resolves its messages from.
.add_systems(Startup, setup_script.after(benilla_assets::AssetSet::Open))
// The in-game UI materializes on entering the world, not at boot (1051) — the
// reference's own seam. Only the font registry loads at `Startup`.
// reference's own seam; only the font registry loads at `Startup`. The entry edge
// ARMS the load; it runs a few frames later, once the loading cover has actually
// presented — the ~0.5 s burst must never stall the frame whose render would first
// show the cover, or what covers it is the frozen character screen (0962's frame
// accounting; see [`lifecycle::PendingEntryUiLoad`]).
.add_systems(
OnEnter(crate::char_select::ClientState::InWorld),
load_ingame_ui_on_world_entry,
lifecycle::arm_entry_ui_load,
)
// The whole shutdown tail — events then writes, in the reference's order. See
// [`shutdown_ui_state`]; the two edges here are its five roots as far as our session
@@ -285,7 +289,13 @@ impl Plugin for UiScriptPlugin {
// `save_config`, discarding a dirty CVar edit, or after `seed_bindings`, giving one
// whole frame with an empty binding table. See [`run_pending_reload`].
.init_resource::<ReloadUiPending>()
.add_systems(PreUpdate, run_pending_reload)
// The armed entry load shares the reload's slot, chained after it: both are
// exclusive edges on the same VM, and a reload must not interleave a pending entry
// load.
.add_systems(
PreUpdate,
(run_pending_reload, lifecycle::run_pending_entry_load).chain(),
)
// `GetFramerate()`'s host half (decision 1195), before the VM ticks so an `OnUpdate`
// handler reads this frame's number rather than the previous one's.
.add_systems(Update, feed_framerate.before(UiInput))
@@ -354,15 +364,18 @@ fn arbitrate_pointer_over_ui(
/// lives there; this module keeps the per-frame bridge. See its header.
mod lifecycle;
pub(crate) use lifecycle::{
end_ui_session, run_pending_reload, setup_script, AddOnIdentity, ReloadUiPending,
end_ui_session, run_pending_reload, setup_script, AddOnIdentity, PendingEntryUiLoad,
ReloadUiPending,
};
// Consumed only from other modules' test code (the emote-table checks, the harness's UI-init
// tail, the quit-once pin) — a plain re-export would warn unused in a non-test build.
#[cfg(test)]
use lifecycle::load_ingame_ui_on_world_entry;
use lifecycle::shutdown_on_exit;
#[cfg(test)]
pub(crate) use lifecycle::{
finish_ui_load, is_emote_token_line, seat_from_roster, shutdown_ui_state,
};
use lifecycle::{load_ingame_ui_on_world_entry, shutdown_on_exit};
/// `GetFramerate()`'s host half: push a **smoothed** frames-per-second into the VM each frame
/// (decision 1195).
+5 -8
View File
@@ -183,14 +183,11 @@ mod tests {
// re-seats off `!script.has_text_measurer()` — it interrogates the VM instead of a memo,
// which is the same guarantee arrived at the other way.
("ui_script/extract/mod.rs", "last_seam"),
// The atlas bake generation beside it (decision 1296) — a fact about the glyph atlas, a
// Bevy resource that outlives any VM, not about the VM. It gates the same re-seat
// `last_seam` does, and a fresh VM re-seats on `!has_text_measurer()` regardless.
("ui_script/extract/mod.rs", "last_bake"),
// How long the raster environment has differed from the baked one (decision 1296's
// re-bake debounce): a stopwatch about the WINDOW, zeroed by its own equality test every
// frame. A new VM neither reads it nor is described by it.
("ui_text/atlas.rs", "drifted_for"),
// The window's `scale_factor` beside it (decision 1342) — the other term a measure is
// only correct under, since a logical height becomes an integer DEVICE-pixel raster size.
// A fact about the window, not about the VM; it gates the same re-seat `last_seam` does,
// and a fresh VM re-seats on `!has_text_measurer()` regardless.
("ui_script/extract/mod.rs", "last_dpi"),
// Pushed unconditionally every frame; there is nothing remembered to go stale.
("ui_script/mod.rs", "smoothed"),
// The cursor systems, both platform arms: these track the OS cursor and an `NSCursor` raw
@@ -568,3 +568,110 @@ fn a_looping_addon_cannot_freeze_world_entry() {
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
// ─────────────────── The deferred entry load (0962's frame accounting) ───────────────────
/// **The director's "frozen char for 1 sec" report, from the other side.** With the cover up,
/// the armed entry load waits [`super::lifecycle::run_pending_entry_load`]'s covered-frame
/// count — the frames whose renders put the cover on the glass — and only then pays the burst.
/// Before the deferral the load ran inside `OnEnter(InWorld)`, which is exactly the frame whose
/// render would first present the cover, so the ~0.5 s of FrameXML + addons + `PLAYER_LOGIN`
/// held the previous present: the frozen character screen.
#[test]
fn the_entry_load_waits_for_the_cover_to_present() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_probe("defer");
let mut world = booted_world();
world.insert_resource(State::new(crate::char_select::ClientState::InWorld));
world.insert_resource(crate::loading_screen::LoadingScreen::test_covering());
world.insert_resource(roster_named("Onehunter", 1));
world.insert_resource(super::PendingEntryUiLoad::default());
// Covered frames 1 and 2: the cover has not provably presented yet — no load.
for frame in 1..=2 {
super::lifecycle::run_pending_entry_load(&mut world);
assert_eq!(
probe_saw(&world),
None,
"covered frame {frame}: the burst must wait for the cover to reach the glass"
);
}
// Covered frame 3: two cover renders have committed — the burst is hidden. Load.
super::lifecycle::run_pending_entry_load(&mut world);
assert_eq!(
probe_saw(&world).as_deref(),
Some("Onehunter"),
"the third covered frame pays the load, behind a presented cover"
);
assert!(
world.get_resource::<super::PendingEntryUiLoad>().is_none(),
"the latch is consumed — the loading screen's clear condition reads its absence"
);
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
/// No cover — a capture booting straight `InWorld`, or the screen's assets missing — means no
/// glass to protect: the armed load runs on the first frame. Without this arm a coverless run
/// would count covered frames that never come and the UI would never load.
#[test]
fn no_cover_means_the_entry_load_runs_at_once() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_probe("nocover");
let mut world = booted_world();
world.insert_resource(State::new(crate::char_select::ClientState::InWorld));
world.insert_resource(crate::loading_screen::LoadingScreen::default());
world.insert_resource(roster_named("Onehunter", 1));
world.insert_resource(super::PendingEntryUiLoad::default());
super::lifecycle::run_pending_entry_load(&mut world);
assert_eq!(
probe_saw(&world).as_deref(),
Some("Onehunter"),
"uncovered: the load runs immediately"
);
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
/// An exit inside the deferral window (an instant disconnect at entry) drops the armed load and
/// **writes nothing**: the session never built a UI, so the shutdown tail running against the
/// boot VM would compose every saved file from emptiness — the wipe
/// [`quitting_from_the_character_screen_does_not_blank_the_session_it_wrote`] guards at the
/// other edge.
#[test]
fn leaving_inside_the_deferral_window_drops_the_load_and_writes_nothing() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_probe("dropped");
let mut world = booted_world();
world.insert_resource(State::new(crate::char_select::ClientState::InWorld));
world.insert_resource(crate::loading_screen::LoadingScreen::test_covering());
world.insert_resource(roster_named("Onehunter", 1));
world.insert_resource(super::PendingEntryUiLoad::default());
super::lifecycle::run_pending_entry_load(&mut world); // covered frame 1 — still pending
super::end_ui_session(&mut world);
assert_eq!(probe_saw(&world), None, "no UI ever loaded");
assert!(
world.get_resource::<super::PendingEntryUiLoad>().is_none(),
"the latch died with the session — it must not fire on the glue"
);
let flat = crate::local_state::saved_variables_path()
.expect("hermetic home resolves the flat saved path");
assert!(
!flat.exists(),
"the shutdown tail was skipped — a UI-less VM must not write saved variables"
);
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
//! Reading the client's own TTFs, and the one metric that has to come off the raw bytes.
use cosmic_text::FontSystem;
/// The four client TTFs (verified present in `fonts.MPQ`, plain TTF — no exotic wrapper), read
/// through the app's own patch chain rather than `std::fs` (there is no `std::fs` path to client
/// data — see [`WorldAssets`]). Index 0 (Friz Quadrata) is the fallback face and is required.
pub(super) const CLIENT_FONTS: &[&str] = &[
"Fonts\\FRIZQT__.TTF",
"Fonts\\ARIALN.TTF",
"Fonts\\MORPHEUS.TTF",
"Fonts\\SKURRI.TTF",
];
/// The face's baseline ascender as a fraction of the em — `hhea.asc / (hhea.asc + |hhea.desc|)` —
/// straight from the raw sfnt bytes. This is the term in the client's glyph *placement* law: the
/// ink hangs from the pixel ascender `[CGxFont+0x17c] = round(em · asc/(asc+|desc|))`, threaded
/// unchanged into `glyph_vplace` (0x5d1360) as the operand that fixes `baseline = cellTop +
/// ascender` (wow-re `system/font`, §5-verified 2026-07-09: the call chain `0x5ca160 → 0x5d1120
/// [ebp+0xc] → 0x5d1360 [ebp+0x10]`, where the cell's own `[[FT_Face+0x54]+0x68]` is the
/// per-glyph `bitmap_top`, the *subordinate* operand). This is the `ComputeRasterMetrics`
/// `load_param`; the FreeType scaled hhea ascender (`asc/upem` ≈ 0.965) appears NOWHERE in the
/// placement path — seating with it drops every line ~3px too low for Friz (965/1215 ≈ 0.794 vs
/// 0.965 → baseline row 10 vs 13 in a 13-tall cell). A tiny table-directory walk (`hhea` →
/// ascender@+4, descender@+6); `None` on any malformed/missing table.
pub(super) fn hhea_ascent_ratio(bytes: &[u8]) -> Option<f32> {
let num = u16::from_be_bytes(bytes.get(4..6)?.try_into().ok()?) as usize;
let (mut asc, mut desc) = (None, None);
for i in 0..num {
let rec = bytes.get(12 + 16 * i..12 + 16 * i + 16)?;
let toff = u32::from_be_bytes(rec[8..12].try_into().ok()?) as usize;
if &rec[0..4] == b"hhea" {
asc = Some(i16::from_be_bytes(bytes.get(toff + 4..toff + 6)?.try_into().ok()?) as f32);
desc = Some(i16::from_be_bytes(bytes.get(toff + 6..toff + 8)?.try_into().ok()?) as f32);
}
}
let (asc, desc) = (asc?, desc?);
// The denominator is narrowed to f32 exactly as the binary does (`fstp m32` @0x5ca0d?),
// then the ratio; `em · ratio + 0.5` floors to the load_param at the call site below.
let denom = asc + desc.abs();
(denom > 0.0 && asc > 0.0).then_some(asc / denom)
}
/// Registers `bytes` (raw TTF) into `font_system`'s `fontdb`, returning `(face_id, family_name)` —
/// the family name is read back off the just-loaded face so callers build an exact `Attrs::family`
/// match with no reliance on hardcoding Blizzard's font-name strings.
pub(super) fn register_font(
font_system: &mut FontSystem,
bytes: Vec<u8>,
) -> anyhow::Result<(fontdb::ID, String)> {
let source = fontdb::Source::Binary(
std::sync::Arc::new(bytes) as std::sync::Arc<dyn AsRef<[u8]> + Sync + Send>
);
let ids = font_system.db_mut().load_font_source(source);
let id = *ids
.first()
.ok_or_else(|| anyhow::anyhow!("font source produced no faces (not a valid TTF?)"))?;
let family = font_system
.db_mut()
.face(id)
.ok_or_else(|| anyhow::anyhow!("face {id:?} vanished right after loading"))?
.families
.first()
.map(|(name, _)| name.clone())
.ok_or_else(|| anyhow::anyhow!("face {id:?} carries no family name"))?;
Ok((id, family))
}
@@ -0,0 +1,216 @@
//! **The Bevy face of the glyph cache** — building it, following the window, and getting each new
//! cell onto the GPU.
//!
//! The upload is a sub-rect `RenderQueue::write_texture` into the sheet's existing texture rather
//! than an `Assets<Image>` mutation, for a reason that is about correctness rather than cost: see
//! [`crate::ui_text::pack`]'s module doc.
use std::sync::{Arc, Mutex};
use bevy::image::Image;
use bevy::prelude::*;
use bevy::render::render_asset::RenderAssets;
use bevy::render::render_resource::{
Extent3d, Origin3d, TexelCopyBufferLayout, TexelCopyTextureInfo, TextureAspect,
};
use bevy::render::renderer::RenderQueue;
use bevy::render::texture::GpuImage;
use bevy::render::{ExtractSchedule, Render, RenderApp, RenderSystems};
use bevy::window::PrimaryWindow;
use benilla_assets::WorldAssets;
use super::{TextEngine, UiFontAtlas};
use crate::ui_text::pack::CellUpload;
/// Cells rasterized this frame, waiting for the render world.
#[derive(Resource, Default)]
struct GlyphUploadQueue(Vec<CellUpload>);
/// The render world's copy, which also carries anything whose page texture was not ready yet.
#[derive(Resource, Default)]
struct GlyphUploads(Vec<CellUpload>);
/// Loads the client's TTFs and drives the on-demand glyph cache.
pub(crate) struct UiTextPlugin;
impl Plugin for UiTextPlugin {
fn build(&self, app: &mut App) {
// `init` runs each Update until it succeeds: the engine needs both the patch chain (opened
// at `AssetSet::Open`) *and* the primary window's real `scale_factor`, which winit only
// reports once the OS window exists. `publish_pages` runs in `Last`, after every producer
// of text quads, so a cell rasterized this frame is queued before the render extract.
app.init_resource::<GlyphUploadQueue>()
.add_systems(Update, init)
.add_systems(Last, publish_sheet);
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
return;
};
render_app
.init_resource::<GlyphUploads>()
.add_systems(ExtractSchedule, extract_glyph_uploads)
.add_systems(
Render,
// After `prepare_assets::<GpuImage>` (PrepareAssets) so a page created this frame
// already has its texture, and before the frame's submit.
upload_glyph_cells.in_set(RenderSystems::PrepareResources),
);
}
}
/// Build the engine on the first frame both the patch chain and the window's real `scale_factor`
/// are available.
fn init(
mut commands: Commands,
world_assets: Option<Res<WorldAssets>>,
images: Res<Assets<Image>>,
windows: Query<&Window, With<PrimaryWindow>>,
existing: Option<Res<UiFontAtlas>>,
) {
if existing.is_some() {
return;
}
let (Some(world_assets), Ok(window)) = (world_assets, windows.single()) else {
return;
};
let Some(engine) = TextEngine::load(&world_assets, &images, window.scale_factor()) else {
return;
};
commands.insert_resource(UiFontAtlas {
engine: Arc::new(Mutex::new(engine)),
generation: 0,
ellipsis: crate::ui_text::EllipsisMemo::default(),
});
}
/// The frame boundary: follow the window's DPI, carry out a pending reset, create any new page's
/// texture, and hand this frame's cells to the render world.
///
/// **Order matters, and it is the order below.** The reset runs *before* the pending cells are
/// handed over, so the frame that exhausted the pages does not upload into shelves it is about to
/// free; and it runs here rather than at the point of failure so no UV can move while quads that
/// reference it are still being pushed (see [`TextEngine::note_exhausted`]).
fn publish_sheet(
atlas: Option<ResMut<UiFontAtlas>>,
mut images: ResMut<Assets<Image>>,
mut queue: ResMut<GlyphUploadQueue>,
windows: Query<&Window, With<PrimaryWindow>>,
) {
let Some(mut atlas) = atlas else {
return;
};
let dpi = windows.single().map_or(1.0, Window::scale_factor);
let (generation, dpi_moved) = {
let mut e = atlas.lock();
// A DPI change invalidates no cell — it changes which cells get *asked for*.
let dpi_moved = (e.dpi - dpi).abs() > 1e-6;
e.dpi = dpi;
if e.reset_pending {
e.reset_pending = false;
e.generation += 1;
e.stats.resets += 1;
e.chars.clear();
e.cells.clear();
e.sheet.reset();
}
let (announce, mut cells) = e.sheet.take_pending();
if announce {
// `insert`, once, on a reserved handle — and never `get_mut` afterwards, which would
// recreate the texture and blank every glyph on it (see `pack`'s module doc).
// A duplicate insert cannot happen (the sheet announces itself once), and there is
// no recovery from a failed one anyway — text simply would not draw.
let _ = images.insert(e.sheet.handle().id(), crate::ui_text::pack::sheet_image());
}
queue.0.append(&mut cells);
(e.generation, dpi_moved)
};
// The one thing a DPI change DOES stale: the ellipsis memo's answers are keyed by a logical
// box against a raster size that just moved.
if dpi_moved {
atlas.ellipsis = crate::ui_text::EllipsisMemo::default();
}
atlas.generation = generation;
report_cache(&atlas);
}
/// Hand the frame's cells to the render world, appending rather than replacing — anything the
/// previous frame could not write (its page's texture was not ready) is still in there.
fn extract_glyph_uploads(
mut main_world: ResMut<bevy::render::MainWorld>,
mut uploads: ResMut<GlyphUploads>,
) {
if let Some(mut q) = main_world.get_resource_mut::<GlyphUploadQueue>() {
uploads.0.append(&mut q.0);
}
}
/// Write each new cell into its page's existing texture — a sub-rect `write_texture`, so the
/// texture's identity never changes and nothing downstream needs invalidating (see `pack`'s module
/// doc for what the obvious alternative would have broken). A cell whose page texture has not been
/// prepared yet stays queued for the next frame rather than being dropped.
fn upload_glyph_cells(
mut uploads: ResMut<GlyphUploads>,
gpu_images: Res<RenderAssets<GpuImage>>,
render_queue: Res<RenderQueue>,
) {
uploads.0.retain(|u| {
let Some(gpu) = gpu_images.get(u.image) else {
return true; // the page's texture is not up yet — try again next frame
};
render_queue.write_texture(
TexelCopyTextureInfo {
texture: &gpu.texture,
mip_level: 0,
origin: Origin3d {
x: u.x,
y: u.y,
z: 0,
},
aspect: TextureAspect::All,
},
&u.rgba,
TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(u.w * 4),
rows_per_image: Some(u.h),
},
Extent3d {
width: u.w,
height: u.h,
depth_or_array_layers: 1,
},
);
false
});
}
/// `WOW_GLYPH_CACHE=1`: one line a second of what the cache holds.
///
/// It exists because [`super::pack::MAX_PAGES`] and [`super::pack::PAGE_SIZE`] are the two numbers
/// this design can get wrong quietly — too small and a session resets on a loading screen, too
/// large and we hold VRAM nobody reads. Both are cheap to re-choose *from a measurement* and
/// expensive to re-choose from an argument, so the measurement ships with them.
fn report_cache(atlas: &UiFontAtlas) {
use std::sync::atomic::{AtomicU64, Ordering};
static LAST: AtomicU64 = AtomicU64::new(0);
if std::env::var_os("WOW_GLYPH_CACHE").is_none_or(|v| v == "0") {
return;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
if LAST.swap(now, Ordering::Relaxed) == now {
return;
}
let e = atlas.lock();
let (used, total) = e.sheet.occupancy();
eprintln!(
"[glyph-cache] {used}/{total} texels ({:.1}%) · {} chars shaped · \
{} cells rasterized · {} resets · dpi {}",
100.0 * used as f64 / total as f64,
e.stats.chars_shaped,
e.stats.cells_rasterized,
e.stats.resets,
e.dpi,
);
}
@@ -0,0 +1,988 @@
//! **The font engine** — an on-demand glyph cache keyed by exact integer device-pixel size.
//!
//! ## What this replaced, and why
//!
//! Until decision 1342 this module *baked*: at startup (and, after 1296, whenever the raster
//! environment moved) it rasterized the whole charset for every registered face at a fixed
//! **ladder** of sizes, packed it into one texture, and published it. A request for a size the
//! ladder did not carry snapped to the nearest rung, and the finished quads were then rescaled by
//! `k = requested / snapped`.
//!
//! That rescale is gone, and with it the ladder. It had to be, because `k` was never a rendering
//! detail — it was a second, parallel definition of where text sits, and every seam that forgot to
//! apply it produced a measure/render disagreement: a caret 28 % past its own text (0989), an
//! ellipsis eating a word that fits (B209), a tooltip past its border (B231), a Main Menu label
//! losing its last letter (1339). And one that names the mechanism better than any of them:
//! **letters that do not share a baseline.** That is what a rescale *is*. The emit pass rounds each
//! glyph onto the device-pixel grid — uniformly, so the line is flat — and the rescale then
//! multiplies each rounded position by a non-integer `k` about an anchor. Glyphs have different
//! vertical offsets (an `O` is not an `x` is not a `p`), so after the multiply every letter lands on
//! a *different* sub-pixel phase and resamples at a different offset. The line stops being a line.
//! No amount of care at the call sites fixes that; only not having a `k` does.
//!
//! ## What the real client does
//!
//! wow-re `system/font` (T3, verified — the lifecycle re-derived by a four-pair cross-check for
//! decision 1342): a `CGxFont` exists per (face, flags, **exact pixel size**), the size being
//! `min(32, round(H · max(reqSize, 2/H)))` (`0x5ca030` → `[CGxFont+0x24c]`) — integer device
//! pixels, always. Glyphs are rasterized **on demand** by `NewCodeDesc` (`0x5cabd0`) into a
//! per-font `TSHashTable<codepoint → CharCodeDesc>` (`+0x30/+0x38`) and land in a row-partitioned
//! texture cache (`texcache_set_cellsize` `0x5cf360`, cell size `em + 2·outline pad`, free-slot
//! search `0x5cf5a0`) of up to 8 pages (`CGxFont+0x18c`). One bitmap per (font, codepoint), zero
//! subpixel phases — the face is sized by `FT_Set_Pixel_Sizes` to a square integer ppem and the
//! module calls no `FT_Set_Transform` anywhere.
//!
//! There is no size ladder and no snapping. **`k` has no counterpart in the ordinary UI path**,
//! and the reason is precise: `CSimpleFontString`'s constructor sets the one-to-one bit
//! (`+0x120 & 0x200`, `0x770dd3`), which makes `GetFontHeight` return the font's own quantized em
//! back — so the draw's `scale = ScreenToPixelHeight / [CGxFont+0x24c]` comes out exactly `1.0`
//! and every glyph is an integer 1 texel : 1 pixel blit.
//!
//! So: [`TextEngine::ppem`] rounds a logical height to whole device pixels, everything downstream
//! is keyed by that integer, and [`TextEngine::logical_size`] is the `0x200` bit — the size a
//! string measures and lays out at is the size its glyphs were rasterized at, not the float that
//! was asked for. That is the mechanism that makes a rescale unnecessary, and it is the one worth
//! copying. (It is also the modern idiomatic design: cosmic-text's `SwashCache` over a shelf
//! allocator, which is what glyphon and egui do.)
//!
//! ## Where this deliberately goes further — and where it is thinner
//!
//! Being exact about the divergences, because "the client does it this way" is doing a lot of work
//! above and it should not be doing more than it has earned:
//!
//! - **The client scales cells in three places, and we scale in none.** `SetTextHeight` clears the
//! one-to-one bit (`0x771600`) and draws at a size the atlas was not rasterized for; a requested
//! em past 32 px clamps the raster while the numerator keeps growing; and every 3-D unit name
//! goes through `UNIT_NAME_FONT` (created at size `0.99f`, so its em is permanently 32) with
//! string flag `0x80` — the explicit magnified mode, ±½-texel UV insets and all. We rasterize
//! all three at their true size instead. That is not a new posture: it is exactly the recorded
//! divergence at [`super::FONTSTRING_EM_CAP`] — the 32-px ceiling is 2004 raster-memory
//! budgeting, and following it literally on a modern display converges every string toward the
//! same size and stretches the ones above it (the era's blurry big crits). **Crisper than the
//! reference, on purpose, in the same three places we had already chosen to be.**
//! - **Measuring rasterizes, in the client.** All five measure/layout kernels
//! (`0x5c6940`, `0x5c6b70`, `0x5c6c50`, `0x5c7300`, `0x5c7470`) call `NewCodeDesc`
//! unconditionally per character, and its miss branch runs `FT_Load_Glyph` + `FT_Render_Glyph`
//! with no flag that can skip the render — the advance the measure needs is written *by the
//! rasterizer*. [`TextEngine::ensure_metrics`] is therefore a **divergence**: we shape for the
//! metrics and skip the bitmap. It produces the same number (the step law reads only the
//! advance) and it is what makes a `GetStringWidth` probe over a string nobody draws cost no
//! texture at all.
//! - **The client evicts, we reset.** `NewCodeDesc`'s full-atlas path (`0x5cad2b`) is a true LRU
//! over `[CGxFont+0x64..0x6c]`: evict the single least-recently-used glyph, free its cell, repack
//! into the hole, and dirty every string on that font that used that page. We drop everything at
//! once instead ([`super::pack::Sheet::reset`]) — coarser, but our sheet is far larger than the
//! client's 256×256 pages, and a shelf allocator cannot reclaim an interior cell without the
//! repack the client's row lists are built for. Recorded, with the occupancy instrument that
//! would overturn it.
//! - **The client flushes on a viewport change; we do not.** `0x5c2b50` (edge-triggered from the
//! OS resize event) walks every live `CGxFont` and calls `0x5ca6f0`, which clears the whole glyph
//! cache in place and re-issues `FT_Set_Pixel_Sizes` — keeping the FreeType face. That is a
//! perfectly good answer to raster-size churn and we may yet want it; what stopped us taking it
//! now is that our nameplate meshes bake UVs, so a flush costs a mesh rebuild on every resize
//! frame, while simply *keeping* the dead cells costs only sheet space. Same instrument decides.
//!
//! ## The shape of the thing
//!
//! Two caches, filled by one operation and read by two very different callers:
//!
//! - **[`CharCell`]** — per `(face, ppem, char)`: which glyphs the character shapes to, their
//! advances, and the pre-summed floor the width law needs. **Never touches the GPU**, which is
//! what lets the script VM's synchronous measurer fill it from inside a Lua call.
//! - **The cells** — per `(face, glyph, ppem, outline radius)`: a rasterized bitmap packed into a
//! [page][super::pack]. Only the emit pass asks for these.
//!
//! Both live in one engine behind one lock, shared by the render path and the VM's measurer, so a
//! string measured mid-tick and the same string measured at extract are not merely equal numbers —
//! they are the same lookup in the same table. (That was already 1289's property; it survives the
//! rewrite because it is the property that keeps measure and render from drifting.)
//!
//! **Lock discipline:** the guard is taken by a leaf entry point and released before it returns.
//! Nothing may hold it across a call into the script VM — the VM's measurer takes the same lock.
mod faces;
mod gpu;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex, MutexGuard};
use bevy::image::Image;
use bevy::math::Rect;
use bevy::prelude::*;
use cosmic_text::{
Attrs, Buffer, CacheKey, Family, FontSystem, Metrics, Shaping, SwashCache, Wrap,
};
use benilla_assets::{LockRecover, WorldAssets};
use faces::{hhea_ascent_ratio, register_font, CLIENT_FONTS};
pub(crate) use gpu::UiTextPlugin;
use super::outline::outlined_cell;
use super::pack::{Cell, Sheet};
/// The raster size floor, in device pixels — the client's own `max(2, …)` (`0x5ca030`). Below this
/// a face has no readable ink and FreeType's metrics degenerate.
const MIN_PPEM: u16 = 2;
/// The raster size ceiling, in device pixels. **Not** the client's `min(32, …)`: that cap is a
/// 2004 raster-memory ceiling which we deliberately apply in LOGICAL units instead, at the
/// [`super::fontstring_em`] seam, so a capped zone splash scales with the screen and stays crisp
/// (the recorded divergence — see [`super::FONTSTRING_EM_CAP`]). World text
/// ([`crate::combat_text`], the nameplates) sizes by its own viewport laws and passes through
/// uncapped. This is the backstop under all of that: a request that reaches it is a bug upstream,
/// not a font to rasterize.
const MAX_PPEM: u16 = 256;
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The cache's own types
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// One registered face.
struct Face {
id: fontdb::ID,
family: String,
/// `hhea.asc / (asc + |desc|)` — see [`hhea_ascent_ratio`]. Friz's ≈ 0.794 is the fallback for
/// a face whose tables would not parse (never the four shipped client fonts).
ascent_ratio: f32,
}
/// `(face, glyph, ppem, outline radius)` — the raster cache's key. The size term is the **exact
/// integer device-pixel size** the glyph was rasterized at, which is the whole point: there is one
/// bitmap per size a caller actually asked for, and it is drawn one texel per device pixel.
type GlyphKey = (fontdb::ID, u16, u16, u8);
/// One rasterized cell. **All fields are PHYSICAL px** — the layout divides by
/// [`TextEngine::dpi`] on the way into logical space.
#[derive(Clone, Copy, Debug)]
pub(super) struct GlyphInfo {
pub(super) uv: Rect,
pub(super) px_w: f32,
pub(super) px_h: f32,
/// Swash convention: `left` rightward from the pen, `top` upward from the baseline.
pub(super) bearing_x: f32,
pub(super) bearing_top: f32,
}
/// One glyph a character shaped to, as the cache remembers it — everything the **pen** needs, with
/// no bitmap involved.
#[derive(Clone, Copy, Debug)]
pub(super) struct GlyphRef {
pub(super) glyph_id: u16,
/// Swash's rasterization key for this glyph at this ppem, at zero subpixel offset — kept so a
/// cell can be rasterized later (for a different outline radius) without re-shaping.
key: CacheKey,
/// The face's own advance, physical px, **unfloored**. The step law floors it
/// ([`super::layout::client_step`]).
pub(super) advance: f32,
/// Vertical offset from the line baseline, physical px (0 for every glyph these four Latin
/// faces shape — cached rather than assumed).
pub(super) y_off: f32,
}
/// One character's whole contribution at one `(face, ppem)`.
///
/// Two numbers rather than one because the client's step law ([`super::layout::client_step`]) adds
/// its bias **per glyph**, not per character: a character that shapes to two glyphs takes two
/// biases. Every character these four faces shape takes one — `glyphs` is length 1 everywhere
/// today — but the law stays the law if that ever stops being true.
pub(super) struct CharCell {
pub(super) glyphs: Vec<GlyphRef>,
/// Σ `advance.floor()` over the glyphs, in **physical** px — the width law's per-character
/// term, pre-summed so a measure is a hash lookup and an add.
pub(super) floor_sum: f32,
}
/// What the cache instrument (`WOW_GLYPH_CACHE=1`) counts.
#[derive(Default, Clone, Copy)]
struct CacheStats {
chars_shaped: u64,
cells_rasterized: u64,
resets: u64,
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The engine
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// The font engine: faces, the two caches, and the texture pages.
pub(crate) struct TextEngine {
font_system: FontSystem,
swash: SwashCache,
faces: Vec<Face>,
/// Blizzard font path (lowercased) → index into [`Self::faces`].
path_to_face: HashMap<String, usize>,
/// The fallback face (Friz Quadrata) for a FontString with no/unknown font path.
default_face: usize,
/// The window's `scale_factor` — physical px per logical px. Every raster size derives from it
/// ([`Self::ppem`]), so moving it invalidates no cell: it simply means new sizes get asked for.
/// (The *measures* answered under the old one are stale, which is the extract pass's business,
/// not the cache's.)
dpi: f32,
/// `(face, ppem, char) → what the pen needs`. **Never touches the GPU** — this is the half the
/// script VM's measurer fills from inside a Lua call.
chars: HashMap<(usize, u16, char), CharCell>,
/// `(face, glyph, ppem, radius) → cell`. `None` records a glyph with no ink (a space) or one
/// the pages could not fit, so a miss is paid for exactly once.
cells: HashMap<GlyphKey, Option<GlyphInfo>>,
sheet: Sheet,
/// Bumped when the cache [resets][Sheet::reset] — the **only** event that can move a UV, and
/// therefore the only thing a cross-frame holder of glyph UVs ([`crate::nameplates`]) has to
/// watch. Under the size ladder this fired on every window resize; now it fires when a session
/// has minted more distinct raster sizes than the pages hold, which is to say almost never.
generation: u64,
/// Set when an allocation failed. Acted on at the frame boundary, never mid-string.
reset_pending: bool,
/// Characters no face could shape, and sizes past the ceiling — reported once each rather than
/// once per frame.
complained: HashSet<char>,
over_ceiling: bool,
stats: CacheStats,
}
impl TextEngine {
/// Read the client TTFs through the app's own patch chain ([`WorldAssets::chain`] — never
/// `std::fs`) and register them. `None` if Friz Quadrata (the fallback face) is unreadable, in
/// which case text simply will not render — the same graceful-absence posture
/// [`crate::ui_script`]'s extraction takes.
fn load(world_assets: &WorldAssets, images: &Assets<Image>, dpi: f32) -> Option<Self> {
let mut font_system = FontSystem::new_with_fonts(std::iter::empty());
let mut faces: Vec<Face> = Vec::new();
let mut path_to_face = HashMap::new();
for &path in CLIENT_FONTS {
let bytes = {
let chain = world_assets.chain.lock_recover();
match chain.read(path) {
Ok(b) => b,
Err(e) => {
warn!("ui_text: failed to read {path} from the patch chain: {e:#}");
continue;
}
}
};
let ascent_ratio = hhea_ascent_ratio(&bytes).unwrap_or(0.794);
match register_font(&mut font_system, bytes) {
Ok((id, family)) => {
path_to_face.insert(path.to_ascii_lowercase(), faces.len());
faces.push(Face {
id,
family,
ascent_ratio,
});
}
Err(e) => warn!("ui_text: failed to register {path}: {e:#}"),
}
}
// Friz is index 0 in CLIENT_FONTS and is the fallback; without it nothing downstream has a
// face to fall back to.
let default_face = *path_to_face.get(&CLIENT_FONTS[0].to_ascii_lowercase())?;
info!(
"ui_text: font engine ready — {} face(s), glyphs rasterized on demand at {dpi}× \
device pixels",
faces.len()
);
Some(Self {
font_system,
swash: SwashCache::new(),
faces,
path_to_face,
default_face,
dpi,
chars: HashMap::new(),
cells: HashMap::new(),
sheet: Sheet::new(images),
generation: 0,
reset_pending: false,
complained: HashSet::new(),
over_ceiling: false,
stats: CacheStats::default(),
})
}
/// The face a Blizzard font path resolves to, or the fallback.
pub(super) fn face_for(&self, path: Option<&str>) -> usize {
path.and_then(|p| self.path_to_face.get(&p.to_ascii_lowercase()))
.copied()
.unwrap_or(self.default_face)
}
/// **The size law.** A logical height becomes the exact integer device-pixel size it will be
/// rasterized and drawn at — the client's `round((height/768)·deviceH)` with the 768-seam
/// already folded into `logical` by the caller ([`super::drawn_px`]), clamped to the raster
/// bounds ([`MIN_PPEM`]/[`MAX_PPEM`]).
///
/// Everything downstream is keyed by this integer. It is why there is no `k`: the size a string
/// is measured at, the size its glyphs are rasterized at, and the size it is drawn at are one
/// number by construction, rather than three numbers kept in agreement by discipline.
pub(super) fn ppem(&mut self, logical: f32) -> u16 {
let px = (logical * self.dpi).round();
if !px.is_finite() {
return MIN_PPEM;
}
if px > f32::from(MAX_PPEM) && !self.over_ceiling {
self.over_ceiling = true;
warn!(
"ui_text: a {logical} logical-px request rasterizes at {px} device px, past the \
{MAX_PPEM} ceiling — clamped. A size this large is an upstream bug, not a font."
);
}
(px as i64).clamp(i64::from(MIN_PPEM), i64::from(MAX_PPEM)) as u16
}
/// The **logical** height a ppem draws at — `ppem / dpi`. The pitch, the block height and every
/// measured extent are this, not the height that was requested: the request rounds to whole
/// device pixels first, exactly as the client rounds it, and everything then agrees with what
/// is actually on the screen.
pub(super) fn logical_size(&self, ppem: u16) -> f32 {
f32::from(ppem) / self.dpi
}
/// Physical px per logical px — the divisor the layout uses to bring cell metrics back into
/// logical space.
pub(super) fn dpi(&self) -> f32 {
self.dpi
}
/// The face's baseline-ascender fraction — the `[CGxFont+0x17c]` load_param the layout seats
/// each line's baseline with.
pub(super) fn ascent_ratio_of(&self, face: usize) -> f32 {
self.faces.get(face).map_or(0.794, |f| f.ascent_ratio)
}
/// The **logical** size a request actually draws at — [`Self::ppem`] rounded and back again.
///
/// The crate-facing shape of the size law, for the world-pass callers that have to normalize
/// their own geometry against it ([`crate::nameplates`]'s mesh bake). Everything inside
/// `ui_text` gets it from the resolved spec instead.
pub(crate) fn drawn_size(&mut self, logical: f32) -> f32 {
let ppem = self.ppem(logical);
self.logical_size(ppem)
}
/// The baseline-ascender fraction for a font path — the `[CGxFont+0x17c]` load_param, for a
/// caller that already holds the lock.
pub(crate) fn ascent_ratio(&self, path: Option<&str>) -> f32 {
self.ascent_ratio_of(self.face_for(path))
}
/// Make sure every character of `text` is in the caches at this `(face, ppem, radius)`,
/// rasterizing whatever is missing. Call it once before walking a string; every
/// [`Self::char_cell`] / [`Self::cell`] lookup afterwards is a hit.
///
/// The client has no such pre-pass — its layout kernels call `NewCodeDesc` (`0x5cabd0`) per
/// character as they walk, which is the same work in a different order. (`AllGlyphsCached`
/// `0x5c9fa0` is *not* it, despite the name: its one caller is `0x5cd3f0` and it gates
/// geometry invalidation after an eviction, never rasterization.) Hoisting it here is what
/// lets the walk itself take `&TextEngine` and stay a pure table read.
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_str`]'s **metrics-only** twin: shape what is missing, rasterize nothing.
///
/// This is the split that makes the whole design work. A width needs only the face and the
/// ppem — no bitmap, no packing, no GPU — so a string the script VM measures inside a Lua call
/// costs a shaping and a hash insert, and a string that is measured but never drawn (every
/// `GetStringWidth` probe, every wrap candidate the ellipsis seam backs off through) never
/// touches the sheet at all.
///
/// **A deliberate divergence**, and worth naming as one: the real client's measure path
/// rasterizes (module doc). The number is identical either way — the step law reads only the
/// advance, which `FT_Load_Glyph` fixes before the render — so what we skip is work, not
/// fidelity.
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);
}
}
/// Move the raster environment under a test. Production drives this from the window
/// ([`publish_sheet`]); nothing else may set it, because a mid-frame change would put two
/// raster sizes in one laid-out string.
#[cfg(test)]
pub(super) fn set_dpi_for_test(&mut self, dpi: f32) {
self.dpi = dpi;
}
/// One character: shape it alone (filling its [`CharCell`]) and rasterize its glyphs at
/// `radius`.
///
/// **Shaped alone, deliberately.** The advance that lands in the width sum must be the face's
/// own advance for the glyph, carrying no neighbour term — the client's law (`ComputeStep`
/// `0x5ca2d0`) drops kerning, so a string's width is a pure function of its characters. Shaping
/// each character by itself is what makes that true rather than approximately true, and it is
/// what lets one table answer a measure and a draw.
fn ensure_char(&mut self, face: usize, ppem: u16, radius: Option<u8>, ch: char) {
if let Some(known) = self.chars.get(&(face, ppem, ch)) {
let Some(radius) = radius else { return };
// The pen metrics are there; the cells for THIS radius may not be (an outlined font
// asking for a character a plain one already drew).
let glyphs = known.glyphs.clone();
let Some(id) = self.faces.get(face).map(|f| f.id) else {
return;
};
for g in &glyphs {
self.ensure_cell(id, ppem, radius, g);
}
return;
}
let Some(f) = self.faces.get(face) else {
return;
};
let (face_id, family) = (f.id, f.family.clone());
let attrs = Attrs::new().family(Family::Name(&family));
let px = f32::from(ppem);
let mut glyphs: Vec<GlyphRef> = Vec::new();
let mut floor_sum = 0.0f32;
{
let mut buf = Buffer::new(&mut self.font_system, Metrics::new(px, px));
buf.set_wrap(&mut self.font_system, Wrap::None);
let mut one = [0u8; 4];
buf.set_text(
&mut self.font_system,
ch.encode_utf8(&mut one),
&attrs,
Shaping::Advanced,
None,
);
buf.shape_until_scroll(&mut self.font_system, false);
for run in buf.layout_runs() {
for g in run.glyphs {
// A lone first glyph on the line has x == y == 0.0, so this is the
// zero-subpixel canonical rasterization for (face, glyph, ppem).
let physical = g.physical((0.0, 0.0), 1.0);
glyphs.push(GlyphRef {
glyph_id: g.glyph_id,
key: physical.cache_key,
advance: g.w,
y_off: g.y,
});
floor_sum += g.w.floor();
}
}
}
if glyphs.is_empty() {
// No face shaped it — it draws nothing and, per the width law's own note, measures
// nothing. Report once, not once per frame.
if self.complained.insert(ch) {
warn!("ui_text: no glyph for {ch:?} in any registered face");
}
return;
}
self.stats.chars_shaped += 1;
if let Some(radius) = radius {
for g in &glyphs {
let g = *g;
self.ensure_cell(face_id, ppem, radius, &g);
}
}
self.chars
.insert((face, ppem, ch), CharCell { glyphs, floor_sum });
}
/// Rasterize + 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);
if self.cells.contains_key(&key) {
return;
}
// Copied out rather than borrowed, so the pages can be written below.
let raster = {
let Self {
swash, font_system, ..
} = self;
swash
.get_image(font_system, g.key)
.as_ref()
.map(|i| (i.placement, i.data.clone()))
};
let Some((placement, cov)) = raster else {
self.cells.insert(key, None);
return;
};
let (w, h) = (placement.width, placement.height);
if w == 0 || h == 0 {
// Whitespace / zero-ink glyph: real metrics, no cell.
self.cells.insert(key, None);
return;
}
self.stats.cells_rasterized += 1;
// An outlined cell grows by `pad` on every side; the bearings move out with it, the
// advance does not (the step law owns tracking).
let (uv, cw, ch, bx, bt) = if radius == 0 {
(
self.sheet.alloc(w, h, &Cell::Coverage(&cov)),
w,
h,
placement.left as f32,
placement.top as f32,
)
} else {
let (rgba, ow, oh, pad) = outlined_cell(&cov, w, h, radius, self.dpi);
(
self.sheet.alloc(ow, oh, &Cell::Rgba(&rgba)),
ow,
oh,
placement.left as f32 - pad as f32,
placement.top as f32 + pad as f32,
)
};
match uv {
Some(uv) => {
self.cells.insert(
key,
Some(GlyphInfo {
uv,
px_w: cw as f32,
px_h: ch as f32,
bearing_x: bx,
bearing_top: bt,
}),
);
}
None => self.note_exhausted(key),
}
}
/// The pages could not fit a cell. Record nothing for it (so the glyph draws nothing for at
/// most one frame) and ask for a reset at the frame boundary.
///
/// A shelf allocator cannot reclaim an interior cell, so there is no piecemeal eviction to
/// reach for: the honest move is to drop everything and refill from what is actually on screen,
/// which costs one frame of rasterization for the visible text. What must NOT happen is doing
/// it here — glyphs already pushed this frame hold UVs into the pages, and moving them mid-pass
/// would draw this frame's text as fragments of other letters (the exact shape of 1339's fault
/// 2, which is worth not rebuilding).
fn note_exhausted(&mut self, key: GlyphKey) {
self.cells.insert(key, None);
if !self.reset_pending {
self.reset_pending = true;
let (used, total) = self.sheet.occupancy();
warn!(
"ui_text: glyph sheet full ({used}/{total} texels, {} cells) — resetting at the \
frame boundary",
self.cells.len()
);
}
}
/// One character's pen metrics — `None` for a character no face can shape (it draws nothing and
/// measures nothing, the one narrow place the measure differs from the render pen).
pub(super) fn char_cell(&self, face: usize, ppem: u16, ch: char) -> Option<&CharCell> {
self.chars.get(&(face, ppem, ch))
}
/// One glyph's cell — `None` for a zero-ink glyph, and for the one frame after an exhaustion.
pub(super) fn cell(
&self,
face: usize,
ppem: u16,
radius: u8,
glyph_id: u16,
) -> Option<GlyphInfo> {
let id = self.faces.get(face)?.id;
self.cells
.get(&(id, glyph_id, ppem, radius))
.copied()
.flatten()
}
/// The one texture every glyph quad samples.
pub(super) fn sheet_image(&self) -> Handle<Image> {
self.sheet.handle()
}
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The Bevy face of it
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// The font engine as the app holds it: one [`TextEngine`] behind one lock, plus the per-region
/// ellipsis memo.
///
/// The lock is what lets the same engine answer the render path and the script VM's synchronous
/// measurer ([`super::AtlasMeasurer`]), which reaches in from inside a Lua call. See the module
/// doc's lock discipline.
#[derive(Resource)]
pub(crate) struct UiFontAtlas {
engine: Arc<Mutex<TextEngine>>,
/// Mirrored out of the engine each frame so the extract gate can read it without taking the
/// lock. Moves only on a cache reset.
pub(crate) generation: u64,
/// Per-region ellipsis display strings ([`super::EllipsisMemo`]) — the client's own
/// `CGxString+0xf8` cache, keyed by the inputs instead of a dirty flag.
pub(super) ellipsis: super::EllipsisMemo,
}
impl UiFontAtlas {
/// Take the engine lock. Release it before calling anything that might re-enter through the
/// VM's measurer.
pub(crate) fn lock(&self) -> MutexGuard<'_, TextEngine> {
self.engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// The shared engine, for the VM's measurer.
pub(crate) fn engine(&self) -> Arc<Mutex<TextEngine>> {
Arc::clone(&self.engine)
}
/// The one texture every glyph draws from — for the world-pass consumers
/// ([`crate::nameplates`]) that bind glyph cells onto 3-D geometry instead of UI quads.
///
/// Stable for the life of the process, which is new: under the size ladder every re-bake
/// published a fresh `Handle<Image>`, and a cache that kept the old one drew the new bake's
/// UVs through the old bake's texture (B272, decision 1339). Cells are written into this
/// texture in place now, so there is no successor to go stale against.
pub(crate) fn image(&self) -> Handle<Image> {
self.lock().sheet_image()
}
}
/// A real-font engine for a test: the client faces, read through the app's own patch chain. `None`
/// when there is no install, or the chain or a face will not open — every caller **skips** rather
/// than fails (`wow_data_or_skip!`).
#[cfg(test)]
pub(super) fn test_engine(dpi: f32) -> Option<TextEngine> {
let data = benilla_formats::wow_data()?;
let chain = benilla_formats::open_chain(&data).ok()?;
let mut font_system = FontSystem::new_with_fonts(std::iter::empty());
let mut faces: Vec<Face> = Vec::new();
let mut path_to_face = HashMap::new();
for &path in CLIENT_FONTS {
let Ok(bytes) = chain.read(path) else {
continue;
};
let ascent_ratio = hhea_ascent_ratio(&bytes).unwrap_or(0.794);
let (id, family) = register_font(&mut font_system, bytes).ok()?;
path_to_face.insert(path.to_ascii_lowercase(), faces.len());
faces.push(Face {
id,
family,
ascent_ratio,
});
}
let default_face = *path_to_face.get(&CLIENT_FONTS[0].to_ascii_lowercase())?;
Some(TextEngine {
font_system,
swash: SwashCache::new(),
faces,
path_to_face,
default_face,
dpi,
chars: HashMap::new(),
cells: HashMap::new(),
sheet: Sheet::new(&Assets::<Image>::default()),
generation: 0,
reset_pending: false,
complained: HashSet::new(),
over_ceiling: false,
stats: CacheStats::default(),
})
}
/// The client font paths, for tests that name a face.
#[cfg(test)]
pub(super) const TEST_FACES: &[&str] = CLIENT_FONTS;
#[cfg(test)]
mod differential_tests {
use super::*;
/// Real strings of the kinds that actually reach a measure: character names, item names,
/// prose, the sequences a ligature table would target, the Latin-1 tail, and the digits the
/// money frame sums.
const CORPUS: &[&str] = &[
"",
" ",
"Onewarrior",
"Probezero",
"Small Brown Pouch",
"Staff of the Shadow Flame",
"The affluent fiend's fickle offer",
"fi fl ffi ffl ff",
"AV Ta Wo Yo LT P, r. v.",
"0123456789",
"Grüße, Ärger; élan côté",
"!@#$%^&*()_+-=[]{}|;':\",./<>?",
];
/// **The character walk selects the glyphs whole-string shaping selects** — over the real
/// client fonts.
///
/// Both halves of this module's design rest on one property: a string's glyph sequence is the
/// concatenation of its characters' glyph sequences. That is what lets
/// [`TextEngine::ensure_char`] shape one character at a time — which in turn is what lets a
/// measure be answered from inside a Lua call, and what lets the emit pass walk characters
/// instead of running a shaper.
///
/// It is a property of the FONTS, not of the code, and exactly the kind that would go silently
/// wrong: a face with a ligature or a contextual substitution would break it and nothing else
/// would notice. Skips without an install.
#[test]
fn a_character_walk_selects_what_the_whole_string_selects() {
let Some(mut e) = test_engine(1.0) else {
eprintln!("skipping: no install / patch chain");
return;
};
let mut checked = 0usize;
for dpi in [1.0f32, 2.0] {
e.set_dpi_for_test(dpi);
for face in 0..e.faces.len() {
let family = e.faces[face].family.clone();
// One small size and one large, so a size-dependent substitution could not hide
// between them.
for ppem in [10u16, 20] {
for text in CORPUS {
let (want, _) = shape_whole(&mut e, &family, ppem, text);
e.ensure_metrics(face, ppem, text);
let got: Vec<u16> = text
.chars()
.filter_map(|c| e.char_cell(face, ppem, c))
.flat_map(|c| c.glyphs.iter().map(|g| g.glyph_id))
.collect();
assert_eq!(
got, want,
"{family} @{ppem} dpi {dpi}: {text:?} — the character walk and the \
whole-string shaping must select the same glyphs"
);
checked += 1;
}
}
}
}
assert!(
checked > 0,
"no client font was readable — nothing was proven"
);
}
/// **The width is the sum of unkerned per-character steps, and that is on purpose.**
///
/// `measure_line_width` reads [`CharCell::floor_sum`], which is pre-summed at shaping time.
/// This recomputes it from each glyph's own stored advance, so a mistake in the pre-summing or
/// in the physical→logical divide fails here rather than on screen.
///
/// The second half is the one that matters more: it asserts the answer **differs** from
/// shaping the whole string, on a corpus of kerning pairs. cosmic-text's `Shaping::Advanced`
/// kerns `AV`, `Ta`, `Wo`, `Yo`, `LT` and friends; the client's law does not
/// (`ComputeStep 0x5ca2d0` applies only negative pair kerns and rounds, which we drop
/// entirely — the module doc's stated v1 simplification). Without this assertion, someone
/// "fixing" the measure to shape whole runs would make text quietly narrower than the
/// reference and nothing would object.
#[test]
fn the_width_is_the_unkerned_per_character_sum() {
let Some(mut e) = test_engine(1.0) else {
eprintln!("skipping: no install / patch chain");
return;
};
let mut kerning_seen = 0usize;
for dpi in [1.0f32, 2.0] {
e.set_dpi_for_test(dpi);
for face in 0..e.faces.len() {
let family = e.faces[face].family.clone();
for ppem in [10u16, 20] {
// Both step biases: plain/NORMAL (+1) and THICK (+2).
for step_extra in [1.0f32, 2.0] {
for text in CORPUS {
let got = super::super::layout::measure_line_width_for_test(
&mut e, face, ppem, step_extra, text,
);
let by_glyph: f32 = text
.chars()
.filter_map(|c| e.char_cell(face, ppem, c))
.flat_map(|c| c.glyphs.iter())
.map(|g| (g.advance.floor() + step_extra) / dpi)
.sum();
assert_eq!(
got, by_glyph,
"{family} @{ppem} dpi {dpi} extra {step_extra}: {text:?} — the \
pre-summed floor must be the per-glyph sum"
);
let (_, kerned) = shape_whole(&mut e, &family, ppem, text);
let kerned =
kerned + step_extra * kerned_glyphs(&mut e, face, ppem, text) / dpi;
if (kerned - got).abs() > 1e-3 {
kerning_seen += 1;
}
}
}
}
}
}
assert!(
kerning_seen > 0,
"the corpus is full of kerning pairs — if the shaped sum never differs from ours, \
either kerning stopped being dropped or this test stopped measuring it"
);
}
/// Glyph count of `text` at `(face, ppem)` — the multiplier the step bias takes.
fn kerned_glyphs(e: &mut TextEngine, face: usize, ppem: u16, text: &str) -> f32 {
e.ensure_metrics(face, ppem, text);
text.chars()
.filter_map(|c| e.char_cell(face, ppem, c))
.map(|c| c.glyphs.len() as f32)
.sum()
}
/// The reference: shape the WHOLE string in one buffer. Returns its glyph ids and the sum of
/// its **kerned** floored advances (no step bias — the caller adds it).
fn shape_whole(e: &mut TextEngine, family: &str, ppem: u16, text: &str) -> (Vec<u16>, f32) {
if text.is_empty() {
return (Vec::new(), 0.0);
}
let attrs = Attrs::new().family(Family::Name(family));
let px = f32::from(ppem);
let mut buf = Buffer::new(&mut e.font_system, Metrics::new(px, px));
buf.set_wrap(&mut e.font_system, Wrap::None);
buf.set_text(&mut e.font_system, text, &attrs, Shaping::Advanced, None);
buf.shape_until_scroll(&mut e.font_system, false);
let (mut ids, mut w) = (Vec::new(), 0.0f32);
for run in buf.layout_runs() {
for g in run.glyphs {
ids.push(g.glyph_id);
w += g.w.floor() / e.dpi;
}
}
(ids, w)
}
}
#[cfg(test)]
mod ppem_tests {
use super::*;
fn engine_or_skip() -> Option<TextEngine> {
match test_engine(1.0) {
Some(e) => Some(e),
None => {
eprintln!("skipping: no client install / patch chain");
None
}
}
}
/// **The size law, which is the whole decision.** A logical height becomes whole device pixels
/// and nothing else; a request "between sizes" does not exist, because there is nothing to be
/// between. Under the ladder, 12.48 snapped to 12 and the draw then stretched every finished
/// quad by 1.04 about an anchor — which is what took the letters off their shared baseline.
#[test]
fn a_logical_height_becomes_whole_device_pixels() {
let Some(mut e) = engine_or_skip() else {
return;
};
assert_eq!(e.ppem(12.0), 12, "an exact size is itself");
assert_eq!(e.ppem(12.48), 12, "…and a fractional one rounds, not snaps");
assert_eq!(e.ppem(12.5), 13);
// The era-shaped windows' `SetScale(0.78)` — the case a fixed ladder could never carry,
// because the Options window and the Game Menu are off it BY CONSTRUCTION.
assert_eq!(e.ppem(16.0 * 0.78), 12);
// Retina: the same logical height, twice the pixels, still an integer.
e.dpi = 2.0;
assert_eq!(e.ppem(12.0), 24);
assert_eq!(e.ppem(16.0 * 0.78), 25);
// The clamps.
assert_eq!(e.ppem(0.0), MIN_PPEM);
assert_eq!(e.ppem(-3.0), MIN_PPEM);
assert_eq!(e.ppem(f32::NAN), MIN_PPEM);
assert_eq!(e.ppem(10_000.0), MAX_PPEM);
}
/// The logical height a ppem draws at round-trips — which is what makes the pitch, the block
/// height and every measured extent agree with the pixels on the screen rather than with the
/// float that was asked for.
#[test]
fn the_drawn_logical_size_is_the_ppem_back_again() {
let Some(mut e) = engine_or_skip() else {
return;
};
assert_eq!(e.drawn_size(12.0), 12.0);
e.dpi = 2.0;
assert_eq!(e.drawn_size(12.0), 12.0);
// A request that does not land on a whole device pixel draws at the size it rounded to,
// and says so — no second, different number anywhere downstream.
assert_eq!(e.drawn_size(12.3), 12.5);
}
/// A character is shaped once and rasterized once per (size, radius); asking again is free, and
/// asking for a second radius adds a cell without re-shaping.
#[test]
fn a_character_is_shaped_once_and_cached_per_size_and_radius() {
let Some(mut e) = engine_or_skip() else {
return;
};
let face = e.face_for(None);
e.ensure_str(face, 14, 0, "Ab");
let (shaped, cells) = (e.stats.chars_shaped, e.stats.cells_rasterized);
assert_eq!(shaped, 2, "two characters");
assert_eq!(cells, 2, "two plain cells");
e.ensure_str(face, 14, 0, "Ab");
assert_eq!(e.stats.chars_shaped, shaped, "a repeat costs no shaping");
assert_eq!(e.stats.cells_rasterized, cells, "…and no raster");
// A second outline radius: new cells, no new shaping.
e.ensure_str(face, 14, 1, "Ab");
assert_eq!(
e.stats.chars_shaped, shaped,
"the pen metrics were already there"
);
assert_eq!(
e.stats.cells_rasterized,
cells + 2,
"…but the ring is a new cell"
);
// A different size is a different raster, as it must be — that is the point.
e.ensure_str(face, 15, 0, "Ab");
assert_eq!(e.stats.chars_shaped, shaped + 2);
// …and the cells are all really there, at both sizes.
for ppem in [14u16, 15] {
for ch in "Ab".chars() {
let c = e.char_cell(face, ppem, ch).expect("shaped");
for g in &c.glyphs {
assert!(e.cell(face, ppem, 0, g.glyph_id).is_some(), "{ch} @{ppem}");
}
}
}
}
/// A space has real metrics and no cell — it must step the pen and draw nothing, and it must
/// not be re-asked every frame.
#[test]
fn a_zero_ink_character_keeps_its_advance() {
let Some(mut e) = engine_or_skip() else {
return;
};
let face = e.face_for(None);
e.ensure_str(face, 14, 0, " ");
let c = e.char_cell(face, 14, ' ').expect("a space still shapes");
assert_eq!(c.glyphs.len(), 1);
assert!(c.floor_sum > 0.0, "a space is wide");
assert!(
e.cell(face, 14, 0, c.glyphs[0].glyph_id).is_none(),
"…and has no ink"
);
let raster = e.stats.cells_rasterized;
e.ensure_str(face, 14, 0, " ");
assert_eq!(e.stats.cells_rasterized, raster, "the miss is paid once");
}
/// Each face resolves to itself, and an unknown path falls back to Friz.
#[test]
fn a_font_path_resolves_to_its_own_face() {
let Some(e) = engine_or_skip() else {
return;
};
let friz = e.face_for(Some(TEST_FACES[0]));
assert_eq!(friz, e.face_for(None), "Friz is the fallback");
assert_eq!(friz, e.face_for(Some("Fonts\\NOSUCH.TTF")));
assert_eq!(friz, e.face_for(Some("fonts\\frizqt__.ttf")), "case-folded");
assert_ne!(friz, e.face_for(Some(TEST_FACES[1])), "ARIALN is its own");
}
}
@@ -0,0 +1,540 @@
//! **Measure and fit** — the width law, the wrap walk, and the ellipsis seam.
//!
//! Split from the emit pass ([`super`]) at decision 1342, which is when the split finally became
//! honest. Under the size ladder these two halves computed in *different spaces*: measure answered
//! in drawn px and the emit pass laid out at the snapped size, with a rescale between them, so
//! every fit decision had to be converted back and forth and each conversion was a place to get it
//! wrong (1339's fault 1 — an auto-sized label wrapping inside its own measured width). With the
//! raster size an exact integer, both halves work in one space over one table, and the seam between
//! them is just "who calls whom".
//!
//! Everything here is a pure function of `(face, ppem, outline bias, characters)`. That is the
//! client's own law — `ComputeStep 0x5ca2d0` has no kerning term and no neighbour term — and it is
//! what lets a measure be answered from inside a Lua call, where a shaper could never reach.
use bevy::math::Rect;
use benilla_ui::script::{JustifyH, JustifyV};
use super::super::engine::{TextEngine, UiFontAtlas};
use super::super::markup::{fontstring_lines, parse_markup, ColorRun};
use super::wrap::{greedy_pack, tokenize_words};
use super::{client_step, overflow, FontSpec, Justify, HEIGHT_LIMIT_MIN, WRAP_MIN_WIDTH};
/// A [`FontSpec`] resolved against the engine — everything downstream needs, looked up once.
///
/// One body, because the three things it produces have to agree: a measure that resolved a
/// different face than the draw, or a different raster size, is exactly the class of bug this
/// decision is about.
#[derive(Clone, Copy)]
pub(super) struct Resolved {
/// Index into the engine's faces.
pub(super) face: usize,
/// The exact integer device-pixel size ([`TextEngine::ppem`]).
pub(super) ppem: u16,
/// The **logical** height that ppem draws at — the line pitch, and the em every block-height
/// law uses. Not the height that was requested: see [`TextEngine::logical_size`].
pub(super) size: f32,
/// The outline cell variant (`0` = plain).
pub(super) radius: u8,
/// The step law's per-glyph bias ([`super::step_extra_of`]).
pub(super) step_extra: f32,
}
/// Resolve a spec. Does **not** touch the caches — call [`TextEngine::ensure_metrics`] (or
/// [`TextEngine::ensure_str`], to draw) for the string itself.
pub(super) fn resolve(e: &mut TextEngine, font: &FontSpec) -> Resolved {
let face = e.face_for(font.path);
let ppem = e.ppem(font.height.unwrap_or(super::super::DEFAULT_FONT_SIZE));
Resolved {
face,
ppem,
size: e.logical_size(ppem),
radius: super::super::outline::radius_of(font.outline),
step_extra: super::step_extra_of(font.outline),
}
}
/// One line's total laid-out width (logical px) under the client's step law
/// ([`client_step`]): the sum of per-character steps, read from the cache
/// ([`TextEngine::char_cell`]). This is the client's `GetTextWidth` (a sum of `ComputeStep`s), so
/// measure == render == the real client's width. Empty text is zero-width.
///
/// **No shaper, and that is the point.** The client's law has no kerning term and no neighbour
/// term, so a string's width is a pure function of its characters, face, size and outline — which
/// is what lets the lookup be cached, and therefore what lets this answer from *inside* a Lua call
/// ([`benilla_ui::script::TextMeasure`]), where a `&mut FontSystem` could never reach. Every
/// measurement in the app runs through this one function, so the synchronous answer and the batch
/// answer cannot be different numbers.
///
/// A character no face can shape contributes 0 — and steps the render pen by 0 too, so unlike
/// under the ladder there is no divergence left to document here.
///
/// The caller must have ensured `text`'s metrics; a character that has not been is skipped rather
/// than shaped, because this half is deliberately `&`-only.
pub(super) fn measure_line_width(e: &TextEngine, r: Resolved, step_extra: f32, text: &str) -> f32 {
let dpi = e.dpi();
text.chars()
.filter_map(|c| e.char_cell(r.face, r.ppem, c))
.map(|c| (c.floor_sum + step_extra * c.glyphs.len() as f32) / dpi)
.sum()
}
/// The width law itself, for the differential test that pins it to a shaped sum.
#[cfg(test)]
pub(crate) fn measure_line_width_for_test(
e: &mut TextEngine,
face: usize,
ppem: u16,
step_extra: f32,
text: &str,
) -> f32 {
e.ensure_metrics(face, ppem, text);
let r = Resolved {
face,
ppem,
size: e.logical_size(ppem),
radius: 0,
step_extra,
};
measure_line_width(e, r, step_extra, text)
}
/// Greedy word-boundary wrap of one markup line (a `\n`-delimited run sequence) into sub-lines that
/// each fit within `max_width` px.
///
/// The break law is the byte-verified regime-2 wrap (`system/ui/scratch/fontstring-overflow.md`,
/// the `0x5c6c50`/`0x5c7780` kernel): break at the last break opportunity; a word with none that
/// overflows alone force-breaks at the last fitting glyph — a rendered line never exceeds the wrap
/// width. Inter-word whitespace is preserved verbatim (a double space after a period stays a double
/// space — see [`tokenize_words`]), and only the trailing separator at a line break drops. Colors
/// are preserved per word (the separating whitespace attaches to the preceding run, since it draws
/// no ink). **Remaining approximation:** break opportunities are ASCII/Unicode whitespace only —
/// the kernel's kinsoku (CJK) opportunity classes and the `nonspacewrap` flag (ui `0x1000`:
/// mid-word breaks become opportunities even when a space exists) are unmodeled; no shipped
/// FontString renders CJK, and the one `nonspacewrap` consumer (MinimapZoneText) sits in a one-line
/// box where opportunity choice cannot change the outcome.
pub(super) fn wrap_line(
e: &TextEngine,
r: Resolved,
line: &[ColorRun],
max_width: f32,
) -> Vec<Vec<ColorRun>> {
// Flatten the line's color runs into words, each tagged with its run color and its verbatim
// inter-word separator (so Blizzard's double spaces survive the wrap).
let words = tokenize_words(line);
if words.is_empty() {
// A blank line (only whitespace / empty) still occupies a row — keep it as one empty line.
return vec![line.to_vec()];
}
greedy_pack(words, max_width, |t| {
measure_line_width(e, r, r.step_extra, t)
})
}
/// Measure `text`'s laid-out size — width of the widest line, height of all (wrapped) lines — at
/// the resolved face/size, wrapping at `wrap_width` when given (the same [`wrap_line`] pass
/// rendering uses, so measure == render). The engine's measure round-trip
/// ([`benilla_ui::script::UiScript::fontstrings_needing_measure`]) sizes height-less FontStrings
/// with this — the real client's layout asks its font engine for string metrics the same way.
pub(crate) fn measure_text(
e: &mut TextEngine,
text: &str,
wrap_width: Option<f32>,
font: FontSpec,
) -> (f32, f32) {
let r = resolve(e, &font);
// The one `&mut` moment: everything below reads the cache this fills. Metrics only — a string
// that is measured and never drawn costs no raster and no GPU.
e.ensure_metrics(r.face, r.ppem, text);
let e = &*e;
let lines = fontstring_lines(text, [1.0, 1.0, 1.0, 1.0]);
let render_lines: Vec<Vec<ColorRun>> = match wrap_width {
Some(w) if w > WRAP_MIN_WIDTH => lines
.iter()
.flat_map(|line| wrap_line(e, r, line, w))
.collect(),
_ => lines,
};
let mut max_w = 0.0f32;
for line in &render_lines {
let mut w = 0.0f32;
for run in line {
if !run.text.is_empty() {
w += measure_line_width(e, r, r.step_extra, &run.text);
}
}
max_w = max_w.max(w);
}
// A hair of headroom. This used to be load-bearing: the emit pass re-wrapped inside this same
// width in a *different* space, and the rescale between them turned float noise into a
// proportional error (1339). With one raster size there is one space, and the emit pass sums
// the identical per-character steps in the identical order — the two numbers are bit-equal, so
// this pixel is now pure belt-and-braces. It stays because removing it narrows every auto-sized
// FontString by a pixel, which is a look change and therefore the director's call, not a
// refactor's.
//
// Height: N lines at the client's intrinsic pitch — the font em, NO outline pad (the byte law:
// `font_textblock_height 0x5c2070` = N·S + (N−1)·gap, spacing 0 for all shipped UI; the +2r
// lives only in the atlas CELL, never the block math — `fontstring-vertical-placement.md`). An
// outlined line's ring pokes past this height by design, exactly as it does in the client.
(max_w.ceil() + 1.0, render_lines.len() as f32 * r.size)
}
/// How many display rows `text` wraps into at `wrap_width` — the message-line half of the measure
/// round-trip ([`benilla_ui::script::UiScript::message_lines_needing_measure`]): the engine's
/// ScrollingMessageFrame allocates `rows × font-height` per ring line from this. Runs the exact
/// [`wrap_line`] pass rendering uses (same step law, same outline bias), so the band height always
/// equals the drawn block height. Never 0 (empty text is one blank row).
pub(crate) fn measure_wrapped_rows(
e: &mut TextEngine,
text: &str,
wrap_width: f32,
font: FontSpec,
) -> u16 {
let r = resolve(e, &font);
e.ensure_metrics(r.face, r.ppem, text);
let rows = wrapped_rows(e, r, text, wrap_width);
rows.clamp(1, usize::from(u16::MAX)) as u16
}
/// The wrapped display-row count of `text` at `wrap_width` — the exact [`wrap_line`] pass the
/// render uses. An unconstrained width counts the `\n` lines.
fn wrapped_rows(e: &TextEngine, r: Resolved, text: &str, wrap_width: f32) -> usize {
wrapped_rows_capped(e, r, text, wrap_width, usize::MAX)
}
/// [`wrapped_rows`] that stops once `cap` rows exist — the client's own fit walk is bounded by the
/// BOX, not by the string: `GxuFont_GetMaxCharsWithinHeight` (`0x5c21c0`) lays out `min(needed,
/// fits + 1)` lines and abandons the rest, because the height test sits downstream of the per-line
/// kernel call (wow-re `system/ui/scratch/fontstring-ellipsis-cost.md`). A caller that only needs
/// to know whether the text OVERFLOWS passes `allowed + 1` and never pays for the tail; the chat
/// band's row count, which is a real number, passes [`usize::MAX`].
fn wrapped_rows_capped(
e: &TextEngine,
r: Resolved,
text: &str,
wrap_width: f32,
cap: usize,
) -> usize {
let lines = fontstring_lines(text, [1.0, 1.0, 1.0, 1.0]);
if wrap_width <= WRAP_MIN_WIDTH {
return lines.len().min(cap);
}
let mut rows = 0usize;
for line in &lines {
rows += wrap_line(e, r, line, wrap_width).len();
if rows >= cap {
return cap;
}
}
rows
}
/// [`wrapped_rows`], for the ellipsis-cost bench.
#[cfg(test)]
pub(super) fn wrapped_rows_for_test(
e: &TextEngine,
r: Resolved,
text: &str,
wrap_width: f32,
) -> usize {
wrapped_rows(e, r, text, wrap_width)
}
/// [`wrapped_rows_capped`], for the ellipsis-cost bench.
#[cfg(test)]
pub(super) fn wrapped_rows_capped_for_test(
e: &TextEngine,
r: Resolved,
text: &str,
wrap_width: f32,
cap: usize,
) -> usize {
wrapped_rows_capped(e, r, text, wrap_width, cap)
}
/// The FontString display string under the height-gated ellipsis-truncate — `CSimpleFontString
/// 0x771ec0`, regime 3 of the overflow verdict (`fontstring-overflow.md`, decision 0292's named
/// residue): when the wrapped text needs more lines than the box allows, the tail is replaced by
/// `"..."`, backed off one char at a time until the candidate fits. `None` = draw the raw text
/// (fits, or the gate fails). The gate is geometric — `boxW > 0 && boxH > 0` on the resolved rect
/// (`maxLines` unmodeled; nothing shipped sets it): an auto-height FontString's rect height IS its
/// wrapped block (the measure round-trip), so it always fits and never truncates — the byte law's
/// intrinsic-height escape. Applies at the region-FontString paint seam only
/// ([`crate::ui_script::extract`]): the client's ellipsis is a FontString mechanism — editbox,
/// message-frame lines, and world text (`combat_text`/nameplates) never take it, exactly as their
/// C++ counterparts never call `0x771ec0`.
///
/// **The box is the box.** Under the ladder this had to divide the rect by the draw's rescale
/// before comparing it against unscaled glyphs, and getting that backwards is what truncated text
/// that fits (B209, decision 0989's named residual). There is no rescale now, so there is no
/// conversion here and none to get backwards.
pub(crate) fn ellipsize_to_fit(
atlas: &mut UiFontAtlas,
region: benilla_ui::widget::RegionHandle,
text: &str,
rect: Rect,
font: FontSpec,
) -> Option<String> {
if rect.width() <= WRAP_MIN_WIDTH || rect.height() <= HEIGHT_LIMIT_MIN {
return None;
}
let (box_w, box_h) = (rect.width(), rect.height());
// The remembered answer, under exactly these inputs ([`super::super::EllipsisMemo`] — the
// client's `CGxString+0xf8`). The paint pass runs every frame; this seam is what the client
// rebuilds only on invalidation, and it is the most expensive thing in the pass by an order of
// magnitude (B240, decision 1332).
if let Some(hit) = atlas.ellipsis.get(region, text, box_w, box_h, &font) {
return hit.clone();
}
let display = {
let mut e = atlas.lock();
let r = resolve(&mut e, &font);
// Every candidate is a prefix of `text` plus "..." — so one ensure covers the whole walk.
e.ensure_metrics(r.face, r.ppem, text);
e.ensure_metrics(r.face, r.ppem, "...");
let e = &*e;
// The fit test only has to decide OVERFLOW, so it stops one row past the box — the client's
// own bound ([`wrapped_rows_capped`]).
let cap = overflow::lines_fitting(box_h.max(r.size), r.size) + 1;
overflow::ellipsize_in_box(text, box_h, r.size, |candidate| {
wrapped_rows_capped(e, r, candidate, box_w, cap)
})
};
atlas
.ellipsis
.put(region, text, box_w, box_h, &font, display.clone());
display
}
/// The drawn line's origin for the EditBox text-UI overlays: the line's x0 under `justifyH` (where
/// the engine's advance-derived caret/selection x-offsets are measured from), plus the single-line
/// cell's top and height — the same face/size resolution, step law, and justify math as
/// [`super::layout_text_quads`], so the overlays sit exactly on the glyphs. Single-line by
/// construction (the EditBox law; a multiLine box would need the wrapped-line walk).
pub(crate) fn line_origin(
e: &mut TextEngine,
drawn: &str,
rect: Rect,
justify: Justify,
font: FontSpec,
) -> (f32, f32, f32) {
let r = resolve(e, &font);
e.ensure_metrics(r.face, r.ppem, drawn);
let e = &*e;
// The line origin under justifyH (LEFT needs no width measure — the chat case).
let x0 = match justify.h {
JustifyH::Left => rect.min.x,
JustifyH::Center | JustifyH::Right => {
let w = measure_line_width(e, r, r.step_extra, drawn);
if matches!(justify.h, JustifyH::Center) {
rect.min.x + ((rect.width() - w) * 0.5).max(0.0)
} else {
rect.max.x - w
}
}
};
// The single-line block's v_offset — [`super::layout_text_quads`]'s law with block_h = 1 line,
// through the same ONE vertical snap ([`super::snap_block_top`]) so the caret/selection cell
// sits exactly on the drawn glyphs.
let top = if rect.height() > f32::EPSILON {
let v_offset = match justify.v {
JustifyV::Top => 0.0,
JustifyV::Middle => (rect.height() - r.size) * 0.5,
JustifyV::Bottom => rect.height() - r.size,
};
super::snap_block_top(rect.min.y + v_offset)
} else {
rect.min.y
};
(x0, top, r.size)
}
/// The EditBox advance-table answer ([`benilla_ui::script::UiScript::set_editbox_advances`]'s
/// payload): per-BYTE cumulative laid-out widths of `text` under the exact step law
/// [`measure_line_width`] uses — len+1 entries, `[0] = 0`, each character's full step landing on
/// its END boundary and its interior (continuation) bytes holding the lead's value, so a mid-char
/// index degrades to the char's start and char-boundary lookups are exact.
///
/// **Indexed by the box's RAW byte, measured over what is DRAWN** (decision 1075). The box stores
/// the escaped string and draws only the visible one, so every `|c…`/`|r`/`|H…|h`/`|T…|t` byte
/// costs zero width: [`crate::ui_text::markup::visible_map`] carries each drawn byte back to its
/// raw offset, and the forward-fill below hands every escape byte the previous boundary's width.
/// Measuring the raw string instead is what parked the caret half a chat bar right of a
/// shift-clicked item link.
///
/// The old shaped-buffer walk carried a stated approximation — the whole drawn string was shaped as
/// one buffer while the draw shaped each color run separately, so kerning across a color boundary
/// could differ by a fraction of a pixel. Stepping per character removes it: there is no buffer and
/// no kerning anywhere in the law, so this table and the drawn pen are the same arithmetic.
pub(crate) fn line_advances(e: &mut TextEngine, text: &str, font: FontSpec) -> Vec<f32> {
let mut cum = vec![0.0f32; text.len() + 1];
if text.is_empty() {
return cum;
}
let (drawn, bounds) = crate::ui_text::markup::visible_map(text);
if drawn.is_empty() {
return cum; // pure markup draws nothing — every boundary sits at x = 0
}
let r = resolve(e, &font);
e.ensure_metrics(r.face, r.ppem, &drawn);
let e = &*e;
let dpi = e.dpi();
let mut written = vec![false; text.len() + 1];
written[0] = true;
let mut x = 0.0f32;
for (off, ch) in drawn.char_indices() {
if let Some(c) = e.char_cell(r.face, r.ppem, ch) {
for g in &c.glyphs {
x += client_step(g.advance, r.step_extra, dpi);
}
}
// This character's end boundary in DRAWN bytes → the RAW boundary it sits on.
let end = bounds[(off + ch.len_utf8()).min(drawn.len())];
cum[end] = x;
written[end] = true;
}
// Forward-fill the unwritten slots (cluster interiors, markup bytes): each carries the
// previous boundary's value.
for i in 1..cum.len() {
if !written[i] {
cum[i] = cum[i - 1];
}
}
cum
}
/// The multiline-EditBox row answer (the 2-D half of
/// [`benilla_ui::script::UiScript::set_editbox_advances`]'s payload): the byte offset where each
/// wrapped display row of `text` begins at `wrap_width` — the exact [`wrap_line`] pass the render
/// wraps with, so the engine's `(row, x)` caret/click law lands on the drawn rows — plus the row
/// pitch (the font em, the same `N·S` block law [`measure_text`] heights with). Row starts are
/// reconstructed by walking the source string past each wrapped row's verbatim text and the
/// separator the break swallowed (wrap keeps inter-word whitespace verbatim and drops only the
/// trailing separator, so the walk is exact) — in DRAWN bytes, mapped back to RAW ones, since the
/// rows index the same raw buffer [`line_advances`] does (decision 1075). Never empty (`[0]` for
/// empty text).
pub(crate) fn line_rows(
e: &mut TextEngine,
text: &str,
wrap_width: f32,
font: FontSpec,
) -> (Vec<usize>, f32) {
let r = resolve(e, &font);
e.ensure_metrics(r.face, r.ppem, text);
let e = &*e;
let mut rows = Vec::new();
let mut base = 0usize; // byte offset of the current '\n' segment within `text`
for seg in text.split('\n') {
let lines = parse_markup(seg, [1.0, 1.0, 1.0, 1.0]);
let sub: Vec<Vec<ColorRun>> = if wrap_width > WRAP_MIN_WIDTH {
lines
.iter()
.flat_map(|line| wrap_line(e, r, line, wrap_width))
.collect()
} else {
lines
};
segment_row_starts(seg, &sub, base, &mut rows);
base += seg.len() + 1; // + the '\n' itself (swallowed by the split)
}
if rows.is_empty() {
rows.push(0);
}
(rows, r.size)
}
/// [`line_rows`]'s reconstruction walk for one `\n` segment: push the byte start of each wrapped
/// sub-line of `seg` (offset by `base`, `seg`'s position in the full string) into `rows`. Wrap
/// keeps inter-word whitespace verbatim inside a row and drops only the separator a break swallows,
/// so each subsequent row starts past the previous row's bytes plus any whitespace (a force-broken
/// word swallowed none — the skip loop stops at its first byte). A blank segment still occupies one
/// row.
fn segment_row_starts(seg: &str, sub: &[Vec<ColorRun>], base: usize, rows: &mut Vec<usize>) {
if sub.is_empty() {
rows.push(base);
return;
}
// A wrapped row is made of DRAWN text (the markup is gone by then) while a row start must be a
// RAW byte — the offset space the box's cursor and [`line_advances`] both live in. So the walk
// runs in drawn bytes and maps each start back (decision 1075).
let (drawn, bounds) = crate::ui_text::markup::visible_map(seg);
let mut p = 0usize; // byte cursor within `drawn`
for (j, line) in sub.iter().enumerate() {
if j > 0 {
while let Some(c) = drawn[p.min(drawn.len())..].chars().next() {
if c.is_whitespace() {
p += c.len_utf8();
} else {
break;
}
}
}
rows.push(base + bounds[p.min(drawn.len())]);
p += line.iter().map(|r| r.text.len()).sum::<usize>();
}
}
#[cfg(test)]
mod row_start_tests {
use super::*;
fn runs(texts: &[&str]) -> Vec<Vec<ColorRun>> {
texts
.iter()
.map(|t| {
vec![ColorRun {
text: (*t).to_string(),
color: [1.0; 4],
link: None,
}]
})
.collect()
}
fn starts(seg: &str, sub: &[Vec<ColorRun>], base: usize) -> Vec<usize> {
let mut out = Vec::new();
segment_row_starts(seg, sub, base, &mut out);
out
}
#[test]
fn a_word_break_swallows_its_separator() {
assert_eq!(starts("hello world", &runs(&["hello", "world"]), 0), [0, 6]);
}
#[test]
fn a_force_broken_word_swallows_nothing() {
assert_eq!(
starts(
"supercalifragilistic",
&runs(&["supercali", "fragilistic"]),
0
),
[0, 9]
);
}
#[test]
fn a_double_space_separator_is_swallowed_whole() {
assert_eq!(starts("one two", &runs(&["one", "two"]), 0), [0, 5]);
}
#[test]
fn inner_whitespace_stays_verbatim_inside_a_row() {
assert_eq!(
starts("a b c d", &runs(&["a b c", "d"]), 0),
[0, 7],
"only the BREAK's separator is dropped"
);
}
#[test]
fn a_blank_segment_still_occupies_a_row_and_base_offsets() {
assert_eq!(starts("", &[], 12), [12]);
assert_eq!(starts("hi there", &runs(&["hi", "there"]), 100), [100, 103]);
}
}
File diff suppressed because it is too large Load Diff
+64
View File
@@ -99,6 +99,40 @@ pub(super) fn parse_markup(input: &str, base_color: [f32; 4]) -> Vec<Vec<ColorRu
lines
}
/// [`parse_markup`] under the FontString **line-count law** — what the client's height kernel
/// `GxuFont_GetTextBlockHeight 0x5c2070` actually counts (wow-re `system/font/scratch/
/// re-wave1-capi.md`; the two edge cases spelled out in `system/ui/scratch/
/// simplehtml-markup-engine.md` §4.4).
///
/// The kernel walks the string: a bare line break at the cursor is an empty line and costs one;
/// otherwise it lays a line, and **that line's own terminating break is consumed with it**. So a
/// trailing break terminates its line rather than opening a new one, and the loop guard
/// (`cmp byte [esi],0`) means an empty string is **zero** lines with height `0.0`, not one.
///
/// The difference from a plain split is exactly one line, on exactly the strings that end in a
/// break — which is every `<BR/>` block SimpleHTML builds (`"\n"`: one blank line in the client,
/// two under a split) and every page body the item-text reader pads. B240's reader drew its
/// section gaps at double height until this existed (the director, 08-15, against his own 1.12.1
/// shot); decision 1343.
///
/// **Not the EditBox's law.** A multiline box that ends in a newline really does show the empty
/// row its caret sits on, and it reaches its rows through `line_rows`/`0x77da80`, not this kernel
/// — so [`parse_markup`] itself is left alone and this is a separate seam over it.
pub(super) fn fontstring_lines(input: &str, base_color: [f32; 4]) -> Vec<Vec<ColorRun>> {
if input.is_empty() {
return Vec::new();
}
let mut lines = parse_markup(input, base_color);
if lines.len() > 1
&& lines
.last()
.is_some_and(|l| l.iter().all(|r| r.text.is_empty()))
{
lines.pop();
}
lines
}
/// Accumulate one drawn char into the current run, and into the open link's visible text.
fn push_visible(c: char, cur: &mut String, link: &mut Option<(String, String, Vec<usize>)>) {
if let Some((_, visible, _)) = link {
@@ -368,6 +402,36 @@ mod markup_tests {
assert_eq!(drawn, "a|Tfoo|tb");
}
/// The FontString **line-count law** (`GxuFont_GetTextBlockHeight 0x5c2070`), pinned on the
/// strings that separate it from a plain split: a **trailing** break terminates its line
/// rather than opening a new one, and an empty string is **zero** lines.
///
/// The director found this against his own 1.12.1 client (08-15): every `<BR/>` in a book page
/// is a `"\n"` block, and under a split that is two blank lines instead of one — so the page's
/// section gaps came out at double height and the text ran off the bottom. The second block
/// below is the mutation check: the cases a split already got right must not move.
#[test]
fn a_trailing_break_does_not_open_a_line() {
let n = |t: &str| fontstring_lines(t, [1.0, 1.0, 1.0, 1.0]).len();
// The three the kernel counts differently from a split.
assert_eq!(n("\n"), 1, "a <BR/> block is ONE blank line");
assert_eq!(n("a\n"), 1);
assert_eq!(n(""), 0, "an empty string is zero lines, height 0.0");
// …and everything else is unchanged.
assert_eq!(n("a"), 1);
assert_eq!(n("a\nb"), 2);
assert_eq!(
n("\nbody\n"),
2,
"the reader's own padding: a blank line, then the body"
);
assert_eq!(n("\n\n"), 2);
assert_eq!(n("a\n\nb"), 3);
// `|n` is the same break token, so it obeys the same law.
assert_eq!(n("a|n"), 1);
assert_eq!(n("a|nb"), 2);
}
/// A malformed escape is literal text to the draw, so it must be literal to the map too —
/// otherwise the metrics would charge zero width for bytes the user can see.
#[test]
+32 -24
View File
@@ -2,24 +2,29 @@
//! [`benilla_ui::script::TextMeasure`].
//!
//! The engine seam (`benilla-ui`'s `script::measure`) exists because the reference answers
//! `GetStringWidth` from its font engine *inline* and ours could not: measuring needed the baked
//! atlas, and the atlas only met the VM at the frame boundary. What closes the gap is that the
//! client's width law is a **table**, not a computation — a sum of per-glyph steps with no kerning
//! and no neighbour term — so [`TextMetrics`] carries everything a measure needs and nothing that
//! needs `&mut`. That table is `Arc`-shared with [`UiFontAtlas`](super::atlas::UiFontAtlas) rather
//! than copied, so the answer a Lua call gets mid-tick and the answer the extract pass computes are
//! not merely equal — they are the same lookup in the same table.
//! `GetStringWidth` from its font engine *inline* and ours could not: measuring needed the atlas,
//! and the atlas only met the VM at the frame boundary. What closes the gap is that the client's
//! width law is a **table**, not a computation — a sum of per-glyph steps with no kerning and no
//! neighbour term — so a measure needs only the face, the raster size, and the characters.
//!
//! **The same engine, not a copy of it.** This holds the very [`TextEngine`] the render path holds,
//! behind the same lock, so the answer a Lua call gets mid-tick and the answer the extract pass
//! computes are not merely equal — they are the same lookup in the same table. That property used
//! to be bought by making the table immutable and `Arc`-sharing it; since decision 1342 the table
//! fills on demand, so the sharing has to carry mutation, and the lock is how. A measure fills only
//! the **metrics** half ([`TextEngine::ensure_metrics`]): a string that is measured and never drawn
//! costs a shaping, never a raster and never a texture page.
//!
//! **One body, both callers.** [`measure_request`] is the whole of it; the batch pass
//! (`ui_script::extract::measure_fontstrings`) and [`AtlasMeasurer`] both call it and neither has
//! any measuring logic of its own. That is the trait's own contract, kept structurally rather than
//! by discipline.
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use benilla_ui::script::{MeasureRequest, TextMeasure};
use super::atlas::TextMetrics;
use super::engine::TextEngine;
/// Answer one [`MeasureRequest`]: `(laid_out_w, laid_out_h, natural_w)` in the region's own
/// frame-local units.
@@ -29,7 +34,7 @@ use super::atlas::TextMetrics;
/// raster size — and the answer divides the whole product back out, because integer-stepped glyph
/// advances do not commute with scaling (see [`MeasureRequest::scale`]).
pub(crate) fn measure_request(
metrics: &TextMetrics,
e: &mut TextEngine,
seam: f32,
r: &MeasureRequest,
) -> (f32, f32, f32) {
@@ -41,47 +46,50 @@ pub(crate) fn measure_request(
// frame-local UI units below.
height: super::drawn_px(r.height, r.text_height, rs),
// The TRUE outline: THICK biases the client's step law (+1px per glyph — GlyphStepBase
// 0x5ca2b0, THICK-only per outline-bake-tint.md) and any outline adds the +2r line pitch,
// so measure must see it.
// 0x5ca2b0, THICK-only per outline-bake-tint.md), so measure must see it.
outline: r.outline,
paint_halo: true, // measure never paints; irrelevant here
alpha_gradient: None, // alpha never changes metrics
};
let wrap = r.wrap_width.map(|w| w * rs);
let (w, h) = super::measure_text(metrics, &r.text, wrap, spec());
let (w, h) = super::measure_text(e, &r.text, wrap, spec());
// …and the NATURAL width, which is what `GetStringWidth` answers with (the reference measures
// its getter's string with no wrap constraint — wow-re `fontstring-overflow.md`, "The
// measurement echo"). A second pass only for the regions that actually carry a declared width;
// for the rest the two are one number.
let natural = if wrap.is_some() {
super::measure_text(metrics, &r.text, None, spec()).0
super::measure_text(e, &r.text, None, spec()).0
} else {
w
};
(w / rs, h / rs, natural / rs)
}
/// The measurer installed into the VM — the shared [`TextMetrics`] plus the host's current screen
/// seam.
/// The measurer installed into the VM — the shared engine plus the host's current screen seam.
///
/// **Rebuilt, not mutated, when the seam moves.** A measurer is only correct for the raster
/// environment it was made under (glyph advances step to whole *physical* pixels), so the host
/// installs a fresh one at exactly the moment it calls
/// **Rebuilt, not mutated, when the seam moves.** A measurer is only correct for the seam it was
/// made under (the seam multiplies into every requested height before it becomes a raster size), so
/// the host installs a fresh one at exactly the moment it calls
/// [`UiScript::forget_text_metrics`](benilla_ui::script::UiScript::forget_text_metrics) — the same
/// edge, for the same reason. Building one is an `Arc` clone and an `f32`.
pub(crate) struct AtlasMeasurer {
metrics: Arc<TextMetrics>,
engine: Arc<Mutex<TextEngine>>,
seam: f32,
}
impl AtlasMeasurer {
pub(crate) fn new(metrics: Arc<TextMetrics>, seam: f32) -> Self {
Self { metrics, seam }
pub(crate) fn new(engine: Arc<Mutex<TextEngine>>, seam: f32) -> Self {
Self { engine, seam }
}
}
impl TextMeasure for AtlasMeasurer {
fn measure(&mut self, req: &MeasureRequest) -> (f32, f32, f32) {
measure_request(&self.metrics, self.seam, req)
// Taken and released inside this call. Nothing on the app side may hold the engine lock
// across a VM tick, because a tick can land here (see `ui_text::engine`'s lock discipline).
let mut e = self
.engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
measure_request(&mut e, self.seam, req)
}
}
+43 -31
View File
@@ -1,24 +1,29 @@
//! Text rendering for `QuadContent::Text` (decision 0068 §2): a glyph atlas baked from the client's
//! own TTFs, shaped through `cosmic-text` 0.16, emitted as [`crate::ui_pass::UiQuad`]s the quad pass
//! draws with no special case. Ported from `probes/text-glyph/src/{font,atlas,markup,layout}.rs`
//! (screenshot-verified there, 2026-07-02) into the real render path; the port's only structural change
//! is the font source (the app's own [`WorldAssets`] patch chain, not a probe-local `Chain::open`) and
//! the output type (this crate's real `UiQuad`, not the probe's mirror struct).
//! Text rendering for `QuadContent::Text` (decision 0068 §2): an on-demand glyph cache over the
//! client's own TTFs, shaped through `cosmic-text` 0.16, emitted as [`crate::ui_pass::UiQuad`]s the
//! quad pass draws with no special case.
//!
//! - [`engine`] — the cache: faces, the size law, and the two lookup tables everything else reads.
//! - [`pack`] — the texture the cells live in, and how they get there.
//! - [`outline`] — the client's outline blit recipe, run per cell.
//! - [`layout`] — the emit pass, and (in `layout::measure`) the width law, the wrap and the
//! ellipsis seam.
//! - [`measurer`] — the same engine, as the script VM holds it for a synchronous `GetStringWidth`.
//!
//! ## Font objects (decision 0084) and remaining v1 simplifications
//!
//! - **Per-`FontString` size + face.** [`QuadContent::Text`] now carries the resolved `{ font, height }`
//! from the engine's font registry (`Fonts.xml`'s named virtual `<Font>` objects). The atlas bakes
//! every registered client TTF at each of [`FONT_SIZES`]; [`layout_text_quads`] selects the face by
//! `font` path (via [`UiFontAtlas::path_to_family`]) and the nearest baked size. A `FontString` with
//! no font object falls back to Friz Quadrata at [`DEFAULT_FONT_SIZE`] (the pre-0084 behavior).
//! - **DPI-aware bake.** The atlas is baked at `logical_size × Window::scale_factor` (physical
//! resolution), and [`layout_text_quads`]/[`measure_text`] shape at that physical size, divide the
//! metrics back to logical, and snap glyph origins to the physical-pixel grid — so a baked cell
//! draws one texel per device pixel instead of a half-res bitmap upscaled by a retina framebuffer.
//! - **Outlines are rasterized** as the classic bitmap outline: [`layout_text_quads`] stamps each
//! glyph's baked coverage in black over a filled `r×r` neighbourhood (`r=1` NORMAL, `r=2` THICK)
//! behind the fill — `cosmic-text` has no native outline. The Number* fonts (action-bar
//! from the engine's font registry (`Fonts.xml`'s named virtual `<Font>` objects). A `FontString`
//! with no font object falls back to Friz Quadrata at [`DEFAULT_FONT_SIZE`] (the pre-0084 behavior).
//! - **One exact raster size, on demand** (decision 1342). A logical height becomes the integer
//! device-pixel size it will be drawn at (`round(height × scale_factor)` — the client's own
//! `0x5ca030` law), and the glyphs for that size are rasterized the first time anything asks for
//! them. There is no size ladder, no snapping, and no rescale of finished quads: the size a
//! string is measured at, rasterized at, and drawn at is one number. See [`engine`] for what that
//! replaced and why.
//! - **Outlines are one composite cell.** An outlined glyph's black ring and its fill are
//! rasterized together into a single cell and blitted once — the client's own baked-outline
//! architecture (`glyph_blit_aa_outline 0x5cea30`; see [`outline`]), which is what makes an
//! outlined string fade as one thing instead of blackening. The Number* fonts (action-bar
//! hotkeys/counts) carry it.
//! - **`justifyH` and `justifyV` are honored** (see [`layout_text_quads`]): lines justify
//! horizontally per line, and the line block vertically (default MIDDLE, the client's
@@ -29,34 +34,42 @@
//!
//! ## The metric-fidelity seam
//!
//! [`layout_text_quads`] shapes every run through `cosmic-text`'s shaper for glyph SELECTION and
//! rasterization, but the pen now **advances by the client's own step law** (wow-re
//! `system/font`): per glyph, `floor(advance) + 1` px (`rasterize_glyph` 0x5d1120's
//! `cosmic-text`'s shaper is used for glyph SELECTION and rasterization, **one character at a
//! time** ([`engine::TextEngine::ensure_char`]); the pen then **advances by the client's own step
//! law** (wow-re `system/font`): per glyph, `floor(advance) + 1` px (`rasterize_glyph` 0x5d1120's
//! `out[5] = (FT_advance>>6)+1.0`), plus another `+1` for a **THICK**-outlined font only
//! (`GlyphStepBase` 0x5ca2b0 biases solely under the THICK flag — `outline-bake-tint.md`,
//! correcting this module's earlier any-outline reading) — the extra tracking that makes real
//! client text read wider/denser than raw font metrics. Remaining divergences, all confined here: kerning is dropped (the client applies only
//! negative pair kerns and rounds — ~0 at UI sizes), the law is applied at logical ppem (the
//! 768-window reference look), and glyph BITMAPS are cosmic/swash unhinted coverage where the
//! client text read wider/denser than raw font metrics.
//!
//! Per character is not a shortcut; it is the law. The client's `ComputeStep` (0x5ca2d0) has no
//! neighbour term, so a string's width is a pure function of its characters — which is what lets
//! one table answer a measure and a draw, and what lets a measure be answered from inside a Lua
//! call. It is a property of the FONTS that the decomposition holds (no ligatures, no contextual
//! substitution in these four faces), so it is asserted directly rather than assumed:
//! `engine::differential_tests`.
//!
//! Remaining divergences, all confined here: kerning is dropped entirely (the client applies only
//! *negative* pair kerns and rounds — ~0 at UI sizes; the test that pins the width law also pins
//! that this is what we are doing), and glyph BITMAPS are cosmic/swash unhinted coverage where the
//! client runs 2004-era FreeType with hinting flags (0x208a/0x20c2) — slightly slimmer stems at
//! small sizes. A future bit-exact replacement (wow-re's font kernel) only changes this module;
//! nothing in its callers moves.
//! nothing in its callers moves. The deliberate *rendering* divergences — where the client stretches
//! a cell and we rasterize the true size — are named in [`engine`]'s module doc.
mod atlas;
mod engine;
mod layout;
mod markup;
mod measurer;
mod outline;
mod pack;
pub(crate) use atlas::{UiFontAtlas, UiTextPlugin};
pub(crate) use engine::{UiFontAtlas, UiTextPlugin};
use benilla_ui::script::Outline;
use benilla_ui::widget::RegionHandle;
pub(crate) use measurer::{measure_request, AtlasMeasurer};
/// The width law itself, for the differential test that pins it to the pre-table shaped sum
/// (`atlas::metrics_tests`). Not a production seam — every caller goes through [`measure_text`].
#[cfg(test)]
pub(crate) use layout::measure_line_width_for_test;
pub(crate) use layout::{
ellipsize_to_fit, layout_text_quads, layout_text_quads_links, line_advances, line_origin,
line_rows, measure_text, measure_wrapped_rows, FontSpec, Justify, UI_SEAT_NUDGE,
@@ -262,7 +275,6 @@ mod ellipsis_memo_tests {
path,
height,
outline,
paint_halo: true,
alpha_gradient: None,
}
}
+210
View File
@@ -0,0 +1,210 @@
//! **The outline blit recipe** — the client's baked-outline architecture, byte-for-byte.
//!
//! An outlined font (`outline="NORMAL"/"THICK"`, the Number* fonts) does not draw a ring and a fill
//! as two things. The real client composites both into **one** atlas cell and blits it once
//! (`glyph_blit_aa_outline 0x5cea30`, dispatched by `0x5cf310` off the font flags at
//! `[CGxFont+0x180]`), which is what makes an outlined string fade correctly: one quad, one alpha,
//! ring and fill thinning together. Stamping a ring behind a fill instead — which this codebase did
//! before the composite cell — blackens mid-fade on the `α(1−α)` compositing term.
//!
//! Under the size ladder these variants had to be **planned**: a census walked the shipped
//! `Fonts.xml` registry for every `(face, size, radius)` triple anything could ask for, baked those,
//! and left any unplanned runtime combination to a legacy stamped-halo fallback. On demand
//! (decision 1342) there is nothing to plan and no fallback to keep: a radius that is asked for is a
//! radius that is rasterized, so the composite cell is now the only outline path there is.
/// The cell radius for a font's outline flag: the ring reach in **logical** px (`r=1` NORMAL,
/// `r=2` THICK) and the glyph cache key's variant discriminant.
pub(super) fn radius_of(outline: benilla_ui::script::Outline) -> u8 {
match outline {
benilla_ui::script::Outline::None => 0,
benilla_ui::script::Outline::Normal => 1,
benilla_ui::script::Outline::Thick => 2,
}
}
/// The client's AA neighbour-count → outline-alpha LUT (`DAT_0080a8ec`, wow-re
/// `system/font/scratch/outline-bake-tint.md`, §5 double-pair + difftests, commit `f80ce699`):
/// index = the number of marked cells in the in-bounds 3×3 box (centre included), value = the
/// 4-bit alpha, here pre-widened to 8-bit (`nibble × 17`).
const AA_NEIGHBOUR_LUT: [u8; 10] = [0, 17, 17, 51, 85, 119, 153, 187, 221, 255];
/// Composite one glyph's coverage bitmap into an **outlined cell** — the byte recipe of the
/// client's AA-outline blit (`glyph_blit_aa_outline 0x5cea30`, dispatched by `0x5cf310`; wow-re
/// `outline-bake-tint.md`, difftested emulation `wow-5875-re/crates/font/src/raster.rs`):
///
/// 1. **Mark** every texel with non-zero coverage.
/// 2. **Dilate** iteratively — each pass marks every virgin texel 8-adjacent to a marked one.
/// The binary runs 1 pass for NORMAL, 2 for THICK; we run `r × round(dpi)` so the ring keeps
/// its logical weight under the device-resolution raster (at `dpi = 1` this IS the byte recipe;
/// the finer grid at retina is the same deliberate resolution upgrade as the raster itself).
/// 3. **Alpha** per texel = [`AA_NEIGHBOUR_LUT`]`[count of marked cells in the in-bounds 3×3
/// box]` — the ring AND the fill edge take the same neighbourhood-graded alpha (interior = 9
/// marked ⇒ opaque; a THICK ring's outer corners grade down to ~⅓ — the real softer outer edge).
/// 4. **Pack**: unmarked ⇒ transparent; zero-coverage (pure ring) ⇒ black at the LUT alpha; else
/// `RGB = the coverage as gray` (the fill ramps *toward the ring's black* at AA edges — not
/// white-with-thin-alpha) at the LUT alpha. The binary quantizes to ARGB4444; we keep 8-bit
/// (same law, no banding — the one deliberate widening, like the LUT `×17`).
///
/// Draw-side law this feeds (same note, prongs 2–3): one quad per glyph, vertex color MODULATE —
/// white fill takes the text tint, black ring stays black — and ONE alpha per pass, so a frame fade
/// thins ring+fill together (no `α(1−α)` blackening, the defect the stamped halos had).
///
/// Returns `(rgba, out_w, out_h, pad)`: the cell grows by `pad = r·round(dpi)` texels each side
/// (the binary's cell `em+2/+4` and origin col `1/2`, generalized); the caller shifts bearings by
/// `pad`. The advance is untouched — the step law owns tracking (THICK `+1`, NORMAL none).
pub(super) fn outlined_cell(
cov: &[u8],
w: u32,
h: u32,
r: u8,
dpi: f32,
) -> (Vec<u8>, u32, u32, u32) {
let passes = (u32::from(r) * (dpi.round().max(1.0) as u32)).max(1);
let pad = passes;
let (out_w, out_h) = (w + 2 * pad, h + 2 * pad);
let cells = (out_w * out_h) as usize;
// 1. Mark coverage into the map (1 = glyph ink), coverage kept alongside for the pack.
let mut map = vec![0u8; cells];
for row in 0..h {
for col in 0..w {
if cov[(row * w + col) as usize] != 0 {
map[((row + pad) * out_w + (col + pad)) as usize] = 1;
}
}
}
// 2. Iterative 8-neighbour dilation: pass k marks virgin cells adjacent to any marked cell
// (the binary's mask-1-write-2 / mask-3-write-4 passes, generalized to k marks).
for pass in 0..passes {
let mark = (pass + 2) as u8; // 1 = ink, 2.. = ring generations
for row in 0..out_h {
for col in 0..out_w {
let c = (row * out_w + col) as usize;
if map[c] != 0 {
continue;
}
'probe: for dr in -1i32..=1 {
for dc in -1i32..=1 {
let (rr, cc) = (row as i32 + dr, col as i32 + dc);
if rr < 0 || rr >= out_h as i32 || cc < 0 || cc >= out_w as i32 {
continue;
}
let n = map[(rr as u32 * out_w + cc as u32) as usize];
if n != 0 && n < mark {
map[c] = mark;
break 'probe;
}
}
}
}
}
}
// 3.+4. Neighbourhood-count alpha + pack.
let mut rgba = vec![0u8; cells * 4];
for row in 0..out_h {
for col in 0..out_w {
let c = (row * out_w + col) as usize;
if map[c] == 0 {
continue;
}
let mut count = 0usize;
for dr in -1i32..=1 {
for dc in -1i32..=1 {
let (rr, cc) = (row as i32 + dr, col as i32 + dc);
if rr < 0 || rr >= out_h as i32 || cc < 0 || cc >= out_w as i32 {
continue;
}
if map[(rr as u32 * out_w + cc as u32) as usize] != 0 {
count += 1;
}
}
}
let alpha = AA_NEIGHBOUR_LUT[count];
let fill = if row >= pad && row < pad + h && col >= pad && col < pad + w {
cov[((row - pad) * w + (col - pad)) as usize]
} else {
0
};
let idx = c * 4;
// Pure ring: black. Fill: the coverage as gray (ramps toward the ring at AA edges).
rgba[idx] = fill;
rgba[idx + 1] = fill;
rgba[idx + 2] = fill;
rgba[idx + 3] = alpha;
}
}
(rgba, out_w, out_h, pad)
}
#[cfg(test)]
mod outlined_cell_tests {
use super::*;
/// Read texel (x, y) of an RGBA cell.
fn px(rgba: &[u8], w: u32, x: u32, y: u32) -> [u8; 4] {
let i = ((y * w + x) * 4) as usize;
[rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]]
}
#[test]
fn normal_ring_takes_the_lut_grades() {
// A 1×1 fully-inked glyph, NORMAL (1 pass), dpi 1 → a 3×3 cell, all 9 texels marked.
// Per the byte recipe the alpha is AA_NEIGHBOUR_LUT[in-bounds 3×3 marked count]:
// centre 9 → 255, edge-mid 6 → 153, corner 4 → 85 — the graded ring, not hard black.
let (rgba, w, h, pad) = outlined_cell(&[255], 1, 1, 1, 1.0);
assert_eq!((w, h, pad), (3, 3, 1));
assert_eq!(px(&rgba, w, 1, 1), [255, 255, 255, 255], "fill core");
assert_eq!(px(&rgba, w, 1, 0), [0, 0, 0, 153], "edge-mid ring: count 6");
assert_eq!(px(&rgba, w, 0, 1), [0, 0, 0, 153]);
assert_eq!(px(&rgba, w, 0, 0), [0, 0, 0, 85], "corner ring: count 4");
assert_eq!(px(&rgba, w, 2, 2), [0, 0, 0, 85]);
}
#[test]
fn thick_outer_ring_is_the_soft_second_pass() {
// THICK = a second dilation pass (the binary's mask-3→4 pass): 5×5, all marked; the
// inner ring sits fully surrounded (count 9 → opaque) while the outer edge grades
// 153/85 — the real client's softer THICK outer edge.
let (rgba, w, h, pad) = outlined_cell(&[255], 1, 1, 2, 1.0);
assert_eq!((w, h, pad), (5, 5, 2));
assert_eq!(px(&rgba, w, 2, 2), [255, 255, 255, 255], "fill core");
assert_eq!(
px(&rgba, w, 1, 1),
[0, 0, 0, 255],
"inner ring: fully surrounded"
);
assert_eq!(
px(&rgba, w, 2, 0),
[0, 0, 0, 153],
"outer edge-mid: count 6"
);
assert_eq!(px(&rgba, w, 0, 0), [0, 0, 0, 85], "outer corner: count 4");
}
#[test]
fn retina_scales_the_pass_count_with_the_raster() {
// dpi 2, NORMAL: 2 passes ⇒ a 2-physical-px (1 logical) ring, dense — the iterative
// dilation leaves no stride holes (the legacy stamp offsets did).
let (rgba, w, h, pad) = outlined_cell(&[255], 1, 1, 1, 2.0);
assert_eq!((w, h, pad), (5, 5, 2));
assert_eq!(px(&rgba, w, 2, 2), [255, 255, 255, 255]);
assert_ne!(px(&rgba, w, 1, 1)[3], 0, "no hole inside the 2-px ring");
assert_eq!(px(&rgba, w, 0, 0), [0, 0, 0, 85]);
}
#[test]
fn aa_fill_ramps_gray_toward_the_ring() {
// Half coverage (128): the fill texel takes the coverage as GRAY at the LUT alpha —
// the byte recipe's `RGB = coverage, A = LUT[count]` — never white-with-thin-alpha.
let (rgba, w, _, _) = outlined_cell(&[128], 1, 1, 1, 1.0);
assert_eq!(
px(&rgba, w, 1, 1),
[128, 128, 128, 255],
"gray fill, LUT alpha"
);
assert_eq!(px(&rgba, w, 1, 0), [0, 0, 0, 153], "ring stays black");
}
}
+367
View File
@@ -0,0 +1,367 @@
//! The glyph cache's texture — one shelf-packed sheet, and the sub-rect uploads that carry each
//! new cell to the GPU.
//!
//! **Why it never repacks.** The cache fills on demand ([`super::engine::TextEngine`]), so cells
//! arrive one at a time forever; the allocator's job is to hand each one a permanent home.
//! Permanent is the load-bearing word: a cell's UV is copied into places this module cannot reach —
//! most sharply into the overhead-name meshes' `ATTRIBUTE_UV_0` ([`crate::nameplates`]), which
//! persist on the GPU across frames. So the sheet is **append-only**: it is packed into shelves, it
//! is never compacted, and a UV is normalized against the sheet's FIXED side length rather than
//! against how full it happens to be. (The old whole-charset bake normalized against a packed
//! height that moved with the size ladder, which is why a re-bake turned every cached nameplate
//! mesh into fragments of other letters — decision 1339's fault 2.)
//!
//! ## The upload path, and why it is not the obvious one
//!
//! The obvious way to add a cell in Bevy is to mutate the sheet's `Image` through
//! `Assets::get_mut`. That is wrong here, for a reason that has nothing to do with cost. In Bevy
//! 0.18.1 `Assets::get_mut` queues `AssetEvent::Modified`, `extract_render_asset` deep-clones the
//! whole `Image`, and `GpuImage::prepare_asset` calls `create_texture_with_data` — it **allocates a
//! new `wgpu::Texture`** and never consults the previous one. Meanwhile [`crate::ui_pass`]'s
//! material pool is keyed on `AssetId<Image>` and its `AsBindGroup` bind group is rebuilt only when
//! the *material* asset changes; nothing in Bevy invalidates a `Material2d` bind group when an
//! `Image` it references is modified. A full re-upload would therefore leave the UI sampling a dead
//! texture through a cached bind group, while the stale view pinned the old allocation alive. (That
//! is also why the old bake published a brand-new `Handle<Image>` every time: the handle churn was
//! a workaround for the missing invalidation — and the thing that made every atlas-derived cache in
//! the app go stale at once, on every window resize.)
//!
//! So cells go up as **sub-rect `RenderQueue::write_texture` writes into the existing texture**, the
//! way glyphon does it. Texture identity never moves, so there is nothing to invalidate: a handle
//! minted once is correct for the life of the process. The sheet is created data-less
//! ([`Image::new_uninit`] — a GPU texture with no CPU mirror, lazily zero-initialized by wgpu), and
//! `Assets::get_mut` must **never** be called on it: a stray `Modified` would hand us a fresh blank
//! texture and silently erase every glyph.
//!
//! ## One sheet, not a growing set of pages
//!
//! The real client keeps up to 8 pages per font (`CGxFont+0x18c`), and pages are the obvious answer
//! to "what happens when it fills". We keep one, because **the world pass binds one texture per
//! mesh**: a nameplate's glyph UVs are baked into a single `Mesh` with a single
//! `StandardMaterial`, so a name whose letters straddled two pages would need its mesh split and
//! its material set doubled — real complexity in [`crate::nameplates`] to serve an event that would
//! fire when a page boundary happened to fall inside one font's cell run. Growing the sheet is not
//! an alternative either: a grown texture is a *new* texture, which is the identity change this
//! whole path exists to avoid.
//!
//! So [`Sheet::reset`] is the eviction policy: when the shelves fill, drop everything at once and
//! refill from what is actually on screen. A shelf allocator cannot reclaim an interior cell, so
//! there is no piecemeal eviction to reach for, and the thing that fills the sheet is not glyph
//! variety but **raster-size churn** — every window resize retires a whole set of cells nothing will
//! ask for again. Dropping all of them is exactly right. It is the only event that can move a UV,
//! which is why it is a frame-boundary operation with a generation counter attached; see
//! [`super::engine::TextEngine::note_exhausted`].
use bevy::asset::RenderAssetUsages;
use bevy::image::Image;
use bevy::math::Rect;
use bevy::prelude::*;
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
/// The sheet's side length in **physical texels**. 2048² of RGBA8 is 16 MB of VRAM, allocated the
/// first time a glyph is drawn and never reallocated.
///
/// Sized by how long it postpones a [reset][Sheet::reset]. At ~110 texels for a typical body-text
/// cell (an 8×11 glyph plus its padding) a sheet holds tens of thousands of cells — order a hundred
/// distinct `(face, raster size)` sets, where one window size in one session uses a handful. The
/// occupancy instrument (`WOW_GLYPH_CACHE=1`) exists so this number gets re-chosen from a
/// measurement rather than from an argument.
pub(super) const SHEET_SIZE: u32 = 2048;
/// Padding between neighbouring cells, in texels — one row/column left untouched, so a bilinear tap
/// at a cell edge cannot reach into its neighbour.
const PAD: u32 = 1;
/// A cell's texel payload, in the two shapes the rasterizer produces.
pub(super) enum Cell<'a> {
/// One byte per texel: coverage, expanded to white × alpha (the vertex color supplies the ink).
Coverage(&'a [u8]),
/// Four bytes per texel, straight alpha: a pre-composited outlined cell (black ring + white
/// fill), taken verbatim.
Rgba(&'a [u8]),
}
/// One cell's texels, waiting for the render world to write them into the sheet.
pub(super) struct CellUpload {
pub(super) image: AssetId<Image>,
pub(super) x: u32,
pub(super) y: u32,
pub(super) w: u32,
pub(super) h: u32,
pub(super) rgba: Vec<u8>,
}
/// The glyph sheet: one texture, a shelf cursor, and the pending uploads.
pub(super) struct Sheet {
/// Reserved up front, before any pixels exist, because the engine that allocates cells lives
/// behind a lock the script VM reaches into mid-tick and can never hold `Assets<Image>`.
handle: Handle<Image>,
/// Whether the `Image` asset exists yet. Announced once, on the first cell.
created: bool,
announce: bool,
/// Left edge of the next cell on the current shelf.
cursor_x: u32,
/// Top edge of the current shelf.
cursor_y: u32,
/// Tallest cell placed on the current shelf.
row_h: u32,
uploads: Vec<CellUpload>,
}
impl Sheet {
/// Reserve the handle. Costs nothing but an id: no texels are allocated and no `Image` asset
/// exists until a cell actually lands.
pub(super) fn new(images: &Assets<Image>) -> Self {
Self {
handle: images.reserve_handle(),
created: false,
announce: false,
cursor_x: 0,
cursor_y: 0,
row_h: 0,
uploads: Vec::new(),
}
}
/// The texture every glyph quad samples.
pub(super) fn handle(&self) -> Handle<Image> {
self.handle.clone()
}
/// Place one cell and queue its texels. `None` means the sheet is full (or the cell could never
/// fit) — the caller's cue to ask for a reset, and to draw nothing for this glyph meanwhile,
/// which is a missing letter for at most one frame.
pub(super) fn alloc(&mut self, w: u32, h: u32, cell: &Cell<'_>) -> Option<Rect> {
if w == 0 || h == 0 || w > SHEET_SIZE || h > SHEET_SIZE {
return None; // a zero-ink glyph (a space), or a cell bigger than the whole sheet
}
// Walked on a copy of the cursor and committed only on success, so a cell too tall for
// the remaining height does not close the open shelf on its way out — a narrower or
// shorter one behind it can still land there.
let (mut x, mut y, mut row_h) = (self.cursor_x, self.cursor_y, self.row_h);
if x + w > SHEET_SIZE {
x = 0; // next shelf
y += row_h + PAD;
row_h = 0;
}
if y + h > SHEET_SIZE {
return None;
}
self.cursor_x = x + w + PAD;
self.cursor_y = y;
self.row_h = row_h.max(h);
let rgba = match cell {
// Plain coverage: white × alpha, so the vertex color is the ink color.
Cell::Coverage(cov) => {
let mut out = vec![255u8; (w * h * 4) as usize];
for (i, &a) in cov.iter().enumerate().take((w * h) as usize) {
out[i * 4 + 3] = a;
}
out
}
// Pre-composited outlined cell: verbatim (black ring, white fill).
Cell::Rgba(rgba) => rgba[..(w * h * 4) as usize].to_vec(),
};
if !self.created {
self.created = true;
self.announce = true;
}
self.uploads.push(CellUpload {
image: self.handle.id(),
x,
y,
w,
h,
rgba,
});
// Normalized against the FIXED side, never against how full the sheet is — the whole reason
// a cached UV stays valid for the life of the texture (module doc).
let s = SHEET_SIZE as f32;
Some(Rect::new(
x as f32 / s,
y as f32 / s,
(x + w) as f32 / s,
(y + h) as f32 / s,
))
}
/// Take whether the `Image` asset still has to be created, and the cells waiting to be written.
pub(super) fn take_pending(&mut self) -> (bool, Vec<CellUpload>) {
(
std::mem::take(&mut self.announce),
std::mem::take(&mut self.uploads),
)
}
/// Free every shelf. The texture, its handle and its texels all survive — only the allocator's
/// memory of what is where resets, so a quad emitted before the reset keeps drawing correctly
/// until its own re-emit replaces it. The caller owns telling the world; see the generation
/// counter on [`super::engine::TextEngine`].
pub(super) fn reset(&mut self) {
self.cursor_x = 0;
self.cursor_y = 0;
self.row_h = 0;
}
/// `(texels committed, texels available)` — the occupancy instrument's numbers. Committed
/// counts closed shelves whole plus the open one, i.e. it includes inter-cell padding and shelf
/// slack, which is the honest answer to "how full is it".
pub(super) fn occupancy(&self) -> (u64, u64) {
let used = u64::from(self.cursor_y) * u64::from(SHEET_SIZE)
+ u64::from(self.cursor_x) * u64::from(self.row_h);
(used, u64::from(SHEET_SIZE) * u64::from(SHEET_SIZE))
}
}
/// The sheet's `Image`: a GPU texture with **no CPU mirror** (`data: None`), lazily zero-initialized
/// by wgpu and thereafter written only by sub-rect `write_texture`. Straight (non-premultiplied)
/// alpha in sRGB, matching what the quad pass has always sampled.
///
/// `MAIN_WORLD | RENDER_WORLD` rather than `RENDER_WORLD` alone: a `RENDER_WORLD`-only image has its
/// data taken on first extract and cannot be re-extracted. There is no data to take here, so the
/// usage costs nothing and leaves a device-lost recreate possible.
pub(super) fn sheet_image() -> Image {
Image::new_uninit(
Extent3d {
width: SHEET_SIZE,
height: SHEET_SIZE,
depth_or_array_layers: 1,
},
TextureDimension::D2,
TextureFormat::Rgba8UnormSrgb,
RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
)
}
#[cfg(test)]
mod tests {
use super::*;
fn sheet() -> Sheet {
// The handle is the only thing `Assets` is consulted for, and a bare `Assets` needs no app.
Sheet::new(&Assets::<Image>::default())
}
fn cov(w: u32, h: u32) -> Vec<u8> {
vec![255u8; (w * h) as usize]
}
/// A cell's UV normalizes against the fixed side, so it is the same rect no matter what lands
/// afterwards. This is the property the nameplate meshes bake into the GPU, and the one whose
/// absence made a re-bake draw letters as fragments of other letters (1339).
#[test]
fn a_uv_is_fixed_against_the_sheet_and_never_moves() {
let mut s = sheet();
let c = cov(8, 11);
let first = s.alloc(8, 11, &Cell::Coverage(&c)).expect("room");
let side = SHEET_SIZE as f32;
assert_eq!(first, Rect::new(0.0, 0.0, 8.0 / side, 11.0 / side));
for _ in 0..2000 {
s.alloc(8, 11, &Cell::Coverage(&c)).expect("room");
}
// Nothing repacked: the first cell's texels are still where its UV says they are.
let (_, uploads) = s.take_pending();
assert_eq!((uploads[0].x, uploads[0].y), (0, 0));
assert_eq!((uploads[0].w, uploads[0].h), (8, 11));
}
/// Coverage becomes white × alpha; a composited cell goes up verbatim. The upload IS the
/// texture write, so this is where that conversion has to be right.
#[test]
fn coverage_uploads_as_white_times_alpha() {
let mut s = sheet();
s.alloc(2, 1, &Cell::Coverage(&[0, 128])).expect("room");
let (_, uploads) = s.take_pending();
assert_eq!(uploads[0].rgba, vec![255, 255, 255, 0, 255, 255, 255, 128]);
let mut s = sheet();
let ring = vec![0u8, 0, 0, 153, 200, 200, 200, 255];
s.alloc(2, 1, &Cell::Rgba(&ring)).expect("room");
let (_, uploads) = s.take_pending();
assert_eq!(uploads[0].rgba, ring, "a composited cell is not re-tinted");
}
/// Shelves wrap downward when a cell will not fit beside its neighbour, and the sheet refuses
/// rather than overrunning its own bounds.
#[test]
fn shelves_wrap_downward_and_the_sheet_eventually_refuses() {
let mut s = sheet();
// Just under half the sheet wide, so exactly two fit on a shelf and the third must wrap.
let (w, h) = (SHEET_SIZE / 2 - PAD, 64);
let c = cov(w, h);
let first = s.alloc(w, h, &Cell::Coverage(&c)).expect("first");
let second = s.alloc(w, h, &Cell::Coverage(&c)).expect("second");
let third = s.alloc(w, h, &Cell::Coverage(&c)).expect("third");
assert_eq!(first.min.y, second.min.y, "the first two share a shelf");
assert!(second.min.x > first.min.x, "…side by side");
assert!(
third.min.y > first.min.y,
"the third wrapped to the next shelf"
);
// …and it fills rather than growing. The bound is generous; what matters is that the loop
// terminates at all, which is the property a growable allocator would not have.
let mut placed = 3usize;
while s.alloc(w, h, &Cell::Coverage(&c)).is_some() {
placed += 1;
assert!(placed < 10_000, "the sheet must fill, not grow");
}
let (used, total) = s.occupancy();
assert!(
used * 10 > total * 9,
"the shelves should be ~full at refusal, not fragmented away: {used}/{total}"
);
}
/// A cell taller or wider than the sheet can never be placed, and says so instead of silently
/// corrupting a neighbour — without consuming shelf space finding out.
#[test]
fn an_oversized_or_empty_cell_is_refused_for_free() {
let mut s = sheet();
let c = cov(4, 4);
assert!(s.alloc(SHEET_SIZE + 1, 4, &Cell::Coverage(&c)).is_none());
assert!(s.alloc(4, SHEET_SIZE + 1, &Cell::Coverage(&c)).is_none());
assert!(s.alloc(0, 0, &Cell::Coverage(&[])).is_none());
assert_eq!(s.occupancy().0, 0);
assert!(s.take_pending().1.is_empty());
}
/// The `Image` is announced for creation exactly once — the asset is created on the first cell
/// and written into ever after. Announcing it twice would recreate the texture and erase
/// everything on it.
#[test]
fn the_texture_is_announced_for_creation_exactly_once() {
let mut s = sheet();
let c = cov(8, 11);
s.alloc(8, 11, &Cell::Coverage(&c)).expect("room");
let (announce, uploads) = s.take_pending();
assert!(announce, "created on the first cell");
assert_eq!(uploads.len(), 1);
s.alloc(8, 11, &Cell::Coverage(&c)).expect("room");
let (announce, uploads) = s.take_pending();
assert!(!announce, "…and never again");
assert_eq!(uploads.len(), 1, "but the cell still goes up");
}
/// A reset frees the shelves and keeps everything the GPU knows about — the handle, and the
/// texels themselves. A quad emitted before the reset keeps drawing correctly.
#[test]
fn a_reset_frees_the_shelves_and_keeps_the_texture() {
let mut s = sheet();
let c = cov(8, 11);
s.alloc(8, 11, &Cell::Coverage(&c)).expect("room");
let handle = s.handle();
assert!(s.take_pending().0);
assert!(s.occupancy().0 > 0);
s.reset();
assert_eq!(s.occupancy().0, 0, "the shelves are free");
assert_eq!(s.handle(), handle, "the texture survives");
// Critically: no re-announcement. Re-creating the image would blank the sheet.
s.alloc(8, 11, &Cell::Coverage(&c)).expect("room");
let (announce, uploads) = s.take_pending();
assert!(!announce, "a reset must not recreate the texture");
assert_eq!((uploads[0].x, uploads[0].y), (0, 0), "the shelf is reused");
}
}
+26 -74
View File
@@ -12,7 +12,10 @@
//! occlusion** (a 2-D overlay — plates draw through walls); snap, no smoothing. Anti-overlap
//! there *is*, since 0367 found the shared solver ([`crate::smart_rect`]) — this file's older
//! "no anti-overlap" was the census that missed it. And the sphere is bounded by the frustum:
//! the unit must be **in view** ([`in_view`]), or its plate is not seated at all.
//! the unit must be **in view** ([`crate::ui_pass::project_overlay`]) or the reference
//! **destroys** the plate outright (`0x60f600`, before the 20-yard cull — wow-re
//! `nameplate-offscreen-cull.md`, §5-VERIFIED 2026-08-15); it is never clamped in from
//! off-screen, which is what ours did (1341/1344).
//! (The friendly-totem exclusion waits on totems existing.)
//! - **Anchor**: the same overhead head point (`0x608640`, [`overhead_anchor`]) **+ 2/3 yd**
//! (`[0x80abfc]`), projected per frame (`0x483ee0`); the plate's **TOP-CENTER** lands on the
@@ -252,31 +255,6 @@ fn device_snap(v: f32, scale: f32) -> f32 {
(v * scale).round() / scale
}
/// **Is the unit in view?** — the gate between the 20-yd sphere and the seat.
///
/// The plate gate is a 20-yard *sphere*; the view is a 45° frustum, so most of what the sphere
/// admits is beside or behind you. That would be harmless if an off-screen desire simply drew
/// off-screen — but the seat's on-screen clamp ([`crate::smart_rect`]'s `normalize`, the ref's
/// `0x509e20`) does not merely tidy a plate straddling an edge: it **translates a rect from
/// anywhere bodily onto the screen**. Without this gate a unit two screens off to the side got a
/// phantom plate pinned to the border — measured walking Goldshire at 1440×810 (the `vpl` trace,
/// [`drive_vplates`]): **30% of every drawn plate while moving, two thirds while standing still**.
/// They are not passive either: each claims bucket 0, so the anti-overlap solve pushed the plates
/// of units you *can* see off phantoms you cannot, and they were live hover targets through
/// [`PlateRects`].
///
/// That was the whole of the reported jitter — 26% of frame-steps moved a plate more than 4 px
/// past its own projected motion, up to 300 px in a single frame, and 90% of those involved a
/// phantom (after: 3.5% and 51 px, which is the solver's own documented snap-back). The projection
/// is smooth throughout — median frame-to-frame acceleration 0.05 px — so none of it was ever a
/// camera or an anchor problem.
///
/// The clamp keeps its job: a plate whose unit *is* in view still gets pulled fully inside the
/// border, which is what it is for.
fn in_view(screen: Vec2, viewport: Vec2) -> bool {
(0.0..=viewport.x).contains(&screen.x) && (0.0..=viewport.y).contains(&screen.y)
}
/// The knee of the plate's gx basis — **a director-pinned DEVIATION from the byte law**
/// (0185/0186). The real client's plates grow diagonal-linear without limit (wow-re §9,
/// byte-closed: ~294 px plate + em-29 name at 2560×1440, bilinear-softened) — the director
@@ -578,11 +556,16 @@ fn drive_vplates(
if !friendly && store.is_some_and(|s| s.0.unit_reads_dead()) {
continue;
}
// Project the plate point: the overhead anchor + 2/3 yd, per frame, no smoothing. Behind
// the camera the projection itself fails and the plate is gone. (This comment used to
// claim "the plate has no clamp — offscreen is gone", which had not been true since the
// seat gained the solver's on-screen normalize: offscreen was dragged to the border. The
// gate below is what makes the sentence true again.)
// Project the plate point: the overhead anchor + 2/3 yd, per frame, no smoothing — and
// honour the projector's ACCEPT verdict ([`crate::ui_pass::project_overlay`]): behind the
// camera or outside the viewport, there is no plate. That is the reference's own law, and
// a hard one — `0x60f600` **destroys** the plate frame on a false verdict, before it ever
// reaches the seat (wow-re `nameplate-offscreen-cull.md`, §5-VERIFIED 2026-08-15).
//
// Ours instead ran an off-screen point into the seat, whose clamp translates a rect from
// anywhere bodily onto the screen: **30% of every drawn plate** was a unit nobody could
// see, pinned to a border, claiming bucket space and shoving the plates of units you can
// (1341 — two thirds of them while standing still, and 90% of the reported jitter).
let anchor = overhead_anchor(
entity,
tf,
@@ -591,13 +574,11 @@ fn drive_vplates(
&anchor_q.2,
&anchor_q.3,
);
let Ok(screen) = cam.world_to_viewport(&cam_tf, anchor + Vec3::Y * PLATE_LIFT) else {
let Some(screen) =
crate::ui_pass::project_overlay(cam, &cam_tf, anchor + Vec3::Y * PLATE_LIFT, viewport)
else {
continue;
};
// …and the unit has to be in view ([`in_view`]).
if !in_view(screen, viewport) {
continue;
}
cands.push((
screen.distance_squared(sort_pt),
screen,
@@ -880,15 +861,15 @@ fn plate_text(
color: [f32; 4],
trace: bool,
) {
// Shape at the nearest baked atlas size, then rescale the quads to the exact target em
// around the anchor (the combat_text unbaked-size recipe) — plate ems are window-derived
// (8 at 768, 11 at 1080, [`text_px`]) and mostly absent from the baked set.
let shaped = atlas.snap_size(px);
// Laid out AT the target em. Plate ems are window-derived (8 at 768, 11 at 1080,
// [`text_px`]) and so landed on almost nothing in the old fixed size ladder: every plate on
// screen used to be shaped at the nearest rung and rescaled about the anchor. Since decision
// 1342 the raster follows the request, so the rescale — and its sub-pixel scatter — is gone.
let mut e = atlas.lock();
let spec = FontSpec {
path: None,
height: Some(shaped),
height: Some(px),
outline: Outline::None,
paint_halo: true,
alpha_gradient: None,
};
let justify = Justify {
@@ -896,7 +877,7 @@ fn plate_text(
v: JustifyV::Middle,
};
let mut main = layout_text_quads(
atlas,
&mut e,
text,
Rect::from_center_size(anchor, Vec2::ZERO),
color,
@@ -904,18 +885,10 @@ fn plate_text(
Z_TEXT,
spec,
);
drop(e);
if main.is_empty() {
return;
}
let s = px / shaped;
for q in main.iter_mut() {
q.rect = Rect::new(
anchor.x + (q.rect.min.x - anchor.x) * s,
anchor.y + (q.rect.min.y - anchor.y) * s,
anchor.x + (q.rect.max.x - anchor.x) * s,
anchor.y + (q.rect.max.y - anchor.y) * s,
);
}
// Seat by measured INK, then the shadow is the same run offset one step right+down.
let dy = {
let bottom = main.iter().map(|q| q.rect.max.y).fold(f32::MIN, f32::max);
@@ -956,7 +929,7 @@ fn plate_text(
y1 = y1.max(q.rect.max.y);
}
eprintln!(
"vplate-trace: text {text:?} px={px:.1} shaped={shaped:.1} ink=({x0:.1},{y0:.1})..({x1:.1},{y1:.1}) anchor=({:.1},{:.1})",
"vplate-trace: text {text:?} px={px:.1} ink=({x0:.1},{y0:.1})..({x1:.1},{y1:.1}) anchor=({:.1},{:.1})",
anchor.x, anchor.y
);
}
@@ -1024,27 +997,6 @@ mod tests {
assert_eq!(CON_GREEN[1], 178.0 / 255.0);
}
/// The in-view gate: the 20-yd sphere reaches far outside the frustum, and everything it
/// admits from out there used to be dragged onto a screen border by the seat's clamp. The
/// bounds are inclusive — a unit exactly on the edge is still in view, and the clamp (not this)
/// is what keeps its plate fully inside.
#[test]
fn only_a_unit_in_view_gets_a_plate() {
let vp = Vec2::new(1440.0, 810.0);
assert!(in_view(Vec2::new(720.0, 405.0), vp));
assert!(in_view(Vec2::ZERO, vp), "the corner is in view");
assert!(in_view(vp, vp), "so is the far corner");
for out in [
Vec2::new(-0.5, 405.0), // off left
Vec2::new(1440.5, 405.0), // off right
Vec2::new(720.0, -0.5), // above
Vec2::new(720.0, 810.5), // below
Vec2::new(1409.0, 1332.0), // the measured phantom: two thirds of a screen below
] {
assert!(!in_view(out, vp), "{out:?} is not on screen");
}
}
/// The snap is on the DEVICE grid, not the logical one. At the 2× scale we develop on, a
/// logical `round()` moved the plate two physical pixels at a time; this moves it one, which
/// is the smallest step that still lands the border blit on a texel boundary. Pinned so the