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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user