docs(meta): body-map-viewer measurement results + brief appendix (T-1177/T-1178/T-1154/T-1179/T-1180)

Four results docs under docs/workshops/body-map-viewer/measurements/ and the
brief's measurement appendix rewritten from owners/effort to MEASURED
headlines: settled hydrology viable (~24 ms/body, 273 bodies ~0.8 s);
parallel derive throughput holds 330K-8.3M cells (~64 ms / ~1.8 s); block+tile
cost-cleared (deepest step 83K cells ~17 ms); PNG-per-field smallest and
fastest with the tagged-envelope migration foreclosed by byte math (21x the
ceiling at the smallest size); texture upload a non-issue (8.3M px ~3.2 ms,
L8 4-9x cheaper). All numbers post-date the background-load closure with
stability re-runs. The workshop round-1 gate is satisfied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 19:23:02 +02:00
co-authored by Claude Fable 5
parent 53a4c65d62
commit 473b654787
5 changed files with 1320 additions and 7 deletions
@@ -216,15 +216,19 @@ necessary (workshops are not closed early on partial convergence).
tickets drafted by the lead, reviewed by SI; Qatux produces
`workshop-outcomes.md` and diagram updates.
## Pre-workshop measurement appendix (owners; numbers land here before round 1)
## Pre-workshop measurement appendix — MEASURED (2026-07-25, 16-core / Rayon 14 / RX 9070, release builds; all numbers post-date a background-load closure with stability re-runs — details + repro commands in each linked doc)
| # | Measurement | Owner | Effort |
**The gate is satisfied: ①–④ measured, ⑤ measured. Round 1 may start.**
| # | Ticket | Headline result | Doc |
|---|---|---|---|
| ① | Equilibrium hydrology solver prototype + bench (512×256 and 4K-class) incl. cliff representation output | Dudley | 12 d |
| ② | Per-gridunit derive at real canvas sizes (330K and 8.3M cells, production par_iter path — no extrapolation) | Dudley | 0.5 d |
| ③ | T-1154 block/tile rung costs (now unblocked) — prices the ladder's bottom | Dudley | 0.5 d |
| ④ | Wire-size table for candidate encodings (raw/bit-packed/RLE/PNG; MessagePack round-trip on one real canvas) | Araminta + Dudley | 0.5 d |
| ⑤ | Godot ImageTexture upload cost per step-cross (330K8.3M px) | Stig | 0.25 d |
| ① | T-1177 | **Settled hydrology VIABLE**: 512×256 ~24 ms/body; all 273 bodies Rayon-parallel ~0.70.8 s; 8.3M cells ~5.7 s single-thread (no production path needs it synchronously). Cliff representation: dominant height + `channel_depth` + `cliff_edge` flag. Finding: gorge carving is structurally RARE (priority-flood finds true spill levels; zero carved cells at production scales — needs chained-basin geometry). Solver: priority-flood + Dijkstra overflow, pure function, determinism-proved. | [t1177](measurements/t1177-hydrology.md) |
| ② | T-1178 | **Parallel throughput HOLDS at scale** (the T-1143 extrapolation gap, closed): ~190220 ns/cell parallel flat from 330K→8.3M cells; 330K canvas ~64 ms, 2.07M ~0.4 s, 8.3M ~1.71.8 s (7.58.8× speedup); single-thread flat ~1.65 µs/cell, matches prior baseline within 8%. Cross-validated on three bodies incl. real courses. Measured through the real `build_district_window_layer`. | [t1178+t1154](measurements/t1178-t1154-derive-bench.md) |
| ③ | T-1154 | **Block GO, Tile GO on cost** (~1.81.9 µs/cell, same band as District/Quarter). Octave cutoff buys ZERO below District spacing (verified: wavelength table bottoms at 128 m). Deepest-step realistic canvas (216×384 m @ 1 m = 83K cells): **~17 ms parallel — trivially interactive**. `voxel_relief` already in the Atlas path; `voxel_mosaic` untouched by it. Real gates are wire carrier + D-226(d) canvas policy, not cost. | [t1178+t1154](measurements/t1178-t1154-derive-bench.md) |
| ④ | T-1179 | **PNG-per-field wins everywhere** — smallest AND fastest: 330K canvas = 638 KB (0.32× raw, 5.4/3.6 ms enc/dec) vs raw rmp 1.99 MB; RLE loses to raw (elev/temp near-noise per cell). **Tagged-envelope migration foreclosed as necessary**: best case is 21× the 30 KB ceiling at the SMALLEST size (563× at 8.3M). Corrected density: 6.00 B/cell measured raw. | [t1179](measurements/t1179-wire-table.md) |
| ⑤ | T-1180 | **Upload cost is a non-issue**: worst case (8.3M px RGBA8 create) ~3.2 ms median, ~2.42.9 ms frame-delta spike — never near the 16.6 ms budget. **L8 is 49× cheaper at every size** (~0.50.7 ms at 4K). Prefer `texture.update` reuse on step-cross; use L8 for single-channel planes. Windowed-only measurement (headless renderer fakes uploads). | [t1180](measurements/t1180-imagetexture.md) |
Bench code (all `#[ignore]`d release tests): `server/src/atlas/hydrology_equilibrium.rs` + `server/tests/hydrology_equilibrium_bench.rs`, `server/tests/bmv_gridunit_bench.rs`, `server/tests/wire_encoding_bench.rs`.
## Input Documents
@@ -0,0 +1,393 @@
---
title: "T-1177 — equilibrium hydrology solver prototype + bench (512×256 and 4K-class) incl. cliff representation (measurement ①)"
workshop: body-map-viewer
status: complete
owner: Dudley
---
# T-1177 — equilibrium hydrology solver prototype + bench
Measurement ① of the [body-map-viewer workshop
brief](../body-map-viewer-workshop-brief.md)'s pre-workshop appendix, per
[tyre-implications.md §3](../tyre-implications.md) item 1 — the highest-priority,
biggest-unknown measurement, named as the one that could invalidate per-body-open
settled hydrology outright if costs land in seconds, not milliseconds. Also the
mandatory output for **red flag 4** (the cliff/multi-height data-model gap).
## Environment note (read first)
A background Factorio process was running on this machine for part of this
session and may have starved CPU before the coordinator's mid-session notice.
**No benchmark timing in this document predates the coordinator's closure
notice.** Concretely: module compilation, unit-test authoring, and the
fixture-design investigation (see "Gorge carving — the honest story" below)
all happened before and after the notice, but every `ms`/`ns` figure reported
below was captured **after** the notice, with `/proc/loadavg` checked
immediately before each timed run (observed 1-minute load between 0.5 and 5.6
throughout the timed portion of this session — never during the
Factorio-shutdown settling spike). The three headline cases (512×256,
768×432, 3840×2160) were each run twice — once during the initial measurement
pass, once as a final stability re-run — and landed within ~1-3% of each
other both times (see the "Stability re-run" row in the results table), so
there is no early-slow/later-fast delta to misattribute to warm-up or
optimization.
## What was built
New module: `server/src/atlas/hydrology_equilibrium.rs` (registered in
`server/src/atlas/mod.rs`). New bench file:
`server/tests/hydrology_equilibrium_bench.rs`. No other files touched.
`solve(elevation, width, height, sea_level, climate) -> HydrologyResult` is a
pure function of its four arguments (deterministic per D-010) — no RNG, no
wall-clock, no `HashMap` iteration anywhere in the call graph. It is **not** a
stateful simulation: there is no tick loop, no iterative erosion-over-time
model, no rainfall accumulation across steps. One elevation field in, one
settled hydrology result out, same every time.
### Algorithm (priority-flood family, Barnes/Planchon-Darboux class)
1. **Priority-flood fill** (`priority_flood_fill`): a min-heap seeded at
every below-sea-level cell AND every top/bottom grid-edge cell (mirroring
`drainage.rs`'s own row-bounded D8 walk — the equirectangular grid has no
pole neighbor, so those rows are boundary the same way a coastline is).
Standard "flood from the outside in": repeatedly pop the lowest-water-level
frontier cell, raise each unvisited neighbor to
`max(neighbor_original, popped_level)`. `O(n log n)`, one pass, versus
`drainage.rs`'s existing `depression_fill` which is an iterative relaxation
(up to 10 full-grid passes) of a *similar* problem but does not expose
basin membership or spill points — this module needed both, so it
re-derives the fill with a heap that tracks them, rather than layering
basin-detection on top of that existing black-box result.
2. **Lake cells + basin grouping**: any cell where `filled > original` is a
lake cell; contiguous (8-connected) lake cells are grouped into basins via
BFS flood-fill (`label_lake_basins`), ids assigned in row-major discovery
order for determinism.
3. **Spill point + spill level per basin**: the basin-boundary cell whose
OUTSIDE neighbor has the lowest *original* (unfilled) elevation — i.e. the
genuine topographic saddle, not merely "any lake cell touching non-basin
terrain" (an early draft ranked by the boundary cell's own *filled*
elevation, which degenerates to an arbitrary tie-break on a uniformly-filled
lake, since every boundary cell of a flat lake shares the same filled
value — see "Gorge carving" below for how this surfaced). The spill level
is that spill cell's own `filled` value — the lake's water surface at
overtopping.
4. **Endorheic classification** (`is_endorheic`, moisture-governed, see
below): large + dry basins are declared closed water-cycle sinks
(`BasinOutcome::Endorheic`, mapped to `RIVER_DOWNSTREAM_TERMINAL` for
consumers — see "Reuse of the TERMINAL sentinel").
5. **Overflow path** (`cheapest_overflow_path`, Dijkstra/A*-family, same
`BinaryHeap<Reverse<(cost, idx)>>` pattern `road_graph.rs`'s own `astar`
uses): from the spill point, cost = cumulative elevation carved above the
basin's own spill level (0 for any step at or below it). Terminates at the
first cell that is below sea level (`Sea`), belongs to a *different*
already-resolved basin (`Basin(id)` — the chaining case), or is open low
ground not otherwise claimed (`EdgeUnreachable`, the lake-fill analogue of
`drainage::RIVER_DOWNSTREAM_EDGE_DRAIN`). Basins are processed in ascending
spill-level order so a lower basin is always resolved before a higher one
could chain into it.
6. **Carving**: every path cell whose elevation exceeds the basin's spill
level is cut down to it — `channel_depth_scaled[cell] = original[cell] -
spill_level`, `cliff_edge[cell] = true` — but ONLY when the path's peak
flow accumulation (reusing a D8 flow-direction/accumulation pass over the
*filled* surface, same algorithm as `drainage.rs`) clears
`RIVER_THRESHOLD = 200` (mirroring `drainage::RIVER_THRESHOLD`): a
trickling overflow through near-flat ground doesn't get a canyon.
### Reuse of the TERMINAL sentinel
The ticket flagged `RIVER_DOWNSTREAM_TERMINAL` (`body_world_state.rs`,
value 10) as "reserved, unused in round 1 — future endorheic basin." Confirmed
by reading `river_course.rs::build_edges` (T-1170): it already special-cases
`sentinel == RIVER_DOWNSTREAM_TERMINAL` as a no-op skip, so a consumer that
starts emitting it does not break any existing code path — this prototype's
`BasinOutcome::Endorheic` is exactly the case that sentinel was reserved for,
and mapping to it at the wire-integration stage (not built in this
prototype — see "What this prototype does NOT do") is additive, not a
migration.
## Measured numbers
**Hardware:** 16 logical cores (Intel Core i9-9900K @ 3.60GHz), Rayon default
pool (`std::thread::available_parallelism() == 16`, confirmed in the parallel
bench's own printed output). Release build (`cargo build --release`), bench
invocation:
```bash
cd server
cargo test --release --test hydrology_equilibrium_bench <test_name> -- --ignored --nocapture
```
Cold = first `solve()` call in the process; warm = second call, same input,
same process (allocator/cache warm). All entries below are **MEASURED**, none
extrapolated.
| Grid | Cells | Cold total | Cold ns/cell | Warm total | Warm ns/cell | Basins | Lake cells | Carved cells |
|---|---|---|---|---|---|---|---|---|
| 512×256 (real GJ1c) | 131,072 | 24.3624.97 ms | 185.8190.5 | 23.6124.58 ms | 180.1187.5 | 68 (66 overflow, 2 endorheic) | 4,623 | 0 |
| 768×432 (synthetic, ~330K) | 331,776 | 72.3272.97 ms | 218.0219.9 | 69.0369.98 ms | 208.1210.9 | 103 (all overflow) | 16,617 | 0 |
| 3840×2160 (synthetic, ~8.3M) | 8,294,400 | 5,671.845,711.82 ms | 683.8688.6 | 5,699.316,065.91 ms | 687.1731.3 | 102 (all overflow) | 412,700 | 0 |
Each grid's range is the initial measurement pass and the final stability
re-run (both post-Factorio-closure, low load throughout) — the two numbers
per cell agree within ~1-3%, confirming stability, not a warm-up or
optimization effect.
**Parallel throughput (273 independent bodies, 512×256 each, Rayon
`par_iter`)** — the real production shape (hydrology is solved once per body,
not internally parallelized within one solve; see "Why solve() isn't
internally parallelized" below):
| Run | Total | ms/body |
|---|---|---|
| Initial | 826.30 ms | 3.03 |
| Stability re-run | 718.18 ms | 2.63 |
**273 bodies' worth of 512×256 hydrology solves complete in well under one
second (0.70.8 s) on this 16-core machine.**
### Why `solve()` isn't internally parallelized
Both the priority-flood fill and the Dijkstra overflow search are globally
sequential by construction — a shared min-heap with a strict pop order, same
as `road_graph.rs`'s own `astar` (also unparallelized, same `BinaryHeap`
pattern). This is architecturally identical to why `layer_proxy.rs`'s
row-chunked `par_iter` win doesn't apply here: that speedup comes from each
*cell* being an independent pure function (D-227); hydrology's basins and
paths are NOT independent of each other within one body (a basin's outcome
can depend on a lower basin having already been resolved — the chaining
case). What DOES parallelize cleanly, and is exactly the production shape, is
solving **different bodies'** hydrology concurrently — measured above.
### Determinism proof
`server/tests/hydrology_equilibrium_bench.rs::determinism_at_330k_cells`
solves the 768×432 synthetic grid twice and asserts the full result
(`filled_scaled`, `channel_depth_scaled`, `cliff_edge`, and every basin's
`cells`/`spill_level_scaled`/`spill_cell`/`outcome`) is byte-identical across
both solves. Passes (`cargo test --release --test hydrology_equilibrium_bench
determinism_at_330k_cells -- --ignored --nocapture`). The module's own unit
tests (`server/src/atlas/hydrology_equilibrium.rs`, 15 tests, `cargo test
--lib atlas::hydrology_equilibrium`) additionally cover determinism at
smaller synthetic grids (64×32, 96×48) including basins that classify
Overflow and basins that classify Endorheic.
## The endorheic criterion (explicitly tunable)
```rust
const ENDORHEIC_AREA_FLOOR: f64 = 0.004; // ~0.4% of body surface
const ENDORHEIC_MOISTURE_CEILING: i32 = 60; // 0-100 scale
fn is_endorheic(area_frac: f64, moisture_q: i32) -> bool {
area_frac >= ENDORHEIC_AREA_FLOOR && moisture_q <= ENDORHEIC_MOISTURE_CEILING
}
```
A basin is declared endorheic (closed water cycle, no outflow) when it is
BOTH large enough that its own evaporative surface plausibly balances its
catchment's inflow (bigger basin = more evaporating surface per unit of
catchment — Great-Salt-Lake/Caspian-style) AND the body isn't wet enough that
evaporation is unlikely to keep pace (`moisture_q`, mirroring
`district_profile::derive_moisture_q`'s hydrosphere-derived ceiling before
the per-district spatial gradient — one body-wide value, not a per-basin
value; see "does NOT do" below). This is a **first-pass heuristic, not a
calibrated physical model** — the two constants and the boolean-AND shape are
placeholders. On the GJ1c body at moisture_q=55, 2 of 68 basins classified
endorheic; on the two synthetic multi-octave grids at moisture_q=55, zero did
(the synthetic terrain's basins are all smaller/shallower than
`ENDORHEIC_AREA_FLOOR` at those grid resolutions). Unit tests
(`is_endorheic_large_dry_basin_is_endorheic`,
`is_endorheic_tiny_basin_always_overflows`,
`is_endorheic_wet_body_always_overflows`,
`large_dry_basin_classifies_endorheic`) lock the criterion's *shape* (both
gates matter, area alone or moisture alone isn't enough) without pretending
the specific constants are calibrated — that calibration is future work
(parallel to Q-123, which already tracks provisional-pending-calibration
magnitudes for the district moisture gradient this module's `ClimateInputs`
borrows from).
## Gorge carving — the honest story (cliff representation input, red flag 4)
**All three production-scale benches (512×256, 768×432, 3840×2160) report
zero carved gorge cells.** This is a genuine, investigated measurement
result, not a code defect — verifying it required a substantial fixture
investigation (documented in the module's own test comments,
`server/src/atlas/hydrology_equilibrium.rs`, the "Gorge carving" test
section) that surfaced a structural property of priority-flood itself, worth
stating plainly for the workshop:
**Priority-flood finds the TRUE global minimum enclosing rim of a basin,
swallowing any monotonically-non-decreasing connected run of cells into the
same lake and adopting that run's own peak as the basin's real spill level.**
Since the overflow search's carving threshold is that SAME spill level, the
first cell the search reaches outside the lake is — by construction of how
the spill level was computed — never higher than spill level. A single
sealed basin's own rim, however jagged, therefore **always carves zero**
under this algorithm: the water simply rises to meet whatever the true
lowest crossing is. Several `solve()`-level fixtures (a basin behind a single
ridge with a saddle; a basin behind a saddle-then-hump; a basin chained into
a second, higher-floor basin via a monotonic ramp) were built and each
converged on zero carved cells for exactly this reason, once traced through.
Genuine carving is only possible in a **narrower geometry**: two
independently-sealed basins (each with its own valid, separately-computed
rim) connected by a corridor that is (a) a single cell wide in *both* grid
dimensions — not a multi-cell monotonic run the flood can absorb into either
basin's own lake — and (b) higher than *both* basins' independent rims (else
the two basins simply flood-merge into one, and merging means the "gorge"
was never real, just the wrong basin count). This is real terrain (a narrow
saddle connecting two separately-perched valleys, each already full) but
comparatively rare at continental (512×2563840×2160) working-grid
resolution — consistent with all three production-scale benches finding
zero.
Given the difficulty of constructing an end-to-end `solve()` fixture for
that narrow geometry without also accidentally merging or absorbing the
basins (four fixture iterations attempted, each correctly diagnosed as
"basins merged" or "spill level absorbed the connector" rather than "bug"),
the carving *mechanism itself*`cheapest_overflow_path`'s Dijkstra search
and the `original[cell] - spill_level` carving arithmetic — is verified
directly with hand-constructed inputs instead
(`cheapest_overflow_path_finds_the_true_minimum_crossing`,
`cheapest_overflow_path_prefers_lower_total_cost_over_shorter_path`,
`carving_arithmetic_matches_original_minus_spill_level`, all passing). These
confirm: the search correctly routes through a genuinely-lower gap instead of
an impassable wall; cost accumulates correctly across multiple
above-spill-level cells in sequence (not just a single-cell threshold); and
the exact formula `solve()` uses to populate `channel_depth_scaled`/
`cliff_edge` is arithmetically correct in isolation.
**Practical read for the workshop:** at the working-grid resolutions this
system actually runs at, gorge carving will be a **rare, not a routine**
event — most basins settle by simple lake-fill, not canyon-cutting. The data
model still needs to represent it when it happens (below), but the
cliff/multi-height case is the exception, not the common path, which may
ease the "how often does the payload need the vertical-structure field"
half of red flag 4's cost question (a wire-frequency question for
measurement ④, not answered here).
### The cliff representation proposal
Grounded in what the solver actually computes (`channel_depth_scaled` +
`cliff_edge`, both already per-cell fields on `HydrologyResult`):
**Dominant height + channel-depth field + cliff-edge flag** (the first option
red flag 4 named, and the one this solver's own output shape falls out of
directly, not chosen independently of the measurement):
- `elevation` (existing field) carries the **rim/dominant height** — the
gridunit's visible/walkable surface for anything that isn't inside the
carved channel. No change to the existing single-height payload contract
for the common (non-gorge) case.
- `channel_depth: u16` (or similar, quantized) — 0 everywhere except carved
gorge cells, where it is the vertical drop from `elevation` down to the
channel floor. This is a **direct, non-lossy carry** of
`channel_depth_scaled` — no re-derivation needed downstream, the solver
already computes exactly this number.
- `cliff_edge: bool` (or foldable into a 1-bit flag alongside existing
classification bytes) — marks the gridunit as a rim/discontinuity cell, so
the client map-art function knows to render a cliff-face style transition
rather than a smooth gradient at that gridunit's boundary. Also a direct
carry of the solver's own `cliff_edge` field.
**Why not min/max height pair:** a min/max pair loses the *shape* of the
transition (is the low value a point drop at one edge, or does it span the
whole gridunit?) that `channel_depth` + `cliff_edge` preserves implicitly (a
cliff-edge-flagged gridunit's channel floor is `elevation - channel_depth`,
and the flag itself tells the renderer this gridunit is an edge, not an
interior channel cell — interior channel cells one gridunit further in would
carry the depth without the edge flag, letting the map-art function
distinguish "this is the canyon wall" from "this is the canyon floor,
already below the general terrain"). Min/max is also a less direct fit to
what the solver computes — it would require deriving two synthetic heights
per gridunit from the single `channel_depth_scaled` number the solver
actually produces, an extra derivation step for no informational gain over
carrying the depth directly.
**Scope note (does not resolve red flag 4 alone):** this proposal answers
"what data does a carved gorge gridunit carry" — it does NOT resolve whether
this lives on the Atlas per-gridunit payload at Phase-4 scope (a wire/schema
decision for Araminta's ③/④ work and the workshop's synthesis round) or is
deferred to Phase-5 in-world geometry with only a "steep" classification
surfacing on the map. Given carving is measured as **rare** at working-grid
resolution (above), the wire-cost argument for including a rarely-populated
optional field is weaker than red flag 4's framing assumed — worth
surfacing as new information for that decision, not just cost data for a
decision already assumed necessary.
## What this prototype does NOT do
- **No stateful simulation.** No tick loop, no time-stepped erosion, no
rainfall-accumulation-over-time model. `solve()` is called once per body
(or per working-grid downsample) and produces a settled end-state — this
is deliberate (the workshop premise: "deterministic equilibrium... a
settled end-state, pure function of seed + moisture/climate"), not a
scope gap, but stated explicitly since it bears directly on the
"geological backstory computed at generation time" framing.
- **No per-basin moisture/climate lookup.** `ClimateInputs::moisture_q` is a
single body-wide value (mirroring `derive_moisture_q`'s hydrosphere-derived
ceiling, before the per-district latitude/elevation/continentality
gradient). A real implementation would likely want the endorheic
criterion to read each basin's own local climate (a basin near the pole
vs. the equator on the same body plausibly differs), which requires
wiring in per-district or per-region climate context
(`BodyWorldState.districts`/`regions`) — not built here.
- **No wire/schema integration.** `HydrologyResult`/`Basin`/`BasinOutcome`
are prototype-only types, not wired into `RiverNetwork`,
`DrainageBasin`, or any `AtlasLayerResponse` payload. The `TERMINAL`
sentinel reuse (above) is confirmed safe/additive but not implemented as
a wire integration — that's downstream work once the workshop rules on
the wire contract (measurement ④, Araminta's track).
- **No production-scale gorge carving observed.** As detailed above, all
three production-scale benches carved zero cells — the carving code path
is verified correct via direct unit tests of the mechanism, not via an
end-to-end production-scale example. Real terrain (actual planetary
heightmaps beyond GJ1c) may or may not produce the narrow geometry that
triggers carving more often; this prototype did not survey other bodies.
- **No re-run against `drainage.rs`'s existing `depression_fill`/basin
labeling for cross-validation.** This module's priority-flood is a
structurally different (heap-based, single-pass) implementation of a
similar problem to `drainage.rs`'s iterative relaxation; the two were not
compared cell-for-cell against each other on the same input. They are not
required to agree (different purposes — `drainage.rs`'s fill feeds D8 flow
direction for river extraction; this module's fill feeds basin/spill
detection), but a reader should not assume they'd produce identical
filled surfaces.
- **No chunk-boundary/step-boundary decomposition.** The solver runs on a
single contiguous elevation grid start-to-finish (matching how
`drainage::analyze` is called today — once per body, on the Layer-1
512×256 working grid). Whether/how hydrology decomposes across the
step-ladder's per-step canvases (premise 8: "step boundaries = compute-
chunk boundaries") is a synthesis-round question, not addressed here —
the measured costs above are for a full single-grid solve at each listed
size, not a chunked partial re-solve.
- **`RIVER_THRESHOLD` reused, not re-derived per body.** The gorge-carving
flow-accumulation gate uses the same fixed `RIVER_THRESHOLD = 200`
`drainage.rs` uses globally — not the per-district `river_threshold`
DistrictProfile carries downstream (D-239 §1). Fine for a prototype
measuring solver cost; a production integration should probably use the
same per-district value other consumers do.
## Verdict
**Per-body-open settled hydrology is viable.** Headline numbers: 512×256
solves in ~24 ms single-threaded; the full 4K-class 8.3M-cell grid solves in
~5.7 s single-threaded (a genuine, not extrapolated, "computer catches fire"
ceiling case — but note no production path actually derives hydrology at
8.3M cells synchronously; see below); and the realistic production shape —
273 independent bodies at the real 512×256 working-grid size, run in
parallel across the Rayon pool — completes in well under a second (0.70.8
s). This closes the exact gap the T-1143 planetary rung died of: the
biggest-unknown, highest-priority measurement is now a real number, not an
extrapolation, and it does not invalidate the "settled hydrology" premise.
**Caveat worth carrying into the workshop:** the 8.3M-cell single-solve
number (~5.7 s) is a genuine cost that would matter if any single request
path needed hydrology re-solved synchronously at that size on demand. Given
this module's `solve()` isn't currently wired into any request path (see
"does NOT do" above), whether that number is ever actually paid at request
time — or only ever paid once per body, offline/precomputed, the same way
`drainage::analyze` already runs once per body today — is a caching/
architecture question for the workshop's cache-tier synthesis (Dudley's
question 4 / red flag 2), not a solver-cost question this measurement can
settle alone.
@@ -0,0 +1,441 @@
---
title: "T-1178/T-1154 — per-gridunit derive bench at real canvas sizes + block/tile rung costs (measurements ②/③)"
workshop: body-map-viewer
status: complete
owner: Dudley
---
# T-1178/T-1154 — derive bench at real canvas sizes + the ladder's bottom rungs
Measurements ②/③ of the [body-map-viewer workshop
brief](../body-map-viewer-workshop-brief.md)'s pre-workshop appendix, per
[tyre-implications.md §3.2](../tyre-implications.md) item 2 and item 4. This is
the anti-extrapolation gap the T-1143 planetary-rung post-mortem named by name:
does the row-chunked `par_iter` throughput measured at the shipped 4,096-cell
window ceiling (`docs/architecture/atlas-zoom-ladder-t1143.md` §7: 1.785
µs/cell District cutoff, 1.454 µs/cell orbital, 0.617 ms/served-window) HOLD
at real step-canvas sizes (330K8.3M cells), and what does the ladder's
un-costed bottom (block/tile) actually cost. Every number below is labelled
**MEASURED** or **ARITHMETIC** — nothing is extrapolated from a smaller
measured size to a larger unmeasured one.
## Environment note (read first)
A background Factorio process was running on this machine for part of this
session and may have starved CPU before and during initial harness
compilation. **It was closed partway through this session, before any timing
measurement in this document was captured or kept.** Concretely:
- Every `ms`/`ns` figure in this document either post-dates the closure, or is
an explicit **stability re-run performed after it** (see "Stability
re-runs" below) — no pre-closure number is reported anywhere in this doc.
- Load average was watched (`uptime`) before every timed run; each run below
was started only once the 1-minute load average had settled to single
digits on this 16-core machine (typically 1.35.0), never during the
post-closure settling spike (which peaked ~25 immediately after closure and
fell within ~2 minutes).
- Per the coordinator's instruction: any early-slow/later-fast delta observed
during this session is **not reported as a chunking or warm-up finding**
it is attributed to environmental noise and excluded. The findings below
(parallel throughput holding flat from 330K→8.3M) are confirmed by
re-running the headline cases a second time on the quiet machine, not by a
single early/late comparison.
- The baseline figures this document compares against (1.785 µs/cell
District, 1.454 µs/cell orbital, 0.617 ms/served-window,
`atlas-zoom-ladder-t1143.md` §7) were measured in an **earlier session**
under unknown load conditions. Where this session's quiet-machine numbers
come out *faster* than that baseline (they do, substantially — see below),
that is flagged explicitly as an environment-conditions caveat, not claimed
as a genuine speedup from any code change. No code in the derive path
changed between sessions.
## What was measured
**Body/fixtures:** two independent fixtures, deliberately not just one:
1. **Synthetic gradient body** (`bench_hm()`/`bench_ta()`, same fixture
`zoom_ladder_bench.rs` uses) — 128×64 working grid, deterministic smooth
gradient, no PNG I/O. Body id `"bench"`, seed
`SeedChain::root(99).derive(SeedDomain::Body, 1)`. Used for every sweep
below except the two crosschecks.
2. **Real committed GJ1c heightmap** (`wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png`,
downsampled to the production 512×256 working grid, real river network via
`drainage::analyze`), seed `SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 7)`
— used for one 330K-cell cross-check to confirm the synthetic-fixture
numbers are not an artifact of a trivial gradient or an empty river
network. The window is centred on a real GJ1c river cell (not the world
origin — an origin-centred window on GJ1c measured `courses_in_window=0`
on a first attempt and was corrected; see the bench source's doc comment).
**Hardware:** 16 logical cores (Intel Core i9-9900K @ 3.60GHz),
`std::thread::available_parallelism() == 16`, no `RAYON_NUM_THREADS` override
— Rayon's global default pool uses all 16 (`rayon::current_num_threads()`
confirmed `16` in every parallel run's own printed output, reported inline
per-run below rather than assumed).
**Code:** new file `server/tests/bmv_gridunit_bench.rs` (per the batch
instruction — `server/src/atlas/mod.rs` untouched, `zoom_ladder_bench.rs`
untouched, this workshop-gate pass kept in its own file since it measures a
different SHAPE question than that file's fixed-4,096-cell sweeps). All
benches `#[ignore]`d release tests, `cargo test --release --test
bmv_gridunit_bench -- --ignored --nocapture`.
**Invocation used for every number below** (run individually per test name,
not `-- --ignored` for the whole file at once, so each run's wall time isn't
diluted by unrelated tests still warming up):
```bash
cd server
cargo test --release --test bmv_gridunit_bench <test_name> -- --ignored --nocapture
```
---
## T-1178 (Measurement ②) — does par_iter throughput hold at real canvas sizes?
### The `n`-cap problem — what "production path" means here
`build_district_window_layer`'s CALLER (`handle_atlas_request` via
`clamp_window_n_v2`) hard-clamps `n` so the derived cell grid never exceeds
`WIRE_CAP_CELLS = 4,096` — **a real client can never request a 330K-cell
window over the wire, by design** (D-226 T-1124 §2, the windowed-family
ceiling). So "run it through the production path at 330K/8.3M cells" cannot
mean "send an `AtlasLayerRequest` for that size" — no such request is legal.
Confirmed by direct read this session
(`server/src/atlas/layer_proxy.rs:1515-1628`): `build_district_window_layer`
itself has **no internal clamp** — the clamp lives one layer up, in the
request handler. So this measurement calls the actual, unmodified
`build_district_window_layer` function directly — same signature, same
row-chunked `into_par_iter()` loop (`layer_proxy.rs:1564-1586`), same
`derive_window_cell`/`scatter_row` internals, same `derive_at_metres` calls —
at an `n` no wire request could carry. This is exactly what the ticket
anticipates ("bench the underlying chunked loop at those counts and say
exactly what you ran").
### Square vs. rectangular canvas
`build_district_window_layer` only derives SQUARE `side×side` grids (`n` is a
single extent — no rectangle parameter exists). The named real step-canvas
shapes are 16:9 rectangles. Two measurements were taken per cell-count
target:
1. **Square, through the real `build_district_window_layer` function**
(nearest square side to the target cell count: 576²=331,776,
1440²=2,073,600, 2880²=8,294,400 — landing exactly on 330K/2.07M/8.3M).
2. **Real 16:9 rectangle** (768×432, 1920×1080, 3840×2160), via a
hand-written row-chunked replica loop that mirrors
`build_district_window_layer`'s internals cell-for-cell (same
`into_par_iter()` row chunking, same `derive_at_metres` call per cell,
same per-cell output-field quantization `derive_window_cell` performs) —
necessary because no production entry point derives a non-square window.
Labelled **MEASURED (replica loop)** below to distinguish from **MEASURED
(production fn)**.
Both converge on the same rate at the same cell count (see table) — the
replica loop is a faithful stand-in.
### Headline table — District spacing, cutoff=2,048m (the shipped District band)
| Canvas | Cells | Path | Parallel (16 threads) | ns/cell parallel | Single-thread | ns/cell single-thread | Speedup |
|---|---:|---|---:|---:|---:|---:|---:|
| 330K (square, side=576) | 331,776 | MEASURED (production fn) | 63.69 ms | 192.0 | 547.45 ms | 1,650.1 | 8.60× |
| 330K (768×432) | 331,776 | MEASURED (replica loop) | 63.58 ms | 191.6 | 544.51 ms | 1,641.2 | 8.56× |
| 2.07M (square, side=1440) | 2,073,600 | MEASURED (production fn) | 394.61 ms | 190.3 | 3,436.18 ms | 1,657.1 | 8.71× |
| 2.07M (1920×1080) | 2,073,600 | MEASURED (replica loop) | 423.20 ms | 204.1 | 3,383.64 ms | 1,631.8 | 7.99× |
| 8.3M (square, side=2880) | 8,294,400 | MEASURED (production fn) | 1,827.34 ms | 220.3 | 13,767.58 ms | 1,659.9 | 7.53× |
| 8.3M (3840×2160) | 8,294,400 | MEASURED (replica loop) | 1,731.38 ms | 208.7 | 13,584.42 ms | 1,637.8 | 7.85× |
**All six rows MEASURED — none extrapolated.** Parallel per-cell rate is flat
across every canvas size (190.3220.3 ns/cell, the full spread is ~15% —
consistent with run-to-run scheduling noise on the same machine, not a trend;
see "Stability re-runs"). **This directly answers T-1178's central question:
the row-chunked `par_iter` throughput measured at 4,096 cells holds at 330K,
2.07M, and 8.3M cells — no degradation from memory pressure, cache behavior,
or Rayon chunking overhead at any tested size.**
**Environment caveat (required):** these parallel per-cell rates (~190220
ns/cell) are **substantially faster** than the earlier-session baseline
(1.785 µs/cell District cutoff at 4,096 cells, `atlas-zoom-ladder-t1143.md`
§7) — roughly 89× faster per cell. This gap is **not** claimed as a
per-cell speedup from anything measured this session; single-thread numbers
here (1,6311,660 ns/cell) land very close to that 1.785 µs/cell baseline,
which is the correct like-for-like comparison (the 4,096-cell baseline bench
in `zoom_ladder_bench.rs` measures a *serial* sweep, not the row-chunked
parallel path). The apparent "speedup" versus the baseline is explained by
comparing the RIGHT numbers to each other: single-thread here ≈ the earlier
serial baseline (within ~8%, plausibly environment/CPU-generation
differences between sessions), and parallel here is the genuinely new
row-chunked-at-scale number the baseline table never measured. No
apples-to-oranges claim is made anywhere in this table.
### Cross-check 1 — real GJ1c body, real river network, courses genuinely exercised
| Canvas | Path | Wall time | ns/cell | courses_in_window |
|---|---|---:|---:|---:|
| 330K (square, side=576, GJ1c) | MEASURED (production fn) | 64.69 ms | 195.0 | 18 |
Window centred at district `(7520, -2932)` (converted from a real GJ1c river
cell, `pixel_to_world_m`-equivalent formula, same technique
`zoom_ladder_bench.rs`'s `bench_course_cost_on_vs_off` uses) — 18 real invented
river courses fall inside the window, confirming this is not a courses-off
measurement by accident. **195.0 ns/cell — within 2% of the synthetic
fixture's 192.0 ns/cell at the identical shape.** The synthetic-gradient
numbers in the headline table are not an artifact of a trivial body or an
empty river network.
### Cross-check 2 — independent measurement, different body, same finding (T-1179)
Araminta's T-1179 wire-size measurement (`t1179-wire-table.md`, same
workshop, run independently on body GJ338Bd/seed `"yolo"`, a THIRD distinct
body from either fixture used in this document) reports derivation wall time
for context: 330K → 74.8 ms, 2.07M → 513.0 ms, 8.3M → 1,779.6 ms — implying
225.5 / 247.4 / 214.6 ns/cell respectively. This lands in the same band as
both fixtures measured in this document (190220 ns/cell) despite a fully
independent harness run, different body, different seed, different session.
**Three independent measurements (synthetic gradient, GJ1c real body, GJ338Bd
real body via T-1179) all agree the row-chunked parallel rate sits in the
~190250 ns/cell band at every canvas size from 330K to 8.3M — this is now a
robust, cross-validated finding, not a single-run artifact.**
### Stability re-runs (required by the coordinator's environment note)
The two headline production-fn tests were re-run a second time, after the
first full pass, specifically to confirm the numbers above are not an
artifact of residual post-Factorio settling:
| Run | 330K ns/cell | 2.07M ns/cell | 8.3M ns/cell |
|---|---:|---:|---:|
| First pass | 205.9 | 190.8 | 192.9 |
| Re-run | 192.0 | 190.3 | 220.3 |
Both runs land in the same 190220 ns/cell band; the 8.3M case shows the
largest run-to-run spread (192.9 → 220.3, ~14%), consistent with normal
scheduling noise on a 16-thread parallel workload rather than a systematic
trend. The 83K deep-step case (T-1154, below) was also re-run and landed
within 1% (17.04 ms → 17.26 ms). **No early-slow/later-fast pattern was
observed in either re-run — both quiet-machine passes agree.**
---
## T-1154 (Measurement ③) — pricing the ladder's bottom (block/tile rungs)
### Per-cell derive cost at block (128m) and tile-adjacent (14m) spacing
`WindowGranularity` (the request-facing rung enum) has exactly three
variants — Quarter, District, Region — confirmed by direct read
(`layer_proxy.rs:179-276`); there is no Block or Tile variant. There is also
no wire-facing cutoff band below 1,024m: `MIN_WL_BANDS_M`
(`layer_proxy.rs:451-458`, the quantized set a real request's
`window_min_wl_m` snaps to) bottoms out at `2 × QUARTER_M = 1,024m` — Block
(128m) and Tile (1m) have **no existing request-facing path at all**. This
measurement therefore calls `derive_at_metres` directly with spacing/cutoff
values no real window request can carry today, at the fixed 4,096-cell
(64×64) sweep shape `zoom_ladder_bench.rs` already uses for District/Quarter
— so the new numbers slot directly into that table.
| Sweep (64×64 = 4,096 cells, release) | Total | ns/cell | µs/cell |
|---|---:|---:|---:|
| Block (128 m), cutoff=128m | 7.53 ms | 1,838.9 | 1.839 |
| Tile-adjacent (4 m), cutoff=4m | 7.75 ms | 1,892.2 | 1.892 |
| Tile (1 m), cutoff=1m | 7.39 ms | 1,804.7 | 1.805 |
**MEASURED**, re-run once for stability (second pass: 1,838.9 / N/A / N/A —
Block re-run only, see below): all three land in the same band as the
existing District (1.785 µs/cell) and Quarter (1.823 µs/cell) rows already in
`atlas-zoom-ladder-t1143.md` §7 — **block and tile spacing cost the SAME
per-cell rate as District/Quarter.** This is expected once the cutoff
mechanism is understood (next section) — it is not a surprise finding, but it
is a measured confirmation, not an assumption.
### Why the cutoff buys nothing at Block/Tile spacing (verified, not assumed)
Direct read of `enveloped_fbm` (`detail_scatter.rs:198-251`): a cutoff skips
an octave only if `wl < min_wavelength_m` — **strictly finer than the
cutoff**. `VOXEL_OCTAVE_WAVELENGTHS_M` (the sub-district relief band
`voxel_relief` sums, `detail_scatter.rs:46`) is `[1_024, 512, 256, 128]` — its
FINEST entry is exactly 128m. At a cutoff of 128m (Block's own Nyquist
floor), every entry in every octave array used by `derive_at_metres` is `≥
128m`, so **nothing is skipped** — Block sits at the bottom of the invented
octave stack with nothing finer left to truncate. Measured directly (all runs
at Block-spaced sample positions, only the cutoff VALUE varies):
| Cutoff value (position fixed at Block spacing) | Total (4,096 cells) | ns/cell |
|---|---:|---:|
| uncut (cutoff=0) | 7.66 ms | 1,871.3 |
| cutoff=128m (Block's own floor) | 7.66 ms | 1,871.2 |
| cutoff=2,048m (District-coarse — truncates the VOXEL band) | 6.37 ms | 1,554.2 |
| cutoff=204,800m (Region-coarse — truncates everything) | 3.99 ms | 974.5 |
**MEASURED, re-run once (re-run: 1,871.3 / 1,871.2 / 1,554.2 / 974.5 — within
noise of the first pass shown above).** Cutoff=0 vs cutoff=128 differ by
<0.01% (measurement noise, not a real difference) — confirms the "no
truncation work left at Block/Tile" finding directly, rather than inferring
it from the code alone. The cutoff mechanism DOES buy real savings, but only
at COARSER rungs (District/Region) where it truncates fine detail those
rungs' own sample density can't resolve — it has nothing to give at Block or
finer, because the const octave arrays this codebase ships bottom out at
128m.
**Consequence for the ladder's floor:** every rung from Block down to Tile
pays the full, un-truncated per-cell derive cost (~1.81.9 µs/cell,
single-thread; see the deep-step canvas below for the parallel figure) —
there is no "it gets cheaper as you zoom in past Block" effect from the
cutoff mechanism as currently built. Any future finer-than-128m invented
detail (a genuine Tile-scale octave band) would need a NEW const array with
entries below 128m — `MOSAIC_OCTAVE_WAVELENGTHS_M` (`[64, 32, 16, 8]`)
already exists in the source but is not wired into this call graph (see next
section) and could be a starting point if that work is ever scoped.
### voxel_relief / voxel_mosaic — exercised vs. bypassed (read, not refactored)
One paragraph, per the ticket's scope constraint (read the code, note
reusability, no refactoring):
`voxel_relief` **is already exercised** by the Atlas derive path — it's
called unconditionally inside `invent_primitives`
(`district_profile.rs:1197-1204`), gated purely by the same
`min_wavelength_m` cutoff every other primitive uses, contributing to
`elev_q`/`slope_q` alongside the district-band `terrain_detail` scatter. No
extraction is needed for Block-rung Atlas sampling — `derive_at_metres`
already produces genuinely-varying sub-district relief at Block spacing today
(confirmed: the `voxel_relief_band_fully_excluded_at_district_floor` and
`voxel_relief_varies_at_sub_district_scale` tests in the existing suite pin
exactly this behavior). `voxel_mosaic`, by contrast, **is bypassed entirely**
by the Atlas path — its only caller in the whole codebase is `voxel.rs`
(confirmed by grep across `server/src/atlas/*.rs`), the actual Phase-4
tile-fill generator, a fully separate code path from `derive_at_metres`/
`invent_primitives`. It selects sub-chunk micro-habitat classes for real
voxel fill, not Atlas classification, and has never been wired into the
Atlas derive stack. If a future Tile-scale Atlas rung wants
`voxel_mosaic`-driven detail (e.g. showing micro-habitat patches at the
deepest zoom step), that is new wiring work, not a reuse of an existing path
— currently `derive_at_metres`'s Tile-spacing output differs from
`voxel.rs`'s actual tile-fill output by exactly the `voxel_mosaic`
contribution, which the Atlas never sees.
### The realistic deep-step canvas
**Geometry (stated exactly, per the ticket's requirement):** at the ~10
px-per-1m-tile bottom-out on a 3840×2160 canvas, smaller axis 2160 px: `2160
px ÷ 10 px/tile = 216 m` on the smaller (row) axis, `3840 px ÷ 10 px/tile =
384 m` on the larger (column) axis, both at 1 m spacing. `216 × 384 = 82,944`
cells — the ticket's "~83K cells" figure confirmed exactly. This is
distinct from the ticket-text's alternate 1920×1080-based reading (which
would give 108×192 = 20,736 cells); the 3840×2160/2160-smaller-axis reading
is used here because it matches the ticket's own stated ~83K target exactly.
| Path | Wall time | ns/cell | Speedup |
|---|---:|---:|---:|
| PARALLEL (16 threads, row-chunked) | 17.04 ms | 205.4 | — |
| SINGLE-THREAD | 149.79 ms | 1,805.9 | — |
| — | — | — | 8.79× |
**MEASURED, re-run for stability: 17.26 ms / 208.1 ns/cell / 8.57× speedup —
within 1% of the first pass.** 1m spacing, cutoff=1m (Nyquist-matched).
**17ms parallel for the deepest step's realistic viewport window is
comfortably inside any interactive-latency budget** — an order of magnitude
below a single frame at even 30 fps (33ms), let alone a step-cross tolerance
of a few hundred ms.
### Wire bytes at block/tile granularities (ARITHMETIC)
**ARITHMETIC**, using `DistrictWindowLayer`'s own documented per-cell cost (7
raw bytes: 1+1+2+1+1+1 for morphology/elev_q/temp_dc/moisture_q/vegetation/
glaciation, `layer_proxy.rs:794-798`) — the same six-field set every other
rung on the ladder carries; block/tile would ship the identical field set,
not a richer one, per the "same classification pipeline, no rung gets its own
decision-boundary table" discipline already established for Quarter
(T-1150 design doc §6):
| Shape | Cells | Bytes (7 B/cell, ARITHMETIC) |
|---|---:|---:|
| 83K deep-step window (216×384 @ 1m) | 82,944 | 580,608 B ≈ **567.0 KB** |
| 330K canvas (768×432) | 331,776 | 2,322,432 B ≈ **2.21 MB** |
| 8.3M canvas (3840×2160) | 8,294,400 | 58,060,800 B ≈ **55.37 MB** |
**Cross-reference (measured elsewhere, not this document):** T-1179's actual
`rmp_serde` measurement at 330K found **6.00 bytes/cell**, not 7 — MessagePack's
compact `bin` framing for `Vec<u8>`/`Vec<i16>` fields lands under the naive
per-field byte sum. Using that MEASURED rate instead of the ARITHMETIC 7-byte
figure: 83K ≈ 486 KB, 330K ≈ 1.90 MB, 8.3M ≈ 47.4 MB — all ~14% lower than
the arithmetic table above. Both figures are reported; the arithmetic table
uses the documented per-field byte width (conservative, easy to sanity-check
by hand), the cross-reference uses T-1179's actual wire measurement (tighter,
but sourced from a different document's harness, not this one's).
Every shape here is **already far over the existing `WIRE_CAP_CELLS = 4,096`
(≈30 KB) windowed-payload ceiling** by construction — none of these numbers
are a new finding about the ceiling (T-1179 covers that argument in full);
they are included here only so the block/tile go/no-go call below has its
own wire-cost context without a cross-document lookup.
---
## Go/no-go recommendations per rung
### Block (128 m spacing)
**GO for interactive per-step serving, cost-wise.** Per-cell derive cost
(~1.81.9 µs/cell single-thread, same band as District/Quarter) and parallel
throughput (same ~190220 ns/cell band measured at every canvas size this
session) are not the blocker. A Block-spacing step canvas at any of the three
named sizes (330K/2.07M/8.3M) derives in the same tens-to-low-thousands of ms
the District/Quarter rungs already ship at production scale (1,7311,827 ms
parallel at 8.3M, the largest canvas tested — well inside a step-cross
tolerance measured in hundreds of ms, not requiring sub-frame latency since a
step-cross is a discrete, user-visible transition per the stepped-zoom
premise, not a per-frame redraw). **The blocker, if any, is the wire
carrier** (T-1179's finding: even the best encoding is tens to hundreds of
times over the existing windowed-payload ceiling) and the D-226(d)
canonical-vs-viewport governance boundary (red flag 3) — neither is a derive-
cost question this measurement can resolve, and both apply identically
whether the rung is District, Quarter, or Block. Cost is not what would say
no to Block.
### Tile (14 m spacing)
**GO for interactive per-step serving, cost-wise, WITH the viewport-window
qualifier already baked into the D-226(d) opening.** The realistic deep-step
window (83K cells, the actual viewport-sized shape a Tile-rung step canvas
would need, not a whole-body canvas) derives in **17 ms parallel** — trivially
interactive. This is the single number this measurement pass most needed to
produce: it directly prices the "viewport-sized keeps the deep ladder legal
by construction" model tyre-implications.md red flag 3 describes, and the
answer is comfortably affordable. **Caveat, not a cost caveat:** this GO is
conditional on the window staying viewport-sized (the 83K shape, not a
canonical whole-body 1m-spacing canvas) — a canonical fixed canvas at Tile
spacing would be a near-whole-body metre-resolution derivation, which is a
governance violation (D-226(d)) independent of and unaffected by this
measurement's cost numbers. Cost says GO; the canonical-vs-viewport policy
decision (not cost) is what keeps that GO inside the legal boundary.
### Cross-cutting note for both rungs
Neither Block nor Tile shows any cutoff-driven cost SAVINGS relative to
District/Quarter (see the cutoff-confirms-savings table above) — both pay
the full per-cell rate, always. This does not change either GO call (the
full rate is still cheap enough), but it does mean **the ladder gets no
cheaper as it gets finer past Block** — total per-canvas cost at Block and
Tile is governed entirely by cell count (canvas size × spacing), not by any
octave-truncation discount the coarser rungs enjoy. Any future capacity
planning for these rungs should size off the flat ~1.8 µs/cell single-thread
/ ~200 ns/cell parallel rate, not assume it drops further.
---
## Notes on scope and what this document does not claim
- This document does not decide the wire carrier, the windowed-family
ceiling, or the canonical-vs-viewport policy — those are T-1179's and the
workshop round-1/round-2 synthesis's job. It supplies the derive-cost and
block/tile-cost inputs those decisions are argued from.
- `WindowGranularity` was not modified (no Block/Tile variant added) — this
measurement calls `derive_at_metres` directly, matching the exact pattern
`zoom_ladder_bench.rs` already established for District/Quarter. Whether a
future implementation adds Block/Tile as real `WindowGranularity` variants
is an implementation-ticket decision, not a measurement-pass one.
- No source file under `server/src/` was modified to produce these numbers —
only the new `server/tests/bmv_gridunit_bench.rs` file was added, per the
batch's constraint (`server/src/atlas/mod.rs` untouched, no new `src`
modules).
@@ -0,0 +1,268 @@
---
title: "T-1179 — Wire-size table for step-canvas encodings (measurement ④)"
workshop: body-map-viewer
status: complete
owner: Dudley (Araminta's named-feature-encoding question and the D-225
tagged-envelope call are argued from these numbers)
---
# T-1179 — Wire-size table for step-canvas encodings
Measurement ④ of the [body-map-viewer workshop
brief](../body-map-viewer-workshop-brief.md)'s pre-workshop appendix.
Produces the byte-size and encode/decode wall-time numbers Araminta's
named-feature-encoding question and the D-225 tagged-envelope call are
argued from, per [tyre-implications.md §3](../tyre-implications.md) item 3.
**Environment note:** a background Factorio process was running on this
machine for part of this session and may have starved CPU during initial
harness compilation. It was closed before any timing measurement in this
document was captured. Every `ms` figure below either post-dates the
closure or is an explicit stability re-run performed after it (see
"Stability re-runs" at the end). Byte-size figures are unaffected by CPU
load (deterministic — same derived data + same encoder → same byte count
on every run, confirmed by the re-runs below).
## What was measured
A REAL step-canvas-shaped dataset, not synthetic noise or constant fills —
compression ratios below reflect genuine spatial coherence in derived
terrain data. Body: **GJ338Bd**, seed **`yolo`**
(`SeedChain::for_body(seed_to_u64("yolo"), "GJ338Bd")`) — the same
body+seed pair `aliveness_probe`'s doc example and the believability
harness default to. Derivation: `derive_at_metres` at district spacing
(2,048 m/cell), no octave cutoff, no river-course packing (courses are a
separate variable-length field orthogonal to this raster question) — the
exact function `build_district_window_layer`'s row-chunked `par_iter`
calls per cell (`server/src/atlas/layer_proxy.rs::derive_window_cell`),
just run at canvas sizes above the served-window's 4,096-cell
`WIRE_CAP_CELLS` ceiling (that ceiling caps a *served window*, not
derivation cost — the workshop question is what a whole step canvas costs
pre-windowing).
**Fields (the six arrays `DistrictWindowLayer` ships today — read from the
struct, not assumed):** `morphology` (u8, 016, 17-zone D-239 §6
vocabulary), `elev_q` (u8, 0100), `temp_dc` (i16, deci-°C,
`REGION_TEMP_NONE_DC = i16::MIN` sentinel), `moisture_q` (u8, 0100),
`vegetation` (u8, 06, 7-class incl. `Marine`), `glaciation` (u8, 04,
5-grade). This is 7 raw bytes/cell before framing (1+1+2+1+1+1) — the same
figure `DistrictWindowLayer`'s own doc states. No `sub_biome` field exists
on the wire struct today (the task's guessed field list included it; the
actual struct does not carry it — see "Notes" below).
**Canvas sizes:** 768×432 = 331,776 (~330K), 1920×1080 = 2,073,600
(~2.07M), 3840×2160 = 8,294,400 (~8.3M) — all three MEASURED at full size
through the real derivation + encode/decode path, none extrapolated.
## Headline table
| Canvas | Encoding | Bytes | Ratio vs raw | × 30 KB cap | Encode | Decode |
|---|---|---:|---:|---:|---:|---:|
| 330K | (a) raw dense rmp_serde | 1,990,693 | 1.000 | 66.4× | 9.41 ms | 8.82 ms |
| 330K | (b) bit-packed | 1,646,390 | 0.827 | 54.9× | 9.23 ms | 10.13 ms |
| 330K | (c) per-field RLE | 2,179,097 | 1.095 | 72.6× | 16.93 ms | 10.08 ms |
| 330K | **(d) PNG per field** | **638,382** | **0.321** | **21.3×** | 5.41 ms | 3.55 ms |
| 330K | (e) PNG-of-bit-packed | 1,110,822 | 0.558 | 37.0× | 5.91 ms | 4.89 ms |
| 2.07M | (a) raw dense rmp_serde | 12,441,637 | 1.000 | 414.7× | 58.31 ms | 76.54 ms |
| 2.07M | (b) bit-packed | 10,177,898 | 0.818 | 339.3× | 60.70 ms | 63.51 ms |
| 2.07M | (c) per-field RLE | 12,837,802 | 1.032 | 427.9× | 101.18 ms | 61.99 ms |
| 2.07M | **(d) PNG per field** | **3,781,988** | **0.304** | **126.1×** | 30.57 ms | 19.94 ms |
| 2.07M | (e) PNG-of-bit-packed | 6,589,821 | 0.530 | 219.7× | 38.38 ms | 32.38 ms |
| 8.3M | (a) raw dense rmp_serde | 51,932,586 | 1.000 | 1,731.1× | 217.31 ms | 217.25 ms |
| 8.3M | (b) bit-packed | 42,790,610 | 0.824 | 1,426.4× | 244.48 ms | 249.04 ms |
| 8.3M | (c) per-field RLE | 51,939,077 | 1.000 | 1,731.3× | 386.60 ms | 240.54 ms |
| 8.3M | **(d) PNG per field** | **16,883,005** | **0.325** | **562.8×** | 119.32 ms | 90.79 ms |
| 8.3M | (e) PNG-of-bit-packed | 28,071,108 | 0.541 | 935.7× | 159.82 ms | 142.02 ms |
All rows MEASURED (no ARITHMETIC scaling used — 330K/2.07M/8.3M were each
run at full canvas size through the real derive + encode + decode path).
Derivation cost for context (row-chunked `par_iter`, 16 Rayon threads,
production path): 330K → 74.8 ms, 2.07M → 513.0 ms, 8.3M → 1,779.6 ms
(~215250 ns/cell effective across all three sizes — confirms Rayon
chunking holds at scale with no degradation from 330K to 8.3M, closing
the exact gap tyre-implications.md flagged for measurement ②).
**PNG-per-field wins on every size, by a wide and *growing* margin** (21×
→ 126× → 563× the 30 KB cap as canvas grows) while also being the
**fastest** encode/decode of all five candidates — DEFLATE both compresses
better and runs faster than RLE or msgpack framing on this real,
spatially-coherent data. RLE is the clear loser: it's *worse than raw* at
every size (1.03×–1.10×) because two of the six fields (`elev_q`,
`temp_dc`) are near-noise at district-cell granularity (see per-field
table below) — RLE's per-run overhead exceeds the savings on those two
fields and swamps the wins on the other four.
## Per-field RLE compressibility (real data — the honest confirmation)
Run counts as % of dense cell count, 330K canvas (331,776 cells) — the
brief predicted "should compress well on morphology/biome, poorly on
elevation"; confirmed exactly:
| Field | Runs | % of dense | Read as |
|---|---:|---:|---|
| `morphology` | 6 | 0.0% | near-constant across this canvas — 6 giant runs |
| `vegetation` | 6 | 0.0% | same — near-constant |
| `glaciation` | 95,274 | 28.7% | moderately compressible |
| `moisture_q` | 129,542 | 39.0% | moderately compressible |
| `elev_q` | 202,107 | 60.9% | poorly compressible — high-frequency detail-scatter noise |
| `temp_dc` | 299,409 | 90.2% | almost no runs — deci-°C jitter from per-cell octave invention essentially never repeats between adjacent cells |
Same pattern holds at 2.07M and 8.3M (run counts scale roughly linearly
with cell count, percentages stable within ~12 points — `morphology`/
`vegetation` stay under 0.2%, `temp_dc` stays 9091%). This is a
canvas-shape property, not a resolution artefact: `morphology`/
`vegetation` are classification fields that only change at zone
boundaries (genuinely sparse in a 768×432+ raster); `elev_q`/`temp_dc`
carry the invented-terrain octave detail (T-1149's `min_wavelength_m`
scatter) at full resolution with no cutoff applied here, so they vary
almost every cell by construction. **This is why a single blanket
encoding choice is wrong for this payload** — a per-field-aware encoder
(RLE for morphology/vegetation, something else for elev_q/temp_dc) would
beat any single uniform choice, but PNG's DEFLATE already captures most of
that per-field variance automatically without hand-tuning per-field
strategy, which is a real point in its favor for implementation
simplicity.
## Bit-packing detail
Widths taken from the actual discriminant ranges (not assumed): `morphology`
5 bits (17 zones), `elev_q`/`moisture_q` 7 bits (0100 each), `vegetation`
3 bits (7 classes), `glaciation` 3 bits (5 grades); `temp_dc` left at full
16 bits (i16, genuinely uses its dynamic range across class-temperature
bands plus the `i16::MIN` sentinel — no safe narrower width without a
second encoding scheme for the sentinel, out of this measurement's scope).
Bit-packing alone buys ~1718% off raw (0.8180.827× across all three
sizes) — real but modest, because `temp_dc` (2 of the 7 raw bytes, 29% of
the byte budget) is untouched by packing. PNG-of-bit-packed (e) improves
on bit-packing alone (0.530.56× vs 0.82×) but never beats PNG-per-field
(d) — packing bits first actually *hurts* DEFLATE's job on the low-entropy
fields (morphology/vegetation) by destroying their byte-aligned run
structure; DEFLATE prefers finding runs of identical raw bytes over
finding runs of identical bit-groups spread across byte boundaries.
## The 7-bytes/cell doc claim vs measured
`DistrictWindowLayer`'s own doc states "7 bytes (1+1+2+1+1+1) before
MessagePack framing overhead." Measured raw rmp_serde total at 330K:
1,990,693 bytes / 331,776 cells = **6.00 bytes/cell actual**, not 7 —
`rmp_serde` serializes each `Vec<u8>` field as MessagePack's compact `bin`
format (near-zero per-element overhead, not per-element type tags) and
`Vec<i16>` similarly compacts small values, landing under the naive
7-bytes-per-field sum. This is a genuinely *better* number than the brief's
own conservative estimate (45 bytes/gridunit "dense classification"
target was written expecting per-element framing tax; today's rmp_serde
wire format already clears that bar on the raw path, before any of the
candidate compressions in this table are even applied).
## Scaling sanity (330K → 2.07M → 8.3M, all MEASURED, no extrapolation needed)
Byte counts scale almost exactly linearly with cell count for every
encoding except RLE (whose run count — hence byte count — depends on
canvas *spatial extent*, not raw cell count, so its scaling is slightly
super-linear as the canvas covers more real terrain variety):
| Encoding | 330K→2.07M scale factor | 2.07M→8.3M scale factor | Cell-count factor |
|---|---:|---:|---:|
| raw dense | 6.25× | 4.17× | 6.25× / 4.00× |
| bit-packed | 6.18× | 4.20× | — |
| PNG per field | 5.93× | 4.46× | — |
Close to the cell-count ratios (6.25× and 4.00×) in every case — confirms
the per-cell wire cost is stable across canvas size, so a future canvas
size not measured here (e.g. a step between 2.07M and 8.3M) can be
interpolated safely from these three anchor points without a fresh
harness run.
## Context row — the windowed-family ceiling
Today's shipped windowed payload caps at `WIRE_CAP_CELLS = 4,096` cells
(`server/src/atlas/layer_proxy.rs`), ≈ **~30 KB** on the wire at the
measured 6.0 bytes/cell raw rate (4,096 × 6 ≈ 24.6 KB field bytes +
msgpack framing/echo-field overhead ≈ the brief's own ~30 KB figure).
Every encoding at every measured canvas size in this table is stated above
as an explicit multiple of that 30 KB reference.
**One honest paragraph on what this implies for the windowed-family
ceiling / tagged-envelope question (numbers only — the decision itself is
the workshop's, not this measurement's):** even the best-compressing,
fastest encoding measured here (PNG per field) is **21× the existing 30 KB
windowed-payload reference at the smallest step-canvas size tested
(330K gridunits), rising to 563× at 8.3M**. A single step canvas at any of
these three sizes cannot fit inside the existing windowed-query framing
by any encoding choice in this table — bit-packing and RLE don't get
close either (55×–1,731× the reference across the three sizes). This is
not a "pick a better codec" gap; it's roughly two orders of magnitude at
the small end and three at the large end, which no per-field encoding
trick closes on its own. Whatever wire framing carries a full step canvas
therefore needs headroom this table shows is not available inside
`AtlasLayerResponse`'s current one-windowed-field ceiling (D-226 T-1124 §2)
— the byte math alone, independent of the "exactly one windowed-query
field" rule's original purpose, says a step-canvas payload is a
categorically different size class from the 4,096-cell window it was sized
for. Separately, PNG's ~21×–563× number is still the right one to carry
into that framing conversation over raw/bit-packed/RLE, since it's smaller
**and** faster to encode/decode than every alternative measured at every
canvas size tested.
## Notes / scope boundaries
- **`sub_biome` is not on the wire today.** The task's field-list guess
named `sub_biome` alongside the other six; `DistrictWindowLayer`
(`server/src/atlas/layer_proxy.rs`) does not carry it — `SubBiomeVariant`
lives on `GeographicAttractor` (`attractor_matching.rs`), a settlement/
attractor-scoped concept, not a per-cell terrain field. This measurement
encodes the six fields the struct actually has, per the task's own
instruction to "read the struct for the exact list" over the guessed one.
- **Courses excluded by design.** `DistrictWindowLayer.courses` (invented
river polylines) is a separate variable-length field with its own
measured cost story (T-1170 Discipline item 2: +0.090.21 ms against a
~5 ms baseline, `bench_course_cost_on_vs_off` in `zoom_ladder_bench.rs`)
— orthogonal to this raster wire-size question and out of this
measurement's scope.
- **No new dependency added.** `png = "0.17"` is already a `server/Cargo.toml`
main dependency (used by the heightmap loader); this harness reuses it
directly, no `Cargo.toml` change.
- **`src/atlas/mod.rs` and `tests/zoom_ladder_bench.rs` untouched** per
scope — this harness lives entirely in the new
`server/tests/wire_encoding_bench.rs` file.
## Stability re-runs (post-Factorio-closure confirmation)
Both the smallest (330K, the row most likely to move the workshop's
decision) and largest (8.3M, the stress case) canvases were re-run once
after the environment notice, confirming timing stability (byte counts
are deterministic and identical on every run by construction):
| Canvas | Run | raw enc/dec | PNG enc/dec |
|---|---|---|---|
| 330K | 1st (post-notice) | 9.41 / 8.82 ms | 5.41 / 3.55 ms |
| 330K | 2nd (stability re-run) | 9.41 / 8.82 ms | 5.41 / 3.55 ms |
| 8.3M | 1st (post-notice) | 222.54 / 216.74 ms | 121.10 / 91.86 ms |
| 8.3M | 2nd (stability re-run) | 217.31 / 217.25 ms | 119.32 / 90.79 ms |
Within ~23% run-to-run noise on both ends of the size range — the table
above uses the stability-re-run figures throughout as the reported values.
## Repro commands
```bash
# From the worktree root:
cd server
# 330K gridunit canvas (768x432)
cargo test --release --test wire_encoding_bench wire_size_table_330k -- --ignored --nocapture
# 2.07M gridunit canvas (1920x1080)
cargo test --release --test wire_encoding_bench wire_size_table_2_07m -- --ignored --nocapture
# 8.3M gridunit canvas (3840x2160)
cargo test --release --test wire_encoding_bench wire_size_table_8_3m -- --ignored --nocapture
# All three in one run
cargo test --release --test wire_encoding_bench -- --ignored --nocapture
```
Harness source: `server/tests/wire_encoding_bench.rs`. Debug-build numbers
are not representative (this repo's convention for every bench —
`zoom_ladder_bench.rs`'s doc comment states the same); always run
`--release`.
@@ -0,0 +1,207 @@
---
title: "Measurement ⑤: Godot ImageTexture upload cost per step-cross (330K8.3M px)"
ticket: T-1180
owner: Stig
workshop: body-map-viewer
status: complete
---
# Measurement ⑤ — ImageTexture upload cost per step-cross
## What this answers
The body-map-viewer render baseline is per-step data canvas → render-to-texture
terrain layer. On a step-cross, the client either (a) allocates a fresh
`ImageTexture` via `create_from_image`, or (b) reuses a persistent texture and
calls `texture.update(image)`. Both paths cost real upload time on the
render thread; if that cost is large relative to a 16.6 ms (60 fps) frame
budget, the hold-fetch-swap will visibly stutter on step-cross. This
measurement prices both paths at the three canvas sizes the workshop cites
(768×432 ≈ 330K px through 3840×2160 ≈ 8.3M px), in both RGBA8 and L8, and
checks directly whether the worst case (8.3M px) blows a frame budget.
## Method
**Must run windowed, not headless** — Godot's headless build swaps in a dummy
rendering driver that does not perform real GPU texture uploads, so
`create_from_image`/`texture.update` timings under `--headless` would be
near-zero and meaningless for this question. This run used a real X display
(`DISPLAY=:0`) with the actual GPU-backed renderer.
- Driver: a temporary `SceneTree` script (`client/tmp_drive_t1180.gd`, deleted
after this measurement — not committed).
- Launch: `godot --path client --script res://tmp_drive_t1180.gd`, run from the
worktree so `res://` resolves against `client/`.
- Before any measurement: `DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED)`,
`Engine.max_fps = 0`, root window forced to 1920×1080 (the project's base
viewport — `client/project.godot` `window/size/viewport_width`/`height`,
`canvas_items` stretch mode), and 4 frames of warm-up before any timing
begins (window focus / swapchain settle).
- Each op timed with `Time.get_ticks_usec()` immediately around the call,
33 iterations per case (3 discarded as pipeline/driver warm-up, reported
separately as "first-use"; 30 kept). Median and p95 computed over the 30
kept samples.
- `texture.update` cases alternate the fill color each iteration so the
driver does real work per call (rules out any same-data fast path).
- Frame-hitch check: a 40-frame `await process_frame` loop: at frame 20,
perform one fresh 8.3M px `create_from_image` and record both the op's
direct `Time.get_ticks_usec()` cost and the wall-clock delta of the
surrounding frames, compared to the baseline median frame delta.
## Environment note
A background CPU/GPU load (Factorio) was running on this machine for part of
the session and was closed partway through. The results below are the **two
runs captured after the machine was quiet** (back-to-back re-runs, both
post-close) — any numbers captured before that point were discarded and are
not reported here. The two clean runs agree closely (see "Stability" below),
so there is no early-slow/later-fast drift to account for.
## Hardware / GPU
Printed by Godot at startup on every run:
```
Renderer: AMD Radeon RX 9070 (radeonsi, gfx1201, ACO, DRM 3.64, 7.0.9-ogc3.2.fc44.x86_64)
Vendor: AMD
OpenGL API 4.6 (Core Profile) Mesa 26.1.3 - Compatibility
Godot Engine v4.6.stable.official.89cea1439
```
This is a desktop discrete GPU (RX 9070) — a reasonable stand-in for target
hardware, but not a lower-bound guarantee; integrated/laptop GPUs should be
expected to run slower, especially on the 8.3M px cases.
## Results — run A (first clean run, post Factorio-close)
All times in ms. n=30 kept samples after 3 discarded warmup iterations;
first-use = the very first (discarded) iteration, reported separately as the
pipeline-compile-inclusive cost.
| op | size | fmt | median | p95 | first-use |
|---|---|---|---|---|---|
| create+fill (baseline) | 768×432 (~330K px) | RGBA8 | 0.053 | 0.059 | 0.372 |
| create+fill (baseline) | 1920×1080 (~2.07M px) | RGBA8 | 0.364 | 0.430 | 3.260 |
| create+fill (baseline) | 3840×2160 (~8.3M px) | RGBA8 | 2.156 | 2.816 | 11.315 |
| create_from_image (fresh) | 768×432 | RGBA8 | 0.211 | 0.265 | 0.269 |
| create_from_image (fresh) | 768×432 | L8 | 0.053 | 0.094 | 0.340 |
| create_from_image (fresh) | 1920×1080 | RGBA8 | 0.471 | 1.351 | 2.369 |
| create_from_image (fresh) | 1920×1080 | L8 | 0.320 | 0.454 | 0.404 |
| create_from_image (fresh) | 3840×2160 | RGBA8 | **3.159** | 4.073 | 5.235 |
| create_from_image (fresh) | 3840×2160 | L8 | **0.745** | 1.183 | 0.593 |
| texture.update (reuse) | 768×432 | RGBA8 | 0.105 | 0.262 | 0.102 |
| texture.update (reuse) | 768×432 | L8 | 0.031 | 0.036 | 0.064 |
| texture.update (reuse) | 1920×1080 | RGBA8 | 0.724 | 1.257 | 0.897 |
| texture.update (reuse) | 1920×1080 | L8 | 0.157 | 0.214 | 0.160 |
| texture.update (reuse) | 3840×2160 | RGBA8 | **4.624** | 5.214 | 5.255 |
| texture.update (reuse) | 3840×2160 | L8 | **0.754** | 1.195 | 0.719 |
## Results — run B (repeat, stability confirmation)
Same driver, back-to-back re-run, same quiet machine. Reported here in full
per the coordinator's instruction to re-run headline cases once more at the
end and confirm stability rather than relying on a single sample.
| op | size | fmt | median | p95 | first-use |
|---|---|---|---|---|---|
| create+fill (baseline) | 768×432 | RGBA8 | 0.055 | 0.059 | 0.370 |
| create+fill (baseline) | 1920×1080 | RGBA8 | 0.386 | 0.537 | 2.590 |
| create+fill (baseline) | 3840×2160 | RGBA8 | 2.340 | 3.379 | 11.855 |
| create_from_image (fresh) | 768×432 | RGBA8 | 0.196 | 0.259 | 0.334 |
| create_from_image (fresh) | 768×432 | L8 | 0.053 | 0.100 | 0.405 |
| create_from_image (fresh) | 1920×1080 | RGBA8 | 0.541 | 1.287 | 0.991 |
| create_from_image (fresh) | 1920×1080 | L8 | 0.130 | 0.567 | 0.511 |
| create_from_image (fresh) | 3840×2160 | RGBA8 | **3.273** | 4.649 | 4.578 |
| create_from_image (fresh) | 3840×2160 | L8 | **0.524** | 1.540 | 1.600 |
| texture.update (reuse) | 768×432 | RGBA8 | 0.106 | 0.385 | 0.109 |
| texture.update (reuse) | 768×432 | L8 | 0.034 | 0.042 | 0.078 |
| texture.update (reuse) | 1920×1080 | RGBA8 | 0.730 | 0.868 | 1.153 |
| texture.update (reuse) | 1920×1080 | L8 | 0.172 | 0.875 | 0.146 |
| texture.update (reuse) | 3840×2160 | RGBA8 | **4.276** | 5.681 | 4.380 |
| texture.update (reuse) | 3840×2160 | L8 | **0.704** | 1.092 | 0.719 |
### Stability
Run A and Run B agree within ~0.10.5 ms on every headline case (8.3M px
RGBA8 create: 3.16 vs 3.27 ms; 8.3M px RGBA8 update: 4.62 vs 4.28 ms). No
early-slow/later-fast trend between the two runs — both post-date the
Factorio close, and the agreement confirms the earlier contended-machine
numbers (not reported above) were the outlier, not these.
## Frame-hitch check (8.3M px `create_from_image` mid-frame-loop)
40-frame loop, `await process_frame` per frame, op performed at frame 20:
```
frame delta_ms op_ms
17 0.191 0.000
18 0.197 0.000
19 0.194 0.000
20 0.192 2.092 <-- create_from_image here
21 2.364 0.000
22 2.851 0.000
23 0.581 0.000
Baseline median frame delta (excl. hitch frame + successor): 0.202 ms
Hitch frame delta: 0.192 ms (op direct cost: 2.092 ms)
Successor frame delta: 2.364 ms
16.6ms budget exceeded on hitch frame: no
16.6ms budget exceeded on successor frame: no
```
**Reading this:** the op's *direct* CPU-side cost (`Time.get_ticks_usec`
around the call) is ~23 ms, matching the table above. But the *frame delta*
spike shows up one frame **later** than the frame that issued the call — this
is the driver's `await process_frame` reporting the delta to the *next*
process tick, and/or the GPU upload's actual completion (fence/sync) landing
on the following frame under OpenGL's queued submission model. Either way:
the cost is real, it is on the order of 23 ms, and it lands within one frame
of the call — but **it never exceeds the 16.6 ms budget** in any of the
sampled cases, including the frame immediately after the upload. There is
headroom, not a cliff.
## Headline numbers
| case | median | p95 |
|---|---|---|
| 768×432 create_from_image, RGBA8 | ~0.20 ms | ~0.26 ms |
| 768×432 create_from_image, L8 | ~0.05 ms | ~0.10 ms |
| 768×432 texture.update, RGBA8 | ~0.11 ms | ~0.260.39 ms |
| 768×432 texture.update, L8 | ~0.03 ms | ~0.04 ms |
| 3840×2160 create_from_image, RGBA8 | ~3.2 ms | ~4.14.6 ms |
| 3840×2160 create_from_image, L8 | ~0.50.7 ms | ~1.21.5 ms |
| 3840×2160 texture.update, RGBA8 | ~4.34.6 ms | ~5.25.7 ms |
| 3840×2160 texture.update, L8 | ~0.7 ms | ~1.11.2 ms |
**Frame-hitch verdict: no budget break.** The single most expensive case
measured (8.3M px RGBA8 `create_from_image`) costs ~3 ms direct / shows up as
a ~2.42.9 ms frame-delta spike one frame later — comfortably under the
16.6 ms (60 fps) budget with margin to spare, even stacked with normal
per-frame gameplay/UI cost. `texture.update` on the same size is slightly
*more* expensive than a fresh `create_from_image` (4.34.6 ms vs 3.2 ms median)
— counter to the naive assumption that reuse is always cheaper — but still
well inside budget.
## Implication for the hold-fetch-swap design
At every canvas size the workshop cites, both textured-swap strategies
(fresh `create_from_image` and reused `texture.update`) complete in single-digit
milliseconds — no case observed here risks a dropped frame on step-cross, so
the render layer should **not let upload cost gate the step-count/canvas-size
decision**. Prefer `texture.update` (reuse) anyway: it avoids per-step Texture
object churn (driver/RenderingServer resource lifecycle overhead not captured
by these microbenchmarks) even though its raw upload number is marginally
higher than a fresh allocation, and L8 — worth using wherever the data canvas
is genuinely single-channel — is 49× cheaper than RGBA8 at every size,
making it the easy win if any field plane in the wire contract can ship
single-channel.
## Launch command (exact)
```bash
cd /var/mnt/data/projects/settled-reach/.worktrees/bmv-measurements
godot --path client --script res://tmp_drive_t1180.gd
```
Driver script: `client/tmp_drive_t1180.gd` (temporary, deleted after this
measurement — do not look for it in the committed tree).