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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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={:?}",
|
||||
|
||||
@@ -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,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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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,
|
||||
(
|
||||
|
||||
@@ -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})");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>>()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user