fix(simulation): Global was a 2x1 canvas — a sentinel outlived the extent inversion

Jeroen's Global map has been two coloured blocks all along. Not missing
hydrology, not a missing layer: serve_step_canvas_request zeroed Global's wire
extent, so every request arrived downstream as (0,0), clamped to (1,1), and
resolved to a 2x1 canvas.

That sentinel was correct when Global's size came from the body's region grid
and the client's extent field was meaningless. The D-255 extent inversion made
Global viewport-sized and this line silently outlived it. The commit titled
"size the Global rung to the viewport" was therefore correct and completely
unreachable — its tests passed by calling resolve_canvas_extent directly
rather than through the serve path, i.e. they tested the function that changed
instead of the path the data takes.

Two more places carried the same dead premise, both meaning the first canvas
ever built answered every later request and a resize could never take effect:

  - GlobalTierCache keyed on body id alone. Now treats a size mismatch as a
    miss, so the re-derive replaces it. Deliberately still ONE entry per body
    rather than one per size: keying by size would make a tier that never
    evicts accumulate an entry per viewport a player has ever used.
  - The client's make_key collapsed Global's extent to a sentinel. Centre
    stays collapsed — Global's canvas really is whole-body and origin-anchored
    — but extent is now part of the key.

project.yaml 0.4.5 forces the 2x1 canvases already on disk to miss.

Verified through the capture harness, not by reasoning: server probe shows
req=(960,540) radius=6238.4 resolved=960x480, and Ferrath's Global now renders
continents, oceans, inland lakes and polar ice where it previously rendered
one solid rectangle.

Server suite green (45 binaries), client 1830 total / 1804 passed / 26 skipped.

Pair session with Jeroen, 2026-07-27.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-27 20:52:23 +02:00
co-authored by Claude
parent b7dfc8c50f
commit bdea719530
7 changed files with 101 additions and 18 deletions
+64 -9
View File
@@ -708,6 +708,7 @@ fn invent_courses_for_canvas(
min_wavelength_m,
));
}
courses
}
@@ -1193,8 +1194,28 @@ impl GlobalTierCache {
Self::default()
}
pub fn get(&self, body_id: &str) -> Option<&EncodedStepCanvas> {
self.entries.get(body_id)
/// The resident global canvas for a body, **only if it is the size being
/// asked for**.
///
/// D-255 extent inversion (2026-07-26): Global used to have exactly one
/// possible size per body (its region grid), so body id alone was a
/// complete key. It is now viewport-sized, and a body-id-only key means the
/// first canvas ever built wins forever — a window resize could never take
/// effect, because the stale-sized entry answered every request.
///
/// Deliberately still ONE entry per body rather than one per (body, size):
/// keying by size would make the keep-always tier accumulate an entry per
/// distinct viewport a player ever used, which is a slow leak in a tier
/// that by policy never evicts (D-227 amendment (1)). A size mismatch is
/// treated as a miss instead, so the re-derive replaces it and the tier
/// holds exactly the size currently in use.
pub fn get(&self, body_id: &str, want: (u32, u32)) -> Option<&EncodedStepCanvas> {
let canvas = self.entries.get(body_id)?;
if (canvas.width, canvas.height) == want {
Some(canvas)
} else {
None
}
}
/// Insert (or replace) a body's global canvas. Never evicted — D-227
@@ -1544,11 +1565,19 @@ pub fn serve_step_canvas_request(
// doc) — its echoed/keyed extent is a fixed sentinel `(0, 0)` rather
// than the unclamped wire value, so a `Global` request's cache key can
// never vary by the client's (ignored) extent field.
let extent = if req.rung.is_global() {
(0, 0)
} else {
clamp_step_canvas_extent(req.extent)
};
// D-255 extent inversion (2026-07-26): Global's wire extent is REAL now.
//
// This used to be `if req.rung.is_global() { (0, 0) }` — a sentinel, on the
// then-true premise that Global's canvas came from the body's region grid
// and the client's extent field was meaningless. The inversion made Global
// viewport-sized, and this line silently outlived it: every Global request
// arrived downstream as (0,0), clamped to (1,1), and `resolve_canvas_extent`
// floored it to a **2x1 canvas** — the two coloured blocks Jeroen kept
// seeing. The viewport-sizing fix committed on 2026-07-26 was correct and
// completely unreachable, because the extent was destroyed one layer above
// it; its tests passed by calling `resolve_canvas_extent` directly rather
// than through this serve path.
let extent = clamp_step_canvas_extent(req.extent);
let body_params = match read_body_params(body_params_reader, &req.body_id) {
Ok(p) => p,
@@ -1568,7 +1597,15 @@ pub fn serve_step_canvas_request(
let body_class = BodyDrivingClockClass::classify(&body_params);
if req.rung.is_global() {
if let Some(canvas) = global_cache.get(&req.body_id) {
// The size this request will actually resolve to — the cache must be
// consulted against THAT, not against the raw wire extent, because
// Global fits the largest 2:1 canvas inside what was asked for.
let want = resolve_canvas_extent(
req.rung,
extent,
body_params.body_radius_km.unwrap_or(0.0),
);
if let Some(canvas) = global_cache.get(&req.body_id, want) {
return StepCanvasResponse {
body_id: req.body_id.clone(),
rung: req.rung,
@@ -2051,7 +2088,25 @@ mod tests {
// No eviction API exists on GlobalTierCache at all — structurally
// keep-always (D-227 amendment (1)). Re-fetch confirms the entry
// is still there with no time/tick argument involved.
assert!(cache.get("BodyA").is_some());
assert!(cache.get("BodyA", (1, 1)).is_some());
}
/// A resident canvas of the WRONG SIZE must read as a miss, so the
/// re-derive replaces it. Post-extent-inversion Global is viewport-sized,
/// and a body-id-only key meant the first canvas ever built answered every
/// later request — a window resize could never take effect. Kept as one
/// entry per body (not per size) so the keep-always tier cannot accumulate
/// an entry per viewport a player has ever used.
#[test]
fn global_tier_cache_misses_on_a_size_change() {
let mut cache = GlobalTierCache::new();
cache.insert("BodyA".to_string(), dummy_canvas()); // 1x1
assert!(cache.get("BodyA", (1, 1)).is_some(), "same size must hit");
assert!(
cache.get("BodyA", (960, 480)).is_none(),
"a different size must MISS, or a resize can never take effect"
);
assert_eq!(cache.len(), 1, "still one entry per body, not one per size");
}
// -----------------------------------------------------------------