facett-map/tests/storage_binding_budget.rs
//! **The chunk budget must respect the limit the DEVICE enforces on a storage
//! binding, not the larger one it enforces on a buffer** (only with `--features wgpu`).
//!
//! `GpuMapRenderer` splits country-scale geometry into chunks so no single buffer
//! exceeds the device's budget (fix #12). Every one of those chunk buffers is then
//! handed to `create_bind_group` as a STORAGE binding — and wgpu governs storage
//! bindings by `max_storage_buffer_binding_size` (128 MiB by default), which is HALF
//! the `max_buffer_size` (256 MiB) the budget was being taken from. So the partition
//! could emit a chunk that `create_buffer` accepted and `create_bind_group` refused.
//!
//! It fired for real on 2026-08-04, on oden's RTX 4090, the moment korp-ui's camera
//! stepped onto the new z15 rung of the [6, 9, 12, 15] pyramid — a hard panic that
//! killed the app mid-drive:
//!
//! ```text
//! wgpu error: Validation Error
//! In Device::create_bind_group, label = 'line_cull_bg'
//! Buffer binding 1 range 198672192 exceeds `max_*_buffer_binding_size` limit 134217728
//! ```
//!
//! **Why this file exists rather than an extra line in `gpu_chunk.rs`.** That test
//! asserts every chunk is `<= max_buffer_size` and it was GREEN across the entire
//! crash, because it checks the same wrong limit the code did. A guard that reads its
//! expectation from the thing under test cannot fail with it. This one asserts against
//! `device.limits()` directly, so the two numbers have independent authors.
//!
//! Run: `cargo test -p facett-map --features wgpu --test storage_binding_budget -- --nocapture --test-threads=1`
//! (`--test-threads=1` because several `wgpu::Instance` bring-ups in one process hang
//! on this box — `kalm-5` in `workspace_nordisk/stinky`.)
#![cfg(feature = "wgpu")]
use facett_map::Way;
use facett_map::gpu::{GpuMapRenderer, build_gpu_geometry, ways_origin};
/// The ONE GPU gate for this crate's device-backed tests (`tests/common/mod.rs`):
/// a missing adapter becomes a SKIP row in the matrix and a panic under CI, never a
/// silent `ok`.
mod common;
fn one_road() -> Vec<Way> {
vec![Way::line(
1,
"road",
vec![(9.00, 47.00), (9.05, 47.02), (9.10, 47.00)],
egui::Color32::from_rgb(0xE8, 0xB0, 0x30),
6.0,
)]
}
#[test]
fn the_chunk_budget_never_exceeds_what_a_storage_binding_may_hold() {
let Some((device, queue)) = common::device_or_skip("the_chunk_budget_never_exceeds_what_a_storage_binding_may_hold", "the chunk budget vs this device's max_storage_buffer_binding_size — the z15 line_cull_bg panic")
else {
return;
};
let limits = device.limits();
let binding_cap = limits.max_storage_buffer_binding_size;
// The fixture the device would have to be able to bind for this test to mean
// anything: the two limits must actually differ, or the assertion below is
// satisfied by coincidence and would stay green with the defect restored.
assert!(
limits.max_buffer_size > binding_cap,
"this device reports max_buffer_size {} <= max_storage_buffer_binding_size {} — the two \
limits do not differ here, so this test cannot tell the fix from the defect and must not \
be read as a pass",
limits.max_buffer_size,
binding_cap,
);
let ways = one_road();
let origin = ways_origin(ways.iter()).expect("non-empty fixture has an origin");
let geo = build_gpu_geometry(ways.iter().map(|w| (w, w.style.lod as u32)), origin);
let mut r = GpuMapRenderer::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb);
r.upload_for_test(&device, &queue, &geo);
let budget = r.max_buffer_size();
println!(
"[storage-budget] device: max_buffer_size={} max_storage_buffer_binding_size={} \
| renderer chunk budget={}",
limits.max_buffer_size, binding_cap, budget,
);
assert!(
budget <= binding_cap,
"the chunk budget is {budget} B but a storage BINDING on this device may hold at most \
{binding_cap} B — the partition can emit a chunk that create_buffer accepts and \
create_bind_group then refuses, which is the z15 'line_cull_bg' panic",
);
// And the chunks the partition actually produced honour it, so the budget is not
// merely stored correctly but used.
//
// **The loop below iterates ZERO times, and says so.** It walks
// `chunk_buffer_bytes()`, the 24 B `LineList` hairline lane — which nothing draws
// and which `HAIRLINE_LANE` now switches off at the bake (stinky `mapdraw-4`), so
// there are no chunks to bound. Even when the lane was live it was vacuous here:
// `one_road()` is a three-point way, a few hundred bytes against a 134 217 728 B
// cap, so no iteration of it could fail, and reading it as proof that the partition
// respects the cap is exactly the hollow green `granska-25` named. It is kept
// because the budget VALUE above is what this file is for; the partition under a
// cap it can actually reach is proven by
// `a_line_payload_over_the_storage_cap_is_split_into_bindable_chunks` below, on the
// LIVE quad lane and on a fixture whose premise is asserted.
for (i, (geom_b, meta_b, compact_b)) in r.chunk_buffer_bytes().into_iter().enumerate() {
for (what, n) in [("geom", geom_b), ("meta", meta_b), ("compact", compact_b)] {
assert!(
n <= binding_cap,
"chunk {i}'s {what} buffer is {n} B, over the {binding_cap} B storage-binding cap",
);
}
}
}
/// Enough zig-zag road to push the **pretty-line** stream past a storage binding.
///
/// `LineVertex` is 48 B and the quad baker emits **6** of them per segment (MEASURED:
/// 1 900 ways x 150 segments baked 1 710 000 quad vertices), so a segment costs 288 B and
/// 134 217 728 B is 466 034 segments. `WAYS × (PTS - 1)` is sized ~1.25× over that, and
/// the test ASSERTS the resulting byte total actually clears the device's cap rather than
/// assuming it — a fixture that quietly fell under the cap would turn the whole check
/// back into the vacuous loop it replaces. That assertion has been seen RED: the first
/// sizing here used 12 vertices per segment, baked 82 080 000 B, and the run stopped on
/// "the fixture ... is UNDER this device's 134 217 728 B storage-binding cap" instead of
/// reporting a green it had not earned.
const WAYS: usize = 3_900;
const PTS: usize = 151;
fn a_country_of_roads() -> Vec<Way> {
let c = egui::Color32::from_rgb(0xE8, 0xB0, 0x30);
(0..WAYS)
.map(|w| {
let lon0 = 9.0 + (w % 100) as f64 * 0.01;
let lat0 = 47.0 + (w / 100) as f64 * 0.01;
let pts: Vec<(f64, f64)> = (0..PTS)
.map(|p| {
// A zig-zag: consecutive points always differ, so the baker cannot
// dedupe the segment away and the byte count is predictable.
(lon0 + p as f64 * 1e-4, lat0 + if p % 2 == 0 { 1e-4 } else { -1e-4 })
})
.collect();
Way::line(w as i64 + 1, "road", pts, c, 6.0)
})
.collect()
}
/// **The partition, on a payload that actually reaches the cap** — the arm the
/// three-point fixture above could never be.
///
/// The z15 crash was not in the 24 B hairline lane the older loop reads; it was
/// `line_cull_bg`, a 48 B `LineVertex` binding of 198 672 192 B, and until now nothing
/// could even read those bytes (`line_chunk_buffer_bytes` is new for this test). So this
/// asserts three things in order, and the first two exist to stop the third going hollow:
///
/// 1. the baked pretty-line stream is LARGER than this device's storage-binding cap —
/// the fixture premise, restated as an assertion;
/// 2. the partition therefore had to split it, `line_chunk_count() >= 2`;
/// 3. every one of those bindings is within the cap.
///
/// Seen RED: with `min(max_buffer_size, max_storage_buffer_binding_size)` at
/// `gpu/mod.rs` reverted to `max_buffer_size` — the defect — this fixture produces ONE
/// 216 MiB line chunk and wgpu refuses the bind group with the verbatim 2026-08-04
/// message. With the `min` in place it produces two, both under the cap.
#[test]
fn a_line_payload_over_the_storage_cap_is_split_into_bindable_chunks() {
const CHECK: &str = "a_line_payload_over_the_storage_cap_is_split_into_bindable_chunks";
let Some((device, queue)) = common::device_or_skip(
CHECK,
"a pretty-line payload larger than max_storage_buffer_binding_size splits into \
bindable chunks — the lane the z15 line_cull_bg panic was in",
) else {
return;
};
let limits = device.limits();
let binding_cap = limits.max_storage_buffer_binding_size;
assert!(
limits.max_buffer_size > binding_cap,
"this device reports max_buffer_size {} <= max_storage_buffer_binding_size {binding_cap} \
— the two limits do not differ here, so this test cannot tell the fix from the defect",
limits.max_buffer_size,
);
let ways = a_country_of_roads();
let origin = ways_origin(ways.iter()).expect("non-empty fixture has an origin");
let geo = build_gpu_geometry(ways.iter().map(|w| (w, w.style.lod as u32)), origin);
let line_bytes =
(geo.line_vertices.len() * std::mem::size_of::<facett_map::gpu::LineVertex>()) as u64;
println!(
"[storage-budget] fixture: {} ways x {PTS} pts -> {} LineVertex = {line_bytes} B \
(binding cap {binding_cap} B, buffer cap {} B)",
ways.len(),
geo.line_vertices.len(),
limits.max_buffer_size,
);
// (1) THE PREMISE. Without this the loop below is the vacuous one it replaces.
assert!(
line_bytes > binding_cap,
"the fixture baked {line_bytes} B of pretty-line geometry, which is UNDER this \
device's {binding_cap} B storage-binding cap — the partition is never forced to \
split and every assertion after this one is vacuous. Raise WAYS/PTS.",
);
// …and under the buffer cap, or the defect would fail at create_buffer instead of at
// create_bind_group and this would be testing the wrong refusal.
assert!(
line_bytes < limits.max_buffer_size,
"the fixture is {line_bytes} B, over max_buffer_size {} — the defect this guards \
is a binding refusal one call AFTER a create_buffer that succeeded",
limits.max_buffer_size,
);
let mut r = GpuMapRenderer::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb);
r.upload_for_test(&device, &queue, &geo);
let chunks = r.line_chunk_buffer_bytes();
let widest = chunks.iter().map(|c| c.0.max(c.1).max(c.2)).max().unwrap_or(0);
println!(
"[storage-budget] {} line chunk(s), widest binding {widest} B, budget {} B",
chunks.len(),
r.max_buffer_size(),
);
// (2) it split.
assert!(
chunks.len() >= 2,
"{line_bytes} B of pretty-line geometry against a {binding_cap} B binding cap \
produced {} chunk(s) — the partition did not split a payload that cannot be bound \
whole, which is the z15 panic",
chunks.len(),
);
// (3) every binding fits.
for (i, (src_b, meta_b, compact_b)) in chunks.into_iter().enumerate() {
for (what, n) in [("src", src_b), ("meta", meta_b), ("compact", compact_b)] {
assert!(
n <= binding_cap,
"line chunk {i}'s {what} buffer is {n} B, over the {binding_cap} B \
storage-binding cap — verbatim the 2026-08-04 refusal: \
'Buffer binding 1 range 198672192 exceeds max_*_buffer_binding_size limit 134217728'",
);
}
}
}