diff --git a/.pql/changelog/ticket_history/2026-07.sql b/.pql/changelog/ticket_history/2026-07.sql index 688c81f88..5e71437a7 100644 --- a/.pql/changelog/ticket_history/2026-07.sql +++ b/.pql/changelog/ticket_history/2026-07.sql @@ -3267,3 +3267,9 @@ Claim ids with ''pql decisions claim D architecture ...'' before writing. DONE 2026-07-27. Both rulings written up: D-259 (enclosed settlements — open-air requires a breathable atmosphere; branch on an explicit property, not the atmosphere string; NULL fails safe to enclosed; D-220''s density model declared physically inapplicable to a pressure vessel rather than merely mistuned; the D-166 door boundary contract identified as the already-planned handoff) and D-260 (generator scope — Sol encoded as Authored with deepest_rung Global, the DLC gate a DEPTH not a boolean, no new wire status because rung-liveness already covers it, Sol art an authored DATA CANVAS not a finished image). The thick/reducing extension of Jeroen''s thin ruling is flagged inside D-259 as the author''s inference rather than his words. D-260 records the concrete hazard that motivated encoding the exclusion: systems.db still carries terrain_reference rows for Sol bodies pointing at heightmaps deliberately never baked, so a real Earth DEM dropped at that path would silently start generating a procedural homeworld. Also records that Earth will never have a walkable world by construction (8.5B people, 14% of the setting) as an explicit choice rather than a gap.', NULL, '2026-07-27 07:09:08', '2026-07-27 07:09:08.309', '2026-07-27 07:09:08.309', NULL, 'c9bf7a93d9c93e09883a5f7a9ca75070', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT1CZSETZPGX9F5747TDBA7W', 'status', 'backlog', 'done', NULL, '2026-07-27 07:09:08', '2026-07-27 07:09:08.345', '2026-07-27 07:09:08.345', NULL, 'ee39b3b41c7eee7ef7a01da8141d0d83', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT0TX2W0BA10PRR7NMJ2362M', 'description', 'D-258: the Atlas cascade gains a single expanded layer (''rung 0.5''), generated once per body from the rung-0 input pair (heightmap.png + reliefmap.png, never displayed), and every zoom tier below it (Region/District/Quarter/Block/Chunk) derives from that layer instead of independently re-deriving from the source files. Hydrology (drainage, course routing, lake fill) resolves ONCE on rung 0.5 and nowhere else -- it is a whole-body computation, not derivable per-window. Global displays rung 0.5 directly (biome-from-orbit, not a photograph). Same-session amendment: lake shorelines run the same shore-morphology code as ocean shorelines (coastline warp applied to both surfaces in the single rung-0.5 pass; shore-morphology gates key on proximity to water, not to ocean; sea-flavored types like TidalFlat/Estuarine-vs-Delta separated by tidal energy, a derived quantity, never by an is-it-the-ocean switch; salinity excluded from morphology entirely). This is a named, principled carve-out from D-227 derive-don''t-store: a whole-body flow solve is not locally computable, so it cannot be re-derived per window at any price -- storage here buys correctness, not convenience. Not yet ticketed prior to this epic (D-258''s own Implementation note). See governance/decisions/architecture.md#d-258.', 'D-258: the Atlas cascade gains a single expanded layer (''rung 0.5''), generated once per body from the rung-0 input pair (heightmap.png + reliefmap.png, never displayed), and every zoom tier below it (Region/District/Quarter/Block/Chunk) derives from that layer instead of independently re-deriving from the source files. Hydrology (drainage, course routing, lake fill) resolves ONCE on rung 0.5 and nowhere else -- it is a whole-body computation, not derivable per-window. Global displays rung 0.5 directly (biome-from-orbit, not a photograph). Same-session amendment: lake shorelines run the same shore-morphology code as ocean shorelines (coastline warp applied to both surfaces in the single rung-0.5 pass; shore-morphology gates key on proximity to water, not to ocean; sea-flavored types like TidalFlat/Estuarine-vs-Delta separated by tidal energy, a derived quantity, never by an is-it-the-ocean switch; salinity excluded from morphology entirely). This is a named, principled carve-out from D-227 derive-don''t-store: a whole-body flow solve is not locally computable, so it cannot be re-derived per window at any price -- storage here buys correctness, not convenience. Not yet ticketed prior to this epic (D-258''s own Implementation note). See governance/decisions/architecture.md#d-258. + +RE-SCOPE REQUIRED BEFORE ANY CHILD STARTS (2026-07-27, D-258 amendment). The structural rationale for this epic was disproven on evidence the day after it was written. D-258 claimed hydrology ''was not derivable at all'' per-window; in fact layer1.rs::run_layer1_with_moisture already solves drainage AND settled-equilibrium hydrology once per body, folds the filled surface into TerrainAnalysis, and every rung bilinearly samples it -- the code''s own comment calls it ''a coarse continuous primitive computed once, sampled fresh at every rung, never re-solved'' (mechanism B, D-255(f)). Compute-once-sample-everywhere already exists. What actually made Global look flat was serve_step_canvas_request zeroing Global''s wire extent (a pre-extent-inversion sentinel), producing a 2x1 canvas -- fixed 2026-07-27; once sized correctly Global reads as a world with no hydrology work at all. Rivers at Global were measured as negligible: 375 courses present, 458 of 518,400 pixels different vs courses-off, because at ~39.7 km/gridunit most courses are shorter than one gridunit. SURVIVES: biome un-summarisation (reliefmap as plurality), composition-on-descent, the conservation invariant, and the lake-shore amendment. WEAKENED: the stored expanded layer and its D-227 carve-out. The live question is no longer ''what does rung 0.5 cost'' but ''does biome un-summarisation need a stored layer at all, or does it ride the existing sample-fresh-at-every-rung mechanism''. Re-scope this epic and T-1212 against that question first.', NULL, '2026-07-27 18:51:42', '2026-07-27 18:51:42.098', '2026-07-27 18:51:42.098', NULL, '6039ad49453a174a1b0b0f12a6c99a94', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT0TYBD74TQNVKTJMKA8D9KM', 'description', 'MUST run and be reviewed before any other T-1211 child starts -- this measurement could reshape the rung-0.5 design, per the pair session''s explicit sequencing note. Measure, at minimum: (1) per-body derive cost for the expanded layer at a resolution sized so a whole body draws at 2 screen px per gridunit on a large display (the D-258 sizing rule) -- both single-body cold-derive time and the full ~271-body population sum; (2) per-body and total disk footprint if the layer is cached/stored (rung 0.5 is a named D-227 carve-out -- storage is deliberate, but its size must be known, not assumed); (3) whole-body hydrology solve cost on this layer (drainage + course routing + lake fill) at the same resolution, since D-258 requires this to run exactly once per body and nowhere else. Reference point: D-255''s own rung-0 always-keep tier estimate went from ~8.85 MB (measured against a stale ~18K-cell/body figure) to an estimated 226 MB (1080p) / ~900 MB (4K) once the extent inversion made Global viewport-sized -- D-255 amendment item 6 explicitly says ''re-measure against rung 0.5, not against this record.'' This ticket is that re-measurement. Report back to the team before T-1211''s other children are started; if the numbers are structurally bad (e.g. rung 0.5 at the sizing D-258 specifies costs an order of magnitude more than the old rung-0 model), that is grounds to revisit the resolution target with Jeroen before writing generator code. See governance/decisions/architecture.md#d-258 (rationale + Implementation note).', 'MUST run and be reviewed before any other T-1211 child starts -- this measurement could reshape the rung-0.5 design, per the pair session''s explicit sequencing note. Measure, at minimum: (1) per-body derive cost for the expanded layer at a resolution sized so a whole body draws at 2 screen px per gridunit on a large display (the D-258 sizing rule) -- both single-body cold-derive time and the full ~271-body population sum; (2) per-body and total disk footprint if the layer is cached/stored (rung 0.5 is a named D-227 carve-out -- storage is deliberate, but its size must be known, not assumed); (3) whole-body hydrology solve cost on this layer (drainage + course routing + lake fill) at the same resolution, since D-258 requires this to run exactly once per body and nowhere else. Reference point: D-255''s own rung-0 always-keep tier estimate went from ~8.85 MB (measured against a stale ~18K-cell/body figure) to an estimated 226 MB (1080p) / ~900 MB (4K) once the extent inversion made Global viewport-sized -- D-255 amendment item 6 explicitly says ''re-measure against rung 0.5, not against this record.'' This ticket is that re-measurement. Report back to the team before T-1211''s other children are started; if the numbers are structurally bad (e.g. rung 0.5 at the sizing D-258 specifies costs an order of magnitude more than the old rung-0 model), that is grounds to revisit the resolution target with Jeroen before writing generator code. See governance/decisions/architecture.md#d-258 (rationale + Implementation note). + +SCOPE INVALIDATED 2026-07-27 (see T-1211 and the D-258 amendment). This ticket was written to measure the cost of moving a whole-body hydrology solve onto rung 0.5. That solve does not need to move -- it already runs once per body in layer1.rs and is sampled fresh at every rung. Do NOT run this measurement as written; it would price work that is not required. If a measurement is still wanted after T-1211 is re-scoped, the question is narrower: what does BIOME un-summarisation cost, and does it need storing at all.', NULL, '2026-07-27 18:51:51', '2026-07-27 18:51:51.056', '2026-07-27 18:51:51.056', NULL, 'c9374708c339c12f68f1121972578a7d', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/ticket_labels/2026-07.sql b/.pql/changelog/ticket_labels/2026-07.sql new file mode 100644 index 000000000..bf3f0639d --- /dev/null +++ b/.pql/changelog/ticket_labels/2026-07.sql @@ -0,0 +1,2 @@ +INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT0TX2W0BA10PRR7NMJ2362M', 'needs-refinement', '2026-07-27 18:51:51.087', '2026-07-27 18:51:51.087', NULL, '79718d3c8cc424a591fc27523346e928', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash); +INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT0TYBD74TQNVKTJMKA8D9KM', 'needs-refinement', '2026-07-27 18:51:51.113', '2026-07-27 18:51:51.113', NULL, '80b9048473acacb0d294799010ed3534', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash); diff --git a/.pql/changelog/tickets/2026-07.sql b/.pql/changelog/tickets/2026-07.sql index fa9b27363..1f1460c81 100644 --- a/.pql/changelog/tickets/2026-07.sql +++ b/.pql/changelog/tickets/2026-07.sql @@ -5562,3 +5562,9 @@ Also record that Sol bodies carry dangling terrain_reference rows pointing at he Claim ids with ''pql decisions claim D architecture ...'' before writing. DONE 2026-07-27. Both rulings written up: D-259 (enclosed settlements — open-air requires a breathable atmosphere; branch on an explicit property, not the atmosphere string; NULL fails safe to enclosed; D-220''s density model declared physically inapplicable to a pressure vessel rather than merely mistuned; the D-166 door boundary contract identified as the already-planned handoff) and D-260 (generator scope — Sol encoded as Authored with deepest_rung Global, the DLC gate a DEPTH not a boolean, no new wire status because rung-liveness already covers it, Sol art an authored DATA CANVAS not a finished image). The thick/reducing extension of Jeroen''s thin ruling is flagged inside D-259 as the author''s inference rather than his words. D-260 records the concrete hazard that motivated encoding the exclusion: systems.db still carries terrain_reference rows for Sol bodies pointing at heightmaps deliberately never baked, so a real Earth DEM dropped at that path would silently start generating a procedural homeworld. Also records that Earth will never have a walkable world by construction (8.5B people, 14% of the setting) as an explicit choice rather than a gap.', 'done', 'high', NULL, 'server', NULL, '2026-07-26 23:12:57.206', '2026-07-27 07:09:08.345', NULL, '58dab2bc8c4d68c3c49469e5df790e98', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT0TX2W0BA10PRR7NMJ2362M', 'epic', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Rung-0.5 expanded layer — whole-body hydrology + biome-from-orbit base (D-258)', 'D-258: the Atlas cascade gains a single expanded layer (''rung 0.5''), generated once per body from the rung-0 input pair (heightmap.png + reliefmap.png, never displayed), and every zoom tier below it (Region/District/Quarter/Block/Chunk) derives from that layer instead of independently re-deriving from the source files. Hydrology (drainage, course routing, lake fill) resolves ONCE on rung 0.5 and nowhere else -- it is a whole-body computation, not derivable per-window. Global displays rung 0.5 directly (biome-from-orbit, not a photograph). Same-session amendment: lake shorelines run the same shore-morphology code as ocean shorelines (coastline warp applied to both surfaces in the single rung-0.5 pass; shore-morphology gates key on proximity to water, not to ocean; sea-flavored types like TidalFlat/Estuarine-vs-Delta separated by tidal energy, a derived quantity, never by an is-it-the-ocean switch; salinity excluded from morphology entirely). This is a named, principled carve-out from D-227 derive-don''t-store: a whole-body flow solve is not locally computable, so it cannot be re-derived per window at any price -- storage here buys correctness, not convenience. Not yet ticketed prior to this epic (D-258''s own Implementation note). See governance/decisions/architecture.md#d-258. + +RE-SCOPE REQUIRED BEFORE ANY CHILD STARTS (2026-07-27, D-258 amendment). The structural rationale for this epic was disproven on evidence the day after it was written. D-258 claimed hydrology ''was not derivable at all'' per-window; in fact layer1.rs::run_layer1_with_moisture already solves drainage AND settled-equilibrium hydrology once per body, folds the filled surface into TerrainAnalysis, and every rung bilinearly samples it -- the code''s own comment calls it ''a coarse continuous primitive computed once, sampled fresh at every rung, never re-solved'' (mechanism B, D-255(f)). Compute-once-sample-everywhere already exists. What actually made Global look flat was serve_step_canvas_request zeroing Global''s wire extent (a pre-extent-inversion sentinel), producing a 2x1 canvas -- fixed 2026-07-27; once sized correctly Global reads as a world with no hydrology work at all. Rivers at Global were measured as negligible: 375 courses present, 458 of 518,400 pixels different vs courses-off, because at ~39.7 km/gridunit most courses are shorter than one gridunit. SURVIVES: biome un-summarisation (reliefmap as plurality), composition-on-descent, the conservation invariant, and the lake-shore amendment. WEAKENED: the stored expanded layer and its D-227 carve-out. The live question is no longer ''what does rung 0.5 cost'' but ''does biome un-summarisation need a stored layer at all, or does it ride the existing sample-fresh-at-every-rung mechanism''. Re-scope this epic and T-1212 against that question first.', 'backlog', 'high', NULL, NULL, 'D-258', '2026-07-26 21:53:56.448', '2026-07-27 18:51:42.097', NULL, '4c86bf9cd1ac6f142b0c229285844479', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT0TYBD74TQNVKTJMKA8D9KM', 'task', '06FT0TX2W0BA10PRR7NMJ2362M', 'Measure rung-0.5 cost/size BEFORE implementing (compute + disk, whole-body)', 'MUST run and be reviewed before any other T-1211 child starts -- this measurement could reshape the rung-0.5 design, per the pair session''s explicit sequencing note. Measure, at minimum: (1) per-body derive cost for the expanded layer at a resolution sized so a whole body draws at 2 screen px per gridunit on a large display (the D-258 sizing rule) -- both single-body cold-derive time and the full ~271-body population sum; (2) per-body and total disk footprint if the layer is cached/stored (rung 0.5 is a named D-227 carve-out -- storage is deliberate, but its size must be known, not assumed); (3) whole-body hydrology solve cost on this layer (drainage + course routing + lake fill) at the same resolution, since D-258 requires this to run exactly once per body and nowhere else. Reference point: D-255''s own rung-0 always-keep tier estimate went from ~8.85 MB (measured against a stale ~18K-cell/body figure) to an estimated 226 MB (1080p) / ~900 MB (4K) once the extent inversion made Global viewport-sized -- D-255 amendment item 6 explicitly says ''re-measure against rung 0.5, not against this record.'' This ticket is that re-measurement. Report back to the team before T-1211''s other children are started; if the numbers are structurally bad (e.g. rung 0.5 at the sizing D-258 specifies costs an order of magnitude more than the old rung-0 model), that is grounds to revisit the resolution target with Jeroen before writing generator code. See governance/decisions/architecture.md#d-258 (rationale + Implementation note). + +SCOPE INVALIDATED 2026-07-27 (see T-1211 and the D-258 amendment). This ticket was written to measure the cost of moving a whole-body hydrology solve onto rung 0.5. That solve does not need to move -- it already runs once per body in layer1.rs and is sampled fresh at every rung. Do NOT run this measurement as written; it would price work that is not required. If a measurement is still wanted after T-1211 is re-scoped, the question is narrower: what does BIOME un-summarisation cost, and does it need storing at all.', 'backlog', 'high', NULL, NULL, 'D-258', '2026-07-26 21:54:06.825', '2026-07-27 18:51:51.056', NULL, 'c5e77cd4de6e9ec9647bbfe12d0ed6e2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_cache.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_cache.gd index 88ff9c48e..de9747c9f 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_cache.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_cache.gd @@ -45,16 +45,24 @@ func _init(max_entries: int = DEFAULT_MAX_ENTRIES) -> void: _max_entries = maxi(1, max_entries) -## Build the cache key. `rung == "Global"` collapses center/extent to a -## fixed sentinel (0,0) regardless of what's passed — matching the wire's -## own "Global ignores center/extent" contract, so a caller that -## accidentally passes a stale center/extent for a Global request still -## lands on the correct single per-body slot. +## Build the cache key. +## +## `rung == "Global"` still collapses the CENTRE to a sentinel (0,0): Global's +## canvas is whole-body and origin-anchored, so the server genuinely ignores +## `center` for it and a caller passing a stale centre still lands on the +## right slot. +## +## Its EXTENT is no longer collapsed (D-255 extent inversion, 2026-07-26). +## Global used to have exactly one possible size per body — its region grid — +## which is what made zeroing the extent safe. It is now viewport-sized, and +## zeroing it collapsed every size onto a single key: the first canvas cached +## for a body answered every later request, so a window resize could never take +## effect, because the differently-sized request was a cache HIT on the old one. static func make_key( body_id: String, rung: String, center: Vector2i, extent: Vector2i, min_wl_m: int = 0 ) -> String: var key_center: Vector2i = Vector2i.ZERO if rung == "Global" else center - var key_extent: Vector2i = Vector2i.ZERO if rung == "Global" else extent + var key_extent: Vector2i = extent return "%s:%s:%d,%d:%d,%d:%d" % [ body_id, rung, key_center.x, key_center.y, key_extent.x, key_extent.y, min_wl_m ] diff --git a/project.yaml b/project.yaml index 42c388632..cc12655e9 100644 --- a/project.yaml +++ b/project.yaml @@ -14,7 +14,12 @@ name: The Settled Reach # through that change, so their cache keys are byte-identical while a District # canvas now covers 3.6 km of ground instead of 1,966 km — a warm cache would # silently serve pre-inversion canvases. 0.4.4 forces those entries to miss. -version: 0.4.4 +# 0.4.4-tagged GLOBAL canvases are all 2x1 — `serve_step_canvas_request` was +# still zeroing Global's wire extent (a pre-extent-inversion sentinel), so +# every Global request resolved to a two-cell canvas regardless of viewport. +# Those entries were also cached under a key that ignored extent, so they +# answered every later request forever. 0.4.5 forces them to miss. +version: 0.4.5 repository: settled-reach diff --git a/server/src/atlas/step_canvas.rs b/server/src/atlas/step_canvas.rs index bb639c6c0..8b9d84de2 100644 --- a/server/src/atlas/step_canvas.rs +++ b/server/src/atlas/step_canvas.rs @@ -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"); } // ----------------------------------------------------------------- diff --git a/server/tests/step_canvas_acceptance_gate.rs b/server/tests/step_canvas_acceptance_gate.rs index b9624cb21..172d08f34 100644 --- a/server/tests/step_canvas_acceptance_gate.rs +++ b/server/tests/step_canvas_acceptance_gate.rs @@ -241,12 +241,13 @@ fn global_tier_cache_round_trip_matches_fresh_derive() { let fresh_encoded = encode_step_canvas(&fresh_raw); let mut cache = GlobalTierCache::new(); - assert!(cache.get("gate-body").is_none()); + let gate_size = (fresh_encoded.width, fresh_encoded.height); + assert!(cache.get("gate-body", gate_size).is_none()); cache.insert("gate-body".to_string(), fresh_encoded.clone()); assert!(cache.contains("gate-body")); let cached = cache - .get("gate-body") + .get("gate-body", gate_size) .expect("global tier cache hit after insert"); assert_eq!( cached, &fresh_encoded,