diff --git a/client/tests/test_step_canvas_transport.gd b/client/tests/test_step_canvas_transport.gd index 39ea7b507..4ce78d01a 100644 --- a/client/tests/test_step_canvas_transport.gd +++ b/client/tests/test_step_canvas_transport.gd @@ -128,7 +128,8 @@ func test_zero_extent_does_not_divide_by_zero() -> void: # ============================================================================= -# Display ratio — deep/mid 1x1, shallow ~5x5, PRESENTATION only (D-255(a)) +# Display ratio — uniform 2x2 since the extent inversion, PRESENTATION only +# (D-255(a): never touches a cache key or a wire request) # ============================================================================= @@ -149,14 +150,17 @@ func test_display_ratio_deep_rungs_all_share_the_deep_ratio() -> void: assert_float(deep).is_greater(0.0) -## Only Global keeps the shallow fallback now — Region joined the deep band -## with the extent inversion (it is a real 262x466 km map, not an orbital -## envelope). -func test_only_global_uses_the_shallow_display_ratio() -> void: - assert_float(StepCanvasTransport.display_ratio_for_rung("Global")).is_equal_approx(5.0, 0.001) - assert_float(StepCanvasTransport.display_ratio_for_rung("Region")).is_equal_approx( - StepCanvasTransport.DISPLAY_RATIO_DEEP, 0.001 - ) +## Every rung reads at the deep ratio since the extent inversion — Region +## because it is a real 262x466 km map rather than an orbital envelope, and +## Global because 5 px/gridunit asked for fewer cells than the heightmap +## already stores. Asserted as a relationship, not a literal, so the ratio +## stays a tunable. +func test_every_rung_uses_the_deep_display_ratio() -> void: + var deep: float = StepCanvasTransport.DISPLAY_RATIO_DEEP + for rung in ["Global", "Region", "District", "Quarter", "Block", "Chunk"]: + assert_float(StepCanvasTransport.display_ratio_for_rung(rung)).override_failure_message( + "%s must read at the deep ratio" % rung + ).is_equal_approx(deep, 0.001) ## Region LEFT the orbital set with the extent inversion — it now rides the @@ -187,9 +191,9 @@ func test_viewport_fit_extent_at_deep_ratio_divides_by_the_deep_ratio() -> void: assert_that(extent).is_equal(expected) -func test_viewport_fit_extent_at_shallow_ratio_divides_by_the_display_ratio() -> void: +func test_viewport_fit_extent_at_global_divides_by_the_display_ratio() -> void: var extent: Vector2i = StepCanvasTransport.viewport_fit_extent(Vector2(1000.0, 500.0), "Global") - assert_that(extent).is_equal(Vector2i(200, 100)) + assert_that(extent).is_equal(Vector2i(500, 250)) func test_viewport_fit_extent_clamps_to_the_fixed_canvas_max_axis() -> void: @@ -289,7 +293,7 @@ func test_world_to_local_and_back_round_trips() -> void: func test_canvas_footprint_px_is_extent_times_display_ratio() -> void: var footprint: Vector2 = StepCanvasTransport.canvas_footprint_px("Global", Vector2i(100, 50)) - assert_that(footprint).is_equal(Vector2(500.0, 250.0)) # 5x5 shallow ratio + assert_that(footprint).is_equal(Vector2(200.0, 100.0)) # 2x2 deep ratio ## Half-extent is now half the rung's own CELL on the short axis, whatever @@ -329,8 +333,10 @@ func test_every_rung_is_live_on_the_smallest_populated_body() -> void: ## ...but a rung whose cell exceeds the body is not a step down the ladder at -## all — it would zoom OUT. systems.db has two such rocks (6 km and 11 km -## radius, both uninhabited). +## all — it would zoom OUT. The only two such bodies in systems.db are Phobos +## (11 km) and Deimos (6 km), and Sol is out of generator bounds entirely, so +## this guard never fires on in-scope content. Kept because it is free and the +## failure it prevents is silent. func test_region_is_not_live_on_a_sub_region_body() -> void: assert_bool(StepCanvasTransport.is_rung_live_on_body("Region", 6.0)).is_false() assert_bool(StepCanvasTransport.is_rung_live_on_body("District", 6.0)).is_true() diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd index fafa77774..12f61b969 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd @@ -85,15 +85,25 @@ const DISPLAY_RATIO_SHALLOW: float = 5.0 ## (D-255(a): "the deep, ground-level steps where the player is closest to ## visible detail"). Chunk's own "1 screen px per 64 m gridunit, no ## magnification margin" bottom-out rule (D-255(a)) is exactly DISPLAY_RATIO_DEEP. -## D-255 amendment 2026-07-26: **Region moved to the deep ratio.** The shallow -## fallback earned its keep only while a Region canvas was an orbital-scale -## picture where extent, not per-cell fidelity, was what grew. Post-inversion -## Region spans 262x466 km — a provincial map with real terrain in it — so it -## takes the same crispness as every rung below it. Jeroen's brief for this -## session in one line: "the global and region maps need to look the same as -## the smaller level maps, but with obviously a different zoom level." +## D-255 amendment 2026-07-26: **every rung now reads at the deep ratio**, so +## this table is uniform and DISPLAY_RATIO_SHALLOW is currently unreferenced +## (kept, not deleted — pending Jeroen's call, and it documents the retired +## band). Jeroen's brief for this session in one line: "the global and region +## maps need to look the same as the smaller level maps, but with obviously a +## different zoom level." +## +## Region: the shallow fallback earned its keep only while a Region canvas was +## an orbital-scale picture where extent, not per-cell fidelity, was what grew. +## Post-inversion it spans 262x466 km — a provincial map with real terrain in +## it — so it takes the same crispness as every rung below. +## +## Global: at 5 px/gridunit a 1920-px window asked for 384 cells across a body +## whose heightmap is stored at 512x256 — discarding stored detail to save +## work it had already done. At 2 px/gridunit it asks for 960, which is +## heightmap-native: nothing thrown away, nothing invented. Both ratios fill +## the same screen area; only the sampling differs. const DISPLAY_RATIO_BY_RUNG: Dictionary = { - RUNG_GLOBAL: DISPLAY_RATIO_SHALLOW, + RUNG_GLOBAL: DISPLAY_RATIO_DEEP, RUNG_REGION: DISPLAY_RATIO_DEEP, RUNG_DISTRICT: DISPLAY_RATIO_DEEP, RUNG_QUARTER: DISPLAY_RATIO_DEEP, diff --git a/project.yaml b/project.yaml index 3ae2906f3..42c388632 100644 --- a/project.yaml +++ b/project.yaml @@ -8,7 +8,13 @@ name: The Settled Reach # position raw (no coast_warp_px) — T-1160 applies the warp at orbital # sampling, changing orbital-rung canvas bytes (elev_q/temperature_dc/ # moisture_q shift near coasts) — 0.4.3 forces those cached entries to miss. -version: 0.4.3 +# 0.4.3-tagged canvases predate the D-255 extent inversion (a rung now fixes +# the canvas EXTENT and the gridunit spacing falls out, rather than the +# reverse). District/Quarter/Block/Chunk kept the same 960x540 cell counts +# 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 repository: settled-reach diff --git a/server/src/atlas/step_canvas.rs b/server/src/atlas/step_canvas.rs index 4bea1afba..eddb3ea33 100644 --- a/server/src/atlas/step_canvas.rs +++ b/server/src/atlas/step_canvas.rs @@ -870,10 +870,37 @@ fn resolve_canvas_extent( extent: (u32, u32), body_radius_km: f64, ) -> (u32, u32) { - match rung.global_cell_counts(body_radius_km) { - Some((cols, rows)) => (cols, rows), - None => clamp_step_canvas_extent(extent), + if !rung.is_global() { + return clamp_step_canvas_extent(extent); } + // Global is VIEWPORT-SIZED like every other rung now (D-255 amendment, + // pair session 2026-07-26) — it used to take its cell counts from + // `global_cell_counts()`, i.e. one gridunit per region. On GJ380c that + // was a 191x95 canvas built from a heightmap stored at 512x256: roughly + // SEVEN TIMES the available cells thrown away before drawing. Worse, the + // count shrank as REGION_M grew, so tuning the scale ladder silently + // degraded the opener. + // + // The one thing Global cannot take from the viewport is its ASPECT: the + // canvas is equirectangular whole-body, 2:1 (360 degrees of longitude by + // 180 of latitude), and cells must stay square or the map shears. So fit + // the largest 2:1 canvas inside the requested extent and let the existing + // letterbox (`center_offset`) absorb the remainder — the viewport is + // ~16:9, so this is normally height-bound. + let (req_w, req_h) = clamp_step_canvas_extent(extent); + let width = req_w.min(req_h.saturating_mul(2)).max(2); + let resolved = (width, (width / 2).max(1)); + debug_assert!( + resolved.0 == resolved.1 * 2, + "Global canvas must stay 2:1 or cells are not square" + ); + // A body with no radius (an asteroid belt is not a sphere and has no + // equirectangular surface) has nothing to fit — fall back to the region + // grid, which degrades to a 1x1 canvas rather than a plausible-looking lie. + if body_radius_km <= 0.0 { + return rung.global_cell_counts(body_radius_km).unwrap_or(resolved); + } + resolved } /// The step-canvas derive core (D-255(f): independent re-derivation, the @@ -1879,31 +1906,59 @@ mod tests { } } + /// Global now SIZES to the request (D-255 amendment 2026-07-26) — it used + /// to derive its cell counts from the body's region grid and discard the + /// wire value, which on GJ380c meant a 191x95 canvas built from a 512x256 + /// heightmap. A hostile wire value must still be clamped, but a legitimate + /// one must be honoured. #[test] - fn global_rung_ignores_wire_extent_entirely() { - // resolve_canvas_extent must derive Global's extent from the body's - // own region grid, never from the (hostile or otherwise) wire - // value — confirmed at u32::MAX, the most adversarial input. - let (w, h) = resolve_canvas_extent(StepCanvasRung::Global, (u32::MAX, u32::MAX), 6371.0); - let expected = StepCanvasRung::Global - .global_cell_counts(6371.0) - .expect("Global has a cell-count shape"); - assert_eq!((w, h), expected); - // Sanity: the body-derived shape is nowhere near u32::MAX — proves - // the wire value truly had zero influence, not just a coincidental - // clamp to the same range. - assert!(w < STEP_CANVAS_MAX_EXTENT_AXIS * 10); - assert!(h < STEP_CANVAS_MAX_EXTENT_AXIS * 10); + fn global_rung_sizes_to_the_request() { + let small = resolve_canvas_extent(StepCanvasRung::Global, (480, 270), 6371.0); + let large = resolve_canvas_extent(StepCanvasRung::Global, (960, 540), 6371.0); + assert!( + large.0 > small.0, + "a bigger viewport must get a bigger Global canvas: {small:?} vs {large:?}" + ); + // Height-bound at a 16:9 request: 540*2 = 1080 > 960, so width wins. + assert_eq!(large, (960, 480)); } + /// Global's canvas is equirectangular whole-body — 360 degrees of + /// longitude by 180 of latitude. It must stay exactly 2:1 at every + /// viewport shape or the cells stop being square and the map shears. #[test] - fn global_rung_extent_is_identical_regardless_of_requested_extent() { - // A second confirmation from the opposite direction: Global's - // resolved extent must be the SAME for a tiny request and a huge - // one — extent has literally no effect on Global's output shape. - let tiny = resolve_canvas_extent(StepCanvasRung::Global, (1, 1), 6371.0); - let huge = resolve_canvas_extent(StepCanvasRung::Global, (u32::MAX, u32::MAX), 6371.0); - assert_eq!(tiny, huge); + fn global_rung_stays_two_to_one_at_every_viewport_shape() { + for req in [ + (960u32, 540u32), // 16:9 landscape — height-bound + (540, 960), // portrait — height is huge, width binds + (700, 700), // square + (4000, 100), // absurdly wide + (1, 1), // degenerate + (u32::MAX, u32::MAX), // adversarial + ] { + let (w, h) = resolve_canvas_extent(StepCanvasRung::Global, req, 6371.0); + assert_eq!(w, h * 2, "request {req:?} produced a non-2:1 canvas {w}x{h}"); + assert!(h >= 1, "request {req:?} collapsed the canvas to zero rows"); + } + } + + /// A hostile wire value must still be clamped to the canvas budget — + /// sizing to the request must not become "trust the request". + #[test] + fn global_rung_still_clamps_an_adversarial_extent() { + let (w, h) = resolve_canvas_extent(StepCanvasRung::Global, (u32::MAX, u32::MAX), 6371.0); + assert!(w <= STEP_CANVAS_MAX_EXTENT_AXIS, "width {w} escaped the axis cap"); + assert!(h <= STEP_CANVAS_MAX_EXTENT_AXIS, "height {h} escaped the axis cap"); + } + + /// A body with no radius is not a sphere (asteroid belt, oort cloud) and + /// has no equirectangular surface to fit. It must degrade to the region + /// grid — a visibly degenerate canvas — rather than a plausible-looking + /// lie at whatever size the viewport happened to ask for. + #[test] + fn global_rung_without_a_radius_does_not_fabricate_a_surface() { + let (w, h) = resolve_canvas_extent(StepCanvasRung::Global, (960, 540), 0.0); + assert_eq!((w, h), (1, 1)); } // -----------------------------------------------------------------