feat(client): size the Global rung to the viewport, not the region grid

Global took its cell counts from `global_cell_counts()` — one gridunit per
region — and discarded the requested extent entirely. On GJ380c that produced
a 191x95 canvas built from a heightmap stored at 512x256: roughly seven times
the available cells thrown away before anything was drawn. The count also
shrank as REGION_M grew, so tuning the scale ladder silently degraded the
opener, which is why the top of the ladder got worse rather than better as
the ladder itself was refined.

Global now fits the largest 2:1 canvas inside the requested extent. It cannot
take its ASPECT from the viewport — the canvas is equirectangular whole-body,
360 degrees of longitude by 180 of latitude, and must stay 2:1 or the cells
stop being square and the map shears — so the existing letterbox absorbs the
remainder. A hostile extent is still clamped; sizing to the request is not
trusting the request.

Global also joins the deep display ratio, making the band uniform. At 5 px
per gridunit a 1920 px window asked for 384 cells across a body whose
heightmap holds 512x256 — discarding stored detail to save work already done.
At 2 px it asks for 960, which is heightmap-native: nothing thrown away,
nothing invented, and the same screen area filled either way.

A body with no radius is not a sphere (asteroid belt, oort cloud) and has no
equirectangular surface to fit. Those degrade to the region grid — a visibly
degenerate 1x1 canvas — rather than a plausible-looking lie at whatever size
the viewport happened to ask for.

project.yaml 0.4.3 -> 0.4.4 invalidates the persisted step-canvas disk cache.
District/Quarter/Block/Chunk kept identical 960x540 cell counts through the
extent inversion, 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.

Global still rides the orbital derive, so it carries no courses yet; that is
the next step and is deliberately separate, being a cost question over the
whole body rather than a sizing one.

Pair session with Jeroen, 2026-07-26.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-26 22:45:40 +02:00
co-authored by Claude
parent 0a0419abcc
commit 9c8fcc2f95
4 changed files with 124 additions and 47 deletions
+20 -14
View File
@@ -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()
@@ -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,
+7 -1
View File
@@ -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
+79 -24
View File
@@ -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));
}
// -----------------------------------------------------------------