facett-map/src/gpu/bake.rs
//! **The baked-vertex store hook + the blob codec** (GFX_V2 backlog: the client-side
//! NVMe baked-vertex cache, measured 6.5× over decode+classify+bake).
//!
//! # What this is
//!
//! The tile lane ([`crate::layer::MapFacet`]'s tiled hand-over) keys every tile group by
//! CONTENT — a 128-bit FNV over the ways' identity + geometry + style + the group's
//! bake origin (`layer::tile_content_key`) — and the renderer keeps the uploaded payload
//! in VRAM under that key. A VRAM miss costs the whole bake: the ear clip of every
//! filled ring (the process-global tessellation memo is cold on a fresh process), the
//! Mercator reprojection, the quad expansion with miter/bevel joins, the fill triangle
//! expansion. All of it is a pure function of the key's inputs, and all of it is POD
//! streams (`Vertex`, `GpuWayMeta`, `LineVertex`) once baked.
//!
//! So: on a VRAM miss the facet asks an installed [`BakeStore`] for the key's blob
//! FIRST. A hit decodes straight into a [`GpuGeometry`] (a `bytemuck` cast per stream —
//! no tessellation, no reprojection, no expansion) and goes to the device; a miss bakes
//! as before and OFFERS the encoded blob to the store. The store is the HOST's (korp
//! keeps it on NVMe under `~/.korp`, bounded, nuked with its tile cache when the data
//! version moves) — facett-map does no disk IO and has no cache-dir concept; this module
//! is the hook and the codec, nothing more. Same key discipline as VRAM, one writer.
//!
//! # The key is not quite enough for a file that outlives the process
//!
//! `tile_content_key` substitutes bbox bits + vertex count for the coordinates (its
//! stated contract: a taken layer's ways are immutable for its lifetime — a PROCESS
//! lifetime argument). Two things close the gap for a blob that persists: the host nukes
//! its store whenever the data version moves (a re-cut is a new world, by design), and
//! the blob carries [`BAKE_FORMAT_GEN`] — the layout of the three POD types + this
//! header — so a facett that changes a vertex struct reads every older blob as a MISS.
//!
//! # Corrupt is a miss
//!
//! [`decode_baked`] answers `None` for a wrong magic, a wrong generation, a length that
//! does not tile into whole elements, a stream that does not fit, a meta entry whose
//! span leaves its stream, a LOD out of range, or a non-monotone `line_lod_end`. The
//! facet then bakes and re-offers, so a bad file heals itself on the next sight.
use std::sync::{Arc, Mutex, OnceLock};
use super::{GpuFillGeometry, GpuGeometry, GpuWayMeta, LineVertex, Vertex};
use crate::style::LOD_COUNT;
/// `"FCTBAKE1"` — the blob magic.
pub const BAKE_MAGIC: u64 = 0x3145_4B41_4254_4346;
/// The blob LAYOUT generation: bump when `Vertex`, `GpuWayMeta`, `LineVertex` or this
/// header change shape — **or when the ORDER of a stream changes**, which is just as
/// invisible to the content key and just as wrong on screen.
///
/// `2` (2026-08-22): the line and fill streams are now emitted in
/// [`crate::paint_order`] order instead of LOD-bucket / input order, and
/// `line_lod_end` is a cumulative per-tier COUNT rather than a contiguous span. A
/// gen-1 blob holds the same vertices in the old, tangled order, and the content key
/// cannot tell — it hashes the ways, not the stream. Bumping makes every stale blob a
/// miss that re-bakes.
///
/// `3` (2026-08-22): `GpuWayMeta::_pad` became `flags`, and bit 0 marks an AREA — the
/// only geometry the minimum-projected-extent gate applies to (stinky `kalm-3`). A
/// gen-2 blob has zeroes there, so every building footprint it carries would slip the
/// gate and the tiny-fill budget would come back for exactly the tiles the NVMe tier
/// serves.
///
/// `4` (2026-08-22): the fill lane's ring OUTLINE stream changed type — 24 B [`Vertex`]
/// `LineList` pairs became 48 B [`LineVertex`] quads, so it gets the same SDF coverage
/// ramp the roads get (stinky `mapgpu-1`). A gen-3 blob's outline stream has half the
/// stride and would decode as garbage geometry, not as a missing feature.
///
/// `5` (2026-08-25): `LOD_COUNT` went 4 → 5 for the z9 `Lod::District` tier, and this
/// header carries `line_lod_end` as `[u32; LOD_COUNT]`. So the header grew — `pad8(16)`
/// became `pad8(20)`, i.e. 16 B → 24 B — and **every stream offset after it shifted by
/// eight bytes**. A gen-4 blob read with a gen-5 layout does not decode as a missing
/// feature; it decodes as garbage geometry from the wrong offset. Independently of the
/// header, the tier VALUE in `GpuWayMeta::lod` was renumbered by the same change
/// (`City` 2 → 3, `Detail` 3 → 4), so a gen-4 blob's `lod = 2` means "city" under the
/// numbering it was written with and "district" under the one that would read it. One
/// bump covers both; every stale blob becomes a miss and re-bakes.
pub const BAKE_FORMAT_GEN: u32 = 5;
/// Header: magic · gen · reserved · 8 stream counts · `line_lod_end` (padded to 8 B).
const HEADER_BYTES: usize = 8 + 4 + 4 + 8 * 8 + pad8(4 * LOD_COUNT);
const fn pad8(n: usize) -> usize {
(n + 7) & !7
}
/// **What a host installs**: a content-keyed blob store. `get` is synchronous and on
/// the paint thread — a host backs it with a RAM/page-cache-warm read; `put` is
/// fire-and-forget — a host queues the bytes to a writer and returns.
pub trait BakeStore: Send + Sync {
/// The blob for `key`, if the store has one.
fn get(&self, key: u128) -> Option<Vec<u8>>;
/// Offer the blob for `key` (the store may drop it — over budget, disk full).
fn put(&self, key: u128, blob: Vec<u8>);
}
static STORE: OnceLock<Mutex<Option<Arc<dyn BakeStore>>>> = OnceLock::new();
fn store_slot() -> &'static Mutex<Option<Arc<dyn BakeStore>>> {
STORE.get_or_init(|| Mutex::new(None))
}
/// Install (or replace) the process-wide bake store. `None` uninstalls.
///
/// Installing also registers the tier's live About row
/// ([`about_note`], via `facett_core::render::lane::register_status_note`) — so any
/// host that installs a store (korp's NVMe tier, a test's RAM store) gets the
/// hit-rate line on its About card and in `state_json.gpu.notes` with no host code.
/// Registration is idempotent; the note reports nothing while no store is installed.
pub fn install_bake_store(store: Option<Arc<dyn BakeStore>>) {
if let Ok(mut g) = store_slot().lock() {
*g = store;
}
facett_core::render::lane::register_status_note(about_note);
}
/// **The baked-vertex tier's one live status line** — the V2.7 "read
/// `bake_counters()` into the About line" item. `None` while no store is installed
/// (a tier that does not exist reports nothing, never a zeroed reading); with a
/// store but no lookups yet it says so rather than faking a 0 % hit rate (the
/// identity-value trap: a cold tier and a useless tier are different facts).
#[must_use]
pub fn about_note() -> Option<String> {
bake_store()?;
let (hits, misses, corrupt) = bake_counters();
let looked = hits + misses;
if looked == 0 {
return Some("bake cache: installed, no lookups yet".to_string());
}
#[allow(clippy::cast_precision_loss)]
let pct = hits as f64 / looked as f64 * 100.0;
Some(format!("bake cache: {hits} hit · {misses} miss · {corrupt} corrupt · {pct:.0} % hit"))
}
/// The installed store, if any.
#[must_use]
pub fn bake_store() -> Option<Arc<dyn BakeStore>> {
store_slot().lock().ok().and_then(|g| g.clone())
}
// ── Counters: what the lane DID, for tests and the About line ────────────────
static HITS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
static MISSES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
static CORRUPT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// `(hits, misses, corrupt)` since process start — a hit is a blob that decoded and
/// replaced a bake; a miss is a bake the store had nothing for; corrupt is a blob the
/// store HAD that did not decode (counted as a miss too).
#[must_use]
pub fn bake_counters() -> (u64, u64, u64) {
use std::sync::atomic::Ordering::Relaxed;
(HITS.load(Relaxed), MISSES.load(Relaxed), CORRUPT.load(Relaxed))
}
/// **The lane's one door**: the geometry for `key`, from the store when it has a good
/// blob, else from `bake()` — and a fresh bake is offered to the store. With no store
/// installed this is exactly `bake()`.
pub fn baked_or_build(key: u128, bake: impl FnOnce() -> GpuGeometry) -> GpuGeometry {
use std::sync::atomic::Ordering::Relaxed;
let Some(store) = bake_store() else { return bake() };
if let Some(blob) = store.get(key) {
if let Some(geo) = decode_baked(&blob) {
HITS.fetch_add(1, Relaxed);
return geo;
}
CORRUPT.fetch_add(1, Relaxed);
}
MISSES.fetch_add(1, Relaxed);
let geo = bake();
store.put(key, encode_baked(&geo));
geo
}
/// The header + the eight POD streams, back to back, each 8-byte aligned.
#[must_use]
pub fn encode_baked(g: &GpuGeometry) -> Vec<u8> {
let streams: [&[u8]; 8] = [
bytemuck::cast_slice(&g.vertices),
bytemuck::cast_slice(&g.way_meta),
bytemuck::cast_slice(&g.line_vertices),
bytemuck::cast_slice(&g.line_meta),
bytemuck::cast_slice(&g.fills.tri_vertices),
bytemuck::cast_slice(&g.fills.tri_meta),
bytemuck::cast_slice(&g.fills.outline_vertices),
bytemuck::cast_slice(&g.fills.outline_meta),
];
let counts: [u64; 8] = [
g.vertices.len() as u64,
g.way_meta.len() as u64,
g.line_vertices.len() as u64,
g.line_meta.len() as u64,
g.fills.tri_vertices.len() as u64,
g.fills.tri_meta.len() as u64,
g.fills.outline_vertices.len() as u64,
g.fills.outline_meta.len() as u64,
];
let total = HEADER_BYTES + streams.iter().map(|s| pad8(s.len())).sum::<usize>();
let mut out = Vec::with_capacity(total);
out.extend_from_slice(&BAKE_MAGIC.to_le_bytes());
out.extend_from_slice(&BAKE_FORMAT_GEN.to_le_bytes());
out.extend_from_slice(&0u32.to_le_bytes());
for c in counts {
out.extend_from_slice(&c.to_le_bytes());
}
for e in g.line_lod_end {
out.extend_from_slice(&e.to_le_bytes());
}
out.resize(HEADER_BYTES, 0);
for s in streams {
out.extend_from_slice(s);
out.resize(pad8(out.len()), 0);
}
debug_assert_eq!(out.len(), total);
out
}
fn read_u64(b: &[u8], at: usize) -> Option<u64> {
b.get(at..at + 8).map(|s| u64::from_le_bytes(s.try_into().expect("8 bytes")))
}
fn read_u32(b: &[u8], at: usize) -> Option<u32> {
b.get(at..at + 4).map(|s| u32::from_le_bytes(s.try_into().expect("4 bytes")))
}
/// Take `n` POD elements of `T` from `bytes` at `*at` (advancing past the 8 B pad);
/// `None` if the stream does not fit.
fn take_stream<T: bytemuck::Pod>(bytes: &[u8], at: &mut usize, n: u64) -> Option<Vec<T>> {
let n = usize::try_from(n).ok()?;
let len = n.checked_mul(std::mem::size_of::<T>())?;
let s = bytes.get(*at..at.checked_add(len)?)?;
*at += pad8(len);
// `pod_collect_to_vec` copies and never cares about the source alignment.
Some(bytemuck::pod_collect_to_vec(s))
}
/// Every meta entry's span must lie inside its stream, and its LOD inside the tiers.
fn metas_fit(meta: &[GpuWayMeta], verts: usize) -> bool {
meta.iter().all(|m| {
(m.lod as usize) < LOD_COUNT
&& (m.vert_start as usize)
.checked_add(m.vert_count as usize)
.is_some_and(|end| end <= verts)
})
}
/// The blob back into a [`GpuGeometry`] — `None` on ANY inconsistency (see the module
/// note: corrupt is a miss, never a wrong picture).
#[must_use]
pub fn decode_baked(bytes: &[u8]) -> Option<GpuGeometry> {
if bytes.len() < HEADER_BYTES || read_u64(bytes, 0)? != BAKE_MAGIC || read_u32(bytes, 8)? != BAKE_FORMAT_GEN {
return None;
}
let mut counts = [0u64; 8];
for (i, c) in counts.iter_mut().enumerate() {
*c = read_u64(bytes, 16 + 8 * i)?;
}
let mut line_lod_end = [0u32; LOD_COUNT];
for (i, e) in line_lod_end.iter_mut().enumerate() {
*e = read_u32(bytes, 16 + 64 + 4 * i)?;
}
let mut at = HEADER_BYTES;
let vertices: Vec<Vertex> = take_stream(bytes, &mut at, counts[0])?;
let way_meta: Vec<GpuWayMeta> = take_stream(bytes, &mut at, counts[1])?;
let line_vertices: Vec<LineVertex> = take_stream(bytes, &mut at, counts[2])?;
let line_meta: Vec<GpuWayMeta> = take_stream(bytes, &mut at, counts[3])?;
let tri_vertices: Vec<Vertex> = take_stream(bytes, &mut at, counts[4])?;
let tri_meta: Vec<GpuWayMeta> = take_stream(bytes, &mut at, counts[5])?;
let outline_vertices: Vec<LineVertex> = take_stream(bytes, &mut at, counts[6])?;
let outline_meta: Vec<GpuWayMeta> = take_stream(bytes, &mut at, counts[7])?;
if at != bytes.len() {
return None; // trailing garbage is not a blob we wrote
}
if !metas_fit(&way_meta, vertices.len())
|| !metas_fit(&line_meta, line_vertices.len())
|| !metas_fit(&tri_meta, tri_vertices.len())
|| !metas_fit(&outline_meta, outline_vertices.len())
{
return None;
}
// The tiers tile the quad buffer: non-decreasing, ending at its length.
if line_lod_end.windows(2).any(|w| w[0] > w[1])
|| line_lod_end.last().copied().unwrap_or(0) as usize != line_vertices.len()
{
return None;
}
Some(GpuGeometry {
vertices,
way_meta,
line_vertices,
line_meta,
line_lod_end,
fills: GpuFillGeometry { tri_vertices, tri_meta, outline_vertices, outline_meta },
})
}
#[cfg(test)]
mod tests {
use super::*;
fn a_geometry() -> GpuGeometry {
let th = facett_core::Theme::default();
let mut ways = Vec::new();
for i in 0..40 {
let x = 18.0 + f64::from(i) * 1e-3;
let tags = vec![("highway".to_string(), "residential".to_string())];
ways.extend(crate::Way::from_lonlat(i64::from(i), &[(x, 59.3), (x + 5e-4, 59.3005), (x + 1e-3, 59.3)], &tags, &th));
}
let origin = super::super::ways_origin(ways.iter()).unwrap_or([0.0, 0.0]);
let mut g = super::super::build_gpu_geometry(ways.iter().map(|w| (w, w.style.lod as u32)), origin);
// A couple of fills too, through the real fill bake off a real ear clip.
let mut fills = Vec::new();
for i in 0..5 {
let x = 18.1 + f64::from(i) * 1e-3;
let tags = vec![("natural".to_string(), "water".to_string())];
fills.extend(crate::Way::from_lonlat(
100 + i64::from(i),
&[(x, 59.2), (x + 4e-4, 59.2), (x + 4e-4, 59.2004), (x, 59.2004), (x, 59.2)],
&tags,
&th,
));
}
let cache = crate::tessellate::FillTessCache::new(fills.len());
g.fills = super::super::build_gpu_fill_geometry(
fills.iter().enumerate().filter_map(|(i, w)| cache.tris(i, &w.lonlat, &w.holes).map(|t| (w, w.style.lod as u32, t))),
origin,
);
assert!(!g.line_vertices.is_empty() && !g.fills.tri_vertices.is_empty(), "the fixture bakes both lanes");
g
}
fn same(a: &GpuGeometry, b: &GpuGeometry) -> bool {
a.vertices == b.vertices
&& a.way_meta == b.way_meta
&& a.line_vertices == b.line_vertices
&& a.line_meta == b.line_meta
&& a.line_lod_end == b.line_lod_end
&& a.fills.tri_vertices == b.fills.tri_vertices
&& a.fills.tri_meta == b.fills.tri_meta
&& a.fills.outline_vertices == b.fills.outline_vertices
&& a.fills.outline_meta == b.fills.outline_meta
}
/// LAW 2: the blob round-trips every stream byte for byte, and an empty geometry too.
#[test]
fn the_blob_round_trips_every_stream() {
let g = a_geometry();
let blob = encode_baked(&g);
let back = decode_baked(&blob).expect("decodes");
assert!(same(&g, &back), "every stream and line_lod_end survive the trip");
let empty = GpuGeometry { vertices: vec![], way_meta: vec![], line_vertices: vec![], line_meta: vec![], line_lod_end: [0; LOD_COUNT], fills: GpuFillGeometry::default() };
let back = decode_baked(&encode_baked(&empty)).expect("an empty blob decodes");
assert!(same(&empty, &back));
}
/// Corrupt is a MISS: garbage, a truncation, a wrong generation, trailing bytes, a
/// flipped count and a meta span past its stream all answer `None`, never a picture.
#[test]
fn a_corrupt_blob_is_a_miss_not_a_wrong_picture() {
let g = a_geometry();
let blob = encode_baked(&g);
assert!(decode_baked(b"not a blob at all").is_none());
assert!(decode_baked(&blob[..blob.len() / 2]).is_none(), "truncated");
let mut trailing = blob.clone();
trailing.extend_from_slice(&[0u8; 8]);
assert!(decode_baked(&trailing).is_none(), "trailing bytes");
let mut newer = blob.clone();
newer[8..12].copy_from_slice(&(BAKE_FORMAT_GEN + 1).to_le_bytes());
assert!(decode_baked(&newer).is_none(), "a newer generation");
let mut count = blob.clone();
let n = u64::from_le_bytes(count[16..24].try_into().unwrap()) + 1;
count[16..24].copy_from_slice(&n.to_le_bytes());
assert!(decode_baked(&count).is_none(), "a count that does not fit");
// A meta span past its stream: poke the first line_meta's vert_count.
let lm_at = HEADER_BYTES + pad8(g.vertices.len() * std::mem::size_of::<Vertex>()) + pad8(g.way_meta.len() * std::mem::size_of::<GpuWayMeta>()) + pad8(g.line_vertices.len() * std::mem::size_of::<LineVertex>());
let mut span = blob.clone();
span[lm_at + 20..lm_at + 24].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(decode_baked(&span).is_none(), "a meta span past its stream");
}
/// **MEASURED, not argued**: a dense tile group (4 000 roads + 400 lakes, the
/// live 2.6-vertex shape) — the full bake (ear clip + reproject + quad-expand +
/// fill-expand) against `decode_baked` of its blob, the work a store HIT replaces.
/// The ratio is printed; the only gate is that a decode is not SLOWER than a bake
/// (if it ever is, the tier is pointless and should say so here).
#[test]
fn a_decode_beats_the_bake_it_replaces() {
let th = facett_core::Theme::default();
let mut ways = Vec::new();
for i in 0..4000_i64 {
let x = 17.0 + (i % 200) as f64 * 2e-4;
let y = 58.0 + (i / 200) as f64 * 2e-4;
let tags = vec![("highway".to_string(), "residential".to_string())];
ways.extend(crate::Way::from_lonlat(i, &[(x, y), (x + 1e-4, y + 1.2e-4), (x + 2.1e-4, y + 0.3e-4)], &tags, &th));
}
let mut fills = Vec::new();
for i in 0..400_i64 {
let x = 17.1 + (i % 20) as f64 * 3e-4;
let y = 58.1 + (i / 20) as f64 * 3e-4;
let tags = vec![("natural".to_string(), "water".to_string())];
fills.extend(crate::Way::from_lonlat(
90_000 + i,
&[(x, y), (x + 2e-4, y), (x + 2.4e-4, y + 1.2e-4), (x + 2e-4, y + 2e-4), (x + 0.5e-4, y + 1.6e-4), (x, y)],
&tags,
&th,
));
}
let origin = super::super::ways_origin(ways.iter().chain(fills.iter())).unwrap();
let bake = || {
// A FRESH cache each time: the first sight of a ring set, which is what a
// store hit spares (the process memo is also bypassed by unique coordinates
// only on the first run; so time the first run separately).
let cache = crate::tessellate::FillTessCache::new(fills.len());
let mut g = super::super::build_gpu_geometry(ways.iter().map(|w| (w, w.style.lod as u32)), origin);
g.fills = super::super::build_gpu_fill_geometry(
fills.iter().enumerate().filter_map(|(i, w)| cache.tris(i, &w.lonlat, &w.holes).map(|t| (w, w.style.lod as u32, t))),
origin,
);
g
};
let t = std::time::Instant::now();
let g = bake();
let cold_bake_ms = t.elapsed().as_secs_f64() * 1e3;
let t = std::time::Instant::now();
for _ in 0..5 {
std::hint::black_box(bake());
}
let warm_bake_ms = t.elapsed().as_secs_f64() * 1e3 / 5.0;
let blob = encode_baked(&g);
let t = std::time::Instant::now();
let enc = encode_baked(&g);
let encode_ms = t.elapsed().as_secs_f64() * 1e3;
assert_eq!(enc.len(), blob.len());
let t = std::time::Instant::now();
for _ in 0..5 {
std::hint::black_box(decode_baked(&blob).expect("decodes"));
}
let decode_ms = t.elapsed().as_secs_f64() * 1e3 / 5.0;
eprintln!(
"[bake] {} roads + {} lakes → {} line verts, {} fill tris, blob {:.2} MiB · bake cold {cold_bake_ms:.2} ms / warm-memo {warm_bake_ms:.2} ms · encode {encode_ms:.2} ms · decode {decode_ms:.2} ms → decode is {:.1}× faster than a cold bake, {:.1}× than a memo-warm one",
ways.len(),
fills.len(),
g.line_vertices.len(),
g.fills.tri_vertices.len() / 3,
blob.len() as f64 / (1024.0 * 1024.0),
cold_bake_ms / decode_ms,
warm_bake_ms / decode_ms,
);
assert!(decode_ms < cold_bake_ms, "a decode ({decode_ms:.2} ms) must beat the bake it replaces ({cold_bake_ms:.2} ms)");
}
/// The door: with no store it bakes; with a store a MISS bakes and OFFERS the blob,
/// a HIT skips the bake entirely (the closure is never called), and a corrupt blob
/// is a miss that re-offers a good one.
#[test]
fn the_store_door_hits_misses_and_heals() {
struct Mem(Mutex<std::collections::HashMap<u128, Vec<u8>>>, std::sync::atomic::AtomicUsize);
impl BakeStore for Mem {
fn get(&self, key: u128) -> Option<Vec<u8>> {
self.0.lock().unwrap().get(&key).cloned()
}
fn put(&self, key: u128, blob: Vec<u8>) {
self.1.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
self.0.lock().unwrap().insert(key, blob);
}
}
let mem = Arc::new(Mem(Mutex::new(Default::default()), 0.into()));
// The slot is process-global: take it for this test and release it after.
install_bake_store(Some(mem.clone()));
let key = 0xDEAD_BEEF_u128 << 64 | 0x1234;
let g = a_geometry();
let (h0, m0, c0) = bake_counters();
let mut bakes = 0;
let out = baked_or_build(key, || { bakes += 1; a_geometry() });
assert!(same(&out, &g));
assert_eq!(bakes, 1, "a miss bakes");
assert_eq!(mem.1.load(std::sync::atomic::Ordering::Relaxed), 1, "…and offers the blob");
let out = baked_or_build(key, || { bakes += 1; a_geometry() });
assert!(same(&out, &g), "the hit is the same geometry");
assert_eq!(bakes, 1, "a hit never bakes");
let (h1, m1, c1) = bake_counters();
assert_eq!((h1 - h0, m1 - m0, c1 - c0), (1, 1, 0));
// Corrupt the stored blob: the next ask is a corrupt-miss that re-bakes and heals.
mem.0.lock().unwrap().get_mut(&key).unwrap().truncate(40);
let out = baked_or_build(key, || { bakes += 1; a_geometry() });
assert!(same(&out, &g));
assert_eq!(bakes, 2, "a corrupt blob re-bakes");
let (_, m2, c2) = bake_counters();
assert_eq!((m2 - m1, c2 - c1), (1, 1));
assert!(decode_baked(&mem.0.lock().unwrap()[&key]).is_some(), "…and the store holds a good blob again");
// ── The About note reads THESE counters through the lane registry ─────
// With the store installed the note is a live row carrying the counts
// above, and it reaches `GpuStatus` (the About card's one row writer)
// through `register_status_note` — asserted on the PROBE's rows, not on
// `about_note()` alone, so the registration wire is part of the proof.
let note = about_note().expect("a live note while the store is installed");
assert!(
note.starts_with("bake cache: ") && note.contains(" hit") && note.contains("% hit"),
"the note carries the hit rate: {note}"
);
let rows = facett_core::render::lane::GpuStatus::probe().rows();
assert!(
rows.iter().any(|r| r == ¬e),
"the registered note reaches the About rows verbatim — rows: {rows:?}"
);
install_bake_store(None);
// RED half: with NO store the tier reports NOTHING — an absent tier must
// not fake a zeroed reading (and the registered callback goes silent
// rather than lingering as a stale row).
assert_eq!(about_note(), None, "no store ⇒ no row");
assert!(
!facett_core::render::lane::GpuStatus::probe()
.rows()
.iter()
.any(|r| r.starts_with("bake cache: ")),
"…and the probe carries no bake row either"
);
}
}