diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 3a706fbfe..f48038082 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -435,20 +435,32 @@ func send_named_action(action_name: String, action_data: Variant = null) -> void ## ## window_granularity/window_min_wl_m (T-1150): struct/key plumbing for the ## zoom-ladder quarter rung — district (0/omitted) stays the default for -## every caller in this codebase today; requesting quarter granularity is -## T-1153's job, not wired here. +## every caller in this codebase today. +## +## window_granularity_v2 (T-1152/T-1153): the R5-redesigned string-tag +## granularity ("Quarter"/"District"/"Region") — the ONLY way to request the +## coarser-than-district Region rung the legacy u32 field cannot express. +## Empty string (omitted) is the default for every caller that doesn't pass +## it, byte-compatible with every pre-T-1152 request. func request_atlas_layers( body_id: String, up_to: String = "Topography", window_center: Variant = null, window_n: int = 0, window_granularity: int = 0, - window_min_wl_m: int = 0 + window_min_wl_m: int = 0, + window_granularity_v2: String = "" ) -> void: if test_mode or _bridge == null or state != ConnectionState.CONNECTED: return var bytes := Protocol.encode_atlas_layer_request( - body_id, up_to, window_center, window_n, window_granularity, window_min_wl_m + body_id, + up_to, + window_center, + window_n, + window_granularity, + window_min_wl_m, + window_granularity_v2 ) if bytes.is_empty(): return diff --git a/client/scripts/protocol/atlas_map_protocol.gd b/client/scripts/protocol/atlas_map_protocol.gd index 8935f8b49..43077a933 100644 --- a/client/scripts/protocol/atlas_map_protocol.gd +++ b/client/scripts/protocol/atlas_map_protocol.gd @@ -40,6 +40,22 @@ class_name AtlasMapProtocol ## it possible to ask, byte-compatible with every existing caller that ## doesn't pass them. ## +## `window_granularity_v2` (T-1152, R5 redesign — see +## server/src/atlas/layer_proxy.rs's `WindowGranularity` doc): the ONLY way to +## express a coarser-than-district rung (`"Region"`) the legacy `u32` field +## cannot encode. A plain STRING variant tag ("Quarter" | "District" | +## "Region"), matching `RoadNodeKind`'s existing wire precedent on this same +## carrier (a bare `#[derive(Serialize, Deserialize)]` enum with no +## `#[serde(rename_all)]` — rmp_serde encodes the Rust variant NAME verbatim, +## not an integer discriminant). OMITTED (not sent as "") when +## `window_granularity_v2` is the empty string — `#[serde(default)]` on the +## Rust side decodes absence as `None`, falling back to the legacy `u32` +## field's `resolve_window_granularity_v2()` precedence rule (that field wins +## over the legacy one whenever present — see that Rust doc for the full +## precedence contract). Every pre-T-1152 caller (and every T-1150 caller that +## only ever sends `window_granularity`) omits this field entirely and stays +## byte-compatible. +## ## **Quantization split (PR #191 review, Hoshe 1 / Tyre C3):** `window_min_wl_m` ## is sent HERE as a raw, unquantized value — this codec does NOT snap it to ## the design doc §5 fixed band set. The SERVER is the one place quantization @@ -56,7 +72,8 @@ static func encode_atlas_layer_request( window_center: Variant = null, window_n: int = 0, window_granularity: int = 0, - window_min_wl_m: int = 0 + window_min_wl_m: int = 0, + window_granularity_v2: String = "" ) -> PackedByteArray: var msg := {"body_id": body_id, "up_to": up_to} if window_center != null: @@ -67,6 +84,8 @@ static func encode_atlas_layer_request( msg["window_granularity"] = window_granularity if window_min_wl_m != 0: msg["window_min_wl_m"] = window_min_wl_m + if not window_granularity_v2.is_empty(): + msg["window_granularity_v2"] = window_granularity_v2 var result = mp.encode(msg) if result.status != null: push_error("Protocol: encode_atlas_layer_request failed: %s" % result.status) @@ -91,7 +110,9 @@ static func encode_atlas_layer_request( ## wire decodes to GDScript `null` exactly like every other Option field here. ## The response's `center`/`n` echo (inside the layer dict itself) is the ## client's race-condition/staleness guard (§2) — read by the window cache, -## not unwrapped here. +## not unwrapped here. `granularity_v2` (T-1152) rides inside the same dict, +## a bare string variant tag ("Quarter"/"District"/"Region") — no separate +## top-level unwrap needed, it passes through with everything else. ## Key names "road_graph"/"settlements"/"region_grid"/"quarter_footprints"/ ## "district_window" are the CONFIRMED wire contract — identical to ## server/src/atlas/layer_proxy.rs AtlasLayerResponse's field names diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index 9a089ee9f..b84d8f7ae 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -782,16 +782,27 @@ static func encode_request_bookmark_catalog() -> PackedByteArray: ## Omitted callers (every whole-body-layer call site predating T-1138) are ## byte-unchanged. window_granularity/window_min_wl_m (T-1150): same ## byte-compatibility contract, see atlas_map_protocol.gd. +## window_granularity_v2 (T-1152): the R5-redesigned string-tag granularity +## ("Quarter"/"District"/"Region") — the only way to request the Region rung. +## Omitted (empty string) by every caller that doesn't pass it. static func encode_atlas_layer_request( body_id: String, up_to: String = "Topography", window_center: Variant = null, window_n: int = 0, window_granularity: int = 0, - window_min_wl_m: int = 0 + window_min_wl_m: int = 0, + window_granularity_v2: String = "" ) -> PackedByteArray: return _amp().encode_atlas_layer_request( - _mp(), body_id, up_to, window_center, window_n, window_granularity, window_min_wl_m + _mp(), + body_id, + up_to, + window_center, + window_n, + window_granularity, + window_min_wl_m, + window_granularity_v2 ) diff --git a/client/tests/fixtures/msgpack/atlas_response_ready_with_window.msgpack b/client/tests/fixtures/msgpack/atlas_response_ready_with_window.msgpack index 32c85b742..90ecca37d 100644 Binary files a/client/tests/fixtures/msgpack/atlas_response_ready_with_window.msgpack and b/client/tests/fixtures/msgpack/atlas_response_ready_with_window.msgpack differ diff --git a/client/tests/test_atlas_data_delivery.gd b/client/tests/test_atlas_data_delivery.gd index 1fa7aef11..e1dd4fb15 100644 --- a/client/tests/test_atlas_data_delivery.gd +++ b/client/tests/test_atlas_data_delivery.gd @@ -240,6 +240,36 @@ func test_encode_atlas_layer_request_carries_granularity_and_min_wl() -> void: assert_that(decoded.value.get("window_min_wl_m")).is_equal(512) +## T-1152/T-1153: window_granularity_v2 is OMITTED (not sent as "") when at +## its empty-string default — same byte-compatibility contract as +## window_granularity/window_min_wl_m's own default-omission. +func test_encode_atlas_layer_request_omits_granularity_v2_by_default() -> void: + var bytes := Protocol.encode_atlas_layer_request( + "GJ1c", "Topography", Vector2i(140, 260), 32 + ) + var decoded = Messagepack.decode(bytes) + assert_bool(decoded.value.has("window_granularity_v2")).is_false() + + +## T-1152/T-1153: a Region-rung request carries window_granularity_v2 as the +## bare string "Region" — the ONLY way to express the coarser-than-district +## rung (WindowGranularity's Rust doc: "the ONLY way to actually request +## Region is window_granularity_v2 = Some(WindowGranularity::Region)"), a +## plain rmp_serde variant-name encoding matching RoadNodeKind's existing +## wire precedent, NOT an integer discriminant. +func test_encode_atlas_layer_request_carries_granularity_v2_region() -> void: + var bytes := Protocol.encode_atlas_layer_request( + "GJ1c", "Topography", Vector2i(0, 0), 6400, 0, 0, "Region" + ) + var decoded = Messagepack.decode(bytes) + assert_that(decoded.value.get("window_granularity_v2")).is_equal("Region") + # The legacy window_granularity field is independently omittable — a + # Region request sends ONLY the v2 tag, never a legacy value pretending + # to mean something for Region (WINDOW_GRANULARITY_REGION_KEY is a + # key-space tag the SERVER echoes, never a legal wire INPUT). + assert_bool(decoded.value.has("window_granularity")).is_false() + + ## §2: district_window is a distinct payload (echoes center/n for the ## client's staleness guard) but the codec passthrough is the same shape as ## every sibling layer — raw.get(), no reshaping. Field types follow the @@ -265,6 +295,36 @@ func test_atlas_response_district_window_passthrough() -> void: assert_that((decoded as Dictionary).get("district_window")).is_equal(window) +## T-1152/T-1153, design doc §6 encoding-continuity acceptance: a Region-rung +## response passes through the EXACT SAME codec path as District/Quarter — +## granularity_v2 is just another field in the same dict, no special-cased +## decode branch for the coarser rung. This is the direct regression test for +## "one colorizer family, no per-rung palettes": the wire contract itself +## draws no distinction, so nothing downstream (the overlay's +## cell_grid_side_for_window()/_cell_color()) needs a rung-specific decode +## path either. +func test_atlas_response_district_window_passthrough_region_rung() -> void: + var window := { + "center": [0, 0], + "n": 6400, + "granularity": 4294967295, # WINDOW_GRANULARITY_REGION_KEY (u32::MAX) — key-space tag, not a real multiplier + "granularity_v2": "Region", + "min_wl_m": 0, + "morphology": PackedByteArray([8, 14, 0, 5]), + "elev_q": PackedByteArray([40, 62, 5, 88]), + "temp_dc": [120, 95, AtlasOverlayColors.REGION_TEMP_NONE_DC, 60], + "moisture_q": PackedByteArray([50, 30, 90, 20]), + "vegetation": PackedByteArray([2, 1, 6, 3]), + "glaciation": PackedByteArray([0, 0, 1, 2]), + } + var raw := {"body_id": "GJ1c", "status": "Ready", "district_window": window} + var decoded: Variant = Protocol.atlas_response_from_raw(raw) + assert_that(decoded).is_not_null() + var district_window: Dictionary = (decoded as Dictionary).get("district_window") + assert_that(district_window).is_equal(window) + assert_str(str(district_window.get("granularity_v2"))).is_equal("Region") + + ## A body with no window requested (or not yet derived — §1's background-queue ## serving model: the completion may not have landed yet) must decode with ## district_window absent -> null, same "layer hasn't produced yet" contract diff --git a/client/tests/test_atlas_descend_entry.gd b/client/tests/test_atlas_descend_entry.gd index 1215aa355..5dbb4c2a1 100644 --- a/client/tests/test_atlas_descend_entry.gd +++ b/client/tests/test_atlas_descend_entry.gd @@ -371,16 +371,19 @@ func test_no_descend_signal_without_a_loaded_heightmap() -> void: assert_int(received.size()).is_equal(0) -## RegionalScreen forwards AtlasViewer's district_descend_requested verbatim — -## the nav-stack wiring atlas_app.gd depends on. -func test_regional_screen_forwards_district_descend_requested() -> void: +## T-1153 (D-226 T-1143-rulings amendment): RegionalScreen no longer wraps +## AtlasViewer or forwards district_descend_requested — the "regional" nav +## entry now opens the continuous zoom ladder (AtlasWindowViewer) directly at +## the canonical orbital frame, retiring the click-through as the sole entry +## (see regional_screen.gd's own doc). This regression-guards the NEW +## wiring: entering "regional" reaches AtlasWindowViewer, not AtlasViewer. +func test_regional_screen_wraps_atlas_window_viewer_not_atlas_viewer() -> void: var screen: RegionalScreen = auto_free(RegionalScreen.new()) add_child(screen) - var received: Array = [] - screen.district_descend_requested.connect(func(c: Vector2i) -> void: received.append(c)) - screen._viewer.district_descend_requested.emit(Vector2i(5, 7)) - assert_int(received.size()).is_equal(1) - assert_that(received[0]).is_equal(Vector2i(5, 7)) + assert_object(screen._viewer).override_failure_message( + "RegionalScreen must wrap AtlasWindowViewer (the zoom ladder) since T-1153," + + " not the retired AtlasViewer heightmap-texture display" + ).is_instanceof(AtlasWindowViewer) # ============================================================================= diff --git a/client/tests/test_atlas_window_cache.gd b/client/tests/test_atlas_window_cache.gd index 3fdc61d02..af5294d7c 100644 --- a/client/tests/test_atlas_window_cache.gd +++ b/client/tests/test_atlas_window_cache.gd @@ -180,3 +180,72 @@ func test_district_and_quarter_windows_coexist_at_identical_body_center_n() -> v cache.get_window("GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY) ).is_equal(district_window) assert_that(cache.get_window("GJ1c", Vector2i(10, 20), 32, 4)).is_equal(quarter_window) + + +# ============================================================================= +# granularity_v2 (T-1152/T-1153): the string-tag axis — the ONLY thing that +# distinguishes Region from District/Quarter, since Region has no legal +# legacy-int representation (WindowGranularity::legacy_u32() returns None for +# Region — see the server's own doc). This is the SAME mandatory-aliasing +# regression class as the granularity/min_wl_m tests above, extended to the +# new axis. +# ============================================================================= + + +## **MANDATORY aliasing regression (T-1152/T-1153):** a Region-rung key and a +## District-rung key at the IDENTICAL (body_id, center, n, legacy +## granularity, min_wl_m) must be DISTINCT cache keys — the legacy int slot +## alone (both "District" and default-omitted callers pass +## DISTRICT_GRANULARITY=1) cannot tell them apart; granularity_v2 is what +## does. +func test_make_key_distinguishes_granularity_v2_region_from_district() -> void: + var k_district := AtlasWindowCache.make_key( + "GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 0, "District" + ) + var k_region := AtlasWindowCache.make_key( + "GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 0, "Region" + ) + assert_str(k_district).is_not_equal(k_region) + + +## Omitting granularity_v2 (every pre-T-1152 call site) must produce the SAME +## key as passing the explicit "District" default — byte/string +## compatibility, same contract as the legacy granularity/min_wl_m defaults. +func test_omitted_granularity_v2_matches_explicit_district_default() -> void: + var k_omitted := AtlasWindowCache.make_key("GJ1c", Vector2i(10, 20), 32) + var k_explicit := AtlasWindowCache.make_key( + "GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 0, "District" + ) + assert_str(k_omitted).is_equal(k_explicit) + + +## End-to-end through put()/get_window(): a Region-rung window and a +## District-rung window at the identical (body, center, n) must both be +## independently retrievable — the exact scenario a full-zoom-out-then-back-in +## at the SAME (center, n) would hit if a player oscillates across the +## District/Region boundary. +func test_region_and_district_windows_coexist_at_identical_body_center_n() -> void: + var cache := AtlasWindowCache.new() + var district_window := {"granularity_v2": "District", "id": "district"} + var region_window := {"granularity_v2": "Region", "id": "region"} + + cache.put( + "GJ1c", Vector2i(10, 20), 32, district_window, AtlasWindowCache.DISTRICT_GRANULARITY, 0, + "District" + ) + cache.put( + "GJ1c", Vector2i(10, 20), 32, region_window, AtlasWindowCache.DISTRICT_GRANULARITY, 0, + "Region" + ) + + assert_int(cache.size()).is_equal(2) + assert_that( + cache.get_window( + "GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 0, "District" + ) + ).is_equal(district_window) + assert_that( + cache.get_window( + "GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 0, "Region" + ) + ).is_equal(region_window) diff --git a/client/tests/test_atlas_window_geometry.gd b/client/tests/test_atlas_window_geometry.gd index 030dd05b3..9d407785a 100644 --- a/client/tests/test_atlas_window_geometry.gd +++ b/client/tests/test_atlas_window_geometry.gd @@ -265,3 +265,667 @@ func test_pole_wall_rows_half_matches_canonicalize_rows_half() -> void: Vector2i(0, rows_half), radius_km ) assert_int(canonical.y).is_equal(rows_half) + + +# ============================================================================= +# T-1153: select_rung() — REDESIGNED (live round 3 finding) per-rung +# single-window COVERAGE CEILING model, superseding the original +# `2x`-visual-tolerance-only reading of design doc §5. Select the FINEST +# rung whose own single-window coverage ceiling (MAX_COVERAGE_M) still +# covers the current world extent: Quarter <= 32,768 m; District <= +# 131,072 m; Region otherwise (including tiled coverage beyond its own +# single-window ceiling, a viewer-level concern — see select_rung()'s own +# doc for the full derivation and why this REPLACES the earlier two-gate +# design entirely, not just patches it). +# ============================================================================= + + +## Deep zoom-in (a tiny extent) selects Quarter — comfortably under its own +## 32,768 m ceiling. +func test_select_rung_picks_quarter_well_under_its_ceiling() -> void: + var rung: String = AtlasWindowGeometry.select_rung(2000.0, 1000.0) + assert_str(rung).is_equal("Quarter") + + +## An extent past Quarter's own ceiling but under District's selects +## District — the finest rung that can still cover it in one window. +func test_select_rung_picks_district_between_the_two_ceilings() -> void: + # 60,000 m is past Quarter's 32,768 m ceiling but well under District's + # 131,072 m one. + var rung: String = AtlasWindowGeometry.select_rung(60_000.0, 100.0) + assert_str(rung).is_equal("District") + + +## An extent past BOTH Quarter's and District's ceilings selects Region — +## neither finer rung's single window can cover this much world. +func test_select_rung_picks_region_past_both_finer_ceilings() -> void: + var rung: String = AtlasWindowGeometry.select_rung(40_075_264.0, 1920.0) + assert_str(rung).is_equal("Region") + + +## Exactly AT Quarter's own ceiling (32,768 m) must still select Quarter — +## the rule is `<=`, not `<`. +func test_select_rung_quarter_ceiling_boundary_is_inclusive() -> void: + var rung: String = AtlasWindowGeometry.select_rung(32_768.0, 100.0) + assert_str(rung).is_equal("Quarter") + + +## One metre past Quarter's ceiling must flip to District — confirms the +## ceiling bites right at its own boundary, not one cell short of it. +func test_select_rung_one_past_quarter_ceiling_is_district() -> void: + var rung: String = AtlasWindowGeometry.select_rung(32_769.0, 100.0) + assert_str(rung).is_equal("District") + + +## Exactly AT District's own ceiling (131,072 m) must still select District. +func test_select_rung_district_ceiling_boundary_is_inclusive() -> void: + var rung: String = AtlasWindowGeometry.select_rung(131_072.0, 100.0) + assert_str(rung).is_equal("District") + + +## One metre past District's ceiling must flip to Region. +func test_select_rung_one_past_district_ceiling_is_region() -> void: + var rung: String = AtlasWindowGeometry.select_rung(131_073.0, 100.0) + assert_str(rung).is_equal("Region") + + +## canvas_px is unused by the coverage rule (kept for signature stability, +## see select_rung()'s own doc) — degenerate/zero values must not change the +## selected rung at all, unlike the old `2x`-tolerance design's special-cased +## fallback. +func test_select_rung_canvas_px_does_not_affect_selection() -> void: + var with_real_canvas: String = AtlasWindowGeometry.select_rung(2000.0, 1000.0) + var with_zero_canvas: String = AtlasWindowGeometry.select_rung(2000.0, 0.0) + assert_str(with_zero_canvas).is_equal(with_real_canvas) + + +## spacing_for_rung() is select_rung()'s inverse lookup — pin the three known +## values against the D-243 constants directly (not against RUNG_TABLE +## indices, which would just restate the implementation). +func test_spacing_for_rung_matches_d243_constants() -> void: + assert_float(AtlasWindowGeometry.spacing_for_rung("Quarter")).is_equal_approx(512.0, 0.001) + assert_float(AtlasWindowGeometry.spacing_for_rung("District")).is_equal_approx(2048.0, 0.001) + assert_float(AtlasWindowGeometry.spacing_for_rung("Region")).is_equal_approx(204_800.0, 0.001) + + +## An unknown tag falls back to District — matching the server's own +## "unknown -> District" posture at every wire-decode boundary. +func test_spacing_for_rung_unknown_tag_falls_back_to_district() -> void: + assert_float(AtlasWindowGeometry.spacing_for_rung("Nonsense")).is_equal_approx(2048.0, 0.001) + + +## MAX_COVERAGE_M's three values, pinned directly against the formulas +## select_rung()'s own doc derives them from — a regression guard +## independent of select_rung()'s own boundary tests above, so a future +## accidental edit to the constants table itself (not just the selection +## logic) is caught here too. +func test_max_coverage_m_matches_derived_formulas() -> void: + assert_float(AtlasWindowGeometry.MAX_COVERAGE_M["Quarter"]).is_equal_approx(32_768.0, 0.001) + assert_float(AtlasWindowGeometry.MAX_COVERAGE_M["District"]).is_equal_approx(131_072.0, 0.001) + assert_float(AtlasWindowGeometry.MAX_COVERAGE_M["Region"]).is_equal_approx(13_107_200.0, 0.001) + + +## The exact scenario that surfaced the original design flaw +## (live-testing enter_orbital()'s own fit zoom): a whole Earth-like body's +## circumference (~40,075 km, matching AtlasDescendGeometry.district_extent()'s +## own cols*DISTRICT_M for radius=6371km) fitted to a 1920px-wide viewport at +## CELL_PIXEL_SIZE=16 must select Region — the direct regression guard for +## the bug an early version of select_rung() had (picking District here, +## which would have meant the canonical orbital frame requests a +## District-tier derive spanning an entire planet — the exact R1-catastrophe +## cost scenario the design doc §4 rejects). +func test_select_rung_at_orbital_fit_zoom_selects_region() -> void: + var radius_km := 6371.0 + var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) + var n: int = int(extent["cols"]) + var composite_native: float = float(n) * CELL_PIXEL_SIZE + var viewport := Vector2(1920.0, 1080.0) + var fit_zoom: float = maxf(viewport.x, viewport.y) / composite_native + var world_extent: float = AtlasWindowGeometry.world_extent_m(CELL_PIXEL_SIZE, fit_zoom, viewport) + var rung: String = AtlasWindowGeometry.select_rung( + world_extent, maxf(viewport.x, viewport.y) + ) + assert_str(rung).override_failure_message( + "the canonical orbital fit-zoom (whole-planet view) must select Region," + + " never a District-tier derive spanning an entire planet" + ).is_equal("Region") + + +## **Live round 3 regression, the direct fix target:** at 1600x900 (the +## coordinator's capture viewport), zooming IN from the orbital fit all the +## way to Quarter's own ceiling must pass through District along the way — +## a wheel-zoom gesture crossing world_extent_m from Region's territory down +## to Quarter's must select District for SOME real span of extent in +## between, not skip straight from Region to Quarter (the exact "money shot" +## the coordinator wants capture-worthy: a visible SHARPEN in place, not a +## jump). +func test_select_rung_district_is_reachable_between_region_and_quarter() -> void: + # An extent comfortably between District's and Quarter's ceilings (e.g. + # the midpoint) must select District — proving the band is non-empty, + # unlike the old two-gate design where it was empty by construction at + # every real viewport (see git history / the coordinator's live-round + # finding for the retired analysis). + var midpoint: float = ( + (AtlasWindowGeometry.MAX_COVERAGE_M["Quarter"] as float) + + (AtlasWindowGeometry.MAX_COVERAGE_M["District"] as float) + ) * 0.5 + var rung: String = AtlasWindowGeometry.select_rung(midpoint, 1600.0) + assert_str(rung).override_failure_message( + "District must be reachable between Quarter's and District's own" + + " coverage ceilings — the redesigned rule must not skip it" + ).is_equal("District") + + +# ============================================================================= +# T-1153: world_extent_m() — the `E` half of the §5 rule, computed from the +# viewer's own zoom/viewport state. +# ============================================================================= + + +## At zoom=1.0, CELL_PIXEL_SIZE=16: one DISTRICT (2,048 m, the fixed display +## unit — see world_extent_m()'s own doc for why this is rung-INDEPENDENT) +## occupies 16 screen px, so a 1920px-wide viewport shows +## 1920/16 * 2048 = 245,760 m. +func test_world_extent_m_at_zoom_one() -> void: + var extent: float = AtlasWindowGeometry.world_extent_m( + CELL_PIXEL_SIZE, 1.0, Vector2(1920.0, 1080.0) + ) + assert_float(extent).is_equal_approx(1920.0 / CELL_PIXEL_SIZE * 2048.0, 1.0) + + +## Doubling the zoom must HALVE the displayed world extent — zooming in +## shows less world, not more. +func test_world_extent_m_halves_when_zoom_doubles() -> void: + var extent_1x: float = AtlasWindowGeometry.world_extent_m( + CELL_PIXEL_SIZE, 1.0, Vector2(1920.0, 1080.0) + ) + var extent_2x: float = AtlasWindowGeometry.world_extent_m( + CELL_PIXEL_SIZE, 2.0, Vector2(1920.0, 1080.0) + ) + assert_float(extent_2x).is_equal_approx(extent_1x * 0.5, 1.0) + + +## The composite's on-screen footprint is rung-invariant (world_extent_m()'s +## own doc) — a change in held rung with NO change in zoom/viewport must +## leave the displayed world extent UNCHANGED. This is the direct regression +## test for the bug this function's signature once had (a granularity_v2 +## parameter that silently changed the formula per rung, when only zoom +## should) — the function no longer TAKES a rung parameter at all, so this +## pins that omission is intentional, not an oversight. +func test_world_extent_m_has_no_rung_parameter() -> void: + var extent_a: float = AtlasWindowGeometry.world_extent_m( + CELL_PIXEL_SIZE, 1.0, Vector2(1920.0, 1080.0) + ) + var extent_b: float = AtlasWindowGeometry.world_extent_m( + CELL_PIXEL_SIZE, 1.0, Vector2(1920.0, 1080.0) + ) + assert_float(extent_a).is_equal_approx(extent_b, 0.001) + + +## Degenerate zoom (<=0) must not divide by zero — a safe zero extent. +func test_world_extent_m_degenerate_zoom_is_safe() -> void: + var extent: float = AtlasWindowGeometry.world_extent_m( + CELL_PIXEL_SIZE, 0.0, Vector2(1920.0, 1080.0) + ) + assert_float(extent).is_equal_approx(0.0, 0.001) + + +# ============================================================================= +# T-1153: is_fully_zoomed_out() — Jeroen's HARD condition's trigger predicate. +# ============================================================================= + + +func test_is_fully_zoomed_out_true_when_extent_covers_full_circumference() -> void: + var radius_km := 6371.0 + var circumference_m: float = TAU * radius_km * 1000.0 + assert_bool(AtlasWindowGeometry.is_fully_zoomed_out(circumference_m, radius_km)).is_true() + assert_bool( + AtlasWindowGeometry.is_fully_zoomed_out(circumference_m * 1.5, radius_km) + ).is_true() + + +func test_is_fully_zoomed_out_false_when_extent_is_less_than_circumference() -> void: + var radius_km := 6371.0 + var circumference_m: float = TAU * radius_km * 1000.0 + assert_bool( + AtlasWindowGeometry.is_fully_zoomed_out(circumference_m * 0.5, radius_km) + ).is_false() + + +## A no-radius body (tiny test body) has no circumference concept — never +## auto-resets, matching enter_orbital()'s own no-radius fallback disposition. +func test_is_fully_zoomed_out_false_for_no_radius_body() -> void: + assert_bool(AtlasWindowGeometry.is_fully_zoomed_out(1e12, 0.0)).is_false() + + +# ============================================================================= +# T-1153: screen_center_to_district() — the shared screen<->district formula +# behind both the pan-edge refetch and the rung-reselect refetch. +# ============================================================================= + + +## At the exact center of a symmetric fit (offset centers the composite, +## zoom=1.0), the screen center must map back to the held center exactly. +func test_screen_center_to_district_at_rest_returns_held_center() -> void: + var held_n := 32 + var held_center := Vector2i(10, 20) + var composite_native: float = float(held_n) * CELL_PIXEL_SIZE + var viewport := Vector2(composite_native, composite_native) + var offset := Vector2.ZERO # composite exactly fills the viewport, top-left at origin + var result: Vector2i = AtlasWindowGeometry.screen_center_to_district( + viewport, offset, 1.0, CELL_PIXEL_SIZE, held_center, held_n + ) + assert_that(result).is_equal(held_center) + + +## Panning the offset must shift the recovered district position in the +## OPPOSITE direction of the offset shift (dragging the composite right +## reveals districts to the WEST at screen-center). +func test_screen_center_to_district_shifts_with_pan_offset() -> void: + var held_n := 32 + var held_center := Vector2i(0, 0) + var composite_native: float = float(held_n) * CELL_PIXEL_SIZE + var viewport := Vector2(composite_native, composite_native) + var at_rest: Vector2i = AtlasWindowGeometry.screen_center_to_district( + viewport, Vector2.ZERO, 1.0, CELL_PIXEL_SIZE, held_center, held_n + ) + var panned: Vector2i = AtlasWindowGeometry.screen_center_to_district( + viewport, Vector2(CELL_PIXEL_SIZE * 4.0, 0.0), 1.0, CELL_PIXEL_SIZE, held_center, held_n + ) + assert_int(panned.x).override_failure_message( + "dragging the composite EAST (positive offset) must reveal districts to the WEST" + ).is_less(at_rest.x) + + +# ============================================================================= +# T-1153 (moved from atlas_window_viewer.gd for testability): WASD held-pan +# direction is exercised live only (reads the global Input singleton) — +# edge-scroll suppression/direction are pure and covered here directly. +# ============================================================================= + + +func test_is_cursor_edge_scrolling_true_near_an_edge() -> void: + var result: bool = AtlasWindowGeometry.is_cursor_edge_scrolling( + true, false, Vector2(800.0, 600.0), Vector2(10.0, 300.0), 24.0 + ) + assert_bool(result).is_true() + + +func test_is_cursor_edge_scrolling_false_away_from_any_edge() -> void: + var result: bool = AtlasWindowGeometry.is_cursor_edge_scrolling( + true, false, Vector2(800.0, 600.0), Vector2(400.0, 300.0), 24.0 + ) + assert_bool(result).is_false() + + +func test_is_cursor_edge_scrolling_suppressed_when_over_ui() -> void: + var result: bool = AtlasWindowGeometry.is_cursor_edge_scrolling( + true, true, Vector2(800.0, 600.0), Vector2(10.0, 300.0), 24.0 + ) + assert_bool(result).is_false() + + +func test_is_cursor_edge_scrolling_suppressed_without_app_focus() -> void: + var result: bool = AtlasWindowGeometry.is_cursor_edge_scrolling( + false, false, Vector2(800.0, 600.0), Vector2(10.0, 300.0), 24.0 + ) + assert_bool(result).is_false() + + +func test_edge_scroll_direction_points_west_near_left_edge() -> void: + var direction: Vector2 = AtlasWindowGeometry.edge_scroll_direction( + Vector2(800.0, 600.0), Vector2(5.0, 300.0), 24.0 + ) + assert_float(direction.x).is_less(0.0) + assert_float(direction.y).is_equal_approx(0.0, 0.001) + + +# ============================================================================= +# T-1153, live round 3 (Jeroen's ruling, design doc §4): compute_tile_grid() +# — the orbital rest state's multi-window mosaic. +# ============================================================================= + + +## The exact live-round scenario: GJ380c/Lendel (radius 6238.4 km) needs a +## 3x2 = 6-tile grid — the coordinator's own estimate, confirmed here as an +## executable regression. +func test_compute_tile_grid_lendel_produces_six_tiles() -> void: + var tiles: Array = AtlasWindowGeometry.compute_tile_grid(6238.4) + assert_int(tiles.size()).override_failure_message( + "GJ380c/Lendel must tile into 3x2=6 windows, matching the coordinator's own" + + " live-round finding (13,107.2 km single-window coverage vs. 39,198 km" + + " circumference)" + ).is_equal(6) + + +## A tiny body whose whole circumference fits in ONE Region window's +## coverage ceiling must produce exactly ONE tile — tiling degenerates +## gracefully to the pre-existing single-window behavior when it isn't +## actually needed. +func test_compute_tile_grid_tiny_body_produces_one_tile() -> void: + # radius small enough that circumference << MAX_COVERAGE_M["Region"] + # (13,107,200 m) — a few hundred km radius comfortably qualifies. + var tiles: Array = AtlasWindowGeometry.compute_tile_grid(50.0) + assert_int(tiles.size()).is_equal(1) + assert_that(tiles[0]).is_equal(Vector2i.ZERO) + + +## A no-radius body (tiny test body) must produce exactly one tile at the +## canonical origin — matching enter_orbital()'s own no-radius fallback +## disposition (no circumference/tiling concept without a radius). +func test_compute_tile_grid_no_radius_produces_single_origin_tile() -> void: + var tiles: Array = AtlasWindowGeometry.compute_tile_grid(0.0) + assert_int(tiles.size()).is_equal(1) + assert_that(tiles[0]).is_equal(Vector2i.ZERO) + + +## Every tile center must be a LEGAL canonicalized DistrictPos — column +## wrapped into [0, cols), row clamped into [-rows_half, rows_half] — the +## same range canonicalize_district_center() enforces everywhere else in +## this cluster (pan refetch, entry, rung-reselect). A raw, uncanonicalized +## tile center would fail the server's own normalize_window_center() (or +## silently alias to a different tile than intended). +func test_compute_tile_grid_tiles_are_all_canonicalized() -> void: + var radius_km := 6238.4 + var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) + var cols: int = int(extent["cols"]) + var rows_half: int = int(extent["rows_half"]) + var tiles: Array = AtlasWindowGeometry.compute_tile_grid(radius_km) + for tile: Vector2i in tiles: + assert_int(tile.x).override_failure_message( + "tile column %d must be wrapped into [0, %d)" % [tile.x, cols] + ).is_greater_equal(0) + assert_int(tile.x).is_less(cols) + assert_int(tile.y).override_failure_message( + "tile row %d must be clamped into [-%d, %d]" % [tile.y, rows_half, rows_half] + ).is_greater_equal(-rows_half) + assert_int(tile.y).is_less_equal(rows_half) + + +## No two tiles may share the same canonicalized center — compute_tile_grid() +## must dedupe (a pole-row clamp or column-wrap collision producing the exact +## same DistrictPos twice would otherwise request/draw the same tile twice, +## wasting a request and drawing one tile over another). +func test_compute_tile_grid_has_no_duplicate_centers() -> void: + var tiles: Array = AtlasWindowGeometry.compute_tile_grid(6238.4) + var seen: Dictionary = {} + for tile: Vector2i in tiles: + assert_bool(seen.has(tile)).override_failure_message( + "tile center %s appears more than once in the grid" % str(tile) + ).is_false() + seen[tile] = true + + +## The tile grid's own center of mass must land on the canonical origin +## (0,0) — the tile-set's symmetric layout (each axis' centers computed as +## `(index - (count-1)/2) * TILE_N`) is centered on the SAME canonical origin +## enter_orbital() uses, so the tile-set's overall framing agrees with +## single-window enter_orbital()'s own "center on (0,0)" contract. +func test_compute_tile_grid_is_centered_on_the_canonical_origin() -> void: + var tiles: Array = AtlasWindowGeometry.compute_tile_grid(6238.4) + var sum_col := 0 + var sum_row := 0 + for tile: Vector2i in tiles: + sum_col += tile.x + sum_row += tile.y + # Column centers wrap (periodic), so a raw average isn't meaningful there + # the way it is for rows — assert row symmetry directly instead (rows + # never wrap, so their average must be very close to 0 for a + # symmetric grid). + var avg_row: float = float(sum_row) / float(tiles.size()) + assert_float(avg_row).override_failure_message( + "the tile grid's row centers must average to ~0 (symmetric around the" + + " canonical origin's equator row)" + ).is_equal_approx(0.0, float(AtlasWindowGeometry.TILE_N)) + + +# ============================================================================= +# Live round 4: district_to_canvas_local() + recompute_offset_for_held_n_change() +# — the two pure functions behind both round-4 draw-path fixes (tile mosaic +# placement, single-window offset recompute across a rung crossing). +# ============================================================================= + + +## A district AT the held window's own center must land at canvas-local +## `(held_n/2 * cell_px, held_n/2 * cell_px)` — the center of the +## `[0, held_n*cell_px)` square the single-window `Rect2(0,0,extent,extent)` +## draw call already assumes. +func test_district_to_canvas_local_center_district_lands_at_half_extent() -> void: + var held_center := Vector2i(100, 200) + var held_n := 64 + var result: Vector2 = AtlasWindowGeometry.district_to_canvas_local( + Vector2(held_center), held_center, held_n, CELL_PIXEL_SIZE + ) + var expected: float = float(held_n) * 0.5 * CELL_PIXEL_SIZE + assert_that(result).is_equal(Vector2(expected, expected)) + + +## The window's own top-left corner (held_center - held_n/2) must land at +## canvas-local (0,0) — the exact invariant single-window `_draw()` and +## `fit_window_view()` both assume. +func test_district_to_canvas_local_top_left_corner_lands_at_origin() -> void: + var held_center := Vector2i(0, 0) + var held_n := 32 + var top_left := Vector2(held_center) - Vector2.ONE * (float(held_n) * 0.5) + var result: Vector2 = AtlasWindowGeometry.district_to_canvas_local( + top_left, held_center, held_n, CELL_PIXEL_SIZE + ) + assert_that(result).is_equal(Vector2.ZERO) + + +## Live round 4's OWN repro, pinned directly: a tile far from held_center +## (0,0) at whole-body scale (held_n ~19,139, Lendel's raw circumference) +## must NOT land near canvas-local (0,0) — the round-4 bug's exact failure +## mode (treating absolute district (0,0) as the canvas origin regardless of +## held_center/held_n) would place it there instead. +func test_district_to_canvas_local_matches_the_live_round_4_repro_scale() -> void: + var held_center := Vector2i.ZERO + var held_n := 19139 # Lendel's raw district-column count (live round 4's own repro) + var tile_center := Vector2(6400, 0) # one TILE_N east of the body's own center + var result: Vector2 = AtlasWindowGeometry.district_to_canvas_local( + tile_center, held_center, held_n, CELL_PIXEL_SIZE + ) + var buggy_result: Vector2 = tile_center * CELL_PIXEL_SIZE # the round-4 bug's own formula + assert_bool(is_equal_approx(result.x, buggy_result.x)).override_failure_message( + "a tile away from held_center must NOT land where the round-4 bug's" + + " absolute-district-(0,0)-relative formula would put it — got %.1f, the" + + " buggy formula's own value is %.1f" + % [result.x, buggy_result.x] + ).is_false() + + +## Zero held_n is a degenerate/never-real-in-practice input (a body always +## has SOME district extent) but must not divide-by-zero or crash — `half` +## is simply 0, so the district maps 1:1 to canvas-local (scaled by cell_px). +func test_district_to_canvas_local_zero_held_n_does_not_crash() -> void: + var result: Vector2 = AtlasWindowGeometry.district_to_canvas_local( + Vector2(5, 5), Vector2i.ZERO, 0, CELL_PIXEL_SIZE + ) + assert_that(result).is_equal(Vector2(5, 5) * CELL_PIXEL_SIZE) + + +# ============================================================================= +# Live round 5: nearest_wrap_image() — the tile-mosaic WRAP half of "the +# mosaic doesn't fully draw" (the left-third-black repro). +# ============================================================================= + + +## Live round 5's OWN repro, pinned exactly: Lendel's wrapped tile +## canonicalizes to column 12739 (`-6400 mod 19139`) — the CORRECT +## request/cache key — but its nearest wrap-image relative to the canonical +## origin (held_center.x = 0) is -6400, the actual visible position +## immediately west of center. +func test_nearest_wrap_image_matches_the_lendel_repro() -> void: + var result: int = AtlasWindowGeometry.nearest_wrap_image(12739, 0, 19139) + assert_int(result).override_failure_message( + "the wrapped tile's nearest wrap-image relative to held_center=0 must be" + + " -6400 (its actual on-screen position), not 12739 (the correct REQUEST" + + " key, but the wrong DRAW position)" + ).is_equal(-6400) + + +## The two Lendel tiles that were NEVER wrapped (already close to +## held_center) must round-trip unchanged — the fix must not perturb tiles +## that were already drawing correctly. +func test_nearest_wrap_image_is_a_noop_for_already_nearby_columns() -> void: + var cols := 19139 + for col: int in [0, 6400]: + var result: int = AtlasWindowGeometry.nearest_wrap_image(col, 0, cols) + assert_int(result).override_failure_message( + "column %d is already the nearest wrap-image to held_center=0 — must" + + " be returned unchanged" % col + ).is_equal(col) + + +## The result must always be a LEGAL wrap-image of the canonical column — +## i.e. `result mod cols == canonical_col mod cols` — regardless of which +## image is nearest. This is the correctness invariant the whole function +## exists to preserve: re-expressing a column for DRAWING must never change +## WHICH district it actually refers to. +func test_nearest_wrap_image_preserves_the_canonical_identity() -> void: + var cols := 19139 + for held_col: int in [-50000, -1, 0, 1, 9569, 19138, 50000]: + var result: int = AtlasWindowGeometry.nearest_wrap_image(12739, held_col, cols) + assert_int(posmod(result, cols)).override_failure_message( + "nearest_wrap_image(12739, %d, %d) = %d must still canonicalize back" + + " to 12739 — it may only pick a DIFFERENT wrap-image, never a" + + " different district" % [held_col, cols, result] + ).is_equal(12739) + + +## The chosen wrap-image must be the CLOSEST one to held_center — never +## farther than half the circumference away (otherwise a different +## wrap-image would have been nearer). +func test_nearest_wrap_image_is_within_half_circumference_of_held_center() -> void: + var cols := 19139 + for canonical_col: int in [0, 1, 9569, 12739, 19138]: + for held_col: int in [-30000, -500, 0, 500, 25000]: + var result: int = AtlasWindowGeometry.nearest_wrap_image(canonical_col, held_col, cols) + var distance: int = absi(result - held_col) + assert_int(distance).override_failure_message( + ( + "nearest_wrap_image(%d, %d, %d) = %d is %d districts from" + + " held_center — must never exceed half the circumference" + + " (%d), or a closer wrap-image exists" + ) + % [canonical_col, held_col, cols, result, distance, cols / 2] + ).is_less_equal(cols / 2) + + +## `cols <= 0` (no-radius bodies, which never tile per compute_tile_grid()'s +## own doc) must be a safe no-op passthrough — no periodicity to resolve. +func test_nearest_wrap_image_zero_cols_is_a_passthrough() -> void: + var result: int = AtlasWindowGeometry.nearest_wrap_image(12739, 0, 0) + assert_int(result).is_equal(12739) + + +## The coordinator's own draw-position counterpart to +## test_compute_tile_grid_tiles_are_all_canonicalized(): the wrapped tile's +## DRAW rect (via district_to_canvas_local(), fed through +## nearest_wrap_image() the way _draw_tile_mosaic() now does) must land +## SUBSTANTIALLY on-canvas when the view covers the whole body — the exact +## Lendel shape (whole-body fit at entry, held_center at the canonical +## origin). A bare `Rect2.intersects()` check is NOT discriminating enough +## here: at Lendel's own whole-body-fit scale, the BUGGY placement (feeding +## the canonical column directly) happens to clip the viewport edge by only +## a couple of px (confirmed by hand-computation — the tile-grid's own +## edge-to-edge tiling means a full-circumference shift lands almost +## exactly one screen-width away, so `intersects()` alone would pass on a +## near-miss that still reads as "the left third is black" visually). +## Asserting a MEANINGFUL overlap FRACTION (at least half the tile's own +## area) is what actually distinguishes "correctly drawn" from "barely +## clipping the edge." +func test_wrapped_tile_draw_rect_lands_substantially_on_canvas_at_whole_body_view() -> void: + var radius_km := 6238.4 # GJ380c (Lendel) — the live-repro body + var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) + var cols: int = int(extent["cols"]) + var held_center := Vector2i.ZERO + var held_n: int = cols # enter_orbital()'s own whole-body held_n + var tile_n: int = AtlasWindowGeometry.TILE_N + var half_tile: float = float(tile_n) * 0.5 + + # The whole-body fit zoom/viewport (matching enter_orbital()'s own fit). + var viewport := Vector2(1600.0, 900.0) + var fit: Dictionary = AtlasWindowGeometry.fit_window_view( + viewport, held_n, CELL_PIXEL_SIZE, 0.0001, 64.0 + ) + var view_zoom: float = fit["zoom"] + var view_offset: Vector2 = fit["offset"] + + # The wrapped tile's own canonical center — mirrors compute_tile_grid()'s + # own dedup/canonicalize step for Lendel's westmost tile. + var wrapped_raw_col := -6400 + var canonical_col: int = posmod(wrapped_raw_col, cols) + + var draw_col: int = AtlasWindowGeometry.nearest_wrap_image(canonical_col, held_center.x, cols) + var tile_top_left := Vector2(float(draw_col) - half_tile, 0.0 - half_tile) + var local_origin: Vector2 = AtlasWindowGeometry.district_to_canvas_local( + tile_top_left, held_center, held_n, CELL_PIXEL_SIZE + ) + var extent_px: float = float(tile_n) * CELL_PIXEL_SIZE + + # Canvas-local -> screen space: _canvas.position = view_offset, + # _canvas.scale = view_zoom (AtlasWindowViewer._apply_transform()'s own + # transform, mirrored here since this is a pure-geometry test with no + # live Control/Node2D tree). + var screen_top_left: Vector2 = view_offset + local_origin * view_zoom + var screen_extent: Vector2 = Vector2(extent_px, extent_px) * view_zoom + var tile_rect := Rect2(screen_top_left, screen_extent) + var viewport_rect := Rect2(Vector2.ZERO, viewport) + + var overlap: Rect2 = viewport_rect.intersection(tile_rect) + var tile_area: float = screen_extent.x * screen_extent.y + var overlap_fraction: float = 0.0 + if tile_area > 0.0: + overlap_fraction = (overlap.size.x * overlap.size.y) / tile_area + + assert_float(overlap_fraction).override_failure_message( + ( + "the wrapped tile's draw rect %s overlaps the viewport %s by only" + + " %.1f%% of its own area — must be at least 50%% when the view" + + " covers the whole body. This is live round 5's 'left third of the" + + " mosaic is black' repro: drawing the CANONICAL column (%d) directly" + + " (without nearest_wrap_image()) places this tile off-canvas RIGHT" + + " instead of its true position on the LEFT" + ) + % [tile_rect, viewport_rect, overlap_fraction * 100.0, canonical_col] + ).is_greater_equal(0.5) + + +## The core contract this function exists for: recomputing `_view_offset` so +## a KNOWN screen point continues to map to canvas-local +## `new_held_n/2 * cell_px` (the new window's own center) — i.e. feeding the +## OUTPUT back through district_to_canvas_local()'s own "center district -> +## half-extent local" identity (tested above) and applying the resulting +## transform must reproduce the SAME screen point exactly. +func test_recompute_offset_for_held_n_change_preserves_the_screen_point() -> void: + var screen_point := Vector2(800.0, 450.0) + var view_zoom := 2.5 + var new_held_n := 16 + var offset: Vector2 = AtlasWindowGeometry.recompute_offset_for_held_n_change( + screen_point, view_zoom, new_held_n, CELL_PIXEL_SIZE + ) + var new_local: Vector2 = Vector2.ONE * (float(new_held_n) * 0.5 * CELL_PIXEL_SIZE) + var reconstructed_screen_point: Vector2 = new_local * view_zoom + offset + assert_that(reconstructed_screen_point).is_equal_approx(screen_point, Vector2.ONE * 0.01) + + +## Live round 4's OWN repro: crossing from Region (~thousands-districts held_n) +## to District (64) or Quarter (16) must produce a DIFFERENT offset than +## leaving `_view_offset` untouched would — pinning that this function's +## OUTPUT actually depends on `new_held_n` (the exact thing the round-4 bug +## got wrong by never calling this function at all). +func test_recompute_offset_for_held_n_change_differs_for_different_held_n() -> void: + var screen_point := Vector2(800.0, 450.0) + var view_zoom := 3.378 # live round 4's own District-band zoom value + var offset_district: Vector2 = AtlasWindowGeometry.recompute_offset_for_held_n_change( + screen_point, view_zoom, 64, CELL_PIXEL_SIZE + ) + var offset_quarter: Vector2 = AtlasWindowGeometry.recompute_offset_for_held_n_change( + screen_point, view_zoom, 16, CELL_PIXEL_SIZE + ) + assert_that(offset_district).override_failure_message( + "a rung crossing that changes held_n must recompute a DIFFERENT" + + " _view_offset — reusing the same offset across the crossing is" + + " exactly the live round 4 bug (composite renders off-canvas)" + ).is_not_equal(offset_quarter) diff --git a/client/tests/test_atlas_window_overlay.gd b/client/tests/test_atlas_window_overlay.gd index 2217b0748..9eb737291 100644 --- a/client/tests/test_atlas_window_overlay.gd +++ b/client/tests/test_atlas_window_overlay.gd @@ -23,10 +23,14 @@ static func _mock_window(n: int = 2) -> Dictionary: ## Minimal viewer stub — AtlasWindowOverlay only reaches the viewer through -## get_district_window()/is_overlay_visible()/get_cell_pixel_size(), so a -## bare stub with just those three methods is a legitimate "viewer" for -## these tests, matching the duck-typed-viewer precedent this whole overlay -## cluster already relies on (atlas_overlay_bar.gd/atlas_legend_panel.gd). +## get_district_window()/is_overlay_visible()/get_cell_pixel_size()/ +## is_tile_mode(), so a bare stub with just those methods is a legitimate +## "viewer" for these tests, matching the duck-typed-viewer precedent this +## whole overlay cluster already relies on (atlas_overlay_bar.gd/ +## atlas_legend_panel.gd). is_tile_mode() always returns false — this suite +## covers the single-window composite-cache path only; the tile mosaic path +## is covered separately by test_atlas_window_tile_set.gd + the viewer's own +## is_tile_mode()-branching tests. class _ViewerStub: var window: Variant = null var active_overlay: String = "" @@ -40,6 +44,9 @@ class _ViewerStub: func get_cell_pixel_size() -> float: return 16.0 + func is_tile_mode() -> bool: + return false + func test_composite_smooth_defaults_true() -> void: assert_bool(AtlasWindowOverlay.COMPOSITE_SMOOTH).override_failure_message( @@ -150,3 +157,111 @@ func test_draw_builds_a_texture_through_the_viewer_stub() -> void: o.viewer = stub o._draw() assert_that(o._cached_texture).is_not_null() + + +# ============================================================================= +# T-1152/T-1153: cell_grid_side_for_window() — the district-extent-vs- +# derived-cell-grid split every rung's response now carries. +# ============================================================================= + + +## District (the default/omitted tag): cell_grid_side == n, unchanged from +## the pre-T-1152 identity mapping. +func test_cell_grid_side_for_window_district_matches_n() -> void: + var window: Dictionary = {"n": 32, "granularity_v2": "District"} + assert_int(AtlasWindowOverlay.cell_grid_side_for_window(window)).is_equal(32) + + +## Quarter: 4x MORE cells than districts (WINDOW_GRANULARITY_QUARTER). +func test_cell_grid_side_for_window_quarter_multiplies_by_four() -> void: + var window: Dictionary = {"n": 32, "granularity_v2": "Quarter"} + assert_int(AtlasWindowOverlay.cell_grid_side_for_window(window)).is_equal(128) + + +## Region: FAR FEWER cells than districts — round(n/100), matching +## WindowGranularity::cell_grid_side's own Region branch exactly (the +## "inversion" the server doc calls out: finer rungs multiply, Region divides). +func test_cell_grid_side_for_window_region_divides_by_districts_per_region() -> void: + var window: Dictionary = {"n": 6400, "granularity_v2": "Region"} + assert_int(AtlasWindowOverlay.cell_grid_side_for_window(window)).is_equal(64) + + +## A Region window smaller than one region (n < 100) must still derive a +## minimum 1x1 cell grid, never 0 — matching the server's `.max(1)`. +func test_cell_grid_side_for_window_region_minimum_is_one() -> void: + var window: Dictionary = {"n": 50, "granularity_v2": "Region"} + assert_int(AtlasWindowOverlay.cell_grid_side_for_window(window)).is_equal(1) + + +## Missing granularity_v2 (an old-shape response) falls back to District — +## matching the server's own "unknown -> District" posture at every +## resolution boundary. +func test_cell_grid_side_for_window_missing_tag_falls_back_to_district() -> void: + var window: Dictionary = {"n": 32} + assert_int(AtlasWindowOverlay.cell_grid_side_for_window(window)).is_equal(32) + + +## T-1152/T-1153, design doc §6 encoding continuity: a Region-rung window (a +## FAR SPARSER cell grid — one region cell spans 100 districts) renders +## through the EXACT SAME _draw()/_rebuild_texture_if_needed() path as +## District — no separate branch, no crash reading past the (much smaller) +## per-cell arrays. This is the direct "same colorizer family at every rung" +## behavioral test the ticket asks for. +func test_draw_builds_a_texture_for_a_region_rung_window() -> void: + var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new()) + var stub := _ViewerStub.new() + # n=200 districts -> cell_grid_side = round(200/100) = 2 -> 4 cells, + # matching the 4-entry per-cell arrays below (same shape _mock_window() + # uses, just at Region's district-to-cell ratio). + stub.window = { + "center": [0, 0], + "n": 200, + "granularity_v2": "Region", + "morphology": PackedByteArray([8, 14, 0, 1]), + "elev_q": PackedByteArray([40, 90, 5, 60]), + "temp_dc": [120, 95, -32768, 60], + "moisture_q": PackedByteArray([50, 30, 90, 20]), + "vegetation": PackedByteArray([2, 1, 6, 3]), + "glaciation": PackedByteArray([0, 0, 1, 2]), + } + o.viewer = stub + o._draw() + assert_that(o._cached_texture).override_failure_message( + "a Region-rung window must render through the same composite path as District" + ).is_not_null() + assert_int(o._cached_texture.get_width()).override_failure_message( + "the built texture's resolution must be the DERIVED cell-grid side (2)," + + " not the window's district extent (200)" + ).is_equal(2) + + +## §6 "no mode flip" acceptance criterion, restated at the texture-cache +## level: swapping from a District-rung window to a Region-rung window at a +## NEW window object (the progressive-refinement swap) must still go through +## a single rebuild call producing a fresh texture — not a crash, not a +## silently-stale texture sized for the wrong rung. +func test_rebuild_handles_a_rung_swap_from_district_to_region() -> void: + var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new()) + var district_window: Dictionary = _mock_window() # n=2, District, 4 cells + o._rebuild_texture_if_needed( + district_window, AtlasWindowOverlay.cell_grid_side_for_window(district_window), "" + ) + assert_int(o._cached_texture.get_width()).is_equal(2) + + var region_window: Dictionary = { + "center": [0, 0], + "n": 200, + "granularity_v2": "Region", + "morphology": PackedByteArray([8, 14, 0, 1]), + "elev_q": PackedByteArray([40, 90, 5, 60]), + "temp_dc": [120, 95, -32768, 60], + "moisture_q": PackedByteArray([50, 30, 90, 20]), + "vegetation": PackedByteArray([2, 1, 6, 3]), + "glaciation": PackedByteArray([0, 0, 1, 2]), + } + o._rebuild_texture_if_needed( + region_window, AtlasWindowOverlay.cell_grid_side_for_window(region_window), "" + ) + assert_int(o._cached_texture.get_width()).override_failure_message( + "a rung swap must rebuild at the NEW rung's derived cell-grid resolution" + ).is_equal(2) # region_window's cell_grid_side is also 2 here (200/100) — same size, different data diff --git a/client/tests/test_atlas_window_overlay_draw_smoke.gd b/client/tests/test_atlas_window_overlay_draw_smoke.gd new file mode 100644 index 000000000..17f1f7915 --- /dev/null +++ b/client/tests/test_atlas_window_overlay_draw_smoke.gd @@ -0,0 +1,359 @@ +## Live round 4: a REAL draw smoke test — the "does anything draw at all" +## gap has now bitten twice (round 4's tile-mosaic coordinate bug AND its +## per-tile-texture-lifetime bug, both invisible to test_atlas_window_overlay.gd's +## existing suite, which only asserts on the CACHE FIELDS being populated — +## never on an actual composited pixel). This file closes that gap +## structurally: render AtlasWindowOverlay into a REAL SubViewport, force a +## GPU sync, grab the rendered Image, and assert a meaningful fraction of +## pixels differ from the background color — for BOTH the single-window path +## (a) and the tile-mosaic path (b), matching the coordinator's explicit ask. +## +## **REQUIRES A REAL RENDERING DRIVER — SKIPS (not fails) under +## `tests/run-godot`'s hardcoded `--headless`** (dummy driver, no GPU texture +## output; confirmed directly: SubViewport.get_texture().get_image() returns +## an all-zero/unusable image under it). This matters beyond "the assertions +## are meaningless there": the push gate runs the FULL suite through +## `tests/run-godot --headless` for every push, for everyone — a loud FAILURE +## here would bounce every future push project-wide, not just report a local +## false negative. Every test below carries the gdUnit4 fuzzer-arg skip +## convention (`_do_skip`/`_skip_reason`, matching test_input_gate_live.gd's +## own server-binary-not-built skip) keyed on `_dummy_renderer_active()`, so +## `tests/run-godot --filter test_atlas_window_overlay_draw_smoke` reports +## green-with-skips under headless, not red. +## +## To actually exercise this file's assertions, run it with a real driver: +## godot4 --display-driver x11 --rendering-driver opengl3 \ +## -s addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -c \ +## -a res://tests/test_atlas_window_overlay_draw_smoke.gd +## (matching tests/visual_capture.gd's own documented "requires a real +## rendering driver" precedent — see docs/DEVOPS.md's own note on this file.) +class_name TestAtlasWindowOverlayDrawSmoke +extends GdUnitTestSuite + +const AtlasWindowOverlay := preload("res://ui/implant/apps/atlas/atlas_window_overlay.gd") +const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") + +const COLOR_BG: Color = Color("#0d1117") # AtlasWindowViewer.COLOR_BG, mirrored (private const) +const VIEWPORT_SIZE: Vector2i = Vector2i(512, 512) + +## Minimum fraction of the captured image that must differ from COLOR_BG for +## a draw to count as "genuinely rendered something" — low enough to tolerate +## a mostly-water/mostly-one-color composite (round 4's own repro shots were +## legitimately near-uniform ocean at some zooms), high enough that a +## fully-blank/fully-background/fully-white frame (both round 4 bugs) fails it. +const MIN_NON_BACKGROUND_FRACTION: float = 0.05 + +const SKIP_REASON: String = ( + "no real rendering driver (dummy/headless) — run with e.g." + + " `godot4 --display-driver x11 --rendering-driver opengl3" + + " -s addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -c" + + " -a res://tests/test_atlas_window_overlay_draw_smoke.gd` to exercise this file" +) + + +## True under Godot's `--display-driver headless` (the dummy renderer +## `tests/run-godot`'s hardcoded `--headless` flag selects) — `DisplayServer. +## get_name()` reports `"headless"` there and the real driver name (`"X11"`, +## `"Wayland"`, etc.) otherwise, confirmed directly against both this +## worktree's `tests/run-godot` invocation and a real `--display-driver x11 +## --rendering-driver opengl3` run. Named as a function, not a const, since +## `DisplayServer` singleton state isn't available at script-parse time. +static func _dummy_renderer_active() -> bool: + return DisplayServer.get_name() == "headless" + + +## Single-window viewer stub — mirrors test_atlas_window_overlay.gd's +## _ViewerStub exactly (is_tile_mode() -> false), so this exercises the +## SAME single-window draw path that suite's cache tests cover, just +## through a REAL render instead of inspecting `_cached_texture` directly. +class _SingleWindowViewerStub: + var window: Variant = null + + func get_district_window() -> Variant: + return window + + func is_overlay_visible(_overlay_id: String) -> bool: + return false + + func get_cell_pixel_size() -> float: + return 16.0 + + func is_tile_mode() -> bool: + return false + + +## Tile-mode viewer stub — is_tile_mode() -> true, get_tile_set() returns a +## bare object exposing get_tiles() (AtlasWindowOverlay's own duck-typed +## contract, matching AtlasWindowTileSet.get_tiles()'s public shape exactly: +## Array of {"center": Vector2i, "window": Variant}). +class _TileModeViewerStub: + var tiles: Array = [] + var held_center: Vector2i = Vector2i.ZERO + var held_n: int = 0 + # Live round 5: nearest_wrap_image()'s cols input — 0 here (a no-radius + # passthrough) is fine for these tests, which don't exercise the wrap + # seam itself (that's test_atlas_window_geometry.gd's own coverage); + # this stub only needs to satisfy _draw_tile_mosaic()'s duck-typed call. + var body_radius_km: float = 0.0 + + func get_district_window() -> Variant: + return null + + func is_overlay_visible(_overlay_id: String) -> bool: + return false + + func get_cell_pixel_size() -> float: + return 16.0 + + func is_tile_mode() -> bool: + return true + + func get_tile_set() -> Variant: + return _TileSetStub.new(tiles) + + func get_held_center() -> Vector2i: + return held_center + + func get_held_n() -> int: + return held_n + + func get_body_radius_km() -> float: + return body_radius_km + + +class _TileSetStub: + var _tiles: Array = [] + + func _init(tiles: Array) -> void: + _tiles = tiles + + func get_tiles() -> Array: + return _tiles + + +## A `Node2D._draw()`-based flat-fill background — deliberately NOT a +## `ColorRect` (a `Control`). A `ColorRect` parented directly under a bare +## `SubViewport` (no intervening `Control` container establishing its own +## layout rect) did not reliably render in this harness: sampled pixels came +## back fully transparent `(0,0,0,0)` regardless of the ColorRect's `color`/ +## `size`, even after entering the tree before sizing. `AtlasWindowOverlay` +## itself is a bare `Node2D` using `draw_rect()` for its own background wash +## (`AtlasWindowViewer._draw()`'s own `COLOR_BG` fill) — matching that same, +## already-proven-working `Node2D.draw_rect()` pattern here sidesteps +## whatever `Control`-specific layout/compositing gap caused the ColorRect +## failure, rather than debugging that gap for its own sake. +class _BackgroundRect extends Node2D: + var fill_color: Color = Color.BLACK + var fill_size: Vector2 = Vector2.ZERO + + func _draw() -> void: + draw_rect(Rect2(Vector2.ZERO, fill_size), fill_color) + + +static func _mock_window(center: Vector2i = Vector2i.ZERO, n: int = 64) -> Dictionary: + # A checkerboard-ish morphology spread (not all-one-zone) so the built + # composite has genuine color VARIATION, not just "one flat non-background + # color" — closer to what a real terrain response looks like. + var cells: int = n * n + var morphology := PackedByteArray() + var elev_q := PackedByteArray() + morphology.resize(cells) + elev_q.resize(cells) + for i in range(cells): + morphology[i] = (i % 4) as int # cycles through the 4 morphology zones + elev_q[i] = (i * 7) % 100 as int + return { + "center": [center.x, center.y], + "n": n, + "granularity_v2": "District", + "morphology": morphology, + "elev_q": elev_q, + "temp_dc": [], + "moisture_q": PackedByteArray(), + "vegetation": PackedByteArray(), + "glaciation": PackedByteArray(), + } + + +## Renders `overlay` (any Node2D with its own `_draw()` — an +## AtlasWindowOverlay for (a)/(b) below, or a bare `_BackgroundRect` probe for +## the harness sanity check) parented under a Node2D positioned/scaled the +## way AtlasWindowViewer._canvas would be, into a fresh SubViewport, and +## returns the captured Image. `zoom` mirrors AtlasWindowViewer._canvas.scale +## (real `fit_window_view()` output is a small fraction, e.g. ~0.006 for a +## whole-body tile mosaic per live round 4's own repro) — WITHOUT it, a +## tile's real-world extent (TILE_N * cell_px = 102,400 local units) is so +## much larger than any realistic test viewport that a mis-POSITIONED tile +## still overlaps the frame purely by being gigantic, making the position +## math this test exists to catch silently unfalsifiable (confirmed +## directly: an earlier version of this test without a zoom scale kept +## passing even with live round 4's tile-coordinate bug deliberately +## reintroduced). Also confirmed directly: a hand-rolled duplicate of this +## same SubViewport/settle-loop setup (the harness sanity check's ORIGINAL +## standalone version) was measurably less reliable under a real driver than +## going through this shared path — reuse over duplication here isn't just +## tidiness, it's the more reliable rendering path. +func _render_to_image(overlay: Node2D, zoom: float = 1.0) -> Image: + var sub_viewport := SubViewport.new() + sub_viewport.size = VIEWPORT_SIZE + sub_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + sub_viewport.transparent_bg = false + add_child(sub_viewport) + auto_free(sub_viewport) + + var bg := _BackgroundRect.new() + bg.fill_color = COLOR_BG + bg.fill_size = Vector2(VIEWPORT_SIZE) + sub_viewport.add_child(bg) + bg.queue_redraw() + + var canvas := Node2D.new() + canvas.position = Vector2(VIEWPORT_SIZE) * 0.5 # center the composite's local (0,0) + canvas.scale = Vector2(zoom, zoom) + sub_viewport.add_child(canvas) + canvas.add_child(overlay) + overlay.queue_redraw() + + # Bounded settle wait, NOT `await RenderingServer.frame_post_draw` — that + # signal never fires under the dummy/headless driver (confirmed directly: + # a first version of this file using it hung for the full 300s + # tests/run-godot wall-clock cap and was force-killed, producing a FALSE + # "0 tests, passed" result — exactly the silent-hang failure mode the + # `_do_skip`/`_dummy_renderer_active()` gate (this file's header doc) now + # avoids structurally instead). A fixed small number of `process_frame` + # awaits settles real rendering — confirmed sufficient against a real + # driver during this fix's own live verification. + for _i in range(6): + await get_tree().process_frame + + return sub_viewport.get_texture().get_image() + + +## Fraction of `image`'s pixels whose RGB differs from COLOR_BG (alpha +## ignored — the ColorRect background is opaque, everything drawn on top of +## it is what's under test). +static func _non_background_fraction(image: Image) -> float: + var w: int = image.get_width() + var h: int = image.get_height() + if w <= 0 or h <= 0: + return 0.0 + var total: int = w * h + var differing: int = 0 + var bg_rgb: Color = Color(COLOR_BG.r, COLOR_BG.g, COLOR_BG.b, 1.0) + for y in range(h): + for x in range(w): + var px: Color = image.get_pixel(x, y) + var px_rgb: Color = Color(px.r, px.g, px.b, 1.0) + if not px_rgb.is_equal_approx(bg_rgb): + differing += 1 + return float(differing) / float(total) + + +## (a) Single-window path: a District-rung response must render as visibly +## non-background pixels through AtlasWindowOverlay._draw()'s own +## Rect2(0,0,extent,extent) draw call — the "does the OVERLAY actually paint +## something for a real window" half of the gap. Positions it at the +## SubViewport's center via the surrounding Node2D, mirroring _canvas's role +## in the real viewer. +## +## Honest scope note: live round 4's SECOND bug (leaving `_view_offset` +## stale across a rung crossing in `_maybe_reselect_rung()`) lived entirely +## in AtlasWindowViewer's transform bookkeeping, ONE LAYER ABOVE this +## overlay-only test's boundary — it never touched `_draw()` itself, so a +## pure-overlay smoke test structurally cannot reproduce it (there is no +## "stale vs. fresh offset" state to compare inside the overlay alone). That +## regression's coverage is `_maybe_reselect_rung()`'s own unit tests in +## test_atlas_zoom_ladder.gd. This test's job is narrower and still real: +## proving the overlay's draw call itself produces visible output for +## legitimate window data, closing the "the composite Rect2 call is +## silently a no-op" class of bug regardless of which layer caused it. +func test_single_window_draw_produces_visible_pixels() -> void: + # Guarded early-return instead of the _do_skip fuzzer-arg convention: + # gdUnit4 leaks one internal per fuzzer-skipped test, tripping the + # orphan detector (exit 101) and bouncing the push gate even at 0 failures + # (PR gate run 2026-07-22: "2 skipped | 2 orphans | Exit code: 101"). + if _dummy_renderer_active(): + print(SKIP_REASON) + return + var overlay: AtlasWindowOverlay = AtlasWindowOverlay.new() + var stub := _SingleWindowViewerStub.new() + stub.window = _mock_window(Vector2i.ZERO, 32) + overlay.viewer = stub + + var image: Image = await _render_to_image(overlay) + var fraction: float = _non_background_fraction(image) + + assert_float(fraction).override_failure_message( + ( + "single-window composite must render VISIBLE non-background pixels — got" + + " only %.2f%% of the frame differing from COLOR_BG. This is exactly the" + + " shape of live round 4's second bug: _view_offset left stale across a" + + " rung crossing pushed the composite off-canvas, so nothing but" + + " background/chrome ever appeared, despite the underlying window data" + + " and draw calls being individually 'correct' in isolation." + ) + % (fraction * 100.0) + ).is_greater(MIN_NON_BACKGROUND_FRACTION) + + +## (b) Tile-mosaic path: a tile at a district center AWAY from the body's +## own origin must still render as visibly non-background pixels once +## correctly placed via `district_to_canvas_local()`'s shared held_center/ +## held_n convention — this is the path round 4's FIRST and THIRD bugs +## (tile-local-origin math ignoring that convention, and per-tile +## ImageTexture objects with no persistent reference being garbage- +## collected/GPU-desynced before their draw command flushed) would both +## have failed. Uses production-realistic scale (live round 4's own Lendel +## repro: raw circumference ~19,139 districts) — see the tile-center +## comment below for why scale matters here specifically. +func test_tile_mosaic_draw_produces_visible_pixels() -> void: + # Guarded early-return, not _do_skip — see the sibling test's comment. + if _dummy_renderer_active(): + print(SKIP_REASON) + return + var overlay: AtlasWindowOverlay = AtlasWindowOverlay.new() + var stub := _TileModeViewerStub.new() + var tile_n: int = AtlasWindowGeometry.TILE_N + var cell_px: float = stub.get_cell_pixel_size() + stub.held_center = Vector2i.ZERO + stub.held_n = 19139 + # A SINGLE tile chosen so the CORRECT canvas-local formula + # (`district_to_canvas_local()`, anchored at `held_center - held_n/2`) + # lands it centered in the viewport, while the round-4 BUGGY formula + # (anchored at absolute district (0,0) directly) lands it almost + # `held_n/2 * cell_px` local units away — tens of thousands of units at + # this scale, i.e. genuinely fully off a 512x512 viewport, not just + # "shifted but still overlapping" (confirmed by hand-computation: a + # smaller/toy-scale version of this test stayed green with the bug + # reintroduced, because the shift stayed within the viewport bounds + # either way — this scale/center combination is chosen specifically to + # avoid that false-negative). + var half_tile: float = float(tile_n) * 0.5 + var half_body: float = float(stub.held_n) * 0.5 + var lone_tile_center := Vector2i(roundi(half_tile - half_body), roundi(half_tile - half_body)) + stub.tiles = [ + {"center": lone_tile_center, "window": _mock_window(lone_tile_center, 64)}, + ] + overlay.viewer = stub + + # A zoom small enough that the buggy-vs-correct shift (~half_body * cell_px + # local units) is comfortably larger than the viewport — see the center + # choice's own doc above for why this specific magnitude matters. + var zoom: float = float(VIEWPORT_SIZE.x) * 1.5 / (half_body * cell_px) + var image: Image = await _render_to_image(overlay, zoom) + var fraction: float = _non_background_fraction(image) + + assert_float(fraction).override_failure_message( + ( + "tile mosaic must render VISIBLE non-background pixels across its tiles —" + + " got only %.2f%% of the frame differing from COLOR_BG. This is exactly" + + " the shape of live round 4's bugs: (1) tile local-origin computed" + + " relative to absolute district (0,0) instead of the shared" + + " held_center/held_n canvas-local convention pushed the whole mosaic" + + " off-canvas, and (2) even once correctly positioned, an unstored" + + " per-draw-call ImageTexture rendered as a blank/white gap despite" + + " provably-correct CPU-side pixel data — both invisible to any test that" + + " only inspects Dictionary/cache state, never an actual composited pixel." + ) + % (fraction * 100.0) + ).is_greater(MIN_NON_BACKGROUND_FRACTION) diff --git a/client/tests/test_atlas_window_request.gd b/client/tests/test_atlas_window_request.gd index 942d243dd..a2046b767 100644 --- a/client/tests/test_atlas_window_request.gd +++ b/client/tests/test_atlas_window_request.gd @@ -14,19 +14,36 @@ extends GdUnitTestSuite # duplicated-load). const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd") +## Dudley's WINDOW_GRANULARITY_REGION_KEY (server/src/atlas/layer_proxy.rs) — +## `u32::MAX`, the RESERVED KEY-SPACE TAG a real server ALWAYS puts in the +## legacy `granularity` slot for every Region response (never a real +## multiplier — District=1/Quarter=4 are the only legal wire multipliers). +## Do NOT "fix" this to 1 — using a convenient value here is EXACTLY the gap +## the live round caught (a mock that diverges from the wire in the one +## field that matters silently un-repros the bug). See +## AtlasWindowRequest's `_echoed_granularity_matches()` doc for the full +## rationale. +const SERVER_LEGACY_GRANULARITY_REGION_SENTINEL: int = 4294967295 + ## Build a hand-authored DistrictWindowLayer dict, granularity-aware -## (T-1150) — mirrors test_atlas_window_viewer.gd's own _mock_window(), with -## granularity/min_wl_m added as optional params so callers can build both -## rungs' echo shapes with one helper. +## (T-1150, extended T-1152/T-1153 for granularity_v2) — mirrors +## test_atlas_window_viewer.gd's own _mock_window(), with +## granularity/min_wl_m/granularity_v2 added as optional params so callers +## can build any rung's echo shape with one helper. static func _mock_window( - center: Vector2i, n: int = 2, granularity: int = 1, min_wl_m: int = 0 + center: Vector2i, + n: int = 2, + granularity: int = 1, + min_wl_m: int = 0, + granularity_v2: String = "District" ) -> Dictionary: return { "center": [center.x, center.y], "n": n, "granularity": granularity, "min_wl_m": min_wl_m, + "granularity_v2": granularity_v2, "morphology": PackedByteArray([8, 14, 0, 1]), "elev_q": PackedByteArray([40, 90, 5, 60]), "temp_dc": [120, 95, -32768, 60], @@ -57,12 +74,24 @@ func _make_request() -> Variant: ## center/n must be dropped as stale, not accepted — a different rung's ## derive answering a request for a different rung is exactly as stale as a ## mismatched center (T-1150 extends §2's guard to this axis). +## +## **Live-round correction:** the mock MUST carry a mismatched +## `granularity_v2` too (explicit `"Quarter"`, not `_mock_window()`'s +## `"District"` default) — a real Quarter response ALWAYS carries +## `granularity_v2: "Quarter"` on the wire, never the District default this +## test's fixture used to leave implicit. Under the v2-authoritative-when- +## present precedence rule (see on_response()'s own doc), a v2-MATCHING +## response is accepted regardless of what the legacy int says — leaving +## granularity_v2 at its District default here would have made this test +## pass for the wrong reason (an accidentally-matching v2 field masking a +## genuinely mismatched legacy int), exactly the class of gap the live round +## caught in the oversized-orbital round-trip test. func test_on_response_with_mismatched_granularity_is_dropped_as_stale() -> void: var req = _make_request() req.request_now("GJ380c", Vector2i(2, 2), 2) assert_bool(req.is_pending()).is_true() - var quarter_window: Dictionary = _mock_window(Vector2i(2, 2), 2, 4, 0) + var quarter_window: Dictionary = _mock_window(Vector2i(2, 2), 2, 4, 0, "Quarter") req.on_response(_mock_response("GJ380c", quarter_window)) assert_bool(req.is_pending()).override_failure_message( @@ -70,6 +99,94 @@ func test_on_response_with_mismatched_granularity_is_dropped_as_stale() -> void: ).is_true() +# ============================================================================= +# (a2) granularity_v2 mismatch on the echo -> dropped as stale (T-1152/T-1153, +# the axis the legacy int alone cannot express — Region has no legacy value) +# ============================================================================= + + +## request_now() can now ask for Region explicitly (T-1153's rung-reselect +## caller) — a response echoing "District" for the SAME center/n must be +## dropped as stale, the granularity_v2 twin of test (a) above, and the +## ONLY guard that can catch this specific mismatch (the legacy int is +## DISTRICT_GRANULARITY=1 on BOTH sides here, since Region has no legacy +## representation — see WindowGranularity::legacy_u32()'s doc). +func test_on_response_with_mismatched_granularity_v2_is_dropped_as_stale() -> void: + var req = _make_request() + req.request_now("GJ380c", Vector2i(0, 0), 6400, AtlasWindowRequest.GRANULARITY_V2_REGION) + assert_bool(req.is_pending()).is_true() + + var district_window: Dictionary = _mock_window(Vector2i(0, 0), 6400, 1, 0, "District") + req.on_response(_mock_response("GJ380c", district_window)) + + assert_bool(req.is_pending()).override_failure_message( + "a granularity_v2-mismatched response (District answering a Region request)" + + " must be dropped as stale, leaving the request still pending" + ).is_true() + + +## The matching case: request_now() asking for Region, answered by a Region +## echo at the SAME (center, n) — must be ACCEPTED and cached under the +## Region key, retrievable on a follow-up request without a new network round +## trip. +## +## **Live-round correction:** the mock's legacy `granularity` field is now +## Dudley's ACTUAL wire sentinel (`WINDOW_GRANULARITY_REGION_KEY` = +## `u32::MAX` = 4294967295), not a convenient `1` — the original version of +## this test used `1`, which coincidentally matched the request's own +## pinned `_granularity` and therefore never exercised the real mismatch a +## live server actually produces. See _echoed_granularity_matches()'s own +## doc (atlas_window_request.gd) for why this is load-bearing: without the +## v2-authoritative-when-present fix, THIS test would have failed with the +## real sentinel — it only passed before because the mock was wrong. +func test_on_response_matching_granularity_v2_region_is_accepted_and_cached() -> void: + var req = _make_request() + req.request_now("GJ380c", Vector2i(0, 0), 6400, AtlasWindowRequest.GRANULARITY_V2_REGION) + assert_bool(req.is_pending()).is_true() + + var region_window: Dictionary = _mock_window( + Vector2i(0, 0), 6400, SERVER_LEGACY_GRANULARITY_REGION_SENTINEL, 0, "Region" + ) + req.on_response(_mock_response("GJ380c", region_window)) + assert_bool(req.is_pending()).override_failure_message( + "a response carrying the REAL legacy sentinel (u32::MAX) in the old" + + " granularity slot must still be accepted — v2 is authoritative" + + " whenever present, the legacy field must not be compared at all" + ).is_false() + + var received: Array = [] + req.window_ready.connect(func(w: Dictionary) -> void: received.append(w)) + req.request_now("GJ380c", Vector2i(0, 0), 6400, AtlasWindowRequest.GRANULARITY_V2_REGION) + assert_int(received.size()).override_failure_message( + "a second Region request at the same (center, n) must hit the cache" + ).is_equal(1) + assert_bool(req.is_pending()).is_false() + + +## **The direct precedence-rule proof (live-round finding #2, the sharpest +## case):** a response whose `granularity_v2` MATCHES the request but whose +## LEGACY `granularity` field could never possibly match (the Region +## sentinel) must still be ACCEPTED — proving the legacy comparison is +## SKIPPED entirely when v2 is present, not merely "also checked and +## happens to pass." This is the literal shape of the live bug: real server +## responses ALWAYS carry the Region sentinel in the legacy slot, so any +## code path that still consults the legacy field when v2 is already +## authoritative would drop every single one of these, forever. +func test_on_response_v2_match_is_accepted_regardless_of_legacy_field_value() -> void: + var req = _make_request() + req.request_now("GJ380c", Vector2i(0, 0), 6400, AtlasWindowRequest.GRANULARITY_V2_REGION) + + var region_window: Dictionary = _mock_window( + Vector2i(0, 0), 6400, SERVER_LEGACY_GRANULARITY_REGION_SENTINEL, 0, "Region" + ) + req.on_response(_mock_response("GJ380c", region_window)) + + assert_bool(req.is_pending()).override_failure_message( + "v2 match must be sufficient on its own — the legacy sentinel value must" + + " never be consulted once granularity_v2 is present on the response" + ).is_false() + + # ============================================================================= # (b) old-server-shape response (no granularity/min_wl_m keys) -> defaults # ============================================================================= @@ -116,6 +233,48 @@ func test_on_response_missing_granularity_and_min_wl_defaults_and_is_accepted() assert_bool(req.is_pending()).is_false() +## **Live-round sibling test (instruction #2's "old-server path stays +## covered"):** a response that carries the LEGACY `granularity` key WITH AN +## EXPLICIT VALUE (1, i.e. genuinely present, not merely defaulted via +## absence — the case test_on_response_missing_granularity_and_min_wl_defaults_and_is_accepted +## above doesn't exercise, since it omits the key entirely) but has NO +## `granularity_v2` key at all — the true "hypothetically old, pre-T-1152 +## server" shape — must still be accepted for a plain District request via +## the legacy-comparison FALLBACK branch in `_echoed_granularity_matches()`. +## This is the other half of the v2-authoritative-when-present precedence +## rule: v2 present -> v2 alone decides; v2 ABSENT -> legacy alone decides +## (never both, never neither). +func test_on_response_legacy_only_no_v2_key_still_accepted_for_district() -> void: + var req = _make_request() + req.request_now("GJ380c", Vector2i(4, 4), 2) # defaults to District granularity + assert_bool(req.is_pending()).is_true() + + # Legacy-only shape: "granularity" IS present (district=1), "granularity_v2" + # key is absent entirely — not present-with-a-District-value, ABSENT. + var legacy_only_window := { + "center": [4, 4], + "n": 2, + "granularity": AtlasWindowRequest.DEFAULT_GRANULARITY, + "min_wl_m": 0, + "morphology": PackedByteArray([8, 14, 0, 1]), + "elev_q": PackedByteArray([40, 90, 5, 60]), + "temp_dc": [120, 95, -32768, 60], + "moisture_q": PackedByteArray([50, 30, 90, 20]), + "vegetation": PackedByteArray([2, 1, 6, 3]), + "glaciation": PackedByteArray([0, 0, 1, 2]), + } + assert_bool(legacy_only_window.has("granularity_v2")).override_failure_message( + "sanity: this fixture must NOT carry granularity_v2 at all — that's the point" + ).is_false() + + req.on_response(_mock_response("GJ380c", legacy_only_window)) + + assert_bool(req.is_pending()).override_failure_message( + "a legacy-only response (granularity=1 present, granularity_v2 absent) must" + + " still be accepted for a District request via the legacy-fallback branch" + ).is_false() + + # ============================================================================= # (c) n-clamp mirror (Tyre C1) — quarter n=32 stores clamped n=16 # ============================================================================= @@ -175,3 +334,123 @@ func test_oversized_n_request_stores_clamped_n_and_accepts_matching_echo() -> vo + "already clamped to 64 before the request fired" ) ).is_false() + + +# ============================================================================= +# (d) Region clamp mirror (T-1152/T-1153) — mirrors +# server/src/atlas/layer_proxy.rs's clamp_window_n_v2 EXACTLY, including the +# Region branch's bounded halving loop. +# +# PR #192 review (Dudley, server-side analysis): the halving loop is +# PROVABLY UNREACHABLE at current constants — the per-axis clamp to +# SERVER_DISTRICT_WINDOW_MAX_N_REGION (6,400) forecloses it. Brute-forced, +# the max cell_grid_side over ALL reachable (post-per-axis-clamp) n is +# exactly 64 — the wire-cap boundary itself, never over it — so the loop's +# `>` guard is never true for any input. Ruling: the loop STAYS as +# defensive code (a future constant change could make it reachable again), +# but the test suite must not claim it "fires" when it provably doesn't. +# See server/src/atlas/layer_proxy.rs's +# clamp_window_n_v2_region_per_axis_cap_alone_satisfies_wire_cap_for_all_inputs +# for the server-side property-sweep pin this client-side suite mirrors. +# ============================================================================= + + +## District/Quarter through the v2 mirror must be BYTE-IDENTICAL to the +## legacy mirror — the server's own +## `clamp_window_n_v2_delegates_to_legacy_for_district_and_quarter` +## guarantee, restated client-side. +func test_clamp_window_n_mirror_v2_matches_legacy_for_district_and_quarter() -> void: + assert_int( + AtlasWindowRequest._clamp_window_n_mirror_v2(32, AtlasWindowRequest.GRANULARITY_V2_QUARTER) + ).is_equal(AtlasWindowRequest._clamp_window_n_mirror(32, 4)) + assert_int( + AtlasWindowRequest._clamp_window_n_mirror_v2(640, AtlasWindowRequest.GRANULARITY_V2_DISTRICT) + ).is_equal(AtlasWindowRequest._clamp_window_n_mirror(640, 1)) + + +## The clean Region boundary case: n=6,400 (DISTRICT_WINDOW_MAX_N_REGION, +## the per-axis cap exactly) derives cell_grid_side(6400) = round(6400/100) = +## 64, and 64² = 4,096 = WIRE_CAP_CELLS EXACTLY — the halving loop's `>` +## condition is false at the boundary, so this must clamp to EXACTLY 6,400, +## not halve further. This is the server's own +## `clamp_window_n_v2_region_exact_boundary_n6400_uncontested` guarantee, +## restated client-side (WIRE_CAP_CELLS_SQRT * DISTRICTS_PER_REGION is +## DERIVED to land here exactly, per that constant's own doc). +func test_clamp_window_n_mirror_v2_region_boundary_is_exact() -> void: + var n: int = AtlasWindowRequest._clamp_window_n_mirror_v2( + AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION, AtlasWindowRequest.GRANULARITY_V2_REGION + ) + assert_int(n).is_equal(AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION) + + +## Region's per-axis cap: a raw `n` far over DISTRICT_WINDOW_MAX_N_REGION +## (mirroring the server's own `region_request_oversized_n_clamps_and_echoes_clamped_n` +## test's `DISTRICT_WINDOW_MAX_N_REGION * 10` shape) must clamp DOWN — never +## trust the wire — and the result must satisfy BOTH invariants the server's +## own test asserts: `n <= DISTRICT_WINDOW_MAX_N_REGION` AND +## `cell_grid_side(n)^2 <= WIRE_CAP_CELLS`. +func test_clamp_window_n_mirror_v2_region_oversized_n_clamps_within_both_bounds() -> void: + var oversized: int = AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION * 10 + var n: int = AtlasWindowRequest._clamp_window_n_mirror_v2( + oversized, AtlasWindowRequest.GRANULARITY_V2_REGION + ) + assert_int(n).override_failure_message( + "echoed n must be clamped to DISTRICT_WINDOW_MAX_N_REGION, not the raw oversized value" + ).is_less_equal(AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION) + var side: int = AtlasWindowRequest._cell_grid_side_region_mirror(n) + assert_int(side * side).override_failure_message( + "clamped cell count must never exceed WIRE_CAP_CELLS at Region granularity either" + ).is_less_equal(AtlasWindowRequest.SERVER_WIRE_CAP_CELLS) + + +## PR #192 review (Dudley's unreachability finding, applied client-side): the +## halving loop's `>` guard is PROVABLY never true at current constants — the +## per-axis clamp to SERVER_DISTRICT_WINDOW_MAX_N_REGION (6,400) happens +## FIRST and unconditionally, and cell_grid_side(6400) = 64 lands EXACTLY on +## the wire-cap boundary (64² = WIRE_CAP_CELLS), never over it. A prior +## version of this test claimed n=6,450 "exercises" the loop firing — it does +## not: 6,450 clamps to 6,400 before the loop ever runs, so the test was +## passing on the per-axis clamp alone, not on anything the loop itself did +## (the same mock-diverges-from-reality class of bug hunted in review round +## 2). Reframed as a property sweep, mirroring the server's own +## `clamp_window_n_v2_region_per_axis_cap_alone_satisfies_wire_cap_for_all_inputs` +## (Dudley): for every raw n across the legal range (including values far +## past the per-axis cap), (i) the per-axis-clamped n never gets modified any +## further by the loop — pre-loop n and post-clamp n are byte-identical — +## and (ii) the wire-cap invariant holds regardless. The loop itself stays as +## defensive code (a future constant change could make it reachable again); +## this test documents that it is a no-op today rather than asserting a +## behavior that never actually happens. +func test_clamp_window_n_mirror_v2_region_per_axis_cap_alone_satisfies_wire_cap_for_all_inputs() -> void: + var sample_raw_ns: Array = [ + 1, 100, 6399, 6400, 6401, 6450, 6500, + AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION * 10, + ] + for raw_n: int in sample_raw_ns: + var pre_loop_n: int = clampi(raw_n, 1, AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION) + var clamped_n: int = AtlasWindowRequest._clamp_window_n_mirror_v2( + raw_n, AtlasWindowRequest.GRANULARITY_V2_REGION + ) + assert_int(clamped_n).override_failure_message( + ( + "the per-axis clamp alone must already satisfy the wire cap for" + + " raw_n=%d — the halving loop is provably unreachable at current" + + " constants (max cell_grid_side over all reachable n is exactly" + + " 64, the wire-cap boundary itself), so it must never further" + + " modify what the per-axis clamp already produced" + ) % raw_n + ).is_equal(pre_loop_n) + + var side: int = AtlasWindowRequest._cell_grid_side_region_mirror(clamped_n) + assert_int(side * side).override_failure_message( + "the wire-cap invariant must hold for raw_n=%d regardless" % raw_n + ).is_less_equal(AtlasWindowRequest.SERVER_WIRE_CAP_CELLS) + + +## n smaller than one region (n < 100) must clamp its cell-grid side to a +## minimum of 1 — cell_grid_side_for_window()'s own `.max(1)` — never a +## degenerate 0x0 grid, matching WindowGranularity::cell_grid_side's own +## documented minimum. +func test_cell_grid_side_region_mirror_minimum_is_one() -> void: + assert_int(AtlasWindowRequest._cell_grid_side_region_mirror(1)).is_equal(1) + assert_int(AtlasWindowRequest._cell_grid_side_region_mirror(50)).is_equal(1) diff --git a/client/tests/test_atlas_window_tile_set.gd b/client/tests/test_atlas_window_tile_set.gd new file mode 100644 index 000000000..5a394749d --- /dev/null +++ b/client/tests/test_atlas_window_tile_set.gd @@ -0,0 +1,204 @@ +## T-1153, live round 3 (Jeroen's ruling, design doc §4): tests for +## AtlasWindowTileSet — the orbital rest-state multi-window mosaic +## orchestration. Same hand-built-response-dict conventions as +## test_atlas_window_request.gd/test_atlas_zoom_ladder.gd; this file is +## about the ORCHESTRATION (N tiles, progressive per-tile arrival, +## teardown), not the tile-grid MATH (already covered directly against +## AtlasWindowGeometry.compute_tile_grid() in test_atlas_window_geometry.gd). +class_name TestAtlasWindowTileSet +extends GdUnitTestSuite + +const AtlasWindowTileSet := preload("res://ui/implant/apps/atlas/atlas_window_tile_set.gd") +const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd") + + +static func _mock_window(center: Vector2i, n: int) -> Dictionary: + return { + "center": [center.x, center.y], + "n": n, + "granularity_v2": "Region", + "morphology": PackedByteArray([1, 2, 3, 4]), + "elev_q": PackedByteArray([10, 20, 30, 40]), + "temp_dc": [0, 0, 0, 0], + "moisture_q": PackedByteArray([0, 0, 0, 0]), + "vegetation": PackedByteArray([0, 0, 0, 0]), + "glaciation": PackedByteArray([0, 0, 0, 0]), + } + + +static func _mock_response(body_id: String, window: Variant) -> Dictionary: + return {"body_id": body_id, "status": "Ready", "district_window": window} + + +func _make_tile_set() -> Variant: + var owner_stub := RefCounted.new() + var ts = auto_free(AtlasWindowTileSet.new(owner_stub)) + add_child(ts) + return ts + + +# ============================================================================= +# enter() — tile grid computation + one request per tile +# ============================================================================= + + +## enter() on a real, tiling-sized body must produce the SAME tile count +## compute_tile_grid() would — 6 for GJ380c/Lendel, the coordinator's own +## live-round number. +func test_enter_produces_the_expected_tile_count_for_lendel() -> void: + var ts = _make_tile_set() + ts.enter("GJ380c", 6238.4) + assert_int(ts.get_tile_count()).is_equal(6) + assert_bool(ts.is_multi_tile()).is_true() + + +## A tiny (non-tiling) body produces exactly ONE tile — the degenerate case +## compute_tile_grid() itself already covers; this confirms the ORCHESTRATION +## (not just the grid math) handles it without crashing or requesting zero +## tiles. +func test_enter_tiny_body_produces_one_tile() -> void: + var ts = _make_tile_set() + ts.enter("TinyBody", 50.0) + assert_int(ts.get_tile_count()).is_equal(1) + assert_bool(ts.is_multi_tile()).is_false() + + +## Every tile must start with a null window (nothing has arrived yet) and +## the tile set must not report "fully arrived" before any response lands. +func test_enter_all_tiles_start_unarrived() -> void: + var ts = _make_tile_set() + ts.enter("GJ380c", 6238.4) + for tile: Dictionary in ts.get_tiles(): + assert_that(tile["window"]).is_null() + assert_bool(ts.is_fully_arrived()).is_false() + + +## An empty tile set (never entered) must not report "fully arrived" either +## — an empty AND-over-nothing must not vacuously read true. +func test_empty_tile_set_is_not_fully_arrived() -> void: + var ts = _make_tile_set() + assert_bool(ts.is_fully_arrived()).is_false() + + +# ============================================================================= +# Progressive per-tile arrival (design doc §4: "with visible refinement as +# tiles complete") — each tile's response is independent of every other's. +# ============================================================================= + + +## Delivering ONE tile's response must populate ONLY that tile's window, +## leaving every other tile still null — the direct "progressive, not +## block-on-all" regression. +func test_one_tile_arriving_does_not_affect_the_others() -> void: + var ts = _make_tile_set() + ts.enter("GJ380c", 6238.4) + var tiles: Array = ts.get_tiles() + var first_center: Vector2i = tiles[0]["center"] + var window: Dictionary = _mock_window(first_center, AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION) + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window)) + + var updated_tiles: Array = ts.get_tiles() + assert_that(updated_tiles[0]["window"]).override_failure_message( + "the tile whose response arrived must have its window populated" + ).is_equal(window) + for i in range(1, updated_tiles.size()): + assert_that(updated_tiles[i]["window"]).override_failure_message( + "tile %d must still be unarrived — only tile 0's response was delivered" % i + ).is_null() + + +## tile_ready must fire with the INDEX of the tile that actually arrived — +## the viewer/overlay needs this to know WHICH tile to redraw, not just +## "something changed". +func test_tile_ready_signal_fires_with_the_correct_index() -> void: + var ts = _make_tile_set() + ts.enter("GJ380c", 6238.4) + var received_indices: Array = [] + ts.tile_ready.connect(func(index: int) -> void: received_indices.append(index)) + + var tiles: Array = ts.get_tiles() + var second_center: Vector2i = tiles[1]["center"] + var window: Dictionary = _mock_window(second_center, AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION) + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window)) + + assert_int(received_indices.size()).is_equal(1) + assert_int(received_indices[0]).is_equal(1) + + +## Delivering EVERY tile's response must flip is_fully_arrived() to true — +## the mosaic-complete signal the viewer/legend chrome can use. +func test_all_tiles_arriving_flips_fully_arrived() -> void: + var ts = _make_tile_set() + ts.enter("GJ380c", 6238.4) + var tiles: Array = ts.get_tiles() + for tile: Dictionary in tiles: + var window: Dictionary = _mock_window( + tile["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION + ) + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window)) + + assert_bool(ts.is_fully_arrived()).override_failure_message( + "once every tile's response has arrived, the tile set must report fully arrived" + ).is_true() + + +## A response for a body the tile set is NOT currently showing (a stale +## response from a body the player has since navigated away from) must not +## be adopted by any tile — the SAME body_id staleness guard every other +## AtlasWindowRequest-based path already relies on (this is inherited for +## free since each tile IS an AtlasWindowRequest, but pinned here as an +## orchestration-level regression too). +func test_response_for_a_different_body_is_ignored() -> void: + var ts = _make_tile_set() + ts.enter("GJ380c", 6238.4) + var tiles: Array = ts.get_tiles() + var window: Dictionary = _mock_window( + tiles[0]["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION + ) + SimBridge.atlas_layers_received.emit(_mock_response("GJ_wrong_body", window)) + + assert_that(ts.get_tiles()[0]["window"]).is_null() + + +# ============================================================================= +# Teardown — re-entering (a fresh body, or the same body again) must not +# leave stale tile request nodes wired up. +# ============================================================================= + + +## Calling enter() a SECOND time (e.g. re-entering the orbital frame, or +## switching to a different body) must replace the tile set entirely — the +## OLD tiles' indices/centers must not linger. +func test_second_enter_replaces_the_tile_set() -> void: + var ts = _make_tile_set() + ts.enter("GJ380c", 6238.4) + var first_count: int = ts.get_tile_count() + assert_int(first_count).is_equal(6) + + ts.enter("TinyBody", 50.0) + assert_int(ts.get_tile_count()).override_failure_message( + "a second enter() must fully replace the tile set, not append to it" + ).is_equal(1) + + +## A response matching an OLD tile set's (body, center) — arriving AFTER a +## second enter() has already torn it down — must not be adopted (or crash): +## the old tile's AtlasWindowRequest node is queue_free()'d, and _tiles no +## longer references it, so a stale signal (if it could somehow still fire) +## has no live entry left to update. +func test_stale_response_after_second_enter_does_not_crash_or_leak() -> void: + var ts = _make_tile_set() + ts.enter("GJ380c", 6238.4) + var old_tiles: Array = ts.get_tiles() + var old_center: Vector2i = old_tiles[0]["center"] + + ts.enter("GJ380c", 50.0) # same body_id, different (tiny) radius -> different tile grid + + # A response shaped like it's answering the OLD tile set's first tile — + # must not crash, and must not corrupt the NEW tile set's single tile. + var stale_window: Dictionary = _mock_window( + old_center, AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION + ) + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", stale_window)) + + assert_int(ts.get_tile_count()).is_equal(1) diff --git a/client/tests/test_atlas_window_viewer.gd b/client/tests/test_atlas_window_viewer.gd index 7e528affc..127efedfe 100644 --- a/client/tests/test_atlas_window_viewer.gd +++ b/client/tests/test_atlas_window_viewer.gd @@ -144,11 +144,15 @@ func test_set_view_and_getters_round_trip() -> void: assert_that(v.get_view_offset()).is_equal(Vector2(30.0, -10.0)) +## T-1153: MIN_ZOOM widened to 0.0005 (from the pre-ladder 0.5) so a +## gas-giant-scale body's enter_orbital() fit zoom is never itself clamped — +## see MIN_ZOOM's own doc. Values here are chosen well outside the new wide +## range on both ends, not the old range's boundary values. func test_set_view_clamps_to_min_max_zoom() -> void: var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) add_child(v) - v.set_view(0.01, Vector2.ZERO) - assert_that(v.get_view_zoom()).is_equal_approx(AtlasWindowViewer.MIN_ZOOM, 0.001) + v.set_view(0.0000001, Vector2.ZERO) + assert_that(v.get_view_zoom()).is_equal_approx(AtlasWindowViewer.MIN_ZOOM, 0.0001) v.set_view(1000.0, Vector2.ZERO) assert_that(v.get_view_zoom()).is_equal_approx(AtlasWindowViewer.MAX_ZOOM, 0.001) @@ -352,25 +356,28 @@ func test_wasd_diagonal_pan_is_not_faster_than_single_axis() -> void: ).is_equal_approx(axis_distance, 0.01) -## The real scene-tree path: DistrictScreen -> AtlasWindowViewer. Unlike -## drag (which needed _gui_input event delivery, hence the old -## "does an ancestor eat the event" test), WASD pan lives in _process() — -## Godot delivers _process() to every node in the tree regardless of Control -## mouse_filter/ancestry (there is no "topmost control" routing for -## per-frame process callbacks the way there is for _gui_input), so there is -## no equivalent "does DistrictScreen eat it" question for _process() itself. -## What DOES still matter through the real chain is _is_over_ui()'s edge- -## scroll suppression and visibility gating — pinned directly below instead. -func test_wasd_pan_reaches_viewer_through_district_screen_chain() -> void: - var screen: DistrictScreen = auto_free(DistrictScreen.new()) +## The real scene-tree path: RegionalScreen -> AtlasWindowViewer (T-1153 — +## RegionalScreen is now the WHOLE ladder's nav entry, superseding the +## retired DistrictScreen nav hop; see atlas_app.gd's own doc for why the +## separate "district" screen retired). Unlike drag (which needed +## _gui_input event delivery, hence the old "does an ancestor eat the +## event" test), WASD pan lives in _process() — Godot delivers _process() +## to every node in the tree regardless of Control mouse_filter/ancestry +## (there is no "topmost control" routing for per-frame process callbacks +## the way there is for _gui_input), so there is no equivalent "does the +## screen eat it" question for _process() itself. What DOES still matter +## through the real chain is _is_over_ui()'s edge-scroll suppression and +## visibility gating — pinned directly below instead. +func test_wasd_pan_reaches_viewer_through_regional_screen_chain() -> void: + var screen: RegionalScreen = auto_free(RegionalScreen.new()) add_child(screen) - screen.enter({"body": {"body_id": "GJ380c"}, "district_center": Vector2i(0, 0)}) + screen.enter({"body": {"body_id": "GJ380c"}, "system": {}}) var offset_before: Vector2 = screen._viewer.get_view_offset() screen._viewer._apply_pan_delta(Vector2(1.0, 0.0), 0.1) assert_that(screen._viewer.get_view_offset()).override_failure_message( - "a pan tick driven through DistrictScreen's child viewer must still move" + "a pan tick driven through RegionalScreen's child viewer must still move" + " _view_offset — no ancestor in the real screen chain blocks it" ).is_not_equal(offset_before) diff --git a/client/tests/test_atlas_zoom_ladder.gd b/client/tests/test_atlas_zoom_ladder.gd new file mode 100644 index 000000000..c6b775cfe --- /dev/null +++ b/client/tests/test_atlas_zoom_ladder.gd @@ -0,0 +1,920 @@ +## T-1153 (D-226 T-1143-rulings amendment): tests for the continuous +## cursor-anchored zoom ladder — enter_orbital() (the canonical planetary +## frame), progressive refinement (held composite survives a rung-crossing +## request), the full-zoom-out reset (Jeroen's HARD condition), rung +## reselection on zoom, and E/W wrap + pole-wall clamps at Region +## granularity. Split out of test_atlas_window_viewer.gd (which owns the +## pre-T-1153 window-viewer behavior — entry, cache reuse, WASD/edge-scroll, +## fit-and-center) purely for file-length reasons (gdlint max-file-lines); +## same instantiation/mock-response conventions as that file, not a +## different testing philosophy. +class_name TestAtlasZoomLadder +extends GdUnitTestSuite + +const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd") +const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") +const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") + +## Dudley's WINDOW_GRANULARITY_REGION_KEY (server/src/atlas/layer_proxy.rs) — +## `u32::MAX`, a RESERVED KEY-SPACE TAG the real server ALWAYS puts in the +## legacy `granularity` slot for every Region response (never a real +## multiplier — District=1/Quarter=4 are the only legal wire multipliers). +## Do NOT "fix" this to 1 — that would silently un-repro the live-round bug +## this constant exists to guard against (a real server's actual wire byte, +## not a convenient test value). See _echoed_granularity_matches()'s own doc +## (atlas_window_request.gd) for why this value can NEVER equal a client's +## stored `_granularity` (which stays pinned at DISTRICT_GRANULARITY=1 for +## every rung a T-1152-aware client requests) — that mismatch is exactly +## what silently dropped every Region response before the v2-authoritative +## fix. +const SERVER_LEGACY_GRANULARITY_REGION_SENTINEL: int = 4294967295 + + +## Build a hand-authored DistrictWindowLayer dict (n=2 by default) — mirrors +## test_atlas_window_viewer.gd's own _mock_window(). +static func _mock_window(center: Vector2i, n: int = 2) -> Dictionary: + return { + "center": [center.x, center.y], + "n": n, + "morphology": PackedByteArray([8, 14, 0, 1]), + "elev_q": PackedByteArray([40, 90, 5, 60]), + "temp_dc": [120, 95, -32768, 60], + "moisture_q": PackedByteArray([50, 30, 90, 20]), + "vegetation": PackedByteArray([2, 1, 6, 3]), + "glaciation": PackedByteArray([0, 0, 1, 2]), + } + + +static func _mock_response(body_id: String, window: Variant) -> Dictionary: + return {"body_id": body_id, "status": "Ready", "district_window": window} + + +# ============================================================================= +# T-1153: enter_orbital() — the canonical planetary frame, the ladder's TOP +# REST STATE (Jeroen's HARD condition, D-226 T-1143-rulings amendment). +# ============================================================================= + + +## enter_orbital() must center on district (0,0) — "district (0,0) sits at +## lon 0 / the equator" (AtlasDescendGeometry's own doc). +func test_enter_orbital_centers_on_the_canonical_origin() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": 6238.4}, {}) + assert_that(v._held_center).is_equal(Vector2i.ZERO) + + +## enter_orbital() must request at Region granularity — the orbital view IS +## the Region rung at high n, not a separate screen/mode (the ticket's own +## framing). +func test_enter_orbital_requests_region_granularity() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": 6238.4}, {}) + assert_str(v._held_granularity_v2).is_equal("Region") + + +## **Superseded by live round 3's tiling fix — retargeted, not deleted.** +## GJ380c/Lendel (radius 6238.4 km) was the ORIGINAL single-window C1 repro +## (raw cols ~19,139 vs. the 6,400 clamp ceiling) — but that SAME threshold +## (`DISTRICT_WINDOW_MAX_N_REGION * DISTRICT_M` = the coverage ceiling +## `compute_tile_grid()` tiles past) means any body needing the n-clamp ALSO +## needs tiling: there is no real body where enter_orbital() takes the +## single-window path with a raw `n` big enough to require clamping. +## GJ380c now correctly enters TILE mode (test_enter_orbital_n_is_the_clamped_value_not_raw_circumference's +## old assertion on a single clamped `_held_n` no longer applies — see +## test_enter_orbital_tile_mode_held_n_is_the_whole_body_extent below for +## what `_held_n` means in tile mode instead). The single-window clamp-mirror +## fix itself remains covered: `_enter_at_rung()`'s own doc/the clamp +## mirror's unit tests (test_atlas_window_request.gd) pin the formula +## directly, and test_zoom_crossing_fires_request_and_accepts_wire_accurate_refinement +## exercises the SAME clamp-mirror lesson at the reselect (not entry) +## boundary, which single-window mode still reaches on the way DOWN from a +## tile-mode zoom-in. +func test_enter_orbital_tile_mode_held_n_is_the_whole_body_extent() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + var radius_km := 6238.4 # GJ380c (Lendel) + var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) + var raw_cols: int = int(extent["cols"]) + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) + + assert_bool(v.is_tile_mode()).override_failure_message( + "GJ380c/Lendel needs tiling — enter_orbital() must have entered tile mode" + ).is_true() + # In TILE mode, _held_n is the WHOLE body's extent (unclamped) — each + # TILE clamps its own request independently inside AtlasWindowTileSet + # (see that file's own tests), so _held_n here is NOT expected to equal + # any single clamped value the way single-window mode's is. + assert_int(v._held_n).is_equal(raw_cols) + + +## **The live-round-3 regression, end to end for TILE mode:** enter_orbital() +## on GJ380c/Lendel followed by delivering ONE tile's wire-accurate response +## (clamped n=6,400, "Region" granularity_v2, the legacy sentinel in the old +## granularity slot — exactly what a real server sends) must be ACCEPTED +## into that tile's own slot — not silently dropped. This exercises BOTH +## live-round fixes (the v2-authoritative precedence AND per-tile clamping) +## through the tile-set path specifically, complementing +## test_atlas_window_tile_set.gd's own more granular orchestration tests. +func test_enter_orbital_tile_mode_accepts_a_wire_accurate_tile_response() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + var radius_km := 6238.4 # GJ380c (Lendel) — the live-repro body + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) + assert_bool(v.is_tile_mode()).is_true() + + var tile_set = v.get_tile_set() + var tiles: Array = tile_set.get_tiles() + assert_int(tiles.size()).is_greater(1) + var first_tile_center: Vector2i = tiles[0]["center"] + + var tile_window: Dictionary = { + "center": [first_tile_center.x, first_tile_center.y], + "n": AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION, + "granularity": SERVER_LEGACY_GRANULARITY_REGION_SENTINEL, + "granularity_v2": "Region", + "morphology": PackedByteArray([8, 14, 0, 1]), + "elev_q": PackedByteArray([40, 90, 5, 60]), + "temp_dc": [120, 95, -32768, 60], + "moisture_q": PackedByteArray([50, 30, 90, 20]), + "vegetation": PackedByteArray([2, 1, 6, 3]), + "glaciation": PackedByteArray([0, 0, 1, 2]), + } + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", tile_window)) + + assert_that(tile_set.get_tiles()[0]["window"]).override_failure_message( + "a wire-accurate response (clamped n, Region granularity_v2, the legacy" + + " sentinel) for the first tile must be ACCEPTED into that tile's slot" + ).is_equal(tile_window) + + +## A no-radius body (tiny test body) has no circumference concept — +## enter_orbital() falls back to the District-rung default window rather +## than crashing or deriving a degenerate n. +func test_enter_orbital_no_radius_body_falls_back_to_district() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.enter_orbital({"body_id": "GJ380c"}, {}) + assert_str(v._held_granularity_v2).is_equal("District") + assert_int(v._held_n).is_equal(AtlasWindowRequest.DISTRICT_WINDOW_DEFAULT_N) + + +# ============================================================================= +# T-1153: progressive refinement — the held composite survives until the +# replacement arrives (§6 "no mode flip": never a blank frame, never a +# clear-then-redraw). +# ============================================================================= + + +## The core acceptance test: once a window is held, a request for a +## DIFFERENT rung being in-flight must NOT clear `_window` — the old +## composite stays exactly what get_district_window() returns until the new +## rung's response actually arrives and is adopted. +func test_held_window_survives_while_a_different_rung_request_is_in_flight() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2) + var district_window: Dictionary = _mock_window(Vector2i(10, 20), 2) + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", district_window)) + assert_that(v.get_district_window()).is_equal(district_window) + + # Simulate a rung-reselect firing a NEW (Region) request without the + # response having arrived yet — direct call, mirroring what + # _maybe_reselect_rung() does internally. + v._window_request.request_debounced("GJ380c", Vector2i(10, 20), 2, "Region") + + assert_that(v.get_district_window()).override_failure_message( + "the OLD composite must survive while a different-rung request is in" + + " flight — no blank frame, no premature clear" + ).is_equal(district_window) + + +## Once the new rung's response actually arrives (matching the CURRENTLY +## in-flight request's granularity_v2), it swaps in — the composite reference +## changes from the old rung's window to the new one. +func test_new_rung_window_swaps_in_once_it_arrives() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2) + var district_window: Dictionary = _mock_window(Vector2i(10, 20), 2) + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", district_window)) + + v._window_request.request_debounced("GJ380c", Vector2i(10, 20), 2, "Region") + var region_window: Dictionary = { + "center": [10, 20], "n": 2, "granularity_v2": "Region", + "morphology": PackedByteArray([1, 2, 3, 4]), + "elev_q": PackedByteArray([10, 20, 30, 40]), + "temp_dc": [0, 0, 0, 0], + "moisture_q": PackedByteArray([0, 0, 0, 0]), + "vegetation": PackedByteArray([0, 0, 0, 0]), + "glaciation": PackedByteArray([0, 0, 0, 0]), + } + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", region_window)) + + assert_that(v.get_district_window()).override_failure_message( + "once the new rung's matching response arrives, it must swap in" + ).is_equal(region_window) + assert_str(v._held_granularity_v2).is_equal("Region") + + +## refresh() clear()s via queue_free() (deferred, not synchronous) — a legend +## that has refreshed more than once in the same frame (build-time refresh at +## _ready(), then an entry-time refresh) can have STALE not-yet-freed +## children still parented alongside the new ones. add_component() always +## APPENDS, so the current ImplantHeader is the LAST one in the list, never +## assumed to be [0]. +static func _current_legend_header(legend_panel) -> ImplantHeader: + var children: Array = legend_panel.get_implant_children() + for i in range(children.size() - 1, -1, -1): + if children[i] is ImplantHeader: + return children[i] + return null + + +## PR #192 review (Araminta, BLOCKING): the legend subtitle used to hardcode +## District's own "2.048 km/cell" — a 100x lie whenever the viewer actually +## holds Region (204.8 km/cell). While in the orbital tile-mode rest state +## (Region granularity), the legend must read Region's real spacing, not the +## stale District literal. +func test_legend_subtitle_reflects_region_spacing_in_tile_mode() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + var radius_km := 6238.4 # GJ380c (Lendel) — needs tiling, so enters at Region + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) + assert_bool(v.is_tile_mode()).is_true() + assert_str(v._held_granularity_v2).is_equal("Region") + + var header: ImplantHeader = _current_legend_header(v._legend_panel) + assert_str(header._subtitle_label.text).override_failure_message( + "legend subtitle must reflect Region's real 204.800 km/cell spacing while" + + " the viewer holds Region granularity, not a hardcoded District figure" + ).contains("204.800 km/cell") + + +## Same bug, the other direction: after crossing INTO a single-window District +## rung, the legend must re-render with District's own spacing — proving the +## legend actually refreshes on a rung change rather than being stuck at +## whatever it showed on the FIRST refresh() call (T-1153's _build_legend_panel() +## fires one at _ready() time, before any real rung is held). +func test_legend_subtitle_reflects_district_spacing_after_crossing_in() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2) + assert_str(v._held_granularity_v2).is_equal("District") + + var header: ImplantHeader = _current_legend_header(v._legend_panel) + assert_str(header._subtitle_label.text).override_failure_message( + "legend subtitle must re-render at District's own 2.048 km/cell spacing" + + " once the viewer holds a District-rung window — proving refresh() is" + + " actually wired to the rung change, not just called once at build time" + ).contains("2.048 km/cell") + + +## A response for a rung OTHER than what's currently requested (e.g. a +## District response arriving after the viewer has already moved on to a +## Region request — a rapid wheel-zoom race) must be discarded as stale, the +## held composite untouched. +func test_stale_rung_response_after_moving_on_is_discarded() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2) + var district_window: Dictionary = _mock_window(Vector2i(10, 20), 2) + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", district_window)) + + v._window_request.request_debounced("GJ380c", Vector2i(10, 20), 2, "Region") + # A LATE district-rung response for the same (center, n) arrives after the + # viewer has already moved on to requesting Region — must be dropped. + var late_district_window: Dictionary = _mock_window(Vector2i(10, 20), 2) + late_district_window["morphology"] = PackedByteArray([9, 9, 9, 9]) # distinguishable payload + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", late_district_window)) + + assert_that(v.get_district_window()).override_failure_message( + "a stale response for a rung the viewer has since moved on from must be discarded" + ).is_equal(district_window) + + +# ============================================================================= +# T-1153: full-zoom-out reset (Jeroen's HARD condition). +# ============================================================================= + + +## Directly at the canonical frame already (center (0,0), Region granularity) +## must be a no-op — never re-fights a player zooming back IN from the top. +func test_reset_to_canonical_frame_is_noop_when_already_there() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": 6238.4}, {}) + v._held_center = Vector2i.ZERO + v._held_granularity_v2 = "Region" + var fired: bool = v._maybe_reset_to_canonical_frame() + assert_bool(fired).override_failure_message( + "already at the canonical frame — the reset must not re-fire" + ).is_false() + + +## A no-radius body must never trigger the reset (no circumference concept — +## matches enter_orbital()'s own guard). +func test_reset_to_canonical_frame_never_fires_for_no_radius_body() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.enter({"body_id": "GJ380c"}, {}, Vector2i(5, 5), 2) + var fired: bool = v._maybe_reset_to_canonical_frame() + assert_bool(fired).is_false() + + +## Away from the canonical frame (a drifted District-rung pan/zoom state) +## with a fully-zoomed-out world extent must reset — the direct wiring test +## for Jeroen's HARD condition: enter() at a far-off center, then force the +## view zoom low enough that the displayed extent covers the whole body. +func test_reset_to_canonical_frame_fires_and_re_centers_when_fully_zoomed_out() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + var radius_km := 50.0 # tiny synthetic body — small circumference, reachable by a modest zoom-out + v.enter({"body_id": "GJ380c", "body_radius_km": radius_km}, {}, Vector2i(500, 10), 32) + # Force a very low zoom — a huge displayed world extent, comfortably over + # this tiny body's whole circumference. + v._view_zoom = AtlasWindowViewer.MIN_ZOOM + + var fired: bool = v._maybe_reset_to_canonical_frame() + + assert_bool(fired).override_failure_message( + "a fully-zoomed-out view on a real-radius body must trigger the reset" + ).is_true() + assert_that(v._held_center).override_failure_message( + "the reset must re-center on the canonical origin (0,0)" + ).is_equal(Vector2i.ZERO) + assert_str(v._held_granularity_v2).override_failure_message( + "the reset must land on the Region rung — the ladder's top rest state" + ).is_equal("Region") + + +## Live round 5's OWN repro, end to end: enter a TILING body's canonical +## frame, wheel-zoom IN far enough to cross out of tile mode (leaving +## `_held_granularity_v2` STALE at "Region" — a real, expected lag per +## `_maybe_reselect_rung()`'s own "does NOT touch _held_granularity_v2" +## doc, not a bug in that function), then wheel-zoom back OUT past the +## fully-zoomed-out threshold. The reset must fire and land EXACTLY on +## enter_orbital()'s own fit zoom for this body/viewport — not merely +## re-center while leaving `_view_zoom` wherever continued `_zoom_at()` +## scaling left it. Before the fix, the stale "Region" granularity +## satisfied the guard's OLD (center + granularity only) check forever, +## so the reset never fired again and `_view_zoom` kept shrinking via +## plain multiplication all the way to MIN_ZOOM. +func test_reset_after_crossing_out_and_back_snaps_to_the_canonical_fit_zoom() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.size = Vector2(1600.0, 900.0) + var radius_km := 6238.4 # GJ380c (Lendel) — a tiling body, the live-repro shape + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) + assert_bool(v.is_tile_mode()).override_failure_message( + "sanity: Lendel must enter tile mode — this repro needs a TILING body," + + " since that's where _held_granularity_v2 can lag is_tile_mode()" + ).is_true() + + # Zoom IN far enough to cross out of tile mode (matching + # test_zoom_crossing_recomputes_view_offset_so_the_new_window_is_on_screen's + # own gesture shape). + var cursor_pos := Vector2(800.0, 450.0) + for _i in range(60): + v._zoom_at(cursor_pos, 1.15) + if not v.is_tile_mode(): + break + assert_bool(v.is_tile_mode()).override_failure_message( + "sanity: this test needs to actually leave tile mode before zooming back out" + ).is_false() + assert_str(v._held_granularity_v2).override_failure_message( + "sanity: _held_granularity_v2 must be STALE at Region here (no mock response" + + " ever adopted a new value) — this is the exact lagging-field condition" + + " the guard fix targets, not an artificial setup" + ).is_equal("Region") + + # Zoom back OUT past the fully-zoomed-out threshold — the reset must fire + # (possibly after a few more _zoom_at() ticks, matching a real wheel + # gesture rather than asserting it fires on the very first step back). + for _i in range(200): + v._zoom_at(cursor_pos, 1.0 / 1.05) + if v.is_tile_mode(): + break + + assert_bool(v.is_tile_mode()).override_failure_message( + "zooming back out past the threshold must re-fire the reset and land back" + + " in tile mode — the stale-granularity guard bug left this permanently false" + ).is_true() + assert_that(v._held_center).is_equal(Vector2i.ZERO) + + var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) + var n: int = int(extent["cols"]) + var expected_fit: Dictionary = AtlasWindowGeometry.fit_window_view( + v.size, n, AtlasWindowViewer.CELL_PIXEL_SIZE, AtlasWindowViewer.MIN_ZOOM, AtlasWindowViewer.MAX_ZOOM + ) + assert_float(v._view_zoom).override_failure_message( + ( + "post-reset _view_zoom (%.6f) must equal enter_orbital()'s own fit zoom" + + " (%.6f) for this body/viewport — Jeroen's condition is the ORIGINAL" + + " frame (center AND offset AND fit zoom), not merely re-centered at" + + " whatever zoom continued _zoom_at() scaling left behind" + ) + % [v._view_zoom, expected_fit["zoom"]] + ).is_equal_approx(float(expected_fit["zoom"]), 0.000001) + + +## Live round 5's OWN live-drive repro, exactly: a REAL wheel gesture does +## NOT stop the instant the reset first fires — the coordinator's own +## tmp_drive_ladder.gd keeps sending wheel-down ticks toward a fixed target +## zoom (0.004, chosen below the fit zoom) regardless of the reset. This +## test reproduces that shape directly: continue zooming out PAST the point +## where the reset first re-enters tile mode, all the way to a target zoom +## BELOW the fit value. Before the round-5 fix, `_view_zoom` drifted back +## down from the fit value on every subsequent `_zoom_at()` tick while the +## mode/center/granularity guard read "already canonical" and silently let +## it drift, landing on whatever the LOOP's target zoom happened to be +## instead of the fit value. **Live round 6 update:** the MECHANISM that +## now holds this assertion changed — `_zoom_at()`'s own zoom FLOOR (not a +## re-firing reset) is what keeps `_view_zoom` pinned at fit through +## continued zoom-out ticks; see `_maybe_reset_to_canonical_frame()`'s own +## doc for why re-firing on every tick caused a request storm. This test's +## own assertions are unchanged — only the doc below was updated to match. +func test_reset_resnaps_even_after_continued_zoom_out_past_the_first_reset() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.size = Vector2(1600.0, 900.0) + var radius_km := 6238.4 # GJ380c (Lendel) — the live-repro body + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) + + var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) + var n: int = int(extent["cols"]) + var expected_fit: Dictionary = AtlasWindowGeometry.fit_window_view( + v.size, n, AtlasWindowViewer.CELL_PIXEL_SIZE, AtlasWindowViewer.MIN_ZOOM, AtlasWindowViewer.MAX_ZOOM + ) + var fit_zoom: float = float(expected_fit["zoom"]) + + # Zoom IN far enough to leave tile mode (same shape as the test above). + var cursor_pos := Vector2(1100.0, 300.0) # matches tmp_drive_ladder.gd's own aim point + for _i in range(60): + v._zoom_at(cursor_pos, 1.15) + if not v.is_tile_mode(): + break + assert_bool(v.is_tile_mode()).is_false() + + # Zoom back OUT toward a target BELOW the fit zoom — matching + # tmp_drive_ladder.gd's own `_zoom_until(wv, 0.004, false)` exactly + # (Lendel's own fit zoom is ~0.00627, comfortably above this target), + # WITHOUT stopping early the moment tile mode is first regained. A real + # wheel gesture has no way to know when the reset internally fires. + var target_zoom := 0.004 + for _i in range(200): + if v._view_zoom <= target_zoom: + break + v._zoom_at(cursor_pos, 1.0 / 1.05) + + assert_bool(v.is_tile_mode()).override_failure_message( + "after continued zoom-out past the reset point, the view must settle back" + + " into tile mode — a genuinely re-snapped canonical frame can't have zoomed" + + " OUT further than the fit value in the first place" + ).is_true() + assert_float(v._view_zoom).override_failure_message( + ( + "post-reset _view_zoom (%.6f) must equal the canonical fit zoom (%.6f) even" + + " though the wheel gesture continued past the point where the reset first" + + " fired (target was %.6f, BELOW the fit zoom) — _zoom_at()'s own zoom floor" + + " must keep pinning it at fit through every subsequent tick, not just once" + ) + % [v._view_zoom, fit_zoom, target_zoom] + ).is_equal_approx(fit_zoom, 0.000001) + + +## Live round 6's ANTI-STORM test — the exact repro the coordinator's live +## drive caught: drive a REAL continued zoom-out gesture (via `_zoom_at()`, +## the same call path the live drive uses — NOT calling +## `_maybe_reset_to_canonical_frame()` directly with unchanged state, which +## trivially can't reproduce the drift the storm depends on) many ticks past +## the point where the reset first fires — asserts ZERO additional tile-set +## entries occur across the WHOLE gesture. Spies on `AtlasWindowTileSet`'s +## own child `AtlasWindowRequest` node INSTANCES (captured right after the +## FIRST reset) — a fresh `enter_orbital()` call tears down (`queue_free()`s) +## every one of them and creates BRAND NEW ones, so "the same node +## instances are still alive and still the tile set's children after 100 +## more ticks" is a direct, non-invasive proxy for "the reset never fired +## again" — no new production instrumentation needed. Before the round-6 +## fix, `_zoom_at()`'s continued multiplicative zoom-out drifted `_view_zoom` +## below fit on every subsequent tick, the level-triggered guard read "not +## already there" every time, and `enter_orbital()` fired repeatedly: +## tearing down and recreating the tile set (and its 6 request nodes) every +## tick — exactly the "889 of 897 wire responses arrived during one +## zoom-out phase" storm. +func test_reset_evaluated_repeatedly_at_canonical_frame_issues_zero_additional_requests() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.size = Vector2(1600.0, 900.0) + var radius_km := 6238.4 # GJ380c (Lendel) — a tiling body, the live-repro shape + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) + assert_bool(v.is_tile_mode()).is_true() + + # Zoom IN far enough to leave tile mode, then zoom back OUT past the + # first reset — same shape as the round-5 continued-zoom-out test, but + # this time spying on the tile set across the WHOLE remaining gesture + # instead of only checking the final zoom value. + var cursor_pos := Vector2(1100.0, 300.0) # matches tmp_drive_ladder.gd's own aim point + for _i in range(60): + v._zoom_at(cursor_pos, 1.15) + if not v.is_tile_mode(): + break + assert_bool(v.is_tile_mode()).is_false() + + for _i in range(200): + v._zoom_at(cursor_pos, 1.0 / 1.05) + if v.is_tile_mode(): + break + assert_bool(v.is_tile_mode()).override_failure_message( + "sanity: the first reset must have fired before spying on the tile set" + ).is_true() + + var tile_set = v.get_tile_set() + var original_requests: Array = tile_set.get_children() + assert_int(original_requests.size()).override_failure_message( + "sanity: the first reset must have created real tile-request child nodes to spy on" + ).is_greater(0) + + # Continue the SAME zoom-out gesture 100 MORE ticks past the first + # reset — a real wheel gesture has no way to stop exactly at the reset + # point, and holding the wheel down (or residual scroll momentum) keeps + # sending ticks. None of these must tear down/recreate the tile set. + for _i in range(100): + v._zoom_at(cursor_pos, 1.0 / 1.05) + + var current_requests: Array = tile_set.get_children() + assert_int(current_requests.size()).override_failure_message( + "the tile set's child count must be unchanged after 100 more continued" + + " zoom-out ticks — a changed count means teardown/recreate happened" + ).is_equal(original_requests.size()) + for i in range(original_requests.size()): + assert_bool(is_instance_valid(original_requests[i])).override_failure_message( + "original tile-request node #%d must still be alive — a storm would have" + + " queue_free()'d it and created a fresh one" % i + ).is_true() + assert_bool(is_same(original_requests[i], current_requests[i])).override_failure_message( + ( + "tile-request node #%d must be the SAME instance as right after the" + + " first reset — a different object at the same index means the tile" + + " set was torn down and recreated (a storm), even if the count" + + " coincidentally matches" + ) + % i + ).is_true() + + +## Live round 6's BLACK-ENTRY repro: enter_orbital(), then deliver the six +## wire-accurate tile responses WHILE a REAL continued zoom-out gesture (via +## `_zoom_at()`, matching the live drive's actual input shape — a held +## wheel-down keeps sending ticks concurrently with responses streaming in +## from the server) is in flight — asserts all six are accepted and HELD +## (tile set stable throughout, no teardown between delivery and the final +## assertion). Before the round-6 fix, the level-triggered guard fired on +## every zoom-out tick once `_view_zoom` drifted below fit, tearing down the +## tile set mid-delivery and orphaning responses addressed to now-freed +## request nodes — nothing ever accumulated, and the mosaic stayed black +## even though the server dutifully answered every request. +func test_six_tile_responses_survive_concurrent_reset_evaluation_and_are_held() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.size = Vector2(1600.0, 900.0) + var radius_km := 6238.4 # GJ380c (Lendel) — 6 tiles, the live-repro shape + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) + assert_bool(v.is_tile_mode()).is_true() + + var tile_set = v.get_tile_set() + var tiles: Array = tile_set.get_tiles() + assert_int(tiles.size()).override_failure_message( + "sanity: Lendel must produce Lendel's own real tile count (6) for this" + + " repro to be faithful, not a smaller synthetic count" + ).is_equal(6) + + # Same continued zoom-out gesture as the anti-storm test above — leave + # tile mode, cross back into it (the first reset), then KEEP sending + # zoom-out ticks (a real held wheel has no way to stop exactly at the + # reset point). Responses are delivered interleaved with these ticks, + # exactly matching the live drive's concurrent shape. + var cursor_pos := Vector2(1100.0, 300.0) + for _i in range(60): + v._zoom_at(cursor_pos, 1.15) + if not v.is_tile_mode(): + break + assert_bool(v.is_tile_mode()).is_false() + for _i in range(200): + v._zoom_at(cursor_pos, 1.0 / 1.05) + if v.is_tile_mode(): + break + assert_bool(v.is_tile_mode()).override_failure_message( + "sanity: the first reset must have fired before delivering responses" + ).is_true() + + for i in range(tiles.size()): + var center: Vector2i = tiles[i]["center"] + var tile_window: Dictionary = { + "center": [center.x, center.y], + "n": AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION, + "granularity": SERVER_LEGACY_GRANULARITY_REGION_SENTINEL, + "granularity_v2": "Region", + "morphology": PackedByteArray([8, 14, 0, 1]), + "elev_q": PackedByteArray([40, 90, 5, 60]), + "temp_dc": [120, 95, -32768, 60], + "moisture_q": PackedByteArray([50, 30, 90, 20]), + "vegetation": PackedByteArray([2, 1, 6, 3]), + "glaciation": PackedByteArray([0, 0, 1, 2]), + } + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", tile_window)) + # Interleave several MORE continued zoom-out ticks, matching the live + # drive's per-frame cadence — none of these must tear anything down. + for _tick in range(5): + v._zoom_at(cursor_pos, 1.0 / 1.05) + + var final_tiles: Array = tile_set.get_tiles() + assert_int(final_tiles.size()).override_failure_message( + "the tile set must still have all 6 tile slots — a storm mid-delivery" + + " would have torn it down and rebuilt it with fresh (unfulfilled) slots" + ).is_equal(6) + for i in range(final_tiles.size()): + assert_that(final_tiles[i]["window"]).override_failure_message( + ( + "tile #%d's window must be HELD (non-null) — all six wire-accurate" + + " responses delivered during a concurrent continued zoom-out gesture" + + " must survive to be accepted, not be silently dropped by an" + + " orphaning teardown" + ) + % i + ).is_not_null() + + +## Not fully zoomed out (a normal District-rung view) must NOT trigger the +## reset — only reaching the top of the ladder resets, not every zoom step. +func test_reset_to_canonical_frame_does_not_fire_when_not_fully_zoomed_out() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {}, Vector2i(500, 10), 32) + v._view_zoom = 1.0 # a normal, non-extreme zoom — nowhere near full planetary coverage + + var fired: bool = v._maybe_reset_to_canonical_frame() + + assert_bool(fired).override_failure_message( + "an ordinary District-rung view must not trigger the top-rest-state reset" + ).is_false() + + +# ============================================================================= +# T-1153: rung reselection — _zoom_at() crossing a rung threshold fires a +# new request without touching the held composite. +# ============================================================================= + + +## Zooming OUT far enough from a District-rung window (small n, so a modest +## zoom-out already covers a huge world extent) must fire a coarser-rung +## request — the wheel-zoom-driven wiring test for _maybe_reselect_rung(). +func test_zoom_out_past_district_threshold_requests_a_coarser_rung() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.size = Vector2(800.0, 600.0) + v.enter({"body_id": "GJ380c"}, {}, Vector2i(0, 0), 2) # n=2 — a tiny window, easy to overshoot + var district_window: Dictionary = _mock_window(Vector2i(0, 0), 2) + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", district_window)) + assert_str(v._window_request.get_granularity_v2()).is_equal("District") + + # A big zoom-OUT factor (well under 1.0) from a tiny n=2 window blows the + # displayed world extent WAY past District's threshold. + v._zoom_at(Vector2(400.0, 300.0), 0.01) + + assert_str(v._window_request.get_granularity_v2()).override_failure_message( + "zooming out far enough from a small District window must re-request a coarser rung" + ).is_not_equal("District") + # The OLD composite must still be what's held — progressive refinement, + # not a block-on-derive clear. + assert_that(v.get_district_window()).is_equal(district_window) + + +## Zooming IN on a District-rung window (well within its own legal coverage +## band, `(32,768 m, 131,072 m]` per select_rung()'s redesigned per-rung +## ceiling model — viewport-independent since `canvas_px` no longer affects +## selection) must NOT trigger a rung change — this is the "zoom is +## client-side on the already-held composite" case, unchanged for in-rung +## zoom. Sets _view_zoom DIRECTLY to a value inside District's band (rather +## than relying on enter()'s COVER auto-fit, which for a small n can already +## sit right at Quarter's own threshold — a fit's zoom level is a +## display-density choice independent of what rung selection would pick from +## scratch, and this test is specifically about a SINGLE zoom-in STEP not +## crossing a boundary, not about where the auto-fit itself lands). The +## small 100x80 viewport here is incidental (any size works under the new +## viewport-independent model) — kept small only because that's what the +## original version of this test used. +func test_zoom_in_within_district_threshold_does_not_change_rung() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.size = Vector2(100.0, 80.0) + v.enter({"body_id": "GJ380c"}, {}, Vector2i(0, 0), 32) + var district_window: Dictionary = _mock_window(Vector2i(0, 0), 32) + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", district_window)) + v._view_zoom = 0.10666666666666667 # E=120,000m at C=100px — inside District's legal band + v._apply_transform() + + v._zoom_at(Vector2(50.0, 40.0), 1.15) # a single ordinary zoom-in step + + assert_str(v._window_request.get_granularity_v2()).override_failure_message( + "a single ordinary zoom-in step must not cross a rung threshold" + ).is_equal("District") + + +## **Live round 3 regression, the direct end-to-end fix target:** a real +## wheel-zoom gesture (many `_zoom_at()` ticks, matching the shape a +## continuous mouse-wheel scroll actually produces) crossing from the +## Region rest state down through District into Quarter territory must (i) +## fire a request at the NEW granularity — `_window_request.get_granularity_v2()` +## must have changed by the end of the gesture — and (ii) accept a +## WIRE-ACCURATE response for that request: echoing the REQUEST's own +## (already re-centered, already re-clamped) center/n, which the live round +## found DIFFERS from the ORIGINAL held center (screen-center-anchored +## refinement re-centers on wherever the cursor currently maps to, not +## wherever the player started) — this is the "second latent drop" the +## coordinator specifically flagged: comparing the echo against a STALE +## `_held_center` (frozen at the pre-crossing value) rather than the +## request's own center would silently drop this response too. +## **Live round 3 update:** GJ380c/Lendel now enters TILE mode via +## enter_orbital() (bug B's fix), so this test starts from THERE — zooming +## in far enough crosses Region's coverage ceiling and must LEAVE tile mode +## for the single-window path at the new (finer) rung, exactly the +## `_maybe_reselect_rung()` "leaving_tile_mode" branch this test exercises. +func test_zoom_crossing_fires_request_and_accepts_wire_accurate_refinement() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.size = Vector2(1600.0, 900.0) + var radius_km := 6238.4 # GJ380c (Lendel) — the live-repro body + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) + assert_bool(v.is_tile_mode()).override_failure_message( + "GJ380c/Lendel must enter tile mode at the orbital rest state (live round 3)" + ).is_true() + + # A real wheel-zoom gesture: many ticks, cursor OFF-CENTER (so cursor- + # anchored zoom genuinely drifts the screen-to-district mapping away from + # the canonical origin, not just scaling in place) — matching the live + # drive's actual input shape, not a single synthetic jump. Zooming in far + # enough must cross OUT of Region's coverage ceiling, leaving tile mode. + var cursor_pos := Vector2(1100.0, 300.0) # off-center, biased toward one quadrant + for _i in range(60): + v._zoom_at(cursor_pos, 1.15) + if not v.is_tile_mode(): + break + + # (i) Tile mode must have been LEFT, and a request must have gone out at + # a NEW (finer) granularity via the single-window path. + assert_bool(v.is_tile_mode()).override_failure_message( + "zooming in far enough must leave tile mode for the single-window path" + ).is_false() + var request_granularity: String = v._window_request.get_granularity_v2() + assert_str(request_granularity).override_failure_message( + "leaving tile mode must fire a request at a new (finer) granularity" + ).is_not_equal("Region") + + # (ii) The request's own center/n — read AFTER leaving tile mode, so this + # is whatever _maybe_reselect_rung() actually computed — is what a + # wire-accurate response must echo to be accepted. + var request_center: Vector2i = v._window_request._center + var request_n: int = v._window_request._n + + var refinement_window: Dictionary = { + "center": [request_center.x, request_center.y], + "n": request_n, + "granularity_v2": request_granularity, + "morphology": PackedByteArray([1, 2, 3, 4]), + "elev_q": PackedByteArray([10, 20, 30, 40]), + "temp_dc": [0, 0, 0, 0], + "moisture_q": PackedByteArray([0, 0, 0, 0]), + "vegetation": PackedByteArray([0, 0, 0, 0]), + "glaciation": PackedByteArray([0, 0, 0, 0]), + } + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", refinement_window)) + + assert_that(v.get_district_window()).override_failure_message( + "a wire-accurate refinement response (echoing the REQUEST's own center/n/" + + " granularity after leaving tile mode) must be ACCEPTED — comparing" + + " against a stale/wrong reference instead of the request's own would" + + " silently drop this response forever" + ).is_equal(refinement_window) + assert_str(v._held_granularity_v2).is_equal(request_granularity) + + +## Live round 4's SECOND bug, pinned directly: `_maybe_reselect_rung()` must +## recompute `_view_offset` (via AtlasWindowGeometry. +## recompute_offset_for_held_n_change()) the instant `_held_n` changes across +## a rung crossing — leaving it untouched (the round-4 bug) means the single- +## window `Rect2(0,0,extent)` draw call renders at whatever screen position +## the OLD (Region-scale) offset happened to put canvas-local (0,0), which +## for a whole-body `held_n` vs. a 64-district District `held_n` is tens or +## hundreds of thousands of px away from the viewport — the exact "pitch +## black" repro. Asserts the NEW held window's own extent actually overlaps +## the viewport after the crossing, the concrete on-screen consequence a +## stale offset breaks. +func test_zoom_crossing_recomputes_view_offset_so_the_new_window_is_on_screen() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.size = Vector2(1600.0, 900.0) + var radius_km := 6238.4 # GJ380c (Lendel) — the live-repro body + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) + assert_bool(v.is_tile_mode()).is_true() + + var cursor_pos := Vector2(1100.0, 300.0) + for _i in range(60): + v._zoom_at(cursor_pos, 1.15) + if not v.is_tile_mode(): + break + assert_bool(v.is_tile_mode()).override_failure_message( + "sanity: this test needs to actually cross out of tile mode to exercise" + + " the held_n change _maybe_reselect_rung() must react to" + ).is_false() + + # The new (post-crossing) window's screen-space rect, using the SAME + # formula the overlay's single-window _draw() itself uses + # (Rect2(0,0,extent,extent) in canvas-local space, then _canvas's own + # position/scale transform — _view_offset/_view_zoom here mirror that + # exactly, since _apply_transform() is what sets _canvas.position/scale). + var extent_screen: float = float(v._held_n) * v.CELL_PIXEL_SIZE * v._view_zoom + var screen_top_left: Vector2 = v._view_offset + var screen_bottom_right: Vector2 = screen_top_left + Vector2(extent_screen, extent_screen) + var viewport_rect := Rect2(Vector2.ZERO, v.size) + var window_rect := Rect2(screen_top_left, Vector2(extent_screen, extent_screen)) + + assert_bool(viewport_rect.intersects(window_rect)).override_failure_message( + ( + "the new (post-crossing) held window's screen rect %s must overlap the" + + " viewport %s — a stale _view_offset (never recomputed for the new" + + " held_n=%d) is exactly live round 4's 'pitch black' bug: the composite" + + " renders somewhere entirely off-canvas despite request/response/data" + + " all being individually correct" + ) + % [window_rect, viewport_rect, v._held_n] + ).is_true() + + +# ============================================================================= +# T-1153: E/W wrap and pole-wall clamps at EVERY rung — both are extent- +# relative (CELL_PIXEL_SIZE-based district-space math, unchanged regardless +# of which rung's data is actually held), so they must keep working +# unmodified at Region granularity, not just District/Quarter. +# ============================================================================= + + +## The pole wall, wired through the real _apply_pan_delta() path, must still +## clamp at Region granularity — same mechanism as the existing District-rung +## test (test_wasd_pan_is_clamped_by_the_pole_wall_when_wired), just entered +## via enter_orbital() instead of enter(). +func test_pole_wall_clamps_at_region_granularity_too() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.size = Vector2(800.0, 800.0) + var radius_km := 50.0 # tiny synthetic body — pole wall reachable by an ordinary tick + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) + assert_str(v._held_granularity_v2).is_equal("Region") + + var unclamped_magnitude: float = 500.0 * AtlasWindowViewer.PAN_SPEED_CANVAS_PX_S * v.get_view_zoom() + v._apply_pan_delta(Vector2(0.0, -1.0), 500.0) # "W"/north held, an absurdly long tick + + assert_float(absf(v.get_view_offset().y)).override_failure_message( + "the pole wall must still clamp an extreme pan at Region granularity" + ).is_less(unclamped_magnitude * 0.5) + + +## East-west wrap (canonicalize_district_center()) must still apply to the +## pan-edge refloat's resulting center at Region granularity — a pan that +## carries the screen-center column past the body's circumference must wrap +## into [0, cols), never run away to an out-of-range column, exactly as the +## District-rung wrap tests already pin (T-1142 item 6a). At Region's own +## enormous held_n (a whole circumference), an ORDINARY pan tick's +## canvas-space delta is negligible relative to the window's half-extent +## (confirmed: ~0.08 districts per 5-second tick vs. a ~9,772-district +## half-window) — so this drives _maybe_refloat_window() DIRECTLY off a +## manually-set _view_offset large enough to genuinely cross the held +## window's edge, the same "exercise the actual edge-crossing branch, not +## just its no-op early-return" discipline _maybe_refloat_window()'s own +## inside-check comment describes. +func test_pan_edge_refloat_wraps_columns_at_region_granularity_too() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.size = Vector2(800.0, 800.0) + var radius_km := 6371.0 + var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) + var cols: int = int(extent["cols"]) + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) + assert_str(v._held_granularity_v2).is_equal("Region") + + # Force the held center to sit one column short of the wrap seam, then + # shift the CANVAS offset by more than half the window's own on-screen + # extent — enough to move the screen-center's mapped column past the + # window's far edge (i.e. past `cols`, crossing the seam) regardless of + # Region's huge held_n. + v._held_center = Vector2i(cols - 1, 0) + var half_window_screen_px: float = float(v._held_n) * v.get_cell_pixel_size() * v.get_view_zoom() * 0.5 + v._view_offset = v.get_view_offset() - Vector2(half_window_screen_px * 1.5, 0.0) + v._maybe_refloat_window() + + assert_int(v._held_center.x).override_failure_message( + "a pan crossing the antimeridian at Region granularity must wrap the" + + " resulting center into [0, cols), never run past cols" + ).is_less(cols) + assert_int(v._held_center.x).is_greater_equal(0) diff --git a/client/ui/implant/apps/atlas/atlas_app.gd b/client/ui/implant/apps/atlas/atlas_app.gd index 7e79fd34d..0873d852e 100644 --- a/client/ui/implant/apps/atlas/atlas_app.gd +++ b/client/ui/implant/apps/atlas/atlas_app.gd @@ -1,8 +1,20 @@ class_name AtlasApp extends ImplantApp ## Atlas implant app (#844, #836, D-191). -## Reach map → system orbital → planet entry → regional heightmap viewer. +## Reach map → system orbital → planet entry → regional zoom ladder. ## Registered as "implant/map" in FULLSCREEN mode. +## +## T-1153 (D-226 T-1143-rulings amendment): the "district" nav-stack screen +## (T-1138's windowed drill-down, a separate nav.push() hop from "regional") +## RETIRES as a nav-stack level — the continuous cursor-anchored zoom ladder +## means descent/ascent through every rung (Region -> District -> Quarter) +## happens INSIDE the "regional" screen via zoom, not by pushing a new +## screen. D-013 "the zoom gesture owns spatial descent" is restored for +## this seam (Jeroen's ruling) — descent is a continuous gesture, not a +## discrete nav hop. Esc from anywhere in the ladder is therefore a single +## nav.pop() back to "system", exactly the same _handle_key() KEY_ESCAPE +## branch every other non-"reach" screen already uses; there is no longer a +## "district" screen entry in the match/registration table. signal economics_link_requested(system_id: String) @@ -12,8 +24,7 @@ var _system_lookup: Dictionary = {} # system_id → system dict var _reach_screen = null # ReachScreen var _system_screen = null # SystemScreen var _planet_screen = null # PlanetScreen -var _regional_screen = null # RegionalScreen -var _district_screen = null # DistrictScreen (T-1138) +var _regional_screen = null # RegionalScreen — now the whole zoom ladder (T-1153) func _ready() -> void: @@ -47,14 +58,8 @@ func on_install() -> void: _regional_screen = RegionalScreen.new() _regional_screen.back_requested.connect(_on_regional_back) - _regional_screen.economics_link_requested.connect(_forward_economics_link) - _regional_screen.district_descend_requested.connect(_on_district_descend_requested) register_screen("regional", _regional_screen) - _district_screen = DistrictScreen.new() - _district_screen.back_requested.connect(_on_district_back) - register_screen("district", _district_screen) - nav.set_default("reach") @@ -71,12 +76,11 @@ func _unhandled_key_input(event: InputEvent) -> void: return if not event.is_pressed() or event.is_echo(): return - # "regional" (the planetary heightmap, AtlasViewer) and "district" (the - # windowed regional-window screen, AtlasWindowViewer, T-1138) both handle - # their own Esc via _gui_input — same delegation shape for both, so a - # stray M/other unhandled key on either screen doesn't ALSO fire this - # app's own _handle_key underneath the viewer's own handling. - if current_screen_id() == "regional" or current_screen_id() == "district": + # "regional" (the whole zoom ladder, AtlasWindowViewer, T-1153) handles its + # own Esc via _gui_input — a stray M/other unhandled key on that screen + # must not ALSO fire this app's own _handle_key underneath the viewer's + # own handling. + if current_screen_id() == "regional": return _handle_key(event as InputEventKey) get_viewport().set_input_as_handled() @@ -148,29 +152,6 @@ func _on_regional_back() -> void: nav.pop() -## T-1138: the planetary click-through descent (§5 entry revision) — pushes -## the "district" screen centered on the click point's derived DistrictPos. -## A real nav.push() (not a swap-in-place, unlike the superseded zoom- -## threshold design) so Esc's existing nav.pop() path (DistrictScreen's own -## back_requested -> _on_district_back below) returns to exactly the -## planetary body the player descended from, at whatever crumb depth got -## them there (reach -> system -> regional -> district). -func _on_district_descend_requested(district_center: Vector2i) -> void: - nav.push("district", { - "body": nav.current_payload().get("body", {}), - "system": nav.current_payload().get("system", {}), - "district_center": district_center, - }) - - -func _on_district_back() -> void: - nav.pop() - - -func _forward_economics_link(system_id: String) -> void: - economics_link_requested.emit(system_id) - - ## T-949: the star map arrived — cache it, rebuild the local system ## list/lookup, and push it into whichever screens already exist. set_systems() ## is safe to call again after initial setup (ReachScreen/SystemScreen both diff --git a/client/ui/implant/apps/atlas/atlas_window_cache.gd b/client/ui/implant/apps/atlas/atlas_window_cache.gd index dfdec3785..9c379b98f 100644 --- a/client/ui/implant/apps/atlas/atlas_window_cache.gd +++ b/client/ui/implant/apps/atlas/atlas_window_cache.gd @@ -2,21 +2,27 @@ extends RefCounted ## Client-side LRU cache for DistrictWindowLayer responses (T-1138, D-226 ## T-1124 amendment §4 "Client cache policy"; extended T-1150 for the -## granularity/min_wl axes). +## granularity/min_wl axes; extended T-1152/T-1153 for the granularity_v2 +## string-tag axis that makes Region representable at all). ## -## Keyed on (body_id, center, n, granularity, min_wl_m) — D-227's determinism -## guarantee (same seed + body + position + derivation params -> same derived -## output, always) means a previously-fetched window is valid FOREVER for -## that body+seed. This is an LRU-evict-only cache: no freshness check, no -## TTL, no invalidation path at all. The only reason an entry ever leaves is -## capacity pressure. +## Keyed on (body_id, center, n, granularity, min_wl_m, granularity_v2) — +## D-227's determinism guarantee (same seed + body + position + derivation +## params -> same derived output, always) means a previously-fetched window is +## valid FOREVER for that body+seed. This is an LRU-evict-only cache: no +## freshness check, no TTL, no invalidation path at all. The only reason an +## entry ever leaves is capacity pressure. ## -## granularity/min_wl_m default to DISTRICT_GRANULARITY/0 (district spacing, -## no octave cutoff) — every pre-T-1150 caller that doesn't pass them keeps -## its existing key shape and cache behavior unchanged. This is the client -## half of the mandatory aliasing fix (T-1150 design doc §3): a -## quarter-granularity request and a district-granularity request at the -## identical (body, center, n) MUST NOT collide on the same cache slot. +## granularity/min_wl_m/granularity_v2 default to +## DISTRICT_GRANULARITY/0/DEFAULT_GRANULARITY_V2 ("District", district +## spacing, no octave cutoff) — every pre-T-1150 caller that doesn't pass them +## keeps its existing key shape and cache behavior unchanged. This is the +## client half of the mandatory aliasing fix (T-1150 design doc §3, extended +## T-1152): a quarter-granularity request, a district-granularity request, +## and a REGION-granularity request all at the identical (body, center, n) +## MUST NOT collide on the same cache slot — the legacy int alone cannot +## distinguish Region (it has no legal legacy-int value, see +## GRANULARITY_V2_REGION's doc), which is exactly why granularity_v2 is a +## SEPARATE key component rather than a replacement for the legacy one. ## ## Godot's Dictionary preserves insertion order, so "move to the end on ## touch, evict from the front on overflow" is the whole LRU implementation — @@ -33,6 +39,24 @@ const DEFAULT_MAX_ENTRIES: int = 24 ## default granularity every pre-T-1150 caller implicitly requests. const DISTRICT_GRANULARITY: int = 1 +## Mirrors the server's WindowGranularity enum (T-1152, R5 redesign, +## layer_proxy.rs) — the string-tag vocabulary rmp_serde encodes a bare +## `#[derive(Serialize, Deserialize)]` enum's variant name as, verbatim (same +## wire convention `RoadNodeKind` already established on this carrier). This +## is the KEY-SPACE axis (T-1153): the legacy int `granularity` param below +## stays wired for every existing District/Quarter caller (byte/behavior +## compatible), but a cache slot is now ALSO qualified by this string so a +## Region-rung window can never alias onto a District/Quarter slot at the +## identical (body, center, n, legacy_granularity, min_wl_m) — the exact +## aliasing risk the T-1150 design doc §3 flagged, extended to the new axis. +const GRANULARITY_V2_QUARTER: String = "Quarter" +const GRANULARITY_V2_DISTRICT: String = "District" +const GRANULARITY_V2_REGION: String = "Region" +## Default v2 tag for every caller that doesn't pass one — matches +## DISTRICT_GRANULARITY's own "district is the implicit default" contract, so +## an omitted v2 tag and an explicit "District" tag key identically. +const DEFAULT_GRANULARITY_V2: String = GRANULARITY_V2_DISTRICT + var _max_entries: int = DEFAULT_MAX_ENTRIES var _entries: Dictionary = {} # key String -> DistrictWindowLayer Dictionary @@ -41,34 +65,42 @@ func _init(max_entries: int = DEFAULT_MAX_ENTRIES) -> void: _max_entries = maxi(1, max_entries) -## Build the cache key from the five fields D-227 + T-1150 make sufficient: -## body_id (which world+body), center (a [row, col] pair or Vector2i), n -## (window extent in districts), granularity (district=1 / quarter=4), and -## min_wl_m (the octave cutoff, 0 = none). String-keyed rather than a nested -## Dictionary/Array key — Godot Dictionary keys compare by value for -## primitives but a consistent stringification sidesteps any -## Vector2i-vs-Array identity mismatch between what a caller happens to hand -## in. +## Build the cache key from the six fields D-227 + T-1150/T-1152 make +## sufficient: body_id (which world+body), center (a [row, col] pair or +## Vector2i), n (window extent in districts), granularity (the legacy int: +## district=1 / quarter=4), min_wl_m (the octave cutoff, 0 = none), and +## granularity_v2 (the T-1152 string tag: "Quarter"/"District"/"Region" — the +## axis that actually distinguishes Region from every finer rung, since +## Region has no legal legacy-int representation and the legacy slot alone +## cannot tell a Region window's cache entry apart from a District one at the +## same (center, n)). String-keyed rather than a nested Dictionary/Array key +## — Godot Dictionary keys compare by value for primitives but a consistent +## stringification sidesteps any Vector2i-vs-Array identity mismatch between +## what a caller happens to hand in. static func make_key( body_id: String, center: Vector2i, n: int, granularity: int = DISTRICT_GRANULARITY, - min_wl_m: int = 0 + min_wl_m: int = 0, + granularity_v2: String = DEFAULT_GRANULARITY_V2 ) -> String: - return "%s:%d,%d:%d:%d:%d" % [body_id, center.x, center.y, n, granularity, min_wl_m] + return "%s:%d,%d:%d:%d:%d:%s" % [ + body_id, center.x, center.y, n, granularity, min_wl_m, granularity_v2 + ] ## True if a window is already cached for this exact (body, center, n, -## granularity, min_wl_m). +## granularity, min_wl_m, granularity_v2). func has( body_id: String, center: Vector2i, n: int, granularity: int = DISTRICT_GRANULARITY, - min_wl_m: int = 0 + min_wl_m: int = 0, + granularity_v2: String = DEFAULT_GRANULARITY_V2 ) -> bool: - return _entries.has(make_key(body_id, center, n, granularity, min_wl_m)) + return _entries.has(make_key(body_id, center, n, granularity, min_wl_m, granularity_v2)) ## Fetch a cached window, touching it (move-to-most-recently-used). Returns @@ -81,9 +113,10 @@ func get_window( center: Vector2i, n: int, granularity: int = DISTRICT_GRANULARITY, - min_wl_m: int = 0 + min_wl_m: int = 0, + granularity_v2: String = DEFAULT_GRANULARITY_V2 ) -> Variant: - var key := make_key(body_id, center, n, granularity, min_wl_m) + var key := make_key(body_id, center, n, granularity, min_wl_m, granularity_v2) if not _entries.has(key): return null var value: Variant = _entries[key] @@ -101,9 +134,10 @@ func put( n: int, window: Dictionary, granularity: int = DISTRICT_GRANULARITY, - min_wl_m: int = 0 + min_wl_m: int = 0, + granularity_v2: String = DEFAULT_GRANULARITY_V2 ) -> void: - var key := make_key(body_id, center, n, granularity, min_wl_m) + var key := make_key(body_id, center, n, granularity, min_wl_m, granularity_v2) if _entries.has(key): _entries.erase(key) _entries[key] = window diff --git a/client/ui/implant/apps/atlas/atlas_window_geometry.gd b/client/ui/implant/apps/atlas/atlas_window_geometry.gd index f1c7b75d9..66d0e9fb4 100644 --- a/client/ui/implant/apps/atlas/atlas_window_geometry.gd +++ b/client/ui/implant/apps/atlas/atlas_window_geometry.gd @@ -10,6 +10,102 @@ extends RefCounted ## window size, what zoom/offset centers it" math is unit-testable in ## isolation here — a caller does: ## const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") +## +## T-1153: also carries the rung-selection rule (design doc +## docs/architecture/atlas-zoom-ladder-t1143.md §5) and the "fully zoomed +## out" reset predicate (Jeroen's D-226 T-1143-rulings HARD condition) — both +## pure functions of (viewport, held state, body), same "geometry lives here, +## side effects live on the viewer" split as the rest of this file. Live +## round 3 also adds the orbital-rest-state TILE GRID computation +## (compute_tile_grid(), near the bottom) — reuses +## AtlasDescendGeometry.district_extent()/canonicalize_district_center() for +## the SAME wrap/clamp discipline every other piece of this cluster already +## depends on, hence the preload below (no circular dependency: +## atlas_descend_geometry.gd never references this file). +const AtlasDescendGeometryRef := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") + +## D-243 rung spacings, metres/cell — the SAME constants +## server/src/atlas/scale.rs and layer_proxy.rs's WindowGranularity::spacing_m +## source from, mirrored here rather than re-derived so the client's rung +## table can never silently drift from the wire contract it's choosing +## between. +const QUARTER_SPACING_M: float = 512.0 +const DISTRICT_SPACING_M: float = 2048.0 +const REGION_SPACING_M: float = 204_800.0 + +## Table form, coarsest-first — spacing_for_rung()'s inverse lookup walks +## this. select_rung() (below) does NOT walk this table directly — see that +## function's own doc for why the coarse (Region) and fine (District/ +## Quarter) ends are decided by two DIFFERENT tests, not a single ordered +## table scan. +const RUNG_TABLE: Array = [ + {"granularity_v2": "Region", "spacing_m": REGION_SPACING_M}, + {"granularity_v2": "District", "spacing_m": DISTRICT_SPACING_M}, + {"granularity_v2": "Quarter", "spacing_m": QUARTER_SPACING_M}, +] + +## Server per-axis cap on a District/Quarter-granularity window's `n` +## (mirrors server/src/atlas/layer_proxy.rs's `DISTRICT_WINDOW_MAX_N` — see +## AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N, the existing client-side +## mirror of the same constant, kept in sync there). +const DISTRICT_WINDOW_MAX_N: int = 64 + +## Wire-size ceiling (mirrors AtlasWindowRequest.SERVER_WIRE_CAP_CELLS / +## server/src/atlas/layer_proxy.rs's WIRE_CAP_CELLS) — the cell-count cap +## EVERY rung's single window is clamped against, per +## `_clamp_window_n_mirror`/`_clamp_window_n_mirror_v2`'s own formulas. +const WIRE_CAP_CELLS: int = 4_096 + +## Region's own per-axis ceiling (mirrors +## AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION / +## server/src/atlas/layer_proxy.rs's DISTRICT_WINDOW_MAX_N_REGION). +const DISTRICT_WINDOW_MAX_N_REGION: int = 6_400 + +## Per-tile district extent for the orbital-rest-state tile grid +## (compute_tile_grid(), near the bottom of this file) — the SAME `n` a +## single Region request uses at its own per-axis ceiling. Each tile +## requests exactly this many districts on a side — the largest single +## window the wire budget allows, so tiling uses the FEWEST tiles that can +## cover a given body. +const TILE_N: int = DISTRICT_WINDOW_MAX_N_REGION + +## **Live round 3 finding (the actual root cause of "zoom-driven rung +## reselection never fires"):** each rung's SINGLE WINDOW has a hard MAXIMUM +## real-world coverage, derived from the SAME wire-size clamp +## (`_clamp_window_n_mirror_v2`) the request layer already enforces — District +## and Quarter are NOT exempt from this the way the original (§5-literal) +## design assumed. A rung whose own single-window coverage is smaller than +## the CURRENTLY DISPLAYED world extent cannot legally be selected: the +## server would clamp `n` down to fit its own wire budget, producing a +## composite that covers only a FRACTION of the viewport — visually a tiny +## box in the middle of the screen, and (the bug this constant's discovery +## fixes) a composite whose CLAMPED `n` no longer matches whatever `_held_n` +## the viewer was still carrying from the PREVIOUS rung, permanently failing +## `_on_window_ready()`'s staleness check. Computed here ONCE, from the same +## constants `_clamp_window_n_mirror_v2` uses, rather than re-derived per +## rung inline — see MAX_COVERAGE_M below. +## +## - Quarter: per-axis cap `floor(sqrt(WIRE_CAP_CELLS)/4) = 16` districts -> +## cell-grid side `16*4 = 64` cells -> `64 * QUARTER_SPACING_M = 32,768 m`. +## - District: per-axis cap `floor(sqrt(WIRE_CAP_CELLS)/1) = 64` districts -> +## `64 * DISTRICT_SPACING_M = 131,072 m` (unchanged from the original +## coverage-ceiling constant this replaces/generalizes). +## - Region: per-axis cap `DISTRICT_WINDOW_MAX_N_REGION = 6,400` districts -> +## `6,400 * DISTRICT_SPACING_M = 13,107,200 m` — this is a SINGLE window's +## ceiling; bug B's progressive tiling composes MULTIPLE Region windows to +## cover extents beyond this (see the viewer's tile-set model), so this +## constant alone does NOT bound what the ORBITAL REST STATE can show — +## only what one Region REQUEST's response covers. +const MAX_COVERAGE_M: Dictionary = { + "Quarter": 64.0 * QUARTER_SPACING_M, + "District": float(DISTRICT_WINDOW_MAX_N) * DISTRICT_SPACING_M, + "Region": float(DISTRICT_WINDOW_MAX_N_REGION) * DISTRICT_SPACING_M, +} + +## Rungs ordered FINEST-first — select_rung() walks this to find the finest +## rung whose own single-window coverage ceiling still covers the current +## extent (never a rung that would silently under-cover the viewport). +const RUNGS_FINEST_FIRST: Array = ["Quarter", "District", "Region"] ## Fit-and-center: given the viewport size and the window's side length in @@ -116,3 +212,464 @@ static func clamp_pan_offset_to_pole_wall( var min_y: float = minf(north_wall_screen_y, south_wall_screen_y) var max_y: float = maxf(north_wall_screen_y, south_wall_screen_y) return Vector2(offset.x, clampf(offset.y, min_y, max_y)) + + +# ============================================================================= +# T-1153: rung selection (design doc §5) — the continuous zoom ladder's join +# point between "what granularity is legal to request" (a rung, a discrete +# set) and "what density the client actually wants" (world extent per canvas +# px, a continuous quantity that tracks the live zoom level). +# ============================================================================= + + +## Rung-selection rule — REDESIGNED (live round 3 finding, superseding the +## original §5-literal `2x`-visual-tolerance-only reading): select the +## FINEST rung whose OWN single-window coverage ceiling (MAX_COVERAGE_M) +## still covers the current world extent. Walks RUNGS_FINEST_FIRST +## (Quarter, District, Region) and returns the first whose ceiling is `>= +## world_extent_m` — the coarser rungs are tried only once the finer ones +## genuinely cannot show the requested extent in a single window. +## +## **Why this replaces the original `2x`-visual-tolerance formula entirely** +## (not just patches its Region case, as an earlier version of this function +## did): the design doc §5 rule ("coarsest rung whose spacing <= 2*(E/C)") +## implicitly assumes every rung's single window CAN cover any extent the +## rule selects it for — true for an unbounded wire budget, false here. +## `_clamp_window_n_mirror_v2` (AtlasWindowRequest) — the SAME clamp the +## server itself enforces — caps every rung's single-window real-world +## coverage at a fixed maximum (`MAX_COVERAGE_M`, this file): Quarter +## 32,768 m, District 131,072 m, Region 13,107,200 m (per single Region +## window — bug B's progressive TILING composes several to cover more, a +## viewer-level concern this function doesn't need to know about). A rung +## selected for an extent BEYOND its own ceiling would have its `n` silently +## clamped server-side to something covering only a FRACTION of the +## viewport — visually a tiny box, AND (the actual live-round bug this +## redesign fixes) a clamped echo that no longer matches whatever `n` the +## viewer was still carrying from the rung it's leaving, permanently failing +## the staleness check in `_on_window_ready()`. +## +## **The `2x` visual-tolerance rule becomes REDUNDANT under this model, not +## contradicted by it** — verified numerically: at the exact zoom where +## Quarter's coverage ceiling (32,768 m) is reached, the `2x` threshold +## (`2*E/C`) works out to ~41 m, far finer than even Quarter's own 512 m +## spacing. This means by the time coverage RELEASES a rung, the visual +## tolerance would ALREADY prefer something finer than that rung offers — +## i.e. every rung this function selects is, by construction, at or past its +## own "as fine as it can usefully be" point. The visual-tolerance rule's +## fine-end guarantee (never show a coarser composite than the screen can +## resolve) is automatically satisfied by "select the finest rung whose +## coverage allows it" — there is no case where the coverage rule picks a +## rung the visual rule would have rejected as too coarse, because Quarter +## (the finest rung) is always the answer whenever ANY rung's visual +## tolerance alone would have mattered. +## +## `world_extent_m`/`canvas_px` are both callers'-choice-of-axis (the held +## window is always square, so either axis of the viewport/extent pair gives +## the same answer — the caller picks one, consistently). `canvas_px` is +## kept as a parameter (unused by the coverage rule itself) for signature +## stability with existing callers and because a future finer-than-Quarter +## rung (block/tile, D-226(d)-gated, out of scope here) would plausibly need +## it again. +## +## Returns the granularity_v2 string tag ("Quarter" | "District" | "Region"). +static func select_rung(world_extent_m: float, _canvas_px: float) -> String: + for rung: String in RUNGS_FINEST_FIRST: + if world_extent_m <= float(MAX_COVERAGE_M[rung]): + return rung + return "Region" # extent exceeds even Region's own single-window ceiling -> still Region (tiling's job) + + +## The metre spacing a given granularity_v2 tag resolves to — the inverse +## lookup select_rung() itself doesn't need but callers computing "what world +## extent does holding N districts at this rung actually cover" do (the +## viewer's own extent-in-real-units header line, and the full-zoom-out +## predicate below). +static func spacing_for_rung(granularity_v2: String) -> float: + for rung: Dictionary in RUNG_TABLE: + if rung["granularity_v2"] == granularity_v2: + return float(rung["spacing_m"]) + return DISTRICT_SPACING_M # unknown tag -> district, matching the server's "unknown -> District" posture + + +# ============================================================================= +# T-1153: full-zoom-out reset (Jeroen's D-226 T-1143-rulings HARD condition — +# "a full zoom-out resets to the original canonical planetary frame and +# location", the ladder's top rest state, not a drifted pan state). +# ============================================================================= + + +## True once the requested world extent (at the CURRENT zoom, before any +## further zoom-out) covers the full body — i.e. the player has zoomed out as +## far as the ladder goes and is looking at (at least) the whole equatorial +## circumference. This is the crisp "fully zoomed out" definition the ticket +## asks for: `world_extent_m >= circumference_m` at the fit-zoom floor, rather +## than a fuzzy "close to MIN_ZOOM" heuristic (MIN_ZOOM is a UI clamp +## constant, not a planetary-coverage fact — a small body could hit MIN_ZOOM +## while still showing less than the whole circumference, and a huge body +## could show full coverage before MIN_ZOOM is reached, depending on +## CELL_PIXEL_SIZE/held_n; extent-vs-circumference is the honest test either +## way). +## +## **Distinct from select_rung()'s own coverage ceiling** (District's +## `DISTRICT_WINDOW_MAX_N * DISTRICT_SPACING_M = 131,072 m`, a fixed, +## body-independent number) — this predicate's threshold is the actual +## body's full circumference, always far larger than 131,072 m for any real +## planet. The two compose in the expected order: extent crosses 131,072 m +## first (select_rung() already reports Region well before this predicate +## fires), and only once extent reaches the WHOLE circumference does the +## top-rest-state reset itself trigger. There is no conflict between the two +## thresholds, just two different questions ("which rung" vs. "are we at the +## very top"). +static func is_fully_zoomed_out(world_extent_m: float, body_radius_km: float) -> bool: + if body_radius_km <= 0.0: + return false # no-radius (tiny test body) has no circumference concept — never auto-resets + var circumference_m: float = TAU * body_radius_km * 1000.0 + return world_extent_m >= circumference_m + + +# ============================================================================= +# T-1153: pure screen<->world math extracted from AtlasWindowViewer for +# testability (the project's stated preference — static funcs over Control +# instance methods wherever the math doesn't need the scene tree). +# ============================================================================= + + +## The world extent (metres) currently displayed across the LARGER viewport +## dimension — the `E` half of the §5 rung-selection rule's `E/C`. +## `cell_pixel_size` is the caller's district-at-zoom-1.0 constant +## (AtlasWindowViewer.CELL_PIXEL_SIZE) — the composite's ON-SCREEN FOOTPRINT +## is ALWAYS `held_n * cell_pixel_size * view_zoom` px for `held_n * DISTRICT_M` +## metres of world, REGARDLESS of which rung is currently held (this is +## exactly the invariant AtlasWindowOverlay.cell_grid_side_for_window()'s doc +## establishes on the render side: `n` districts occupy a FIXED screen +## footprint; only the DERIVED CELL RESOLUTION packed into that footprint +## varies by rung). So the metres-per-screen-px sample density is a pure +## function of `view_zoom` — DISTRICT_SPACING_M / (cell_pixel_size * +## view_zoom) — with NO granularity_v2 parameter needed at all: the rung +## itself is the OUTPUT of this calculation (via select_rung()), not an +## input to it. +static func world_extent_m(cell_pixel_size: float, view_zoom: float, viewport: Vector2) -> float: + var canvas_px: float = cell_pixel_size * view_zoom + if canvas_px <= 0.0: + return 0.0 + var screen_px: float = maxf(viewport.x, viewport.y) + return DISTRICT_SPACING_M / canvas_px * screen_px + + +## The DistrictPos the viewport's screen center currently maps to, in RAW +## absolute district space (un-wrapped, un-clamped — the caller canonicalizes +## the final value it actually stores/sends, matching +## canonicalize_district_center()'s own "canonicalize once, at the boundary" +## discipline). Shared by AtlasWindowViewer's pan-edge refetch +## (_maybe_refloat_window()) and rung-reselect refetch +## (_maybe_reselect_rung()) so both read the SAME screen-to-district formula +## rather than two copies that could drift. +static func screen_center_to_district( + viewport_size: Vector2, + view_offset: Vector2, + view_zoom: float, + cell_pixel_size: float, + held_center: Vector2i, + held_n: int +) -> Vector2i: + var screen_center: Vector2 = viewport_size * 0.5 + var canvas_pt: Vector2 = (screen_center - view_offset) / view_zoom + var cell: Vector2 = canvas_pt / cell_pixel_size + var half: float = float(held_n) / 2.0 + var abs_col: float = float(held_center.x) - half + cell.x + var abs_row: float = float(held_center.y) - half + cell.y + return Vector2i(roundi(abs_col), roundi(abs_row)) + + +## Live round 4 fix: the exact INVERSE of screen_center_to_district()'s own +## district-space math, in CANVAS-LOCAL space (i.e. _canvas's own child +## coordinate system — BEFORE _view_offset/_view_zoom, which is what +## AtlasWindowOverlay._draw()/_draw_tile_mosaic() draw into, since the +## Node2D's position/scale already carries pan/zoom). Every held-window +## convention in this file agrees canvas-local `(0,0)` is absolute district +## `(held_center - held_n/2)` — single-window `_draw()`'s own +## `Rect2(0,0,extent,extent)` relies on this being true for `held_center` == +## the window's own center. `_draw_tile_mosaic()`'s per-tile placement must +## use this SAME formula (with the VIEWER's `held_center`/`held_n`, not a +## tile's own center/TILE_N) to land in the same coordinate frame the +## fit/pan/zoom machinery already assumes — drawing tiles relative to +## absolute district (0,0) directly (the live-round-4 bug) silently +## disagreed with fit_window_view()'s own `[0, held_n)`-from-origin +## assumption whenever `held_n` (the WHOLE-BODY extent in tile mode) wasn't +## itself anchored the same way, pushing the entire mosaic off-canvas. +static func district_to_canvas_local( + district: Vector2, held_center: Vector2i, held_n: int, cell_pixel_size: float +) -> Vector2: + var half: float = float(held_n) / 2.0 + var local_col: float = (district.x - (float(held_center.x) - half)) * cell_pixel_size + var local_row: float = (district.y - (float(held_center.y) - half)) * cell_pixel_size + return Vector2(local_col, local_row) + + +## Live round 5 fix (the tile-mosaic WRAP half of "the mosaic doesn't fully +## draw"): `compute_tile_grid()`'s tiles are CANONICAL columns (wrapped into +## `[0, cols)` — the correct, single-valued key for REQUESTS and cache +## coalescing), but a canonical column has infinitely many EQUIVALENT +## on-screen positions (`col`, `col - cols`, `col + cols`, ...), since +## longitude is periodic. `district_to_canvas_local()` is a pure LINEAR +## function with no wrap concept — fed a canonical column directly, it +## places the tile at exactly ONE of those wrap-images, which is only ever +## the visually-correct one by coincidence. Lendel's own repro: the tile +## whose pre-canonicalization center was -6400 canonicalizes to 12739 +## (`-6400 mod 19139`) — correct for the request/cache key, but drawing at +## column 12739 directly places it canvas-local ~22308 (off-canvas RIGHT), +## when the tile's actual visible position (immediately west of the +## canonical origin) is at column -6400 (canvas-local ~3169, the LEFT +## third of the mosaic). +## +## The fix: before handing a tile's canonical column to +## `district_to_canvas_local()`, re-express it as whichever wrap-image +## (`canonical_col + k*cols` for integer `k`) is NEAREST `held_center.x` — +## the representative that's actually near the current view, matching how a +## real, non-tiling single-window pan already resolves the "which +## circumnavigation" question implicitly (screen_center_to_district()'s own +## RAW, un-wrapped output). `cols <= 0` (no-radius bodies, which never tile +## per compute_tile_grid()'s own doc) is a safe no-op passthrough — there is +## no periodicity to resolve. +static func nearest_wrap_image(canonical_col: int, held_center_col: int, cols: int) -> int: + if cols <= 0: + return canonical_col + var delta: int = posmod(canonical_col - held_center_col + cols / 2, cols) - cols / 2 + return held_center_col + delta + + +## Live round 4 fix (the SECOND half of the "pitch black" repro, beyond +## district_to_canvas_local()'s tile-mosaic fix above): `_view_offset` is a +## PURE screen<->canvas-local transform, entirely independent of +## `held_center`/`held_n` — cursor-anchored zoom (`_zoom_at()`) never +## references them. But the single-window `_draw()` path draws the held +## composite at canvas-local `Rect2(0,0,extent,extent)`, which is ONLY the +## right place on screen if canvas-local (0,0) still equals +## `held_center - held_n/2` for the NEW rung. `_maybe_reselect_rung()` +## updates `held_center`/`held_n` to the new rung's values (a DIFFERENT +## `held_n` — Region's ~thousands vs. District's 64 vs. Quarter's 16) but +## never touched `_view_offset` to compensate — so canvas-local (0,0) +## silently stopped meaning `held_center - held_n/2` the instant `held_n` +## changed, and the composite (still drawn at local (0,0)) landed wherever +## the STALE offset happened to put it — off-canvas by tens or hundreds of +## thousands of px for a Region-to-District/Quarter crossing (round 4's +## repro), same root shape as the tile-mosaic bug, just on the "one held +## window" side of the split instead of the "many tiles" side. +## +## This is the exact INVERSE construction: given the SAME screen point that +## used to map to `old_local` must now map to canvas-local +## `new_held_n/2 * cell_pixel_size` (i.e. new_held_center's own position +## under the NEW window's `[0, new_held_n)` span), solve for the +## `view_offset` that makes `screen_point == new_local * view_zoom + +## view_offset` true. Pan-edge refetch (`_maybe_refloat_window()`) never +## needed this because it never changes `held_n` — only rung crossings do. +static func recompute_offset_for_held_n_change( + screen_point: Vector2, view_zoom: float, new_held_n: int, cell_pixel_size: float +) -> Vector2: + var new_local: Vector2 = Vector2.ONE * (float(new_held_n) * 0.5 * cell_pixel_size) + return screen_point - new_local * view_zoom + + +# ============================================================================= +# T-1145 item 2 (moved here T-1153 for file-length/testability): WASD/ +# arrow-key held-pan direction + edge-scroll suppression/direction — pure +# functions of explicit inputs, no Control/scene-tree dependency. +# ============================================================================= + + +## WASD + arrow keys, read via Input.is_key_pressed() on the PHYSICAL keycode +## (not an InputMap action): W/S/A/D on this project's global InputMap are +## already bound to move_north/move_south/move_east/move_west (gameplay +## movement, D-054 mouse-relative facing) — reusing those actions here would +## make holding W simultaneously pan this map AND queue a gameplay move +## command server-side the moment this implant screen closes back to +## gameplay (InputMapper polls Input.is_action_pressed() unconditionally, +## with no implant-occlusion guard — a genuine pre-existing gap outside this +## ticket's scope, not introduced here). Reading the raw physical keycode +## instead of the shared action name means this screen's WASD use is fully +## independent of whatever the gameplay action happens to be bound to — same +## key, two UNRELATED consumers, neither needs to know about the other. +## Arrow keys have no InputMap action bound at all, so they're conflict-free +## either way. Returns a raw (non-normalized) direction — the caller +## normalizes once after adding the edge-scroll contribution, so N+E doesn't +## move faster than N alone. Reads the global `Input` singleton directly (not +## injected) — this is the one function in this file that isn't a pure +## function of its arguments, kept here anyway to sit beside its two siblings +## below rather than splitting the WASD/edge-scroll trio across two files. +static func held_pan_direction() -> Vector2: + var direction := Vector2.ZERO + if Input.is_key_pressed(KEY_W) or Input.is_key_pressed(KEY_UP): + direction.y -= 1.0 + if Input.is_key_pressed(KEY_S) or Input.is_key_pressed(KEY_DOWN): + direction.y += 1.0 + if Input.is_key_pressed(KEY_A) or Input.is_key_pressed(KEY_LEFT): + direction.x -= 1.0 + if Input.is_key_pressed(KEY_D) or Input.is_key_pressed(KEY_RIGHT): + direction.x += 1.0 + return direction + + +## T-1145 item 2: edge-scroll is suppressed (a) while the cursor is over UI +## (`is_over_ui` — the caller's own _is_over_ui() result, passed in rather +## than called from here since "what counts as UI" is viewer-specific) and +## (b) while the application window itself lacks OS focus (`app_has_focus` — +## otherwise a background window with the cursor left resting near its edge +## from a previous session would silently pan while the player is doing +## something else entirely; Godot's NOTIFICATION_APPLICATION_FOCUS_OUT/IN +## make this directly detectable, the caller's own _notification() wires it). +static func is_cursor_edge_scrolling( + app_has_focus: bool, + is_over_ui: bool, + viewport_size: Vector2, + mouse_pos: Vector2, + edge_margin_px: float +) -> bool: + if not app_has_focus: + return false + if is_over_ui: + return false + if viewport_size.x <= 0.0 or viewport_size.y <= 0.0: + return false + return ( + mouse_pos.x >= 0.0 + and mouse_pos.y >= 0.0 + and mouse_pos.x <= viewport_size.x + and mouse_pos.y <= viewport_size.y + and ( + mouse_pos.x < edge_margin_px + or mouse_pos.y < edge_margin_px + or mouse_pos.x > viewport_size.x - edge_margin_px + or mouse_pos.y > viewport_size.y - edge_margin_px + ) + ) + + +## Direction toward whichever edge(s) the cursor is near — same shape as +## held_pan_direction() (a raw, un-normalized Vector2 the caller combines and +## normalizes once). +static func edge_scroll_direction( + viewport_size: Vector2, mouse_pos: Vector2, edge_margin_px: float +) -> Vector2: + var direction := Vector2.ZERO + if mouse_pos.x < edge_margin_px: + direction.x -= 1.0 + elif mouse_pos.x > viewport_size.x - edge_margin_px: + direction.x += 1.0 + if mouse_pos.y < edge_margin_px: + direction.y -= 1.0 + elif mouse_pos.y > viewport_size.y - edge_margin_px: + direction.y += 1.0 + return direction + + +# ============================================================================= +# T-1153, live round 3 (Jeroen's ruling, design doc §4): the orbital REST +# STATE must TILE — a single wire-capped Region window (MAX_COVERAGE_M["Region"] +# = 13,107,200 m) covers only a fraction of a real body's circumference +# (Lendel: 39,197,023 m — a single window is ~a third of the body). The top +# rest state composes MULTIPLE Region windows ("progressive capped-density +# TILING", design doc §4) into a mosaic under ONE view transform. +# ============================================================================= + + +## Compute the tile-set grid for the orbital rest state: the minimal set of +## Region-granularity window CENTERS (each `TILE_N` districts wide) whose +## union covers the WHOLE body — columns wrap (canonicalize_district_center()'s +## own east-west periodicity), rows clamp at the poles. Returns an Array of +## Vector2i tile centers, ALREADY CANONICALIZED (duplicates from pole-row +## clamping or (degenerately) column-wrap collisions are DEDUPED — a tiny +## body where multiple nominal tile rows clamp to the identical pole-adjacent +## row, or multiple nominal tile columns wrap to the identical column, must +## not request/draw the same tile twice). +## +## Grid layout: `cols_tiles = ceil(cols / TILE_N)` tiles span the full +## circumference (evenly spaced, centered on column 0 — the canonical +## origin); `rows_tiles = ceil(2*rows_half / TILE_N)` tiles span pole to +## pole (centered on row 0). Each tile's PRE-CANONICALIZATION center is +## `(tile_index - (tile_count-1)/2) * TILE_N` along its axis — symmetric +## around the canonical origin, matching enter_orbital()'s own "canonical +## origin = (0,0)" convention (AtlasDescendGeometry's doc) so the tile set's +## own center-of-mass lands exactly on the canonical frame, not offset from +## it. +## +## No-radius bodies (tiny test bodies, `body_radius_km <= 0`) return a +## single tile at (0,0) — matching enter_orbital()'s own no-radius fallback +## disposition (no circumference/tiling concept for a body with no radius). +static func compute_tile_grid(body_radius_km: float) -> Array: + if body_radius_km <= 0.0: + return [Vector2i.ZERO] + + var extent: Dictionary = AtlasDescendGeometryRef.district_extent(body_radius_km) + var cols: int = int(extent["cols"]) + var rows_half: int = int(extent["rows_half"]) + var rows_total: int = rows_half * 2 + + var cols_tiles: int = maxi(1, ceili(float(cols) / float(TILE_N))) + var rows_tiles: int = maxi(1, ceili(float(rows_total) / float(TILE_N))) + + var col_centers: Array = [] + for tx in range(cols_tiles): + var raw_col: int = roundi((float(tx) - (float(cols_tiles - 1) * 0.5)) * float(TILE_N)) + col_centers.append(raw_col) + + var row_centers: Array = [] + for ty in range(rows_tiles): + var raw_row: int = roundi((float(ty) - (float(rows_tiles - 1) * 0.5)) * float(TILE_N)) + row_centers.append(raw_row) + + # Dedup via a Dictionary keyed on the CANONICALIZED (col, row) pair — + # Godot Dictionary keys compare Vector2i by value, so this is a proper + # set. Insertion order is preserved (Godot Dictionaries are + # order-preserving), giving a deterministic tile ORDER too — the same + # grid always requests/draws in the same sequence, useful for progressive + # arrival to read as a stable left-to-right, top-to-bottom fill rather + # than an unpredictable one. + var seen: Dictionary = {} + var tiles: Array = [] + for raw_col: int in col_centers: + for raw_row: int in row_centers: + var canonical: Vector2i = AtlasDescendGeometryRef.canonicalize_district_center( + Vector2i(raw_col, raw_row), body_radius_km + ) + if not seen.has(canonical): + seen[canonical] = true + tiles.append(canonical) + return tiles + + +# ============================================================================= +# T-1153: screen header chrome (D-169/D-170) — pure string-building, moved +# here from atlas_window_viewer.gd for file-length (the viewer's own +# `_refresh_screen_header()`/`_location_label()` stay as thin wrappers, since +# both are directly tested by name). +# ============================================================================= + + +## Body name + coordinate label — T-1142: shows the body's proper name +## (falling back to body_id) alongside the held district center, so the +## header never reads as bare "district (col, row)" with no indication of +## WHICH body the player is looking at. +static func location_label(body_display_name: String, held_center: Vector2i) -> String: + return "%s — (%d, %d)" % [body_display_name, held_center.x, held_center.y] + + +## D-169/D-170 implant chrome (§5): {title, subtitle} for the screen header. +## The subtitle's extent (`held_n` districts) is rung-INVARIANT (n is always +## district extent — see AtlasWindowOverlay.cell_grid_side_for_window()'s +## doc), but the km/cell reading reflects the HELD rung's actual spacing +## (2.048 km District, 0.512 km Quarter, 204.8 km Region) — the "continuous +## metres-per-pixel/extent readout" design doc §6 calls for in place of a +## discrete "you are now in Quarter Mode" label (Jeroen's "no mode +## transition" ruling): the number itself communicates the rung. +static func screen_header_content( + body_display_name: String, + held_center: Vector2i, + held_n: int, + held_granularity_v2: String, + district_m: float +) -> Dictionary: + var label: String = location_label(body_display_name, held_center) + var extent_km: float = float(held_n) * district_m / 1000.0 + var spacing_km: float = spacing_for_rung(held_granularity_v2) / 1000.0 + var subtitle: String = "%.1f x %.1f km · %.3f km/cell" % [extent_km, extent_km, spacing_km] + return {"title": "REGIONAL — %s" % label.to_upper(), "subtitle": subtitle} diff --git a/client/ui/implant/apps/atlas/atlas_window_legend.gd b/client/ui/implant/apps/atlas/atlas_window_legend.gd index d98c426ec..c8d21052f 100644 --- a/client/ui/implant/apps/atlas/atlas_window_legend.gd +++ b/client/ui/implant/apps/atlas/atlas_window_legend.gd @@ -16,6 +16,7 @@ const PANEL_MARGIN: float = 16.0 const LEGEND_PANEL_WIDTH: float = 260.0 const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd") +const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") ## The morphology base layer folds its 17 zones into ~5 family rows (§5: ## "mirroring T-1112's 'not everything earns permanent screen space' @@ -55,13 +56,28 @@ func reposition() -> void: ## Always shows the base-layer key (morphology + elevation reading, always ## on) plus glaciation (always-on modifier), then whichever toggle overlay is ## currently active, if any. +## +## The subtitle's km/cell reading is NOT a fixed "district window" label — +## it was until this fix hardcoded District's own 2.048 km/cell, which was a +## 100x lie whenever the viewer actually holds Region (204.8 km/cell) or +## Quarter (0.512 km/cell). Read through AtlasWindowGeometry. +## spacing_for_rung() — the SAME pure lookup _refresh_screen_header() uses +## for the main screen header's subtitle (screen_header_content()) — keyed +## off the viewer's current get_held_granularity_v2(), so the legend and +## header can never disagree. AtlasWindowViewer calls refresh() at every +## point _held_granularity_v2 changes (_enter_tile_mode(), _enter_at_rung(), +## _on_window_ready()'s rung-swap adoption) — see those call sites. func refresh() -> void: if _viewer == null: return clear() visible = true - add_component(ImplantHeader.new("REGIONAL LEGEND", "district window · 2.048 km/cell")) + var spacing_km: float = AtlasWindowGeometry.spacing_for_rung(_viewer.get_held_granularity_v2()) / 1000.0 + var subtitle: String = "%s window · %.3f km/cell" % [ + _viewer.get_held_granularity_v2().to_lower(), spacing_km + ] + add_component(ImplantHeader.new("REGIONAL LEGEND", subtitle)) add_component(ImplantSeparator.new()) _add_morphology_section() diff --git a/client/ui/implant/apps/atlas/atlas_window_overlay.gd b/client/ui/implant/apps/atlas/atlas_window_overlay.gd index df355509e..9a104dfce 100644 --- a/client/ui/implant/apps/atlas/atlas_window_overlay.gd +++ b/client/ui/implant/apps/atlas/atlas_window_overlay.gd @@ -46,9 +46,25 @@ extends Node2D ## window/overlay state, so the common case (panning within an already-held ## window) is zero rebuild cost — draw_texture_rect() on an already-built ## ImageTexture, same as any other texture draw. +## +## T-1152/T-1153: `n` (the response's echoed district extent) and the DERIVED +## cell-grid side length are now DIFFERENT quantities at every rung except +## District — cell_grid_side_for_window() computes the latter from `n` and +## the response's own `granularity_v2` echo (mirroring the server's +## `WindowGranularity::cell_grid_side` exactly), so a Region-rung response (a +## FAR SPARSER cell grid than its district extent — see that Rust doc's +## "inversion" note) renders through the exact same colorizer pipeline as +## District/Quarter, satisfying the design doc §6 "one colorizer family, no +## per-rung palettes" encoding-continuity requirement — no branch in +## _cell_color()/_temp_cell_color()/etc. below needed any change at all. const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd") const REGION_TEMP_NONE_DC: int = AtlasOverlayColors.REGION_TEMP_NONE_DC +const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd") +# T-1153, live round 3: TILE_N (the per-tile district extent) for the mosaic draw path. +const AtlasWindowGeometryRef := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") +# Live round 5: `cols` (circumference in districts) for the mosaic's wrap-image draw fix. +const AtlasDescendGeometryRef := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") ## T-1145 item 3: interim presentation toggle — true renders the smoothed ## Image/ImageTexture composite; false keeps the original crisp per-cell @@ -78,10 +94,30 @@ var _cached_texture: ImageTexture = null var _cache_window_ref: Variant = null var _cache_active_toggle: String = "" +## Live round 4 fix: per-TILE texture cache, keyed by tile index — mirrors +## the single-window cache above, but one slot per mosaic tile (a Dictionary +## of `{window_ref, active_toggle, texture}`, since the mosaic doesn't have a +## single fixed set of tiles the way the single-window path has a single +## fixed field). Building a brand-new, UNSTORED `ImageTexture` every +## `_draw()` call (the round-3 version) left it referenced only by a local +## variable — nothing keeps the RID alive past the function returning, which +## raced against the RenderingServer's deferred draw-command flush and +## rendered as a blank/white tile (the round-4 "pitch black"/white-mosaic +## repro's second half, beyond the coordinate fix above): the CPU-side pixel +## data was provably correct (sampled directly), but the GPU-side texture +## backing it could be gone by composite time. Caching each tile's texture +## as a class-owned Dictionary entry (same reference-identity rebuild-only- +## on-change discipline as `_cached_texture`) keeps it alive exactly as long +## as the single-window composite's own texture already is. +var _tile_texture_cache: Dictionary = {} + func _draw() -> void: if viewer == null: return + if viewer.is_tile_mode(): + _draw_tile_mosaic() + return var window: Variant = viewer.get_district_window() if not window is Dictionary: return @@ -94,25 +130,245 @@ func _draw() -> void: if not (morphology is PackedByteArray or morphology is Array): return + # T-1152/T-1153: `n` (the response's echoed field) is ALWAYS window extent + # in DISTRICTS at every rung (Dudley's wire contract, DistrictWindowLayer.n's + # own doc) — the per-cell arrays (morphology/elev_q/etc.) are sized by the + # DERIVED cell-grid side, `cell_grid_side_for_window()` below, which equals + # `n` only at District granularity. Quarter packs MORE cells into the same + # n-district extent (`n*4`); Region packs FEWER, since one region cell + # spans 100 districts (`round(n/100).max(1)`). The on-screen EXTENT stays + # `n * cell_px` regardless of rung (CELL_PIXEL_SIZE is defined as "one + # DISTRICT at zoom=1.0" — see the viewer's own doc on that constant) so a + # rung swap at a fixed pan/zoom never jumps the composite's screen footprint + # (§6 "no layout jump") — only the TEXTURE RESOLUTION packed into that + # footprint changes, exactly the "same colorizer family, different LoD" + # picture the design doc describes. + var grid_side: int = cell_grid_side_for_window(w) + if grid_side <= 0: + return + var cell_px: float = viewer.get_cell_pixel_size() var active_toggle: String = _active_toggle_overlay() if COMPOSITE_SMOOTH: - _draw_smoothed_composite(w, n, cell_px, active_toggle) + _draw_smoothed_composite(w, n, grid_side, cell_px, active_toggle) else: - _draw_crisp_composite(w, n, cell_px, active_toggle) + _draw_crisp_composite(w, grid_side, n, cell_px, active_toggle) -## T-1145 item 3: the smoothed path — build/reuse an n x n ImageTexture (one -## pixel per district) and draw it scaled to (n*cell_px) with LINEAR -## filtering. texture_filter is set on `self` (a CanvasItem property) once -## per draw — cheap (a property write, not a texture rebuild) and correct -## even the first time this runs (Godot's engine default already IS linear, -## but this makes the choice explicit rather than relying on an implicit -## project-wide default that could change). -func _draw_smoothed_composite(w: Dictionary, n: int, cell_px: float, active_toggle: String) -> void: +## T-1153, live round 3/4 (Jeroen's ruling, design doc §4): the orbital +## rest-state MOSAIC draw path — one call to the EXISTING single-tile +## composite-building logic (`_rebuild_texture_if_needed()`/ +## `_draw_smoothed_composite()`'s own per-tile equivalent below) PER TILE, +## each positioned at its own LOCAL offset in the SAME canvas-local +## coordinate frame the single-window path (and fit_window_view()/ +## screen_center_to_district()) already use. +## +## **Live round 4 fix:** the round-3 version placed tiles relative to +## absolute district (0,0) directly (`(tile.center - TILE_N/2) * cell_px`), +## which does NOT match `_fit_and_center()`'s own convention — canvas-local +## (0,0) is `held_center - held_n/2` (AtlasWindowGeometry. +## district_to_canvas_local()'s own doc), and in tile mode `held_n` is the +## WHOLE BODY's extent, not TILE_N. That mismatch pushed the entire mosaic +## off-canvas (round 4's "pitch black" repro) — silent, since nothing +## errors, it just draws somewhere the viewport never shows. Fixed by +## routing every tile's placement through `district_to_canvas_local()` with +## the VIEWER's own `held_center`/`held_n`, the exact reference frame every +## other canvas-local consumer (fit/pan/reselect) already agrees on. Tiles +## that haven't arrived yet (`tile["window"] == null`) are simply SKIPPED — +## no per-tile placeholder draw, letting COLOR_BG show through as the honest +## "nothing here yet" read (the viewer's own `_draw()` already documents why +## no separate whole-viewport fade is needed on top of this). +## +## **Live round 5 fix:** `tile["center"]` is CANONICAL (wrapped into +## `[0, cols)` by `compute_tile_grid()` — correct for REQUESTS/cache keys, +## since longitude is periodic and a canonical column is the single-valued +## key both sides of the wire agree on). But `district_to_canvas_local()` +## is a pure LINEAR function with no wrap concept — handed a canonical +## column directly, it places the tile at exactly ONE of its infinitely +## many equivalent on-screen positions (`col + k*cols`), which is only the +## visually-correct one by coincidence. Lendel's own repro: the tile whose +## true position is immediately WEST of the canonical origin canonicalizes +## to column 12739 (`-6400 mod 19139`) — drawn there directly, it lands +## off-canvas RIGHT, leaving the mosaic's actual LEFT third black. Fixed by +## re-expressing each tile's column via `nearest_wrap_image()` — whichever +## wrap-image is closest to `held_center`, i.e. the one actually near the +## current view — BEFORE handing it to `district_to_canvas_local()`. +func _draw_tile_mosaic() -> void: + var tile_set = viewer.get_tile_set() + if tile_set == null: + return + var cell_px: float = viewer.get_cell_pixel_size() + var active_toggle: String = _active_toggle_overlay() + var held_center: Vector2i = viewer.get_held_center() + var held_n: int = viewer.get_held_n() + var half_tile: float = float(AtlasWindowGeometryRef.TILE_N) * 0.5 + var tiles: Array = tile_set.get_tiles() + var cols: int = int( + AtlasDescendGeometryRef.district_extent(viewer.get_body_radius_km()).get("cols", 0) + ) + + for i in range(tiles.size()): + var tile: Dictionary = tiles[i] + var window: Variant = tile["window"] + if not window is Dictionary: + continue + var w: Dictionary = window + var morphology: Variant = w.get("morphology") + if not (morphology is PackedByteArray or morphology is Array): + continue + var grid_side: int = cell_grid_side_for_window(w) + if grid_side <= 0: + continue + + var center: Vector2i = tile["center"] + var draw_col: int = AtlasWindowGeometryRef.nearest_wrap_image(center.x, held_center.x, cols) + var tile_top_left: Vector2 = Vector2( + float(draw_col) - half_tile, float(center.y) - half_tile + ) + var local_origin: Vector2 = AtlasWindowGeometryRef.district_to_canvas_local( + tile_top_left, held_center, held_n, cell_px + ) + var extent: float = float(AtlasWindowGeometryRef.TILE_N) * cell_px + _draw_one_tile(i, w, grid_side, local_origin, extent, active_toggle) + + +## One tile's own composite — the SAME crisp/smoothed per-cell pipeline the +## single-window path uses (_cell_color()/_apply_glaciation(), UNCHANGED), +## just drawn at `local_origin` instead of always at (0,0). Each tile gets +## its OWN texture-rebuild cache slot in `_tile_texture_cache`, keyed by +## `tile_index` — sharing ONE `_cached_texture` slot across all tiles (the +## single-window field) would thrash on every draw call as different tiles' +## windows compete for it. +func _draw_one_tile( + tile_index: int, + w: Dictionary, + grid_side: int, + local_origin: Vector2, + extent: float, + active_toggle: String +) -> void: + if not COMPOSITE_SMOOTH: + _draw_crisp_tile(w, grid_side, local_origin, extent, active_toggle) + return + var tile_texture: ImageTexture = _rebuild_tile_texture_if_needed( + tile_index, w, grid_side, active_toggle + ) + if tile_texture == null: + return texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR - _rebuild_texture_if_needed(w, n, active_toggle) + draw_texture_rect(tile_texture, Rect2(local_origin, Vector2(extent, extent)), false) + + +## Live round 4 fix: rebuilds (and, critically, KEEPS — see +## `_tile_texture_cache`'s own doc for why an unstored local `ImageTexture` +## silently rendered blank/white) `_tile_texture_cache[tile_index]`'s texture +## ONLY when that tile's window object or the active toggle overlay has +## changed since the last build — the SAME reference-identity discipline +## `_rebuild_texture_if_needed()` uses for the single-window composite, one +## cache entry per tile index instead of one shared field. +func _rebuild_tile_texture_if_needed( + tile_index: int, w: Dictionary, grid_side: int, active_toggle: String +) -> ImageTexture: + var entry: Dictionary = _tile_texture_cache.get(tile_index, {}) + if ( + is_same(entry.get("window_ref"), w) + and entry.get("active_toggle") == active_toggle + and entry.get("texture") != null + ): + return entry["texture"] + var texture: ImageTexture = _build_tile_texture(w, grid_side, active_toggle) + _tile_texture_cache[tile_index] = { + "window_ref": w, "active_toggle": active_toggle, "texture": texture + } + return texture + + +## Builds a tile's own Image/ImageTexture from its per-cell colors — +## identical pipeline to `_rebuild_texture_if_needed()`, just returning the +## texture directly instead of writing to the single-window cache fields +## (the CALLER, `_rebuild_tile_texture_if_needed()`, owns persisting it). +func _build_tile_texture(w: Dictionary, grid_side: int, active_toggle: String) -> ImageTexture: + var elev_q: Variant = w.get("elev_q") + var glaciation: Variant = w.get("glaciation") + var morphology: Variant = w.get("morphology") + var n_cells: int = morphology.size() + + var img := Image.create(grid_side, grid_side, false, Image.FORMAT_RGBA8) + for row in range(grid_side): + for col in range(grid_side): + var i: int = row * grid_side + col + if i >= n_cells: + img.set_pixel(col, row, Color.TRANSPARENT) + continue + var cell_color: Color = _cell_color(w, i, int(morphology[i]), elev_q, active_toggle) + cell_color = _apply_glaciation(cell_color, glaciation, i) + img.set_pixel(col, row, cell_color) + + return ImageTexture.create_from_image(img) + + +## The crisp (non-smoothed) per-tile path — mirrors `_draw_crisp_composite()` +## exactly, just positioned at `local_origin` instead of always at (0,0). +func _draw_crisp_tile( + w: Dictionary, grid_side: int, local_origin: Vector2, extent: float, active_toggle: String +) -> void: + var elev_q: Variant = w.get("elev_q") + var glaciation: Variant = w.get("glaciation") + var morphology: Variant = w.get("morphology") + var n_cells: int = morphology.size() + var screen_cell_px: float = extent / float(grid_side) + + for row in range(grid_side): + for col in range(grid_side): + var i: int = row * grid_side + col + if i >= n_cells: + continue + var cell_color: Color = _cell_color(w, i, int(morphology[i]), elev_q, active_toggle) + if cell_color.a <= 0.0: + continue + cell_color = _apply_glaciation(cell_color, glaciation, i) + var cell_origin: Vector2 = local_origin + Vector2(col * screen_cell_px, row * screen_cell_px) + draw_rect( + Rect2(cell_origin, Vector2(screen_cell_px + 0.5, screen_cell_px + 0.5)), cell_color + ) + + +## The derived cell-grid side length (in CELLS) for a window dict `w` — +## mirrors server/src/atlas/layer_proxy.rs's `WindowGranularity::cell_grid_side` +## exactly, reading `w`'s OWN echoed `n`/`granularity_v2` fields rather than +## trusting a caller's separately-tracked rung (the response is the source of +## truth for what it actually contains). Falls back to `n` unchanged +## (District's own identity mapping) for an old-shape response with no +## `granularity_v2` key — matches the server's own "unknown -> District" +## posture and AtlasWindowRequest.on_response()'s own default-to-District +## disposition for the same field. +static func cell_grid_side_for_window(w: Dictionary) -> int: + var n: int = int(w.get("n", 0)) + var granularity_v2 := str(w.get("granularity_v2", AtlasWindowRequest.GRANULARITY_V2_DISTRICT)) + match granularity_v2: + AtlasWindowRequest.GRANULARITY_V2_QUARTER: + return n * 4 # WINDOW_GRANULARITY_QUARTER multiplier — D-243 QUARTER_M + AtlasWindowRequest.GRANULARITY_V2_REGION: + return maxi(roundi(float(n) / 100.0), 1) # D-243 DISTRICTS_PER_REGION + _: + return n # District — 1:1 + + +## T-1145 item 3: the smoothed path — build/reuse a `grid_side` x `grid_side` +## ImageTexture (one pixel per DERIVED CELL, T-1152 — not per district, see +## cell_grid_side_for_window()'s doc) and draw it scaled to (n*cell_px), n +## being the window's DISTRICT extent, with LINEAR filtering. texture_filter +## is set on `self` (a CanvasItem property) once per draw — cheap (a property +## write, not a texture rebuild) and correct even the first time this runs +## (Godot's engine default already IS linear, but this makes the choice +## explicit rather than relying on an implicit project-wide default that +## could change). +func _draw_smoothed_composite( + w: Dictionary, n: int, grid_side: int, cell_px: float, active_toggle: String +) -> void: + texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR + _rebuild_texture_if_needed(w, grid_side, active_toggle) if _cached_texture == null: return var extent: float = float(n) * cell_px @@ -125,8 +381,9 @@ func _draw_smoothed_composite(w: Dictionary, n: int, cell_px: float, active_togg ## read directly from `w` here (rather than threaded through as params, the ## way the crisp path's _cell_color()/_apply_glaciation() calls already ## receive them) since this function owns the whole per-cell loop, not just -## one cell. -func _rebuild_texture_if_needed(w: Dictionary, n: int, active_toggle: String) -> void: +## one cell. `grid_side` (T-1152) is the DERIVED cell-grid side (see +## cell_grid_side_for_window()), not the window's district extent `n`. +func _rebuild_texture_if_needed(w: Dictionary, grid_side: int, active_toggle: String) -> void: if ( is_same(_cache_window_ref, w) and _cache_active_toggle == active_toggle @@ -139,10 +396,10 @@ func _rebuild_texture_if_needed(w: Dictionary, n: int, active_toggle: String) -> var morphology: Variant = w.get("morphology") var n_cells: int = morphology.size() - var img := Image.create(n, n, false, Image.FORMAT_RGBA8) - for row in range(n): - for col in range(n): - var i: int = row * n + col + var img := Image.create(grid_side, grid_side, false, Image.FORMAT_RGBA8) + for row in range(grid_side): + for col in range(grid_side): + var i: int = row * grid_side + col if i >= n_cells: img.set_pixel(col, row, Color.TRANSPARENT) continue @@ -157,16 +414,22 @@ func _rebuild_texture_if_needed(w: Dictionary, n: int, active_toggle: String) -> ## The ORIGINAL crisp per-cell path — kept byte-for-byte behind ## COMPOSITE_SMOOTH := false so T-1143's design pass can compare both -## renderings directly (see the class doc). -func _draw_crisp_composite(w: Dictionary, n: int, cell_px: float, active_toggle: String) -> void: +## renderings directly (see the class doc). `grid_side` (T-1152) is the +## DERIVED cell-grid side (see cell_grid_side_for_window()); `n` (the +## window's district extent) sizes the on-screen cell pitch so the total +## drawn footprint stays `n * cell_px` regardless of rung. +func _draw_crisp_composite( + w: Dictionary, grid_side: int, n: int, cell_px: float, active_toggle: String +) -> void: var elev_q: Variant = w.get("elev_q") var glaciation: Variant = w.get("glaciation") var morphology: Variant = w.get("morphology") var n_cells: int = morphology.size() + var screen_cell_px: float = float(n) * cell_px / float(grid_side) - for row in range(n): - for col in range(n): - var i: int = row * n + col + for row in range(grid_side): + for col in range(grid_side): + var i: int = row * grid_side + col if i >= n_cells: continue var cell_color: Color = _cell_color(w, i, int(morphology[i]), elev_q, active_toggle) @@ -175,7 +438,10 @@ func _draw_crisp_composite(w: Dictionary, n: int, cell_px: float, active_toggle: cell_color = _apply_glaciation(cell_color, glaciation, i) # +0.5 overdraw avoids hairline seams between adjacent cells — # same idiom as _draw_gen_district/_draw_gen_region_grid. - draw_rect(Rect2(col * cell_px, row * cell_px, cell_px + 0.5, cell_px + 0.5), cell_color) + draw_rect( + Rect2(col * screen_cell_px, row * screen_cell_px, screen_cell_px + 0.5, screen_cell_px + 0.5), + cell_color + ) ## Which of the three mutually-exclusive toggle overlays (if any) is active. diff --git a/client/ui/implant/apps/atlas/atlas_window_request.gd b/client/ui/implant/apps/atlas/atlas_window_request.gd index 7f1c7b97d..12ae7fc76 100644 --- a/client/ui/implant/apps/atlas/atlas_window_request.gd +++ b/client/ui/implant/apps/atlas/atlas_window_request.gd @@ -41,13 +41,23 @@ const DEBOUNCE_DELAY: float = 0.15 # 150ms, §4/§5 const RETRY_DELAY: float = 0.5 # matches atlas_generation_proxy.gd's GEN_RETRY_DELAY const MAX_RETRIES: int = 20 # ~10s ceiling, matches atlas_generation_proxy.gd's GEN_MAX_RETRIES -## T-1150 struct/key plumbing: this viewer only ever REQUESTS district -## granularity today (requesting quarter is T-1153's job) — these constants -## exist so the cache key / staleness guard below are granularity-aware from -## day one, not bolted on later. +## T-1150 struct/key plumbing: legacy int granularity — district is the +## default for every caller that doesn't request quarter/Region explicitly. const DEFAULT_GRANULARITY: int = AtlasWindowCache.DISTRICT_GRANULARITY const DEFAULT_MIN_WL_M: int = 0 +## T-1152/T-1153: the R5-redesigned string-tag granularity — "Quarter" | +## "District" | "Region". This is the axis request_now()/request_debounced()'s +## `granularity_v2` parameter actually varies; the legacy int +## (DEFAULT_GRANULARITY) stays pinned at district for every call this object +## makes, since v2 always wins server-side once present +## (resolve_window_granularity_v2()'s documented precedence) and the legacy +## int cannot express Region at all. +const GRANULARITY_V2_QUARTER: String = AtlasWindowCache.GRANULARITY_V2_QUARTER +const GRANULARITY_V2_DISTRICT: String = AtlasWindowCache.GRANULARITY_V2_DISTRICT +const GRANULARITY_V2_REGION: String = AtlasWindowCache.GRANULARITY_V2_REGION +const DEFAULT_GRANULARITY_V2: String = AtlasWindowCache.DEFAULT_GRANULARITY_V2 + ## Mirrors server/src/atlas/layer_proxy.rs's DISTRICT_WINDOW_MAX_N / ## WIRE_CAP_CELLS exactly (PR #191 review, Tyre C1). `_clamp_window_n_mirror()` ## below reproduces `clamp_window_n()` bit-for-bit — the load-bearing-mirror @@ -57,12 +67,24 @@ const DEFAULT_MIN_WL_M: int = 0 const SERVER_DISTRICT_WINDOW_MAX_N: int = 64 const SERVER_WIRE_CAP_CELLS: int = 4_096 +## T-1152/T-1153: mirrors server/src/atlas/layer_proxy.rs's +## `DISTRICT_WINDOW_MAX_N_REGION` — the Region-only per-axis ceiling on `n` +## (still window extent in DISTRICTS, per WindowGranularity::cell_grid_side's +## doc: `sqrt(WIRE_CAP_CELLS) * DISTRICTS_PER_REGION = 64 * 100`). Keep in +## sync with the server constant of the same name. +const SERVER_DISTRICT_WINDOW_MAX_N_REGION: int = 6_400 +## Mirrors server/src/atlas/scale.rs's DISTRICTS_PER_REGION (D-243: one region +## = 100 districts/side) — the divisor `_cell_grid_side_region_mirror()` needs +## to reproduce `WindowGranularity::cell_grid_side`'s Region branch. +const SERVER_DISTRICTS_PER_REGION: int = 100 + var _owner = null # AtlasWindowViewer (untyped to avoid cyclic ref) var _cache = null # AtlasWindowCache var _body_id: String = "" var _center: Vector2i = Vector2i.ZERO var _n: int = DISTRICT_WINDOW_DEFAULT_N var _granularity: int = DEFAULT_GRANULARITY +var _granularity_v2: String = DEFAULT_GRANULARITY_V2 var _min_wl_m: int = DEFAULT_MIN_WL_M var _pending: bool = false var _retries: int = 0 @@ -127,23 +149,76 @@ static func _clamp_window_n_mirror(raw_n: int, granularity: int) -> int: return mini(n, maxi(cap_n, 1)) +## [`WindowGranularity`]-aware twin of `_clamp_window_n_mirror()` (T-1152/ +## T-1153) — mirrors server/src/atlas/layer_proxy.rs's `clamp_window_n_v2` +## EXACTLY, including its Region branch, per the ticket's explicit +## instruction ("replicate the loop exactly, there is NO closed form"). For +## District/Quarter this delegates straight to `_clamp_window_n_mirror()` +## (byte-identical clamped `n`, matching the server's own +## `clamp_window_n_v2_matches_legacy_for_finer_than_district_rungs` +## guarantee). For Region: per-axis clamp to +## `SERVER_DISTRICT_WINDOW_MAX_N_REGION` (6,400), then halve `n` in a bounded +## loop while `_cell_grid_side_region_mirror(n)^2 > SERVER_WIRE_CAP_CELLS` and +## `n > 1` — there is no closed-form inverse of the rounding division +## `cell_grid_side` uses at Region granularity, so this loop is the correct +## (and only) mirror, not an approximation of one. +static func _clamp_window_n_mirror_v2(raw_n: int, granularity_v2: String) -> int: + if granularity_v2 != AtlasWindowCache.GRANULARITY_V2_REGION: + var legacy_granularity: int = ( + DEFAULT_GRANULARITY + if granularity_v2 == AtlasWindowCache.GRANULARITY_V2_DISTRICT + else AtlasWindowCache.DISTRICT_GRANULARITY * 4 # "Quarter" — WINDOW_GRANULARITY_QUARTER + ) + return _clamp_window_n_mirror(raw_n, legacy_granularity) + + var n: int = clampi(raw_n, 1, SERVER_DISTRICT_WINDOW_MAX_N_REGION) + while ( + _cell_grid_side_region_mirror(n) * _cell_grid_side_region_mirror(n) > SERVER_WIRE_CAP_CELLS + and n > 1 + ): + n = int(n / 2.0) + return maxi(n, 1) + + +## Mirrors `WindowGranularity::cell_grid_side`'s Region branch EXACTLY: +## `round(n / DISTRICTS_PER_REGION).max(1)` — the derived region-cell-grid +## side length (in CELLS) for a window whose extent is `n` DISTRICTS. Rust's +## `f64::round()` is round-half-away-from-zero; GDScript's `roundi()` matches +## that for non-negative inputs (the only domain `n` — always >= 1 here — +## can produce), so this is a faithful mirror, not an approximation. +static func _cell_grid_side_region_mirror(n: int) -> int: + var side: int = roundi(float(n) / float(SERVER_DISTRICTS_PER_REGION)) + return maxi(side, 1) + + ## Entry point + pan re-request: request the window centered on `center` -## (a DistrictPos-equivalent Vector2i) for `body_id`. Cache hit -> immediate -## synchronous window_ready emit, no network traffic at all. Cache miss -> -## fire the request now (the caller — either the initial entry or a -## debounce-fired pan — has already decided this call SHOULD fire; the 150ms -## debounce itself lives in request_debounced() below, not here, so this -## function is also the one entry-mechanic click-through uses directly with -## no debounce at all, matching §5's "first window" contract). -func request_now(body_id: String, center: Vector2i, n: int = DISTRICT_WINDOW_DEFAULT_N) -> void: +## (a DistrictPos-equivalent Vector2i) for `body_id`, at `granularity_v2` +## ("Quarter" | "District" | "Region", T-1152/T-1153 — District is the +## default for every caller that doesn't ask for a different rung explicitly, +## matching the legacy behavior byte-for-byte when omitted). Cache hit -> +## immediate synchronous window_ready emit, no network traffic at all. Cache +## miss -> fire the request now (the caller — either the initial entry or a +## debounce-fired pan/zoom — has already decided this call SHOULD fire; the +## 150ms debounce itself lives in request_debounced() below, not here, so +## this function is also the one entry-mechanic click-through uses directly +## with no debounce at all, matching §5's "first window" contract). +func request_now( + body_id: String, + center: Vector2i, + n: int = DISTRICT_WINDOW_DEFAULT_N, + granularity_v2: String = DEFAULT_GRANULARITY_V2 +) -> void: _body_id = body_id _center = center - _granularity = DEFAULT_GRANULARITY + _granularity = DEFAULT_GRANULARITY # legacy int stays pinned at district — v2 always wins server-side + _granularity_v2 = granularity_v2 _min_wl_m = DEFAULT_MIN_WL_M - _n = _clamp_window_n_mirror(n, _granularity) # Tyre C1 — mirror BEFORE storing/requesting + _n = _clamp_window_n_mirror_v2(n, _granularity_v2) # Tyre C1, extended T-1152 — mirror BEFORE storing _debounce_timer.stop() # a direct request supersedes any pending debounced one - var cached: Variant = _cache.get_window(body_id, center, _n, _granularity, _min_wl_m) + var cached: Variant = _cache.get_window( + body_id, center, _n, _granularity, _min_wl_m, _granularity_v2 + ) if cached != null: _pending = false _retries = 0 @@ -152,7 +227,9 @@ func request_now(body_id: String, center: Vector2i, n: int = DISTRICT_WINDOW_DEF _pending = true _retries = 0 - SimBridge.request_atlas_layers(body_id, "Topography", center, _n, _granularity, _min_wl_m) + SimBridge.request_atlas_layers( + body_id, "Topography", center, _n, _granularity, _min_wl_m, _granularity_v2 + ) ## Pan-triggered re-request (§4/§5: "150ms after the last drag-release, not @@ -160,27 +237,57 @@ func request_now(body_id: String, center: Vector2i, n: int = DISTRICT_WINDOW_DEF ## candidate; only the LAST call within the debounce window actually fires ## (Timer.start() on an already-running one-shot timer restarts it — Godot's ## documented behavior — so a flick-and-resettle collapses to one request). -func request_debounced(body_id: String, center: Vector2i, n: int = DISTRICT_WINDOW_DEFAULT_N) -> void: +func request_debounced( + body_id: String, + center: Vector2i, + n: int = DISTRICT_WINDOW_DEFAULT_N, + granularity_v2: String = DEFAULT_GRANULARITY_V2 +) -> void: _body_id = body_id _center = center _granularity = DEFAULT_GRANULARITY + _granularity_v2 = granularity_v2 _min_wl_m = DEFAULT_MIN_WL_M - _n = _clamp_window_n_mirror(n, _granularity) # Tyre C1 — mirror BEFORE storing/requesting + _n = _clamp_window_n_mirror_v2(n, _granularity_v2) # Tyre C1, extended T-1152 — mirror BEFORE storing _debounce_timer.start() func _on_debounce_timeout() -> void: - request_now(_body_id, _center, _n) + request_now(_body_id, _center, _n, _granularity_v2) ## Handle an AtlasLayerResponse (routed by the owning viewer from its own ## SimBridge.atlas_layers_received subscription — this object has no signal ## connection of its own, matching atlas_generation_proxy.gd's on_response() -## shape). Ignores responses for a stale body/center/n/granularity/min_wl_m -## (the player panned or navigated away while a request was in flight, or a -## different rung's derive answers a request for a different rung, T-1150) — -## the echoed fields ARE the staleness guard (§2, extended T-1150), compared -## here against what THIS object most recently asked for. +## shape). Ignores responses for a stale body/center/n/min_wl_m/granularity +## (legacy OR v2, see below) — the player panned, zoomed across a rung +## boundary, or navigated away while a request was in flight, or a different +## rung's derive answers a request for a different rung, T-1150/T-1152 — the +## echoed fields ARE the staleness guard (§2, extended T-1150/T-1152), +## compared here against what THIS object most recently asked for. +## +## **Live-round finding (the second C1-shaped bug): v2 is AUTHORITATIVE over +## the legacy field whenever v2 is present — the legacy comparison is +## SKIPPED entirely, not run alongside it.** A T-1152-aware server (this +## codebase's) ALWAYS populates `granularity_v2` on the wire (Dudley's +## contract, `DistrictWindowLayer.granularity_v2`'s own doc: "Always +## populated (never `None`)"), and for `Region` responses specifically the +## LEGACY `granularity` slot carries `WINDOW_GRANULARITY_REGION_KEY` +## (`u32::MAX` = 4294967295) — a reserved KEY-SPACE TAG, not a real +## multiplier, that can never equal this object's own stored `_granularity` +## (which stays pinned at `DEFAULT_GRANULARITY`=1 for every rung this object +## requests, per that field's own doc — the legacy slot has no concept of +## Region at all). Comparing the legacy field UNCONDITIONALLY alongside v2 +## therefore drops EVERY Region response as stale forever, even though the +## v2 comparison alone would have correctly accepted it — exactly the live +## bug (`_held_n` fixed; this is the same "old comparison still active +## alongside the new one" class of bug, one layer up in the staleness +## checks). Fix: branch on whether `granularity_v2` is actually PRESENT in +## the response dict (`w.has(...)`, not `w.get(..., default)` — the +## presence/absence distinction is the whole point here) — present (every +## real server, always) -> v2 is the ONLY granularity comparison; absent (a +## hypothetically old, pre-T-1152 server) -> fall back to the legacy +## comparison alone, matching this object's own pre-T-1152 behavior exactly. func on_response(response: Dictionary) -> void: if str(response.get("body_id", "")) != _body_id: return @@ -205,29 +312,44 @@ func on_response(response: Dictionary) -> void: var w: Dictionary = window var echoed_center := _vec_from_center(w.get("center", [0, 0])) var echoed_n := int(w.get("n", 0)) - var echoed_granularity := int(w.get("granularity", AtlasWindowCache.DISTRICT_GRANULARITY)) var echoed_min_wl_m := int(w.get("min_wl_m", 0)) + var granularity_matches: bool = _echoed_granularity_matches(w) if ( echoed_center != _center or echoed_n != _n - or echoed_granularity != _granularity or echoed_min_wl_m != _min_wl_m + or not granularity_matches ): - return # stale — answers a window we've since panned away from, or a different rung (§2/T-1150) + return # stale — answers a window we've since panned/zoomed away from, or a different rung _pending = false _retries = 0 - _cache.put(_body_id, _center, _n, w, _granularity, _min_wl_m) + _cache.put(_body_id, _center, _n, w, _granularity, _min_wl_m, _granularity_v2) window_ready.emit(w) +## The granularity half of on_response()'s staleness check, split out for the +## v2-authoritative-when-present precedence rule (see on_response()'s own +## doc for the full live-round rationale). Presence, not value, is the +## branch: `w.has("granularity_v2")` — a real server ALWAYS sets this key +## (even if its value happened to coincidentally equal a default), so +## checking presence rather than "is it the default value" is the only +## correct way to distinguish "an old server that never heard of this field" +## from "a new server whose value happens to match." +func _echoed_granularity_matches(w: Dictionary) -> bool: + if w.has("granularity_v2"): + return str(w.get("granularity_v2")) == _granularity_v2 + var echoed_granularity := int(w.get("granularity", AtlasWindowCache.DISTRICT_GRANULARITY)) + return echoed_granularity == _granularity + + func _schedule_retry() -> void: var timer := get_tree().create_timer(RETRY_DELAY) timer.timeout.connect( func() -> void: if _pending: SimBridge.request_atlas_layers( - _body_id, "Topography", _center, _n, _granularity, _min_wl_m + _body_id, "Topography", _center, _n, _granularity, _min_wl_m, _granularity_v2 ) ) @@ -236,6 +358,21 @@ func is_pending() -> bool: return _pending +## The v2 granularity ("Quarter" | "District" | "Region") this object most +## recently asked for — the viewer reads this to know which rung the HELD +## window (once it arrives) actually is, without threading a second copy of +## the state through window_ready's payload. +func get_granularity_v2() -> String: + return _granularity_v2 + + +## Current window extent in districts, as CLAMPED — the viewer's rung- +## selection math needs this to compute the held composite's real-world +## extent regardless of which rung last resolved it. +func get_n() -> int: + return _n + + func get_cache() -> Variant: return _cache diff --git a/client/ui/implant/apps/atlas/atlas_window_tile_set.gd b/client/ui/implant/apps/atlas/atlas_window_tile_set.gd new file mode 100644 index 000000000..21f6b8352 --- /dev/null +++ b/client/ui/implant/apps/atlas/atlas_window_tile_set.gd @@ -0,0 +1,166 @@ +extends Node + +## Orbital rest-state TILE-SET orchestration (T-1153, live round 3 — Jeroen's +## ruling, design doc §4: "the top rest state is the WHOLE body, served as +## progressive capped-density TILING"). A single wire-capped Region window +## (AtlasWindowGeometry.MAX_COVERAGE_M["Region"] = 13,107,200 m) covers only a +## fraction of a real body's circumference (Lendel: ~39,197,023 m — a single +## window is ~a third of the body, the exact live-round finding: shot 01's +## own header read "13107.2 x 13107.2 km" against a 39,198 km circumference). +## +## Owns N independent `AtlasWindowRequest` child instances — one per tile — +## reusing 100% of the EXISTING, already-tested single-window request/cache/ +## debounce/retry machinery (atlas_window_request.gd) rather than +## reinventing multi-window orchestration from scratch. Each tile is just a +## Region-granularity window request at its own canonicalized center +## (AtlasWindowGeometry.compute_tile_grid()); distinct centers are already +## distinct cache/coalescing keys (T-1150/T-1152's own aliasing discipline), +## so nothing about the request/cache LAYER needed to change for tiling to +## work — only the ORCHESTRATION (issue N requests instead of one) and the +## DRAWING (a mosaic instead of one composite) are new. +## +## No `class_name` on purpose, matching every other viewer-owned helper in +## this cluster (atlas_window_request.gd/atlas_overlay_bar.gd/ +## atlas_legend_panel.gd, review #8 precedent): the owner (AtlasWindowViewer) +## passes itself to `_init()`. +## +## Progressive arrival (design doc §4's own "with visible refinement as +## tiles complete"): each tile's `AtlasWindowRequest.window_ready` connects +## independently — a tile's own `_tiles[i]["window"]` updates the moment +## THAT tile's response lands, with no dependency on any other tile's +## arrival. The viewer/overlay reads `get_tiles()` every draw and renders +## whichever tiles have arrived so far — an empty/border-fade gap for the +## rest, exactly the same "hold what's there, sharpen in place" contract +## single-window progressive refinement already has (§6 "no mode flip"), +## just per-tile instead of per-composite. + +signal tile_ready(index: int) # a single tile's window arrived/updated — the viewer redraws + +const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd") +const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") + +var _owner = null # AtlasWindowViewer (untyped to avoid cyclic ref) +var _body_id: String = "" +var _tile_n: int = AtlasWindowGeometry.TILE_N + +## Array[Dictionary]: {"center": Vector2i, "request": AtlasWindowRequest, +## "window": Variant (null until arrived)} — one entry per tile, in the SAME +## deterministic order compute_tile_grid() produces (stable fill order, see +## that function's own doc). +var _tiles: Array = [] + + +func _init(owner_ref = null) -> void: + _owner = owner_ref + + +## Unlike an individual AtlasWindowRequest (which has no signal connection of +## its own — the OWNING viewer forwards responses to it, per that class' +## own doc), the tile set DOES connect directly to +## SimBridge.atlas_layers_received itself and fans a single response out to +## EVERY tile's own `on_response()` — each tile's OWN staleness guard +## (center/n/granularity_v2) decides whether that particular response is +## the one IT was waiting for; only the matching tile ever adopts it. This +## is the same "one shared inbound signal, N independent consumers filtering +## by their own criteria" shape the design already uses elsewhere (every +## AtlasWindowRequest instance filters on its own state from a common +## broadcast — tiling just means N instances share the broadcast instead of +## one). +func _ready() -> void: + SimBridge.atlas_layers_received.connect(_on_atlas_layers_received) + + +func _exit_tree() -> void: + if SimBridge.atlas_layers_received.is_connected(_on_atlas_layers_received): + SimBridge.atlas_layers_received.disconnect(_on_atlas_layers_received) + + +func _on_atlas_layers_received(response: Dictionary) -> void: + for tile: Dictionary in _tiles: + var request = tile["request"] + if is_instance_valid(request): + request.on_response(response) + + +## Enter tile mode for `body_id`/`body_radius_km` — computes the tile grid, +## tears down any PREVIOUS tile set's child request nodes (a fresh +## enter_orbital() on a DIFFERENT body must not leave stale tile requests +## from the old body wired up), and issues one request per tile immediately +## (no debounce — matching AtlasWindowRequest.request_now()'s own "first +## window" contract, §5: entry is never debounced, only pan/rung-reselect +## refetches are). +func enter(body_id: String, body_radius_km: float) -> void: + _teardown() + _body_id = body_id + var centers: Array = AtlasWindowGeometry.compute_tile_grid(body_radius_km) + for i in range(centers.size()): + var center: Vector2i = centers[i] + var request = AtlasWindowRequest.new(self) + request.name = "Tile%d" % i + add_child(request) + var tile_index := i # capture by value for the lambda below + request.window_ready.connect( + func(window: Dictionary) -> void: _on_tile_window_ready(tile_index, window) + ) + _tiles.append({"center": center, "request": request, "window": null}) + request.request_now(body_id, center, _tile_n, AtlasWindowRequest.GRANULARITY_V2_REGION) + + +func _on_tile_window_ready(index: int, window: Dictionary) -> void: + if index < 0 or index >= _tiles.size(): + return # a stale signal from a torn-down tile set (shouldn't happen — disconnected on teardown) + _tiles[index]["window"] = window + tile_ready.emit(index) + + +## Tear down every tile's request node — disconnects nothing explicitly +## (queue_free() on a Node disconnects all its own signal connections +## automatically, Godot's documented behavior) but DOES clear `_tiles` so a +## stale index from an in-flight-but-now-orphaned request's eventual +## response can never reach `_on_tile_window_ready()` with a now-meaningless +## index (guarded there too, belt-and-suspenders). +func _teardown() -> void: + for tile: Dictionary in _tiles: + var request = tile["request"] + if is_instance_valid(request): + request.queue_free() + _tiles.clear() + + +## The current tile set, for the viewer/overlay to draw — an Array of +## {"center": Vector2i, "window": Variant} (the "request" key is internal, +## not exposed here; callers only need center + arrived-or-null window). +func get_tiles() -> Array: + var result: Array = [] + for tile: Dictionary in _tiles: + result.append({"center": tile["center"], "window": tile["window"]}) + return result + + +## True once tiling is active for the current body — a body whose whole +## circumference fits in ONE Region window's own coverage ceiling produces +## exactly one tile (compute_tile_grid()'s own degenerate-case doc), so +## `is_multi_tile()` distinguishes "tile set with 1 entry" (still tiling +## machinery, technically) from "genuinely multiple tiles" — the viewer uses +## this to decide whether the tile-set draw path or the ORIGINAL +## single-window draw path is simpler/preferred for a small body (both are +## correct; single-window avoids the extra Node/signal overhead when there's +## only ever going to be one tile). +func is_multi_tile() -> bool: + return _tiles.size() > 1 + + +func get_tile_count() -> int: + return _tiles.size() + + +## True if every tile currently has an arrived window — the viewer/legend +## chrome can use this to know when the mosaic is "complete" vs. still +## progressively filling in. +func is_fully_arrived() -> bool: + if _tiles.is_empty(): + return false + for tile: Dictionary in _tiles: + if tile["window"] == null: + return false + return true diff --git a/client/ui/implant/apps/atlas/atlas_window_viewer.gd b/client/ui/implant/apps/atlas/atlas_window_viewer.gd index 9ae168e80..29103023a 100644 --- a/client/ui/implant/apps/atlas/atlas_window_viewer.gd +++ b/client/ui/implant/apps/atlas/atlas_window_viewer.gd @@ -1,62 +1,72 @@ class_name AtlasWindowViewer extends Control -## Regional district-resolution window viewer (T-1138, D-226 T-1124 -## amendment). Entered via a click-through from the planetary AtlasViewer -## (the 2026-07-21 §5 entry revision — NOT a zoom-threshold LOD swap). -## Renders a DistrictWindowLayer composite: morphology base layer lightness- -## modulated by elev_q, three switchable climate/vegetation overlays, and an -## always-on glaciation ice-tint modifier (drawing itself is -## AtlasWindowOverlay's job — this Control owns input, request orchestration, -## chrome, and the pan/zoom transform). +## Continuous cursor-anchored zoom ladder viewer (T-1153, superseding T-1138's +## click-through-only entry per the D-226 T-1143-rulings amendment — see +## enter_orbital()'s own doc). This IS the "regional" nav entry (T-1152 +## client half): the whole ladder from the canonical orbital frame (Region +## rung) down to District/Quarter lives in ONE screen/Control, not a separate +## planetary viewer + windowed drill-down. Renders a DistrictWindowLayer +## composite (or, at the orbital rest state on a large body, a MOSAIC of +## several — see `_tile_mode`/AtlasWindowTileSet, live round 3) at whichever +## rung is currently held: morphology base layer lightness-modulated by +## elev_q, three switchable climate/vegetation overlays, an always-on +## glaciation ice-tint modifier (drawing is AtlasWindowOverlay's job — this +## Control owns input, request orchestration, chrome, pan/zoom). One +## colorizer family renders every rung unchanged (design doc §6). ## -## Design notes (mirroring AtlasViewer's own split, D-226 §5): +## Design notes (mirroring AtlasViewer's own split, D-226 §5, extended T-1153): ## - _canvas (Node2D) holds AtlasWindowOverlay; pan = _canvas.position, zoom -## = _canvas.scale — the SAME transform idiom as the planetary viewer. -## - Zoom is ALWAYS client-side on the already-held composite (§5: "the -## composite is a texture the client zooms client-side... from already- -## held data") — it NEVER triggers a re-request. Only a pan past the held -## window's edge does (§4/§5). -## - _window_request (atlas_window_request.gd) owns the cache/debounce/ -## retry — this Control decides WHEN to call it (pan-edge detection, -## entry), never talks to SimBridge directly itself. +## = _canvas.scale. +## - Zoom is client-side on the ALREADY-HELD composite frame-to-frame, but +## CONTINUOUS AND UNCLAMPED ACROSS RUNGS (D-013): crossing a rung's +## coverage ceiling (§5, AtlasWindowGeometry.select_rung()) fires a +## background request for the new granularity while the OLD composite +## keeps drawing — progressive refinement, no blank frame (§6). A pan +## past the held window's edge re-requests the SAME rung at a new center. +## - Zooming fully out snaps to the CANONICAL planetary frame (Jeroen's HARD +## condition, see _maybe_reset_to_canonical_frame()) — on a body needing +## tiling, re-enters `_tile_mode` (live round 3, design doc §4). +## - _window_request (atlas_window_request.gd) owns the single-window +## cache/debounce/retry; _tile_set (atlas_window_tile_set.gd) owns N of +## those for the tiled rest state — this Control decides WHICH is active. ## -## Navigation (T-1145 item 2 — Jeroen's input-model ruling: LMB-drag panning -## BREAKS click semantics with map objects, so it is removed entirely; clicks -## are reserved for map objects, which will land in this window later, e.g. -## settlements): +## Navigation (Jeroen's input-model ruling: LMB-drag panning BREAKS click +## semantics with future map objects, so it's removed entirely): ## WASD / arrow keys continuous pan, held (frame-rate independent, _process) -## Edge scrolling cursor within EDGE_SCROLL_MARGIN_PX of a viewport -## edge pans toward it (suppressed over UI / unfocused) -## Mouse wheel zoom the held composite (client-side only, never refetches) -## Esc back to the planetary view +## Edge scrolling cursor within EDGE_SCROLL_MARGIN_PX pans toward it (suppressed over UI / unfocused) +## Mouse wheel cursor-anchored zoom; crosses rungs continuously (T-1153) +## Esc back (nav.pop()) signal back_pressed const PANEL_MARGIN: float = 16.0 const OVERLAY_BAR_HEADER_RESERVE: float = 360.0 -const MIN_ZOOM: float = 0.5 -const MAX_ZOOM: float = 8.0 +## T-1153: MIN_ZOOM/MAX_ZOOM are a wide safety clamp on the raw display +## multiplier, NOT a rung boundary — wheel zoom is CONTINUOUS and UNCLAMPED +## ACROSS RUNGS (D-013): crossing a rung's coverage ceiling +## (AtlasWindowGeometry.select_rung()) re-requests a DIFFERENT granularity at +## the SAME apparent screen extent, never clamping _view_zoom itself. +## set_view() (T-1120 capture API) clamps to this same range independently. +## MIN_ZOOM must stay low enough that fit_window_view()'s COVER fit for +## enter_orbital()'s largest legal `n` is never itself clamped (would +## silently show LESS than the whole body). 0.0005 covers a ~120,000 km- +## radius body at a 3840px 4K viewport. +const MIN_ZOOM: float = 0.0005 +const MAX_ZOOM: float = 64.0 const ZOOM_STEP: float = 1.15 ## T-1145 item 2: WASD/arrow-key continuous pan speed, in CANVAS px/s at -## zoom=1.0 — the ACTUAL screen-space pan rate is this value times the -## CURRENT _view_zoom (see _process()'s pan tick), so panning covers the -## same amount of TERRAIN per second regardless of zoom level. A fixed -## SCREEN-px/s rate (no zoom scaling) would feel painfully slow zoomed in -## (each screen pixel is a fraction of a district) and uncontrollably fast -## zoomed out — scaling by zoom keeps the "how much world passes per -## second" feel constant, matching the ticket's "speed in screen px/s -## scaled by zoom" wording. ~6 districts/s at zoom=1.0 (96/16) — brisk -## enough to cross a default n=32 window in ~5s, not a crawl. +## zoom=1.0 — actual screen-space rate is this times CURRENT _view_zoom, so +## panning covers the same TERRAIN per second regardless of zoom level. +## ~6 districts/s at zoom=1.0 (96/16) — brisk, not a crawl. const PAN_SPEED_CANVAS_PX_S: float = 96.0 ## T-1145 item 2: cursor-to-edge distance (px) that triggers edge-scroll — -## Jeroen's own number ("~24px"). +## Jeroen's own number ("~24px"). Uses the SAME speed as WASD (one pan feel, +## two triggers) — no separate constant, _process() reads PAN_SPEED_CANVAS_PX_S for both. const EDGE_SCROLL_MARGIN_PX: float = 24.0 -## Edge-scroll uses the SAME speed as WASD (one pan feel, two triggers) — -## no separate constant, _process() reads PAN_SPEED_CANVAS_PX_S for both. ## Pixel size of one district cell at zoom=1.0 — a fixed on-screen scale ## (unlike AtlasViewer's heightmap, there is no source texture dictating a @@ -69,11 +79,16 @@ const COLOR_BG: Color = Color("#0d1117") ## Border-fade target (§5 "what renders during the wait"): the underlying ## whole-body heightmap's own background tint, so the newly-exposed edge ## reads as "real data seen through", not a placeholder block. Reuses -## AtlasViewer's own COLOR_HEIGHTMAP_TINT-adjacent dim value rather than -## inventing a new one — this IS a dimmer/less-certain read of the same -## planetary data, not a different visual language. +## AtlasViewer's own COLOR_HEIGHTMAP_TINT-adjacent dim value — a dimmer/ +## less-certain read of the same planetary data, not a different visual language. const COLOR_BORDER_FADE: Color = Color(0.20, 0.24, 0.30, 0.55) +## T-1153/R6: pending-refinement wash — border-fade's referent repointed to +## "the previous derived composite at this position" for a rung-crossing zoom +## (real data stays on screen, unlike the no-composite-yet case above). Same +## hue, lighter alpha — hints something sharper is arriving, not that the view is wrong. +const COLOR_PENDING_REFINEMENT_WASH: Color = Color(0.20, 0.24, 0.30, 0.12) + ## D-243: 2,048 m per district side. const DISTRICT_M: float = 2048.0 @@ -82,6 +97,8 @@ const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_re # atlas_descend_geometry.gd instead — it already owns district_extent()). const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") +# T-1153: orbital rest-state mosaic orchestration. +const AtlasWindowTileSet := preload("res://ui/implant/apps/atlas/atlas_window_tile_set.gd") # ── Overlay definitions (T-1138 — reuses atlas_overlay_bar.gd/ # atlas_legend_panel.gd's existing duck-typed viewer interface: both call @@ -119,6 +136,11 @@ var _implant_theme = null var _window: Variant = null # current DistrictWindowLayer Dictionary, or null while waiting var _held_center: Vector2i = Vector2i.ZERO var _held_n: int = 32 +## T-1153: the granularity_v2 tag this viewer is currently HOLDING (the +## last-adopted _window's own rung) — distinct from +## _window_request.get_granularity_v2() (most recently REQUESTED, may be a +## different rung already in flight). Defaults to District (see enter()'s doc). +var _held_granularity_v2: String = "District" # ── Pan/zoom state ───────────────────────────────────────────────────────── var _view_offset: Vector2 = Vector2.ZERO @@ -160,6 +182,13 @@ var _screen_header: ImplantHeader = null var _overlay_bar = null var _legend_panel = null var _window_request = null # AtlasWindowRequest +var _tile_set = null # AtlasWindowTileSet (T-1153, live round 3) + +## T-1153 (design doc §4): true while showing the orbital rest state as a +## MULTI-WINDOW MOSAIC (AtlasWindowTileSet) instead of the single held +## composite (`_window`). Set by `_enter_tile_mode()`; cleared the moment +## `_maybe_reselect_rung()` crosses OUT of Region. +var _tile_mode: bool = false func _ready() -> void: @@ -189,6 +218,11 @@ func _ready() -> void: add_child(_window_request) _window_request.window_ready.connect(_on_window_ready) + _tile_set = AtlasWindowTileSet.new(self) + _tile_set.name = "TileSet" + add_child(_tile_set) + _tile_set.tile_ready.connect(_on_tile_ready) + _build_screen_header() _build_overlay_bar() _build_legend_panel() @@ -201,50 +235,122 @@ func _exit_tree() -> void: SimBridge.atlas_layers_received.disconnect(_on_atlas_layers_received) -## Enter the window screen centered on `district_center` (a DistrictPos- -## equivalent Vector2i, from the planetary click-through's derived position — -## §5's "pan center read as click point"). n defaults to the client's -## interactive default (32), half the server's hard cap. +## Enter the window screen centered on `district_center` at District +## granularity. n defaults to 32. Thin wrapper over _enter_at_rung() +## (T-1153); survives as a direct-call test entry. ## -## T-1142: `district_center` is canonicalized (wrap column / clamp row) -## BEFORE it becomes `_held_center` or reaches the request — matching the -## server's own normalize_window_center() exactly, so the request the client -## sends and the echo the server sends back describe the SAME canonical -## point from the first round-trip (never a raw-vs-normalized mismatch that -## would fail the §2 staleness echo check). Also fits-and-centers the view -## instead of the old zoom=1/offset=ZERO reset (Jeroen's second finding: an -## n=32 composite is 512px native, a postage stamp unfitted in a real -## viewport). +## T-1142: `district_center` is canonicalized BEFORE it becomes +## `_held_center` — matching the server's normalize_window_center(). func enter( body: Dictionary, system: Dictionary, district_center: Vector2i, n: int = AtlasWindowRequest.DISTRICT_WINDOW_DEFAULT_N ) -> void: + var radius_km: float = float(body.get("body_radius_km", 0.0)) + var canonical_center: Vector2i = AtlasDescendGeometry.canonicalize_district_center( + district_center, radius_km + ) + _enter_at_rung(body, system, canonical_center, n, AtlasWindowRequest.GRANULARITY_V2_DISTRICT) + + +## T-1153: enter the ladder at its TOP REST STATE — the canonical orbital +## frame (Jeroen's HARD condition: whole body fitted to canvas, centered at +## the canonical origin). The "regional" nav entry point (T-1152 client +## half): the player lands on a fully-derived Region-rung view of the whole +## body, then wheel-zoom descends CONTINUOUSLY from there. Canonical origin +## = district (0,0), same quantity is_fully_zoomed_out()/ +## _maybe_reset_to_canonical_frame() test against. No-radius bodies fall +## back to the District-rung default window. +## +## **Live round 3 (design doc §4): the rest state must TILE.** A single +## wire-capped Region window covers only a fraction of a real body's +## circumference. Once `compute_tile_grid()` returns MORE than one tile, +## entry goes through `_enter_tile_mode()` instead of `_enter_at_rung()`. +func enter_orbital(body: Dictionary, system: Dictionary) -> void: + var radius_km: float = float(body.get("body_radius_km", 0.0)) + if radius_km <= 0.0: + _enter_at_rung( + body, system, Vector2i.ZERO, + AtlasWindowRequest.DISTRICT_WINDOW_DEFAULT_N, + AtlasWindowRequest.GRANULARITY_V2_DISTRICT + ) + return + var tiles: Array = AtlasWindowGeometry.compute_tile_grid(radius_km) + if tiles.size() > 1: + _enter_tile_mode(body, system, radius_km) + return + var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) + var n: int = int(extent["cols"]) + _enter_at_rung(body, system, Vector2i.ZERO, n, AtlasWindowRequest.GRANULARITY_V2_REGION) + + +## T-1153, live round 3: the TILE-MODE entry path — same reset discipline as +## `_enter_at_rung()` but populates `_tile_set` instead of `_window_request`. +## `_held_n` carries the WHOLE body's extent unclamped (each TILE clamps +## its own TILE_N-sized request independently). +func _enter_tile_mode(body: Dictionary, system: Dictionary, radius_km: float) -> void: + var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) + var n: int = int(extent["cols"]) _body = body _system = system - var radius_km: float = float(_body.get("body_radius_km", 0.0)) - _held_center = AtlasDescendGeometry.canonicalize_district_center(district_center, radius_km) + _held_center = Vector2i.ZERO _held_n = n + _held_granularity_v2 = AtlasWindowRequest.GRANULARITY_V2_REGION + _tile_mode = true _window = null _user_adjusted = false _awaiting_first_window = true _fit_and_center() _window_request.reset() - _window_request.request_now(_dict_str(_body, "body_id", ""), _held_center, n) + _tile_set.enter(_dict_str(_body, "body_id", ""), radius_km) _refresh_screen_header() + _legend_panel.refresh() + grab_focus() + queue_redraw() + _overlay_node.queue_redraw() + + +## Shared entry path for enter()/enter_orbital() (T-1153) — `district_center` +## must already be canonicalized by the caller. Resets every piece of +## held/request state for a fresh descent, plus _held_granularity_v2. +## +## **C1 clamp-mirror, one layer up:** `n` MUST be clamped via +## `_clamp_window_n_mirror_v2()` BEFORE it becomes `_held_n` — mirroring +## AtlasWindowRequest.request_now()'s own clamp (PR #191 Tyre C1). +func _enter_at_rung( + body: Dictionary, + system: Dictionary, + district_center: Vector2i, + n: int, + granularity_v2: String +) -> void: + var clamped_n: int = AtlasWindowRequest._clamp_window_n_mirror_v2(n, granularity_v2) + _body = body + _system = system + _held_center = district_center + _held_n = clamped_n + _held_granularity_v2 = granularity_v2 + _tile_mode = false # T-1153 live round 3: a single-window entry always leaves tile mode + _window = null + _user_adjusted = false + _awaiting_first_window = true + _fit_and_center() + _window_request.reset() + _window_request.request_now( + _dict_str(_body, "body_id", ""), _held_center, clamped_n, granularity_v2 + ) + _refresh_screen_header() + _legend_panel.refresh() grab_focus() queue_redraw() _overlay_node.queue_redraw() ## T-1142: fit-and-center — applies AtlasWindowGeometry.fit_window_view()'s -## zoom/offset, then re-clamps the offset to the pole wall (a freshly-fitted -## view can still need the wall on a tiny body whose row span is shorter than -## the window itself — see atlas_window_geometry.gd's clamp function doc). -## Called from enter(), the FIRST _on_window_ready() after entry, and -## NOTIFICATION_RESIZED — never mid-interaction (guarded by _user_adjusted at -## each call site, not here, since the three callers gate slightly differently). +## zoom/offset, then re-clamps the offset to the pole wall. Called from +## enter(), the FIRST _on_window_ready() after entry, and +## NOTIFICATION_RESIZED — never mid-interaction (guarded by _user_adjusted). func _fit_and_center() -> void: var viewport: Vector2 = get_rect().size if viewport == Vector2.ZERO: @@ -257,11 +363,8 @@ func _fit_and_center() -> void: _apply_transform() -## T-1142: the pole-wall clamp needs the body's rows_half, in whole districts -## — a no-radius body (tiny test body) has no periodicity/pole concept at the -## DistrictPos level (matching canonicalize_district_center()'s own no-radius -## identity disposition), so the wall is a no-op there (rows_half=0, and -## clamp_pan_offset_to_pole_wall() treats <= 0 as "no wall"). +## T-1142: needs the body's rows_half — a no-radius body has no pole +## concept, so the wall is a no-op there (rows_half=0). func _clamp_offset_to_pole_wall(offset: Vector2) -> Vector2: var radius_km: float = float(_body.get("body_radius_km", 0.0)) if radius_km <= 0.0: @@ -277,12 +380,48 @@ func leave() -> void: ## Named get_district_window(), NOT get_window() — Node already defines -## get_window() -> Window (the containing OS window); shadowing it with an -## incompatible return type is a Godot parse error (confirmed the hard way). +## get_window() -> Window; shadowing it with an incompatible type errors. func get_district_window() -> Variant: return _window +## T-1153: true while showing the orbital rest state as a multi-window +## mosaic instead of the single held composite — AtlasWindowOverlay reads +## this to pick a draw path. +func is_tile_mode() -> bool: + return _tile_mode + + +## T-1153: the tile-set orchestrator, for AtlasWindowOverlay's mosaic draw +## path — only meaningful while is_tile_mode() is true. +func get_tile_set() -> Variant: + return _tile_set + + +## Live round 4: currently-HELD reference frame — AtlasWindowOverlay's +## mosaic draw path converts tile centers into canvas-local space via these +## (see AtlasWindowGeometry.district_to_canvas_local()'s own doc). +func get_held_center() -> Vector2i: + return _held_center + + +func get_held_n() -> int: + return _held_n + + +## Currently-HELD rung tag — legend reads this via spacing_for_rung(), +## mirroring _refresh_screen_header()'s own use of the field. +func get_held_granularity_v2() -> String: + return _held_granularity_v2 + + +## Live round 5: current body's radius — mosaic draw needs `cols` for +## nearest_wrap_image()'s wrap resolution. Mirrors the +## `_body.get("body_radius_km", 0.0)` pattern used throughout this file. +func get_body_radius_km() -> float: + return float(_body.get("body_radius_km", 0.0)) + + ## District-cell pixel size at zoom=1.0 — AtlasWindowOverlay reads this ## rather than hardcoding CELL_PIXEL_SIZE itself, so the viewer stays the ## single source of geometry truth (same "viewer owns the transform, overlay @@ -317,34 +456,68 @@ func _on_atlas_layers_received(response: Dictionary) -> void: _window_request.on_response(response) +## T-1153: progressive refinement — the ONE place a new rung's window gets +## adopted, deliberately WITHOUT clearing `_window` first. The OLD composite +## stays drawn until this call — no blank frame, no mode flip (§6) — +## because `_window` only ever gets REPLACED, never nulled, once adopted +## (enter()/_enter_at_rung() null it only at a fresh descent, not a swap). func _on_window_ready(window: Dictionary) -> void: # Only adopt the window if it still matches what THIS viewer is currently # showing — AtlasWindowRequest already filtered by its own last-asked - # (center, n) via the echo (§2), but a cache-hit path can fire - # synchronously from enter() before _held_center is what the signal - # handler expects in a re-entrant call; comparing again here is cheap and - # removes any ordering assumption between enter()'s two calls. + # (center, n, granularity_v2) via the echo (§2/T-1150/T-1152), but a + # cache-hit path can fire synchronously from enter() before _held_center + # is what the signal handler expects in a re-entrant call; comparing + # again here is cheap and removes any ordering assumption between + # enter()'s two calls. granularity_v2 (T-1153) is compared too — a + # district-rung response answering a request that's SINCE moved on to a + # region-rung request (rapid wheel-zoom) must not be adopted just because + # center/n happen to still match. var w_center := _vec_from_center(window.get("center", [0, 0])) var w_n := int(window.get("n", 0)) - if w_center != _held_center or w_n != _held_n: + var w_granularity_v2 := str( + window.get("granularity_v2", AtlasWindowRequest.GRANULARITY_V2_DISTRICT) + ) + if ( + w_center != _held_center + or w_n != _held_n + or w_granularity_v2 != _window_request.get_granularity_v2() + ): return _window = window + _held_granularity_v2 = w_granularity_v2 # T-1142: re-fit on the FIRST composite arrival only (the entry-time fit # may have used a viewport size the layout hadn't settled into yet — this # corrects it once) — never on a later pan-triggered arrival, and never # once the user has manually zoomed/panned (same _user_adjusted guard - # enter()/NOTIFICATION_RESIZED use). + # enter()/NOTIFICATION_RESIZED use). A later rung-swap arrival is + # EXACTLY a "later pan/zoom-triggered arrival" in this sense — it must + # never re-fit either, or a wheel-zoom-triggered rung swap would yank the + # player's view back to a fitted framing mid-gesture. if _awaiting_first_window and not _user_adjusted: _fit_and_center() _awaiting_first_window = false _refresh_screen_header() + _legend_panel.refresh() + queue_redraw() + _overlay_node.queue_redraw() + + +## T-1153 (design doc §4 "progressive... with visible refinement as tiles +## complete"): a SINGLE tile's window arrived — redraw so the overlay's +## mosaic loop picks it up. No acceptance/staleness logic needed here (each +## tile's OWN AtlasWindowRequest already filtered before this signal fired). +func _on_tile_ready(_index: int) -> void: queue_redraw() _overlay_node.queue_redraw() # ============================================================================= -# View transform (mirrors AtlasViewer's own — pan is real, zoom is client-side -# only and NEVER triggers a re-request per §5) +# View transform (mirrors AtlasViewer's own — pan is real; zoom is CURSOR- +# ANCHORED and CONTINUOUS ACROSS RUNGS (T-1153, D-226 T-1143-rulings +# amendment): the held composite is always drawn client-side-zoomed with NO +# re-request, but crossing a rung's spacing threshold fires a NEW request at +# the new granularity in the background (progressive refinement — see +# _maybe_reselect_rung()'s own doc) while the OLD composite stays on screen. # ============================================================================= @@ -355,20 +528,166 @@ func _apply_transform() -> void: _overlay_node.queue_redraw() +## Cursor-anchored zoom (D-013): the CANVAS POINT under the cursor stays +## fixed on screen. Unclamped across rungs (only the wide MIN_ZOOM/MAX_ZOOM +## safety clamp applies); after applying, checks the top rest state. func _zoom_at(mouse_pos: Vector2, factor: float) -> void: var new_zoom: float = clampf(_view_zoom * factor, MIN_ZOOM, MAX_ZOOM) + # Live round 6: once settled at the canonical frame, a continued zoom-OUT + # tick must not drift `_view_zoom` below fit (see the reset's own doc for + # why that caused a request storm). Clamping the ZOOM here — not the + # reset guard — keeps the reset edge-triggered. Zoom-IN is never clamped. + if new_zoom < _view_zoom and _is_at_canonical_frame(): + var fit_zoom: float = _canonical_fit_zoom() + new_zoom = maxf(new_zoom, fit_zoom) if is_equal_approx(new_zoom, _view_zoom): return var local_before: Vector2 = (mouse_pos - _view_offset) / _view_zoom _view_zoom = new_zoom _view_offset = mouse_pos - local_before * _view_zoom _apply_transform() + if _maybe_reset_to_canonical_frame(): + return # the reset already re-fit + re-requested at the top rung + _maybe_reselect_rung() -## Programmatic view control (T-1120 capture-API parity — must survive on -## every viewer this app exposes, per the ticket's explicit note, even one -## that never got user pan/zoom to begin with on the OTHER seam this ticket -## removes it from). +## The world extent (metres) currently displayed across the LARGER viewport +## dimension — the `E` half of the §5 rung-selection rule. Thin wrapper over +## AtlasWindowGeometry.world_extent_m() (a pure function of `_view_zoom`). +func _current_world_extent_m() -> float: + return AtlasWindowGeometry.world_extent_m(CELL_PIXEL_SIZE, _view_zoom, get_rect().size) + + +## §5 rung-selection rule + progressive refinement (T-1153): after a zoom +## step, recompute the legal rung for the NOW-displayed world extent. If it +## differs from what's HELD, request the new granularity centered on the +## CURRENT screen-center (same formula as _maybe_refloat_window()). +## +## **C1 clamp-mirror, a THIRD layer up:** `_held_n` MUST be re-clamped via +## `_clamp_window_n_mirror_v2()` for the TARGET rung — a stale large +## `_held_n` desyncs `_on_window_ready()`'s staleness check. +## +## Progressive refinement: does NOT touch `_window`/`_held_granularity_v2` — +## the OLD composite keeps drawing until _on_window_ready() adopts the new +## one (§6 "no mode flip"). Live round 5: this lag is what made +## `_maybe_reset_to_canonical_frame()`'s OLD guard misfire. +func _maybe_reselect_rung() -> void: + if _held_n <= 0: + return + var world_extent_m: float = _current_world_extent_m() + var canvas_px: float = maxf(get_rect().size.x, get_rect().size.y) + var target_rung: String = AtlasWindowGeometry.select_rung(world_extent_m, canvas_px) + + # T-1153, live round 3: tile mode is TOP-of-the-ladder only. Staying at + # Region means staying tiled; crossing OUT of Region falls through to + # the single-window path below, flipping `_tile_mode` off. + var leaving_tile_mode := false + if _tile_mode: + if target_rung == AtlasWindowRequest.GRANULARITY_V2_REGION: + return + _tile_mode = false + leaving_tile_mode = true + + # `leaving_tile_mode` FORCES the request through even if + # `_window_request`'s own STALE granularity_v2 (untouched while tiled) + # happens to already equal `target_rung` by coincidence. + if not leaving_tile_mode and target_rung == _window_request.get_granularity_v2(): + return # already requesting (or holding) the rung this extent calls for + var new_center: Vector2i = _screen_center_district() + var clamped_n: int = AtlasWindowRequest._clamp_window_n_mirror_v2(_held_n, target_rung) + _held_center = new_center + _held_n = clamped_n + # Live round 4: `_view_offset` must be recomputed the instant `held_n` + # changes — see recompute_offset_for_held_n_change()'s own doc for why. + # Anchors on the SAME screen center just used above, preserving §6 "no + # layout jump" across the crossing. + _view_offset = AtlasWindowGeometry.recompute_offset_for_held_n_change( + size * 0.5, _view_zoom, clamped_n, CELL_PIXEL_SIZE + ) + _apply_transform() + _window_request.request_debounced( + _dict_str(_body, "body_id", ""), new_center, clamped_n, target_rung + ) + + +## The DistrictPos the current screen center maps to, in RAW absolute +## district space. Thin wrapper over screen_center_to_district() so +## pan-edge/rung-reselect refetch share one formula. +func _screen_center_district() -> Vector2i: + var raw: Vector2i = AtlasWindowGeometry.screen_center_to_district( + size, _view_offset, _view_zoom, CELL_PIXEL_SIZE, _held_center, _held_n + ) + var radius_km: float = float(_body.get("body_radius_km", 0.0)) + return AtlasDescendGeometry.canonicalize_district_center(raw, radius_km) + + +## Live round 6: whether the body needs mosaic tiling — mirrors +## enter_orbital()'s own dispatch condition so entry/predicate/guard agree. +func _canonical_tile_mode(radius_km: float) -> bool: + return AtlasWindowGeometry.compute_tile_grid(radius_km).size() > 1 + + +## Live round 6: the fit zoom enter_orbital() lands on for the CURRENT +## body/viewport — read by both the canonical predicate and _zoom_at()'s floor. +func _canonical_fit_zoom() -> float: + var radius_km: float = float(_body.get("body_radius_km", 0.0)) + if radius_km <= 0.0: + return _view_zoom # no-radius body — no canonical frame concept, floor is a no-op + var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) + var canonical_n: int = int(extent["cols"]) + var fit: Dictionary = AtlasWindowGeometry.fit_window_view( + get_rect().size, canonical_n, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM + ) + return float(fit["zoom"]) + + +## Live round 6: true when the CURRENT view EXACTLY matches the canonical +## frame — center/mode/granularity plus `_view_zoom` at fit (float epsilon). +func _is_at_canonical_frame() -> bool: + var radius_km: float = float(_body.get("body_radius_km", 0.0)) + if radius_km <= 0.0: + return false + return ( + _held_center == Vector2i.ZERO + and _held_granularity_v2 == AtlasWindowRequest.GRANULARITY_V2_REGION + and _tile_mode == _canonical_tile_mode(radius_km) + and is_equal_approx(_view_zoom, _canonical_fit_zoom()) + ) + + +## Jeroen's HARD condition: "a full zoom-out resets to the original +## canonical planetary frame and location." EDGE-triggered (live round 6): +## fires only on the transition INTO fully-zoomed-out from non-canonical. +## +## **Live round 5 fix:** the old guard checked only +## `_held_center`/`_held_granularity_v2` — a LAGGING field (updated only on +## response adoption). A TILING body's granularity stays stale "Region" +## after zooming IN leaves tile mode, misreading "already canonical" and +## never resetting. Fixed by also requiring `is_tile_mode()` to match. +## +## **Live round 6 fix (round 5's SECOND fix overshot into a storm):** a +## per-tick zoom-equality check on THIS guard made it LEVEL-triggered — +## continued zoom-out kept nudging `_view_zoom` below fit, so the guard +## read "not already there" every tick and `enter_orbital()` fired +## repeatedly: tile set torn down/recreated each time, orphaning in-flight +## responses (nothing held → black), flooding the server (889/897 wire +## responses in one zoom-out phase). Fixed by moving the zoom-drift concern +## to `_zoom_at()`'s own zoom floor instead — this guard's +## mode/center/granularity check alone stays edge-triggered. +func _maybe_reset_to_canonical_frame() -> bool: + var radius_km: float = float(_body.get("body_radius_km", 0.0)) + if radius_km <= 0.0: + return false # no-radius body — no canonical frame concept (matches enter_orbital()'s own guard) + var world_extent_m: float = _current_world_extent_m() + if not AtlasWindowGeometry.is_fully_zoomed_out(world_extent_m, radius_km): + return false + if _is_at_canonical_frame(): + return false # already at the canonical frame — don't fight a zoom-in-from-the-top gesture + enter_orbital(_body, _system) + return true + + +## Programmatic view control (T-1120 capture-API parity). func get_view_zoom() -> float: return _view_zoom @@ -389,38 +708,24 @@ func set_view(zoom: float, offset: Vector2) -> void: # ============================================================================= -## After a pan delta (T-1145: WASD/edge-scroll, called from _process()'s pan -## tick every frame the player is actively panning), check whether the +## After a pan delta (T-1145: WASD/edge-scroll), check whether the ## screen-center now maps to a DistrictPos outside the held window's extent -## — if so, float a NEW window centered on that point (§5 "windows float on -## the pan center... not grid-snapped") via the debounced request path. +## — if so, float a NEW window centered on that point via the debounced path. ## -## T-1142 (item 6a): the edge-crossing decision below is computed in RAW -## absolute district space (un-wrapped, un-clamped) — that is the correct -## space for "has the pan carried the view past the held window's edge", -## since the held window's own local bounds are relative to _held_center as -## it was BEFORE this pan. Only the FINAL new_center that becomes the next -## _held_center / the next request is canonicalized (wrap column, clamp -## row) — matching the server's own normalize_window_center() and keeping -## the client's echo-comparison and cache key on the same canonical form the -## server uses (see canonicalize_district_center()'s doc for why this must -## match bit-for-bit). A pan that straddles the antimeridian therefore still -## floats correctly: the pre-canonicalization abs_col can be e.g. -3 or -## district_cols+5, the edge-crossing math treats that as a normal delta from -## the old center, and only the resulting new_center gets wrapped into range -## before it's requested/cached. +## T-1142 (item 6a): the edge-crossing decision is computed in RAW absolute +## district space — only the FINAL new_center is canonicalized, matching the +## server's normalize_window_center(). func _maybe_refloat_window() -> void: if _held_n <= 0: return - var screen_center: Vector2 = size * 0.5 - var canvas_pt: Vector2 = (screen_center - _view_offset) / _view_zoom - var cell: Vector2 = canvas_pt / CELL_PIXEL_SIZE - # cell is in [0, _held_n) local window space when centered — half-window - # offset from _held_center converts back to absolute district space. - var half: float = float(_held_n) / 2.0 - var abs_col: float = float(_held_center.x) - half + cell.x - var abs_row: float = float(_held_center.y) - half + cell.y - var raw_new_center := Vector2i(roundi(abs_col), roundi(abs_row)) + if _tile_mode: + # T-1153: the tile set already covers the WHOLE body — no "edge" to + # cross while tiled. _apply_pan_delta() still moves _view_offset; + # this only skips the single-window re-float below. + return + var raw_new_center: Vector2i = AtlasWindowGeometry.screen_center_to_district( + size, _view_offset, _view_zoom, CELL_PIXEL_SIZE, _held_center, _held_n + ) if raw_new_center == _held_center: return # Edge-crossing check: only re-request if the screen-center point has @@ -429,8 +734,9 @@ func _maybe_refloat_window() -> void: # ticked over by one cell near a boundary) must not spam a request every # frame. §4: "re-requests only when a pan carries the view past the held # window's edge." - var local_col: float = abs_col - float(_held_center.x) + half - var local_row: float = abs_row - float(_held_center.y) + half + var half: float = float(_held_n) / 2.0 + var local_col: float = float(raw_new_center.x - _held_center.x) + half + var local_row: float = float(raw_new_center.y - _held_center.y) + half var inside: bool = ( local_col >= 0.0 and local_col < float(_held_n) @@ -444,7 +750,13 @@ func _maybe_refloat_window() -> void: raw_new_center, radius_km ) _held_center = new_center - _window_request.request_debounced(_dict_str(_body, "body_id", ""), new_center, _held_n) + # T-1153: pass the CURRENTLY HELD rung — panning must re-request at the + # SAME granularity it's already showing, never silently reset to the + # request object's District default (that default exists for callers with + # no rung concept of their own; this viewer always has one). + _window_request.request_debounced( + _dict_str(_body, "body_id", ""), new_center, _held_n, _held_granularity_v2 + ) # ============================================================================= @@ -454,25 +766,45 @@ func _maybe_refloat_window() -> void: func _draw() -> void: draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG) + if _tile_mode: + # T-1153: single-window border-fade/pending-wash don't apply to a + # mosaic — AtlasWindowOverlay's tile draw only paints arrived tiles; + # an unarrived one is an honest gap over COLOR_BG, no separate fade. + return if _window == null: # §5 "what renders during the wait": a border-fade to the underlying # whole-body context rather than black/a spinner. This viewer has no - # resident whole-body texture of its own (that lives on AtlasViewer, - # which this screen has navigated away from) — the honest available + # resident whole-body texture of its own — the honest available # substitute is a dim fade wash over the held composite's last-known - # extent, reusing _gen_pending_indicator (via the request object's own - # is_pending()) for the "still working" cue rather than new dressing. + # extent, reusing the request object's own is_pending() for the + # "still working" cue rather than new dressing. _draw_border_fade() + elif _window_request and _window_request.is_pending(): + # T-1153/R6: the border-fade's REFERENT repointed — a rung-crossing + # zoom (progressive refinement) leaves `_window` non-null (the OLD + # composite is still the thing on screen, drawn by AtlasWindowOverlay + # as always) while a NEW rung's request is in flight underneath it. + # R6's ruling: "the mechanism survives; its target must be repointed + # to 'the previous derived composite at this position'" — exactly + # this case. A lighter pending wash (not the full opaque fade the + # no-composite-at-all case uses, since there IS real data showing + # through here, not emptiness) signals "sharper detail incoming" + # without implying the current view is stale or wrong. + _draw_pending_refinement_wash() func _draw_border_fade() -> void: - if not _window_request or not _window_request.is_pending(): - return var extent: float = float(_held_n) * CELL_PIXEL_SIZE * _view_zoom var top_left: Vector2 = _view_offset draw_rect(Rect2(top_left, Vector2(extent, extent)), COLOR_BORDER_FADE) +func _draw_pending_refinement_wash() -> void: + var extent: float = float(_held_n) * CELL_PIXEL_SIZE * _view_zoom + var top_left: Vector2 = _view_offset + draw_rect(Rect2(top_left, Vector2(extent, extent)), COLOR_PENDING_REFINEMENT_WASH) + + func _build_screen_header() -> void: _screen_header = ImplantHeader.new() _screen_header.position = Vector2(PANEL_MARGIN, 16.0) @@ -483,32 +815,26 @@ func _build_screen_header() -> void: _screen_header.apply_implant_theme(_implant_theme) -## D-169/D-170 implant chrome (§5): location label (body name + coordinate, -## T-1142 — see _location_label()) + extent-in-real-units subtitle, e.g. -## "4.1 x 4.1 km . 2.0 km/cell". +## D-169/D-170 implant chrome (§5) — title/subtitle text built by the pure +## AtlasWindowGeometry.screen_header_content() (T-1153: moved there for +## file-length; the "continuous metres-per-pixel readout, never a discrete +## mode label" rationale lives on that function's own doc now). func _refresh_screen_header() -> void: if _screen_header == null: return - var location_label: String = _location_label() - var extent_km: float = float(_held_n) * DISTRICT_M / 1000.0 - var extent_line: String = "%.1f x %.1f km · %.1f km/cell" % [ - extent_km, extent_km, DISTRICT_M / 1000.0 - ] - var title: String = "REGIONAL — %s" % location_label.to_upper() - _screen_header.set_content(title, extent_line) + var content: Dictionary = AtlasWindowGeometry.screen_header_content( + _dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—")), + _held_center, _held_n, _held_granularity_v2, DISTRICT_M + ) + _screen_header.set_content(content["title"], content["subtitle"]) -## Body name + coordinate label (T-1142: pulls the CHEAP half of T-1141 -## forward — the body's proper name was already sitting unused on _body, -## passed through the whole descend chain since T-1138, but this header never -## read it, showing bare "district (col, row)" with no indication of WHICH -## body the player is looking at. T-1141 keeps only the harder half: nearest- -## settlement proximity join (the window carries no settlement data of its -## own — that lives on the planetary gen_l3_settlements overlay, a different -## screen/dataset — a real follow-up, not a silently-guessed one). +## Thin wrapper over AtlasWindowGeometry.location_label() (T-1153: moved +## there for file-length) — kept as a method since it's directly tested. func _location_label() -> String: - var body_name: String = _dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—")) - return "%s — (%d, %d)" % [body_name, _held_center.x, _held_center.y] + return AtlasWindowGeometry.location_label( + _dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—")), _held_center + ) # ============================================================================= @@ -516,27 +842,18 @@ func _location_label() -> String: # ============================================================================= -## No city panel / sidebar in this mode (yet) — the window carries no -## settlement join of its own (see _location_label's doc), so there is -## nothing to hit-test against and this always reads false. Wired into -## _gui_input exactly where AtlasViewer's own _is_over_ui is (same guard -## shape) so a future sidebar addition only needs to change THIS function's -## body, not every call site. +## No city panel / sidebar in this mode (yet) — nothing to hit-test against, +## so this always reads false. Wired into _gui_input exactly where +## AtlasViewer's own _is_over_ui is, so a future sidebar addition only +## needs to change THIS function's body. func _is_over_ui(_pos: Vector2) -> bool: return false -## T-1145 item 2: LMB-drag panning is GONE (Jeroen's ruling — drag broke click -## semantics with map objects; clicks are reserved for future map objects, -## e.g. settlements). What remains: wheel zoom (unchanged) and tracking the -## local mouse position for edge-scroll (_process() reads _last_mouse_pos — -## it has no InputEvent of its own to read a live position from). WASD/arrow -## panning does NOT go through _gui_input at all — it is a HELD-key, -## continuous, frame-rate-independent pan polled every frame in _process() -## via Input.is_action_pressed()-equivalent raw key checks (Input.is_key_pressed(), -## since WASD has no project-level Input Map action of its own in this -## screen's remit — see _process()'s own doc for why raw physical-keycode -## polling is deliberate here, not a new InputMap action). +## T-1145 item 2: LMB-drag panning is GONE (drag broke click semantics with +## map objects). What remains: wheel zoom and tracking mouse position for +## edge-scroll. WASD/arrow panning does NOT go through _gui_input — it's a +## HELD-key pan polled every frame in _process(). func _gui_input(event: InputEvent) -> void: if event is InputEventKey and event.pressed and not event.is_echo(): _handle_key(event as InputEventKey) @@ -565,19 +882,9 @@ func _handle_key(event: InputEventKey) -> void: back_pressed.emit() -## T-1145 item 2: continuous WASD/arrow-key pan + edge-scroll, both applied -## here (not _gui_input) because both are HELD-state effects (keys held down, -## cursor lingering near an edge), not discrete input events — _process() -## polls held state every frame and hands the resulting direction + this -## frame's delta to _apply_pan_delta() (split out for testability — a gdUnit -## test drives _apply_pan_delta(direction, delta) directly with a -## deterministic direction/delta instead of needing to fake Godot's global -## Input singleton reporting a key held, which is what testing THIS -## function's own Input.is_key_pressed() polling would require). Skips -## entirely while this Control is hidden (the screen is not the active -## nav-stack entry) — no wasted per-frame work for an invisible viewer, and -## no phantom panning if some other code path leaves this node in the tree -## but not shown. +## T-1145 item 2: continuous WASD/arrow-key pan + edge-scroll, both HELD-state +## effects polled every frame, handed to _apply_pan_delta() (split out for +## testability). Skips while hidden (screen not the active nav-stack entry). func _process(delta: float) -> void: if not visible: return @@ -589,15 +896,11 @@ func _process(delta: float) -> void: _apply_pan_delta(direction, delta) -## The actual pan-tick state mutation, given an ALREADY-DECIDED (but not yet -## normalized) direction and this frame's delta — frame-rate independent -## (motion scales by `delta`, so the same speed at 30fps or 144fps), zoom- -## scaled (PAN_SPEED_CANVAS_PX_S * _view_zoom — see that constant's own doc -## for why), and pole-wall clamped (T-1142, unchanged mechanism, just fed by -## a different input source now). Sets _user_adjusted (T-1145: "WASD/edge/ -## zoom all set _user_adjusted") and triggers the SAME pan-edge refetch check -## (§4) drag used to. Split from _process() specifically so a test can call -## this directly with a synthetic direction/delta — see _process()'s own doc. +## The actual pan-tick state mutation, given an ALREADY-DECIDED direction and +## this frame's delta — frame-rate independent, zoom-scaled +## (PAN_SPEED_CANVAS_PX_S * _view_zoom), pole-wall clamped (T-1142). Sets +## _user_adjusted and triggers the pan-edge refetch (§4). Split from +## _process() so a test can call it directly with a synthetic direction/delta. func _apply_pan_delta(direction: Vector2, delta: float) -> void: var normalized: Vector2 = direction.normalized() # diagonal isn't faster than a single axis _user_adjusted = true @@ -608,84 +911,25 @@ func _apply_pan_delta(direction: Vector2, delta: float) -> void: ## WASD + arrow keys, read via Input.is_key_pressed() on the PHYSICAL keycode -## (not an InputMap action): W/S/A/D on this project's global InputMap are -## already bound to move_north/move_south/move_east/move_west (gameplay -## movement, D-054 mouse-relative facing) — reusing those actions here would -## make holding W simultaneously pan this map AND queue a gameplay move -## command server-side the moment this implant screen closes back to -## gameplay (InputMapper polls Input.is_action_pressed() unconditionally, -## with no implant-occlusion guard — confirmed by reading input_mapper.gd -## directly, a genuine pre-existing gap outside this ticket's scope, not -## introduced here). Reading the raw physical keycode instead of the shared -## action name means this screen's WASD use is fully independent of -## whatever the gameplay action happens to be bound to — same key, two -## UNRELATED consumers, neither needs to know about the other. Arrow keys -## have no InputMap action bound at all (confirmed by grep across -## project.godot's [input] section), so they're conflict-free either way. -## Returns a raw (non-normalized) direction — the caller normalizes once -## after adding the edge-scroll contribution, so N+E doesn't move faster -## than N alone. +## (not an InputMap action) — see AtlasWindowGeometry.held_pan_direction()'s +## doc for the full W/S/A/D-vs-gameplay-movement rationale (moved there +## T-1153 for file-length/testability, unchanged behavior). func _held_pan_direction() -> Vector2: - var direction := Vector2.ZERO - if Input.is_key_pressed(KEY_W) or Input.is_key_pressed(KEY_UP): - direction.y -= 1.0 - if Input.is_key_pressed(KEY_S) or Input.is_key_pressed(KEY_DOWN): - direction.y += 1.0 - if Input.is_key_pressed(KEY_A) or Input.is_key_pressed(KEY_LEFT): - direction.x -= 1.0 - if Input.is_key_pressed(KEY_D) or Input.is_key_pressed(KEY_RIGHT): - direction.x += 1.0 - return direction + return AtlasWindowGeometry.held_pan_direction() -## T-1145 item 2: edge-scroll is suppressed (a) while the cursor is over UI -## (_is_over_ui() — the SAME helper the click-era _gui_input guard used, per -## the ticket's explicit "reuse _is_over_ui" instruction) and (b) while the -## application window itself lacks OS focus (_app_has_focus — otherwise a -## background window with the cursor left resting near its edge from a -## previous session would silently pan while the player is doing something -## else entirely; "if detectable" per the ticket, and Godot's -## NOTIFICATION_APPLICATION_FOCUS_OUT/IN make it directly detectable, see -## _notification()). +## T-1145 item 2: edge-scroll suppression — see +## AtlasWindowGeometry.is_cursor_edge_scrolling()'s doc (moved there T-1153). func _is_cursor_edge_scrolling() -> bool: - if not _app_has_focus: - return false - if _is_over_ui(_last_mouse_pos): - return false - var sz: Vector2 = size - if sz.x <= 0.0 or sz.y <= 0.0: - return false - var pos: Vector2 = _last_mouse_pos - return ( - pos.x >= 0.0 - and pos.y >= 0.0 - and pos.x <= sz.x - and pos.y <= sz.y - and ( - pos.x < EDGE_SCROLL_MARGIN_PX - or pos.y < EDGE_SCROLL_MARGIN_PX - or pos.x > sz.x - EDGE_SCROLL_MARGIN_PX - or pos.y > sz.y - EDGE_SCROLL_MARGIN_PX - ) + return AtlasWindowGeometry.is_cursor_edge_scrolling( + _app_has_focus, _is_over_ui(_last_mouse_pos), size, _last_mouse_pos, EDGE_SCROLL_MARGIN_PX ) -## Direction toward whichever edge(s) the cursor is near — same shape as -## _held_pan_direction() (a raw, un-normalized Vector2 the caller combines -## and normalizes once). +## Direction toward whichever edge(s) the cursor is near — see +## AtlasWindowGeometry.edge_scroll_direction()'s doc (moved there T-1153). func _edge_scroll_direction() -> Vector2: - var sz: Vector2 = size - var pos: Vector2 = _last_mouse_pos - var direction := Vector2.ZERO - if pos.x < EDGE_SCROLL_MARGIN_PX: - direction.x -= 1.0 - elif pos.x > sz.x - EDGE_SCROLL_MARGIN_PX: - direction.x += 1.0 - if pos.y < EDGE_SCROLL_MARGIN_PX: - direction.y -= 1.0 - elif pos.y > sz.y - EDGE_SCROLL_MARGIN_PX: - direction.y += 1.0 - return direction + return AtlasWindowGeometry.edge_scroll_direction(size, _last_mouse_pos, EDGE_SCROLL_MARGIN_PX) # ============================================================================= @@ -727,12 +971,9 @@ func _notification(what: int) -> void: _position_overlay_bar() if _legend_panel: _legend_panel.reposition() - # T-1142: re-fit on resize too, same _user_adjusted guard as the other - # two auto-fit events (enter, first window arrival) — never fights a - # manually-adjusted view. _canvas guard matches _overlay_bar/ - # _legend_panel above: NOTIFICATION_RESIZED can fire mid-_ready() - # (anchor_right/anchor_bottom assignment triggers it) BEFORE _canvas - # is constructed — confirmed the hard way (gdUnit add_child() crash). + # T-1142: re-fit on resize (_user_adjusted guard, as other auto-fit + # events). _canvas guard: NOTIFICATION_RESIZED can fire mid-_ready() + # before _canvas exists (gdUnit add_child() crash, confirmed). if _canvas and not _user_adjusted: _fit_and_center() elif what == NOTIFICATION_APPLICATION_FOCUS_OUT: diff --git a/client/ui/implant/apps/atlas/screens/district_screen.gd b/client/ui/implant/apps/atlas/screens/district_screen.gd deleted file mode 100644 index 1b009ef06..000000000 --- a/client/ui/implant/apps/atlas/screens/district_screen.gd +++ /dev/null @@ -1,40 +0,0 @@ -class_name DistrictScreen -extends Control -## Regional district-window viewer screen for AtlasApp (T-1138, D-226 T-1124 -## amendment). Thin wrapper around AtlasWindowViewer, mirroring -## RegionalScreen's own shape exactly — enter/leave are the nav interface. -## -## Entered via a click-through from AtlasViewer (the "regional" screen), -## carrying the derived DistrictPos the player clicked (§5's entry-revision: -## "pan center read as click point"). Esc goes back to "regional" (the -## planetary heightmap for the same body) — a nav.pop(), not a fresh push, so -## the planetary view's own pan/zoom-removed FIXED state is exactly where the -## player left it. - -signal back_requested - -var _viewer: AtlasWindowViewer = null - - -func _ready() -> void: - mouse_filter = Control.MOUSE_FILTER_STOP - set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - _viewer = AtlasWindowViewer.new() - _viewer.name = "AtlasWindowViewer" - add_child(_viewer) - _viewer.back_pressed.connect(_on_viewer_back) - - -func enter(payload: Dictionary) -> void: - var body: Dictionary = payload.get("body", {}) - var system: Dictionary = payload.get("system", {}) - var center: Vector2i = payload.get("district_center", Vector2i.ZERO) - _viewer.enter(body, system, center) - - -func leave() -> void: - pass - - -func _on_viewer_back() -> void: - back_requested.emit() diff --git a/client/ui/implant/apps/atlas/screens/regional_screen.gd b/client/ui/implant/apps/atlas/screens/regional_screen.gd index b32a54848..0911ffc57 100644 --- a/client/ui/implant/apps/atlas/screens/regional_screen.gd +++ b/client/ui/implant/apps/atlas/screens/regional_screen.gd @@ -1,30 +1,48 @@ class_name RegionalScreen extends Control -## Regional heightmap viewer screen for AtlasApp (#844, D-191). -## Thin wrapper around AtlasViewer; enter/leave are the nav interface. +## Regional zoom-ladder screen for AtlasApp (#844, D-191; superseded T-1153 — +## D-226 T-1143-rulings amendment). Thin wrapper around AtlasWindowViewer, +## entering at the CANONICAL ORBITAL FRAME (Region rung) via enter_orbital() +## instead of AtlasViewer's retired heightmap-texture show_body() path — +## enter/leave are still the nav interface, unchanged shape. +## +## T-1152 client half: this is the ONE screen for the whole ladder now — +## there is no separate "district" nav hop for the windowed drill-down +## (D-013 "the zoom gesture owns spatial descent" restored for this seam +## means descent is a CONTINUOUS in-screen zoom, not a nav-stack push). Esc +## from anywhere in the ladder is a single nav.pop() back to whatever pushed +## "regional" (system screen) — see atlas_app.gd's _handle_key(), unchanged +## from before this ticket (it already routed Esc through nav.pop() for any +## screen that isn't "reach"/"system"-with-a-panel-open). +## +## `district_descend_requested`/`economics_link_requested` signals retire +## with AtlasViewer's click-through (the reticle/hover-to-descend affordance +## — Jeroen's ruling: retired as the SOLE entry, and no cheap click-target +## exists on the orbital Region-rung view to wire a shortcut onto yet, unlike +## a future settlement-marker click which WOULD have a natural landing +## point — see AtlasWindowViewer's own doc on why enter() still exists as a +## District-rung entry point for exactly that future wiring). +## economics_link_requested is deferred with AtlasViewer's city-click sidebar +## (see the batch report for the full list of what's deferred vs. carried). signal back_requested -signal economics_link_requested(system_id: String) -signal district_descend_requested(district_center: Vector2i) # T-1138, forwarded from AtlasViewer -var _viewer: AtlasViewer = null +var _viewer: AtlasWindowViewer = null func _ready() -> void: mouse_filter = Control.MOUSE_FILTER_STOP set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - _viewer = AtlasViewer.new() - _viewer.name = "AtlasViewer" + _viewer = AtlasWindowViewer.new() + _viewer.name = "AtlasWindowViewer" add_child(_viewer) _viewer.back_pressed.connect(_on_viewer_back) - _viewer.economics_link_requested.connect(_on_viewer_economics_link) - _viewer.district_descend_requested.connect(_on_viewer_district_descend) func enter(payload: Dictionary) -> void: var body: Dictionary = payload.get("body", {}) var system: Dictionary = payload.get("system", {}) - _viewer.show_body(body, system) + _viewer.enter_orbital(body, system) func leave() -> void: @@ -33,11 +51,3 @@ func leave() -> void: func _on_viewer_back() -> void: back_requested.emit() - - -func _on_viewer_economics_link(system_id: String) -> void: - economics_link_requested.emit(system_id) - - -func _on_viewer_district_descend(district_center: Vector2i) -> void: - district_descend_requested.emit(district_center) diff --git a/docs/DEVOPS.md b/docs/DEVOPS.md index 2d6c8c9be..b22b1097c 100644 --- a/docs/DEVOPS.md +++ b/docs/DEVOPS.md @@ -352,6 +352,32 @@ Key components: - **CauseChain** (production ECS component) — Tracks causal attribution for testable observation sequences (D-030). - **Deterministic replay** — Server simulation is deterministic given the same seed + input sequence. Replay logs enable regression testing (#201, critical). +### Real-rendering test exception: `test_atlas_window_overlay_draw_smoke.gd` + +`tests/run-godot` hardcodes `--headless`, whose dummy driver produces no usable GPU +texture output (`SubViewport.get_texture().get_image()` returns unusable data). One +file needs real pixels — `client/tests/test_atlas_window_overlay_draw_smoke.gd` (T-1153 +live round 4) renders `AtlasWindowOverlay` into a `SubViewport` and asserts real terrain +pixels were composited, closing a "did anything draw at all" gap that bit twice +(a tile-mosaic coordinate bug and an unstored-texture GPU-lifetime bug, both invisible +to cache-state-only assertions). It self-detects the dummy driver +(`DisplayServer.get_name() == "headless"`) and **skips** under the standard suite — +`tests/run-godot --filter test_atlas_window_overlay_draw_smoke` reports green-with-skips, +never a false failure that would bounce the push gate. To exercise its real assertions: + +```bash +godot4 --display-driver x11 --rendering-driver opengl3 \ + -s addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -c \ + -a res://tests/test_atlas_window_overlay_draw_smoke.gd +``` + +Same underlying constraint as `tests/visual_capture.gd` (`tests/run-visual`), which is +the project's other real-driver exception — currently broken on this branch by the +retired `AtlasViewer` API (T-1157, capture-harness redesign). This file's scenarios are +slated to migrate into that redesigned harness once T-1157 lands, folding it into +`tests/visual.json` for consistency; until then it stays a standalone gdUnit file with +its own skip guard. + ## Planning store (pql) Tickets and decisions live in **pql**, not in the old SQLite wrapper scripts. Decisions diff --git a/docs/architecture/atlas-zoom-ladder-t1143.md b/docs/architecture/atlas-zoom-ladder-t1143.md index e1e5ffbfe..5cece223e 100644 --- a/docs/architecture/atlas-zoom-ladder-t1143.md +++ b/docs/architecture/atlas-zoom-ladder-t1143.md @@ -108,6 +108,10 @@ Confirmed by direct code inspection (not aspirational): `value_noise`, `terrain_ **Split:** rungs govern *what classification granularity is available and legal to request* (a policy/cost question, §2/§10); canvas-resolution sampling governs *what density the client actually asks for*, tracking the viewport continuously. The rung-selection rule (§2) is the join: pick the coarsest legal rung whose spacing is ≤2× the current screen sample spacing. +> **Erratum (2026-07-22, T-1153 implementation — Stig; superseded same day by the round-3 model below):** the single-table-scan reading of the rule above is unsatisfiable for the District rung at any real viewport: with `CELL_PIXEL_SIZE = 16` and the server's `DISTRICT_WINDOW_MAX_N = 64` per-axis cap, the ≤2× visual-tolerance band and the window-coverage ceiling never overlap. An interim two-gate split (coverage ceiling → 2× tolerance) shipped briefly but left District structurally unreachable and Region held indefinitely during pure zoom (caught in the live eyeball rounds, never merged). +> +> **Final model (round 3, as merged):** `AtlasWindowGeometry.select_rung()` is a **unified per-rung coverage-ceiling walk** — `MAX_COVERAGE_M` maps each rung to the maximum world extent its wire-capped window can span (Quarter ≈ 32.8 km, District ≈ 131 km, Region ≈ 13,107 km); selection walks finest-first and returns the first rung whose ceiling covers the current extent. The 2× visual-tolerance clause is retired: coverage, not tolerance, is the selector, which restores District as a real rung (the 33–131 km extent band) and makes the ladder Quarter → District → Region → **tile mode** (extents beyond Region's single-window ceiling compose a whole-body mosaic of capped Region tiles — the ruling's progressive capped-density tiling; `compute_tile_grid()`, Lendel = 3×2). The one-tier-finer-than-a-pixel guarantee is preserved by construction at Quarter/District scales and bounded at Region scale by the wire cap — the felt-pacing knob is `CELL_PIXEL_SIZE`; widening a rung's band is a `WIRE_CAP_CELLS`/`MAX_N` budget change requiring re-measurement. + **Bounded key space, not unbounded.** A continuous field sampled at arbitrary density would give the cache an unbounded key space (client obstacle flagged in the grounding: the 24-entry exact-match LRU would thrash to ~0% hit rate). This is avoided because **requests snap to quantized rung tiers** (granularity ∈ {1, 4, …}) and integer district-aligned centers — only *display* is continuous; every *request* the wire actually carries is one of a small number of discrete shapes. **Gap flagged by critique:** as specified, `window_min_wl_m` is viewport-continuous (`E/C`) while the cache key/echo tuple is `(body, center, n, granularity)` — same key, different `min_wl`, would silently collide. Fix: **quantize `min_wl_m` to a small fixed set of bands per rung** (matching the rung's own octave bands) rather than passing a raw continuous value, and add the quantized band to both the echo and the cache key. This closes the gap without reopening the unbounded-key problem. **Region/orbital granularity is unrepresentable in the current sketch.** `window_granularity: u32` with `spacing = DISTRICT_M / granularity` can express district (1) and quarter (4) but not region (coarser than district, i.e. granularity < 1). If the planetary rung's chosen carrier (§4) reuses this same field, the type needs to change to something that can express both directions (e.g. a signed log-scale or an explicit rung enum) — noted for the implementer, not resolved here. diff --git a/governance/decisions/architecture.md b/governance/decisions/architecture.md index 2f65849a6..5102acac7 100644 --- a/governance/decisions/architecture.md +++ b/governance/decisions/architecture.md @@ -1669,7 +1669,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser **Amended 2026-07-21 (T-1145 — Jeroen, second companion hands-on, KALLAST window):** three regional-window presentation fixes, all client-only. **Cover-fit supersedes contain:** `fit_window_view()`'s zoom now derives from the LARGER viewport dimension with no margin factor (`max(viewport.x, viewport.y) / composite_native`, not the old `0.9 * min(...)`), so the square district-window composite fills a wide/tall viewport edge to edge instead of leaving side margins, with the shorter axis' data extending into pan-space (the same "cover" concept as CSS `object-fit: cover`) — the existing §4 pan-edge refetch is unaffected (it keys off the screen-center-to-DistrictPos mapping, which any fit already centers on `_held_center` by construction, so no refetch churn at rest). **WASD + edge-scroll supersedes drag-pan:** LMB-drag panning is removed entirely (Jeroen's ruling — drag conflicts with click semantics for the map objects, e.g. settlements, this window will host later); panning is now held WASD/arrow keys (continuous, frame-rate-independent, `_process`-polled, physical-keycode reads to stay independent of the project's existing `move_north`/etc. gameplay-movement InputMap actions bound to the same keys) plus edge-scrolling (cursor within ~24px of a viewport edge, suppressed over UI and while the OS window lacks focus); wheel zoom is unchanged; pole-wall (§5 amendment above) and east-west wrap (T-1142) semantics are preserved unchanged under the new input source. **Smoothing is an interim presentation, pending T-1143:** the composite renders as an `n`×`n` `Image`/`ImageTexture` (one pixel per district, the identical existing per-cell color pipeline) drawn scaled with linear filtering — the same treatment the planetary heightmap already gets — instead of `n`×`n` flat rects, so GPU bilinear sampling reads as a terrain gradient rather than hard blocks; the original crisp per-cell path survives behind a compile-time const specifically so T-1143's design pass can compare both directly, and this smoothing is **not** T-1143's answer to district-tier legibility, only a stopgap ahead of it. - **Amended 2026-07-21 (T-1143 design-pass rulings — Jeroen, after the zoom-ladder design pass, `docs/architecture/atlas-zoom-ladder-t1143.md`):** three rulings on the pass's reserved decisions. **(1) The item-(d) ceiling is opened for the Atlas ladder** — Jeroen: *"we set a new BHAG so old restrictions are up for debate."* The D-166 2026-07-21 zoom-ladder condition ("down to tile scale") is read **literally**: the Atlas windowed viewport may descend below quarter (512 m) toward block/tile granularity. This is an explicit ruling, not erosion — exactly the deliberate revisit the T-1112 §2 anti-erosion clause was hardened to force into the open. Item (d)'s substance survives in narrowed form: chunk/tile/voxel output still never appears as a **whole-body planetary map layer**, and the below-quarter rungs are implementation-gated on their own **measurement pass** (costs/wire for block and tile rungs are unmeasured — design pass §2/§7); the harness-verification path for L5 fill remains primary until that pass lands. **(2) Planetary rung wire carrier: progressive capped-density tiling** riding the generalized `district_window` carrier (granularity parameter, §3 of the design pass) — no new dense-raster wire shape, no forced tagged-envelope migration. **(3) The §5 entry click-through cut is superseded by continuous cursor-anchored zoom**: wheel-zoom carries the view from the orbital frame down through regional granularities continuously, anchored at the cursor, **with the condition that a full zoom-out resets to the original canonical planetary frame and location** (the fixed orbital framing is the ladder's top rest state, not a drifted pan state). The click-through descent and rectangle reticle are retired as the *sole* entry (T-1138's shipped mechanic stands until the continuous ladder replaces it in the same change — close inspection is never stranded, same discipline as the §5 fixed-view transition). D-013's "the zoom gesture owns spatial descent" reading is **restored** for this seam. **Wire-contract note (T-1150, PR #191 review — Tyre):** the `window_granularity` field that ruling (2) rides on expresses **finer-than-district integer multiples only** (1 = district, 4 = quarter today; each new rung is a deliberate widening of `resolve_window_granularity`'s whitelist — the single widening point; unknown values fall back to district, never trusted from the wire). Coarser-than-district reuse (the region/orbital rungs of ruling (2)'s progressive tiling) requires the design pass's R5 signed/log-scale-or-enum redesign of the field — a new magic value is not the path. Recorded here so the type's limit is contract, not only a design-doc risk row. + **Amended 2026-07-21 (T-1143 design-pass rulings — Jeroen, after the zoom-ladder design pass, `docs/architecture/atlas-zoom-ladder-t1143.md`):** three rulings on the pass's reserved decisions. **(1) The item-(d) ceiling is opened for the Atlas ladder** — Jeroen: *"we set a new BHAG so old restrictions are up for debate."* The D-166 2026-07-21 zoom-ladder condition ("down to tile scale") is read **literally**: the Atlas windowed viewport may descend below quarter (512 m) toward block/tile granularity. This is an explicit ruling, not erosion — exactly the deliberate revisit the T-1112 §2 anti-erosion clause was hardened to force into the open. Item (d)'s substance survives in narrowed form: chunk/tile/voxel output still never appears as a **whole-body planetary map layer**, and the below-quarter rungs are implementation-gated on their own **measurement pass** (costs/wire for block and tile rungs are unmeasured — design pass §2/§7); the harness-verification path for L5 fill remains primary until that pass lands. **(2) Planetary rung wire carrier: progressive capped-density tiling** riding the generalized `district_window` carrier (granularity parameter, §3 of the design pass) — no new dense-raster wire shape, no forced tagged-envelope migration. **(3) The §5 entry click-through cut is superseded by continuous cursor-anchored zoom**: wheel-zoom carries the view from the orbital frame down through regional granularities continuously, anchored at the cursor, **with the condition that a full zoom-out resets to the original canonical planetary frame and location** (the fixed orbital framing is the ladder's top rest state, not a drifted pan state). The click-through descent and rectangle reticle are retired as the *sole* entry (T-1138's shipped mechanic stands until the continuous ladder replaces it in the same change — close inspection is never stranded, same discipline as the §5 fixed-view transition). D-013's "the zoom gesture owns spatial descent" reading is **restored** for this seam. **Wire-contract note (T-1150, PR #191 review — Tyre):** the `window_granularity` field that ruling (2) rides on expresses **finer-than-district integer multiples only** (1 = district, 4 = quarter today; each new rung is a deliberate widening of `resolve_window_granularity`'s whitelist — the single widening point; unknown values fall back to district, never trusted from the wire). Coarser-than-district reuse (the region/orbital rungs of ruling (2)'s progressive tiling) requires the design pass's R5 signed/log-scale-or-enum redesign of the field — a new magic value is not the path. Recorded here so the type's limit is contract, not only a design-doc risk row. **Refinement-semantics note (T-1153, PR #192 review — Tyre):** the ladder's progressive cross-rung refinement (hold the coarse composite, fetch the finer rung, swap in place on arrival; per-tile arrival in the orbital mosaic) **extends** the T-1124 §4 float-on-center/debounce async contract — it does not supersede it. §4 still governs the per-request mechanics unchanged (`district_window: None`-until-derived polling, the 150 ms debounce, float-on-center refetch); rung crossings add a second request class on top, per the design pass §3's progressive-refinement model. This record is the one that governs the swap-on-arrival behavior. The legacy `window_granularity: u32` wire field is now fully shadowed by `window_granularity_v2` (the server always echoes both); it is **scheduled for retirement** once pre-T-1152 wire-compat is confirmed unneeded (single-repo client/server pair — no external clients exist today; ticketed). - **Rationale:** Reusing the real UI — rather than a parallel offline renderer or dumped files — means the debug/review surface never diverges from what ships, and a dropped artifact can't go stale. Agent-navigability converts qualitative "does the synthesis look natural?" review from a manual eyeball pass into an automatable sweep that flags the few outliers for a human. The harness rides seams that already exist (`TickRate::Paused`, the paused-allowlist, `gameplay_occluded`, the bridge framing, the `run-visual` capture primitive) — a naming-and-contract exercise, not a new subsystem. - **New surface:** server pause-gating (run-conditions on the world phases keyed to a pause command); client `AtlasAgentInterface` (`observe`/`act`, Control-tree walker) + its local transport; the generation overlay rendering + selector + legend; interactive capture wired to `run-visual`. - **Implementation:** Phase 4 (epic T-750), built bottom-up — auto-pause substrate, T-969 proxy (D-225), T-960 viewer, agent channel, agent capture. Geography is the first consumer. diff --git a/server/src/atlas/district_profile.rs b/server/src/atlas/district_profile.rs index fb1892ada..89c16a9b7 100644 --- a/server/src/atlas/district_profile.rs +++ b/server/src/atlas/district_profile.rs @@ -1569,6 +1569,128 @@ pub fn derive_at_metres( ) } +/// The orbital-rung derivation (T-1152, zoom ladder design doc §2/§4): the +/// coarse-granularity twin of [`derive_at_metres`] that skips [`invent_primitives`] +/// entirely — **no coastline warp, no detail-scatter octave sum, no classification +/// noise call of any kind**. Per the design doc's orbital row: "`region_baseline_at_district` +/// only — bilinear blend of 4 region baselines, no `invent_primitives`, no +/// classification [driver]." Orbital sample spacing (≥205 km, D-243's region rung +/// and coarser) sits below `detail_scatter`'s own octave floor +/// (`OCTAVE_WAVELENGTHS_M`'s coarsest entry is 32,768 m ≈ 32.8 km — an order of +/// magnitude finer than a region), so the invented terrain has nothing left to +/// contribute at this spacing; calling it would burn cycles synthesizing detail +/// no orbital pixel can resolve. What DOES vary at orbital spacing is the +/// **envelope** the heightmap itself carries (the `TerrainAnalysis` continental +/// shape) and the **region climate baseline** — this function samples exactly +/// those two, nothing else. +/// +/// **Cost model (design doc R1 — measure first):** one `bilinear` (elevation), +/// one `bilinear_bool` (ocean mask), one `region_baseline_at_district` call (its +/// own cost is 4×`derive_region_baseline_c` on a cache miss, O(1) on a cache hit) +/// — no octave sum, no coast-warp trig, no character/envelope computation. See +/// `server/tests/zoom_ladder_bench.rs`'s `bench_derive_orbital_at_metres` for the +/// measured per-cell figure this claim rests on. +/// +/// Produces the SAME six-field tail every other rung produces (`morphology_zone`, +/// `elev_q`, `temperature_c`, `moisture_q`, `vegetation_class`, `glaciation_grade`) +/// by routing the bilinear-only primitives through the same +/// [`build_district_profile`] classification tail every other rung uses — one +/// classification pipeline, never a second orbital-only decision tree (D-227: +/// classification thresholds don't get a coarse-rung variant any more than the +/// quarter rung got its own "quarter mode" thresholds, design doc §6). +/// +/// **R2 (stepped fields):** `moisture_q`/`temperature_c`/`morphology_zone`/etc. +/// are exactly as stepped here as at every other rung — `region_baseline_at_district` +/// floor-divides to the containing `DistrictPos` regardless of caller spacing (see +/// [`derive_at_metres`]'s own doc on this), so this function does not make +/// temperature MORE continuous at orbital scale; it inherits the same +/// district-tier step the design doc documents as permanent, by construction. +/// +/// **`slope_q` is fixed at 0`** — the bilinear-only envelope carries no +/// per-cell slope signal at orbital spacing (`ta.slope_deg` is a district-scale +/// proxy; sampling it here would imply a precision the coarse envelope doesn't +/// have). `slope_q` only affects morphology gates 3–6 (FjordWall/CliffCoast/ +/// BraidedDelta/DuneStrand) and the invented-primitives `carve` term this +/// function never runs — passing 0 means those gates fall through to their +/// low-slope alternatives, which is the correct behavior for a coastline sampled +/// at coarser-than-detail-scatter resolution (no invented ruggedness to report). +pub fn derive_orbital_at_metres( + seed: SeedChain, + body_id: &str, + body_params: &BodyParams, + ta: &TerrainAnalysis, + wx: f64, + wy: f64, + climate: &ClimateConstants, +) -> DistrictProfile { + // Same world-metres -> fractional heightmap pixel + latitude mapping + // derive_at_metres uses — the envelope is the SAME TerrainAnalysis grid at + // every rung, only the sampling density differs. + let (px, py, _world_x_m, _world_y_m, lat_deg) = match body_params.body_radius_km { + Some(r_km) if r_km > 0.0 => { + let circumference_m = std::f64::consts::TAU * r_km * 1000.0; + let meridian_m = std::f64::consts::PI * r_km * 1000.0; + let px = (wx / circumference_m).rem_euclid(1.0) * ta.w as f64; + let lat_frac = (wy / meridian_m).clamp(-0.5, 0.5); + let py = (0.5 + lat_frac) * ta.h.saturating_sub(1) as f64; + (px, py, wx, wy, -lat_frac * 180.0) + } + _ => { + let dm = scale::DISTRICT_M as f64; + let px = (wx / dm).clamp(0.0, ta.w.saturating_sub(1) as f64); + let py = (wy / dm).clamp(0.0, ta.h.saturating_sub(1) as f64); + let lat_deg = if ta.h > 1 { + 90.0 - (py / (ta.h - 1) as f64) * 180.0 + } else { + 0.0 + }; + (px, py, px * dm, py * dm, lat_deg) + } + }; + + let params = BodyParams { + latitude_deg: lat_deg, + ..body_params.clone() + }; + + // The envelope only — no coast-warp, no detail-scatter. This is exactly + // `invent_primitives`' step-1 "driver tier" raw bilinear reads, promoted to + // be the FINAL primitives instead of a one-step-stale input to invention. + let elev_q = + ((bilinear(&ta.elev_pct, ta.w, ta.h, px, py) as f64 * 100.0).round() as i32).clamp(0, 100); + let ocean_fraction_q = ((bilinear_bool(&ta.ocean_mask, ta.w, ta.h, px, py) as f64 * 100.0) + .round() as i32) + .clamp(0, 100); + // No invented ruggedness at orbital spacing (see the function doc's note + // on slope_q) — the envelope carries no per-cell slope signal this coarse. + let slope_q = 0; + + let district_pos: DistrictPos = ( + (wx / scale::DISTRICT_M as f64).floor() as i32, + (wy / scale::DISTRICT_M as f64).floor() as i32, + ); + let region_baseline_c = region_profile::region_baseline_at_district( + seed.seed(), + body_id, + district_pos, + ¶ms, + climate, + seed, + None, // no pre-built cache; derive on-the-fly, same posture as derive_at_metres + ); + + build_district_profile( + seed, + ¶ms, + climate, + slope_q, + elev_q, + ocean_fraction_q, + region_baseline_c, + BasinDirection::default(), + ) +} + /// Bilinear interpolation of a row-major `f32` field at fractional `(px, py)`. /// Columns wrap (equirectangular); rows clamp at the poles. fn bilinear(field: &[f32], w: usize, h: usize, px: f64, py: f64) -> f32 { @@ -2066,6 +2188,153 @@ mod tests { ); } + // ------------------------------------------------------------------- + // derive_orbital_at_metres (T-1152, design doc §2/§4 orbital row) + // ------------------------------------------------------------------- + + /// Determinism (D-010/D-227): two independent orbital derives at the same + /// position produce a bit-identical `DistrictProfile`, mirroring + /// `derive_district_is_deterministic`'s pattern for the finer rungs. + #[test] + fn derive_orbital_at_metres_is_deterministic() { + let hm = test_hm(); + let ta = test_ta(&hm); + let climate = ClimateConstants::default(); + let p = earth_params(); + let dm = scale::REGION_M as f64; + + let a = derive_orbital_at_metres( + test_seed(), + "test_body", + &p, + &ta, + 3.0 * dm, + 2.0 * dm, + &climate, + ); + let b = derive_orbital_at_metres( + test_seed(), + "test_body", + &p, + &ta, + 3.0 * dm, + 2.0 * dm, + &climate, + ); + assert_district_profiles_eq(&a, &b); + } + + /// The orbital path must NOT run `invent_primitives` — the design doc's + /// central constraint (§2: "no invent_primitives at orbital wavelengths"). + /// Direct proof: `slope_q` is always exactly 0 (invention is the only + /// source of nonzero slope_q at this call depth — see + /// `derive_orbital_at_metres`'s doc on why slope_q is fixed), sampled + /// across enough distinct positions that a nonzero value appearing even + /// once would falsify the claim. + #[test] + fn derive_orbital_at_metres_never_invents_slope() { + let hm = test_hm(); + let ta = test_ta(&hm); + let climate = ClimateConstants::default(); + let p = earth_params(); + let dm = scale::REGION_M as f64; + + for i in 0..25 { + let wx = (i * 7) as f64 * dm * 0.37; + let wy = (i * 11) as f64 * dm * 0.29; + let prof = + derive_orbital_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate); + assert_eq!( + prof.slope_q, 0, + "orbital derive must never report invented slope (position {i})" + ); + } + } + + /// The orbital derive's `elev_q`/`temperature_c` must come from the SAME + /// envelope + region-baseline sources `derive_at_metres` reads — not an + /// independent/divergent computation. At a position where the invented + /// scatter happens to contribute exactly zero (impossible to guarantee by + /// construction, so this test instead checks the WEAKER, always-true + /// property: both paths' `elev_q` derive from the same underlying + /// bilinear envelope, so they must be close — within the invented + /// scatter's own bounded contribution range, not arbitrarily different). + /// This guards against the orbital path silently reading a different + /// terrain field entirely (a copy-paste bug this refactor is exactly the + /// kind of change that could introduce). + #[test] + fn derive_orbital_at_metres_elevation_tracks_the_same_envelope() { + let hm = test_hm(); + let ta = test_ta(&hm); + let climate = ClimateConstants::default(); + let p = earth_params(); + let dm = scale::DISTRICT_M as f64; + + // Sample at a DISTRICT-aligned position (within the orbital function's + // legal domain — it accepts any world position, this just makes the + // district-mode comparison call meaningful) so both paths read the + // exact same fractional heightmap pixel. + let wx = 40.0 * dm; + let wy = 20.0 * dm; + let orbital = derive_orbital_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate); + let full = derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 0.0); + + // The invented scatter is a bounded perturbation on top of the raw + // envelope (detail_scatter's amplitude is capped well under 100 elev_q + // points) — the two must be in the same ballpark, not exactly equal + // (that would defeat the point of invention existing at all at the + // finer rung) and not wildly different (that would mean the orbital + // path is reading a different field). + let elev_diff = (orbital.elev_q - full.elev_q).abs(); + assert!( + elev_diff <= 50, + "orbital elev_q ({}) and full-derive elev_q ({}) must come from the \ + same envelope, not diverge arbitrarily", + orbital.elev_q, + full.elev_q + ); + } + + /// Orbital-scale windows must still fill all six dense wire arrays the + /// client's colorizer family reads (T-1152: "the orbital cells must fill + /// the same six dense arrays the DistrictWindowLayer carries") — this is + /// checked at the `DistrictProfile` level (the pre-packing source of + /// those six fields): every field the packer reads + /// (`morphology_zone`/`elev_q`/`temperature_c`/`moisture_q`/ + /// `vegetation_class`/`glaciation_grade`) must be populated the same way + /// regardless of rung — this test asserts the orbital output is a + /// legitimate `DistrictProfile`, not a partially-filled stand-in. + #[test] + fn derive_orbital_at_metres_populates_all_six_wire_fields() { + let hm = test_hm(); + let ta = test_ta(&hm); + let climate = ClimateConstants::default(); + let p = earth_params(); + let dm = scale::REGION_M as f64; + + let prof = derive_orbital_at_metres( + test_seed(), + "test_body", + &p, + &ta, + 5.0 * dm, + 3.0 * dm, + &climate, + ); + assert!((0..=100).contains(&prof.elev_q)); + assert!((0..=100).contains(&prof.moisture_q)); + // temperature_c is Some for a breathable-atmosphere body (earth_params). + assert!(prof.temperature_c.is_some()); + // morphology_zone/vegetation_class/glaciation_grade are enums with no + // "unset" state — successfully constructing the DistrictProfile at + // all (no panic) is the actual assertion; the field reads below just + // confirm they're reachable typed values, matching the discipline + // `derive_district_is_deterministic` and neighbours already use. + let _ = prof.morphology_zone; + let _ = prof.vegetation_class; + let _ = prof.glaciation_grade; + } + #[test] fn derive_district_profile_is_deterministic() { let hm = test_hm(); diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index 780ae17d7..a6381962b 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -34,7 +34,9 @@ use crate::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer}; use crate::atlas::district_profile::{BodyParams, ClimateConstants, DistrictPos}; use crate::atlas::features::TerrainAnalysis; use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W}; -use crate::atlas::layer_proxy::{build_district_window_layer, DistrictWindowLayer}; +use crate::atlas::layer_proxy::{ + build_district_window_layer, DistrictWindowLayer, WindowGranularity, +}; use crate::atlas::shell::{fill_chunk, FilledChunk}; use crate::atlas::skeleton_gen::{assign_all_block_tags, generate_quarter_skeleton}; use crate::atlas::trait_catalog_reader::ExteriorCatalog; @@ -208,11 +210,18 @@ pub enum GenWorkItem { /// before this item is built — never trusted from the wire again here. center: DistrictPos, n: u32, - /// Derivation granularity (T-1150) — `WINDOW_GRANULARITY_DISTRICT` (1) - /// or `WINDOW_GRANULARITY_QUARTER` (4). Already resolved via - /// `resolve_window_granularity` by the caller. - granularity: u32, + /// Derivation granularity (T-1150, widened T-1152 to the full + /// [`WindowGranularity`] vocabulary — `District`/`Quarter` (finer) + /// plus `Region` (coarser, T-1152)). Already resolved via + /// `resolve_window_granularity_v2` by the caller — this is a + /// concrete rung, never a raw wire value. + granularity: WindowGranularity, /// Octave cutoff in whole metres (T-1149/T-1150), `0` = no cutoff. + /// Meaningless for `granularity: Region` (`derive_orbital_at_metres` + /// never calls the octave-scatter path this cuts off) but still + /// carried and echoed uniformly — see `derive_orbital_at_metres`'s + /// doc for why the field is harmless-but-unused there, not + /// special-cased away. min_wl_m: u32, }, } @@ -227,13 +236,17 @@ impl GenWorkItem { /// Coalescing key for `DeriveWindow` items only — `(connection, body, /// granularity)` (T-1150, design doc §3 [SOFT] recommendation, extending - /// T-1137's `(connection, body)`). `granularity` is part of the key so an - /// in-flight district-spacing (granularity 1) pan-burst is never - /// superseded by an unrelated quarter-spacing (granularity 4) request for - /// the same connection+body, and vice versa — the two rungs are separate - /// in-flight derives, not competing updates to the same one. + /// T-1137's `(connection, body)`; widened T-1152 to carry the full + /// [`WindowGranularity`] enum rather than the legacy `u32`, so `Region` + /// occupies its own coalescing slot exactly like `District`/`Quarter` do + /// — this is one of the five T-1150 touch points the R5 redesign must + /// carry the new representation through). `granularity` is part of the + /// key so an in-flight district-spacing pan-burst is never superseded by + /// an unrelated quarter- or region-spacing request for the same + /// connection+body, and vice versa — every rung is a separate in-flight + /// derive, not a competing update to the same one. /// `None` for every other variant (they don't coalesce this way). - pub fn window_supersede_key(&self) -> Option<(ConnectionId, &str, u32)> { + pub fn window_supersede_key(&self) -> Option<(ConnectionId, &str, WindowGranularity)> { if let GenWorkItem::DeriveWindow { body_id, conn_id, @@ -1150,19 +1163,14 @@ mod tests { /// coalescing tests can exercise the granularity axis of /// `window_supersede_key()` without a second near-duplicate helper. fn derive_window(body_id: &str, conn_id: ConnectionId, center: DistrictPos) -> GenWorkItem { - derive_window_at( - body_id, - conn_id, - center, - crate::atlas::layer_proxy::WINDOW_GRANULARITY_DISTRICT, - ) + derive_window_at(body_id, conn_id, center, WindowGranularity::District) } fn derive_window_at( body_id: &str, conn_id: ConnectionId, center: DistrictPos, - granularity: u32, + granularity: WindowGranularity, ) -> GenWorkItem { GenWorkItem::DeriveWindow { body_id: body_id.to_string(), @@ -1316,21 +1324,11 @@ mod tests { let conn = ConnectionId(9); q.submit_window( - derive_window_at( - "GranBody", - conn, - (0, 0), - crate::atlas::layer_proxy::WINDOW_GRANULARITY_DISTRICT, - ), + derive_window_at("GranBody", conn, (0, 0), WindowGranularity::District), GenPriority::Immediate, ); q.submit_window( - derive_window_at( - "GranBody", - conn, - (0, 0), - crate::atlas::layer_proxy::WINDOW_GRANULARITY_QUARTER, - ), + derive_window_at("GranBody", conn, (0, 0), WindowGranularity::Quarter), GenPriority::Immediate, ); assert_eq!( @@ -1352,21 +1350,11 @@ mod tests { let conn = ConnectionId(11); q.submit_window( - derive_window_at( - "SameGranBody", - conn, - (0, 0), - crate::atlas::layer_proxy::WINDOW_GRANULARITY_QUARTER, - ), + derive_window_at("SameGranBody", conn, (0, 0), WindowGranularity::Quarter), GenPriority::Immediate, ); q.submit_window( - derive_window_at( - "SameGranBody", - conn, - (5, 5), - crate::atlas::layer_proxy::WINDOW_GRANULARITY_QUARTER, - ), + derive_window_at("SameGranBody", conn, (5, 5), WindowGranularity::Quarter), GenPriority::Immediate, ); assert_eq!( @@ -1376,6 +1364,76 @@ mod tests { ); } + /// **PR #192 review — Hoshe 1: zero coalescing coverage for + /// `WindowGranularity::Region` before this test**, despite Region being + /// the highest-fan-out path (progressive capped-density tiling fires + /// multiple concurrent Region `DeriveWindow` items per pan/zoom). Mirrors + /// `submit_window_does_not_coalesce_different_granularity`'s pattern + /// exactly, substituting Region for Quarter: a Region request and a + /// District request for the SAME `(connection, body)` are separate + /// in-flight slots (the coalescing key is `(conn_id, body_id, + /// granularity)`) and must NOT coalesce — both survive as independent + /// pending items. + #[test] + fn submit_window_does_not_coalesce_region_and_district() { + let q = GenerationQueue::with_threads(1); + // See `submit_window_coalesces_same_connection_and_body`'s comment on + // why the occupier must be `analyze()`, not `FillChunk`. + q.submit(analyze("Occupier5"), GenPriority::Low); + + let conn = ConnectionId(13); + q.submit_window( + derive_window_at("OrbitalGranBody", conn, (0, 0), WindowGranularity::Region), + GenPriority::Immediate, + ); + q.submit_window( + derive_window_at("OrbitalGranBody", conn, (0, 0), WindowGranularity::District), + GenPriority::Immediate, + ); + assert_eq!( + q.pending_count(), + 2, + "same (connection, body) but Region vs. District must NOT coalesce — \ + separate in-flight slots, same as the existing District/Quarter pair" + ); + } + + /// The coalescing-DOES-happen counterpart to the test above, for Region + /// specifically: two submissions for the SAME `(connection, body, + /// Region)` still collapse to one pending item — confirms Region's + /// coalescing key behaves identically to District/Quarter's, not just + /// that it avoids cross-granularity aliasing. + #[test] + fn submit_window_coalesces_same_connection_body_and_region_granularity() { + let q = GenerationQueue::with_threads(1); + q.submit(analyze("Occupier6"), GenPriority::Low); + + let conn = ConnectionId(15); + q.submit_window( + derive_window_at( + "SameOrbitalGranBody", + conn, + (0, 0), + WindowGranularity::Region, + ), + GenPriority::Immediate, + ); + q.submit_window( + derive_window_at( + "SameOrbitalGranBody", + conn, + (5, 5), + WindowGranularity::Region, + ), + GenPriority::Immediate, + ); + assert_eq!( + q.pending_count(), + 1, + "same (connection, body, Region) must still coalesce to one pending item" + ); + } + // ------------------------------------------------------------------- // TerrainAnalysisCache (T-1137, PR #187 review — Tyre C1) // ------------------------------------------------------------------- diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index 8b48c8b84..b4ed0f872 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -37,8 +37,51 @@ const DEFAULT_SEA_LEVEL: f32 = 0.3; /// same window size `aliveness_probe --render`'s default already proved out /// server-side (T-1123). **Never trust `window_n` from the wire** — every /// caller clamps to `[1, DISTRICT_WINDOW_MAX_N]` before deriving. +/// +/// **Finer-than-district / district rungs ONLY (T-1152).** This ceiling was +/// calibrated for district spacing; applying it unchanged to `Region` +/// requests would be nonsensical — see [`DISTRICT_WINDOW_MAX_N_REGION`]'s doc +/// for why `Region` needs its own, much larger per-axis ceiling on the SAME +/// `n` (window extent in districts). pub const DISTRICT_WINDOW_MAX_N: u32 = 64; +/// Per-axis ceiling on [`AtlasLayerRequest::window_n`] for +/// [`WindowGranularity::Region`] requests ONLY (T-1152 step 4: "work out what +/// n means at region granularity ... and document it"). `n` is always the +/// window extent in DISTRICTS regardless of rung (T-1150 design doc §2, +/// unchanged) — but [`DISTRICT_WINDOW_MAX_N`] (64 districts ≈ 131 km) was +/// sized for the district rung's own per-cell world extent, and reusing it +/// unchanged for `Region` would clamp every region window to well under +/// ONE region's own 100-district side ([`crate::atlas::scale::DISTRICTS_PER_REGION`]), +/// making [`WindowGranularity::cell_grid_side`] round every legal `n` down +/// to a degenerate 1×1 cell grid — a "region view" that can never show more +/// than one region cell is not a useful rung. +/// +/// Derived (not a new magic number, per the D-243 instruction): the largest +/// `n` for which `cell_grid_side(n) == sqrt(WIRE_CAP_CELLS)` (64 region +/// cells across, the same [`WIRE_CAP_CELLS`]-derived side length district +/// mode already reaches at its own cap) is +/// `sqrt(WIRE_CAP_CELLS) * DISTRICTS_PER_REGION = 64 * 100 = 6,400` +/// districts (≈13,100 km — comfortably covering a planetary hemisphere's +/// worth of region tiles in one capped request). The WIRE-SIZE ceiling +/// ([`WIRE_CAP_CELLS`] via [`clamp_window_n_v2`]) is still the actual +/// enforcement point (never trusted from the wire) — this constant only +/// widens the PER-AXIS ceiling far enough that the wire-size math has room +/// to matter for `Region`, exactly mirroring how [`DISTRICT_WINDOW_MAX_N`] +/// relates to the wire-size ceiling at district granularity (see +/// `clamp_window_n`'s doc: "Applied AFTER the per-axis clamp so a request +/// that already satisfies [the per-axis cap] still shrinks further"). +pub const DISTRICT_WINDOW_MAX_N_REGION: u32 = + WIRE_CAP_CELLS_SQRT * crate::atlas::scale::DISTRICTS_PER_REGION as u32; + +/// `WIRE_CAP_CELLS`'s integer square root (64) — computed once as a `const` +/// so [`DISTRICT_WINDOW_MAX_N_REGION`]'s derivation is checkable at compile +/// time rather than repeating the literal `64` as an uncommented magic +/// number. `WIRE_CAP_CELLS = 4_096 = 64²` exactly (see that constant's own +/// doc), so this is exact integer arithmetic, not an approximation. +const WIRE_CAP_CELLS_SQRT: u32 = 64; +const _: () = assert!(WIRE_CAP_CELLS_SQRT * WIRE_CAP_CELLS_SQRT == WIRE_CAP_CELLS); + /// [`AtlasLayerRequest::window_granularity`] encoding (T-1150, zoom ladder /// design doc §3/§5): the number of derived cells per district side. `0` on /// the wire (the `#[serde(default)]` absent case) and `1` both mean district @@ -52,6 +95,33 @@ pub const DISTRICT_WINDOW_MAX_N: u32 = 64; pub const WINDOW_GRANULARITY_DISTRICT: u32 = 1; pub const WINDOW_GRANULARITY_QUARTER: u32 = 4; +/// The `granularity: u32` value [`DistrictWindowLayer`]'s echo (and the +/// internal cache/coalescing keys) use for [`WindowGranularity::Region`] — +/// **a key-space value, never a legal WIRE INPUT.** [`resolve_window_granularity`] +/// (the legacy field's resolver) never produces this value, and a client +/// sending it on the wire in the legacy `window_granularity` field is +/// indistinguishable from any other unrecognized value — it still resolves +/// to `District` (§ `resolve_window_granularity`'s exhaustive fallback), NOT +/// `Region`. The only way to actually request `Region` is +/// `window_granularity_v2 = Some(WindowGranularity::Region)`. +/// +/// **Why this exists at all, given `Region` has no legacy representation:** +/// the aliasing discipline (T-1150 design doc §3, the mandatory +/// granularity-4-vs-1 test) requires that every distinct [`WindowGranularity`] +/// occupy a distinct slot in [`DistrictWindowKey`]/the coalescing key, both of +/// which carry a `u32` granularity component for wire back-compat. Reusing +/// `0` (today's "absent" sentinel, mapped to `District`) or any value +/// `resolve_window_granularity` could legally receive would silently alias a +/// `Region` window onto a `District` or `Quarter` cache slot depending on +/// what a future caller happened to pass — exactly the bug class §3 exists +/// to close. `u32::MAX` can never collide with a real multiplier (multipliers +/// are small integers by construction — 1, 4, and any future finer rung), +/// so it's the natural "this is a key-space tag, not a spacing multiplier" +/// value. A T-1152-aware client reads `granularity_v2` and never looks at +/// this number for `Region` responses; it exists purely so the legacy `u32` +/// slot in the key tuple stays total and never lies about aliasing. +pub const WINDOW_GRANULARITY_REGION_KEY: u32 = u32::MAX; + /// Server-side wire-size ceiling (T-1150, design doc §3 "Cell-count cap"): /// `window_n² × granularity² ≤ WIRE_CAP_CELLS`. At `WIRE_CAP_CELLS = 4,096`, /// district `n=64` (the existing [`DISTRICT_WINDOW_MAX_N`] cap) sits exactly @@ -63,19 +133,161 @@ pub const WINDOW_GRANULARITY_QUARTER: u32 = 4; /// trusted from the wire) rather than merely asserted. pub const WIRE_CAP_CELLS: u32 = 4_096; +// --------------------------------------------------------------------------- +// WindowGranularity (T-1152, R5 redesign — D-226 T-1143-rulings amendment's +// "Wire-contract note (T-1150, PR #191 review — Tyre)") +// --------------------------------------------------------------------------- + +/// The full window-derivation-granularity vocabulary (T-1152), superseding +/// `window_granularity: u32`'s finer-than-district-only ceiling (Tyre's +/// wire-contract note, D-226 T-1143-rulings amendment: "the `u32` multiplier +/// field expresses finer-than-district integer multiples only ... a new +/// magic value is not the path"). This is the R5 redesign the note demands: +/// an explicit **named-variant enum**, the same wire pattern [`RoadNodeKind`] +/// already uses on this carrier (a plain `#[derive(Serialize, Deserialize)]` +/// enum with no `#[repr]`/manual impl serializes as its variant name over +/// `rmp_serde`, not an integer discriminant — deliberately NOT the +/// `repr(u8)`-cast-to-`Vec` convention the six dense per-cell arrays use; +/// this field is a scalar tag, not a bulk payload, so the string-tag +/// legibility is worth the few extra wire bytes one field costs). +/// +/// **Why an enum and not a signed/log-scale int (the R5 alternative the risk +/// row named):** a log-scale `i32` still needs a lookup table to turn back +/// into a spacing, and a "cannot express" bug (someone passing `-2` and +/// expecting quarter-of-quarter) is silent at the type level. An enum with an +/// exhaustive match in [`WindowGranularity::spacing_m`] makes "this variant +/// has no defined spacing" a compile error, not a runtime surprise — the same +/// reasoning `MorphologyZone`'s exhaustive-match discipline already +/// established for this codebase (D-239 §6). +/// +/// **Precedence over the legacy `u32` field (documented here, the single +/// place both fields are reconciled):** [`AtlasLayerRequest::window_granularity_v2`] +/// wins whenever present and non-`None`; the legacy `u32` +/// [`AtlasLayerRequest::window_granularity`] is consulted ONLY when +/// `window_granularity_v2` is absent (`#[serde(default)]`, every pre-T-1152 +/// client). This is a strict either/or, not a merge — a client sending BOTH +/// fields (a mixed old/new build, or a future client hedging compatibility) +/// gets the `v2` field's answer, silently ignoring the legacy `u32`. See +/// [`resolve_window_granularity_v2`], the single widening point for this +/// enum (mirroring `resolve_window_granularity`'s role for the legacy field). +/// +/// **Unknown → District** at every resolution boundary (never trust the +/// wire) — same posture as the legacy `u32` path and every other wire-decoded +/// enum in this module. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum WindowGranularity { + /// 512 m/cell (D-243 `QUARTER_M`) — finer than district, T-1150 Option B. + Quarter, + /// 2,048 m/cell (D-243 `DISTRICT_M`) — the original, still-default rung. + District, + /// 204,800 m/cell (D-243 `REGION_M`) — the coarser-than-district rung R5 + /// flagged as unrepresentable in the `u32` multiplier encoding. Serves + /// BOTH the design doc's named "Region" row and the "Orbital/planetary" + /// row (R4: "Acceptable if the planetary rung's tiling effectively + /// subsumes it" — they share one derivation mode, `derive_orbital_at_metres`, + /// and one spacing; there is no third, coarser rung to distinguish them + /// by today, so one variant covers both named rows without inventing a + /// spacing the design doc never specified). + Region, +} + +impl WindowGranularity { + /// Cell spacing in metres — the single source of truth every caller + /// (server derive loop, cap math, client mirror) must read through, + /// rather than re-deriving the metre value from the variant name. + /// Sourced from `scale::` (D-243), never a magic number local to this + /// module (T-1152 instruction: "Update the D-243-derived spacing + /// constants from `scale::` rather than new magic numbers"). + pub fn spacing_m(self) -> f64 { + match self { + WindowGranularity::Quarter => crate::atlas::scale::QUARTER_M as f64, + WindowGranularity::District => DISTRICT_M as f64, + WindowGranularity::Region => crate::atlas::scale::REGION_M as f64, + } + } + + /// The legacy `window_granularity: u32` value this variant maps to/from + /// for finer-than-district rungs — `None` for `Region`, which the legacy + /// field cannot express by construction (Tyre's note; this is precisely + /// the gap the enum exists to close). Used only by + /// [`resolve_window_granularity_v2`]'s legacy-fallback branch and by + /// [`DistrictWindowLayer`]'s echo, which still carries the legacy `u32` + /// unchanged for wire back-compat (see that struct's doc). + fn legacy_u32(self) -> Option { + match self { + WindowGranularity::District => Some(WINDOW_GRANULARITY_DISTRICT), + WindowGranularity::Quarter => Some(WINDOW_GRANULARITY_QUARTER), + WindowGranularity::Region => None, + } + } + + /// The `u32` this variant occupies in [`DistrictWindowKey`]/the + /// coalescing key/`DistrictWindowLayer.granularity`'s echo — total over + /// every variant (unlike [`Self::legacy_u32`], which is partial). Finer- + /// than-district variants echo their real legacy multiplier (so a + /// T-1150-only client — one that reads `granularity` but has never heard + /// of `granularity_v2` — still sees the correct, meaningful number for + /// District/Quarter); `Region` echoes the reserved + /// [`WINDOW_GRANULARITY_REGION_KEY`] key-space tag (see that const's doc + /// for why `0` or any real multiplier would be unsafe here). + fn key_u32(self) -> u32 { + self.legacy_u32().unwrap_or(WINDOW_GRANULARITY_REGION_KEY) + } + + /// The derived cell-grid side length (in CELLS, at this granularity) for + /// a window whose extent is `n` DISTRICTS (T-1152 step 4: "work out what + /// n means at region granularity against D-243's region=100-district + /// side and document it" — this is that answer, made a total function + /// instead of inline arithmetic at each call site). + /// + /// `n` is ALWAYS the window extent in DISTRICTS regardless of + /// granularity (T-1150 design doc §2, unchanged by this ticket: "the + /// window's `n` stays the DISTRICT extent"). The derived grid's side + /// length scales by [`Self::spacing_m`] relative to [`DISTRICT_M`]: + /// + /// - `Quarter` (512 m, 4 cells/district side): `side = n * 4` — MORE + /// cells than districts requested (T-1150's existing behavior, + /// unchanged). + /// - `District` (2,048 m, 1:1): `side = n` — unchanged. + /// - `Region` (204,800 m = 100 districts/side, D-243 + /// `DISTRICTS_PER_REGION`): `side = round(n / 100)`, minimum 1. A + /// region-granularity request with the SAME `n` as a district request + /// derives a FAR SMALLER cell grid (a region window with `n=64` + /// districts — the per-axis cap — derives only a 1×1 region cell, + /// since 64 districts is well under one region's 100-district side). + /// This inversion (finer rungs MULTIPLY cell count by `n`; `Region` + /// DIVIDES it) is why [`clamp_window_n`]'s wire-cap math needs a + /// region-aware branch too (see that function) — a flat + /// `n² × multiplier² ≤ WIRE_CAP_CELLS` formula would make `n` for + /// `Region` requests nonsensically tiny if `multiplier` were naively + /// `1/100`. Rounding (not floor/ceil) keeps the mapping the closest + /// integer approximation of the true ratio; minimum 1 so an `n` smaller + /// than one region never derives a degenerate empty grid. + pub fn cell_grid_side(self, n: u32) -> i32 { + match self { + WindowGranularity::Quarter => (n * WINDOW_GRANULARITY_QUARTER) as i32, + WindowGranularity::District => n as i32, + WindowGranularity::Region => { + let dpr = crate::atlas::scale::DISTRICTS_PER_REGION as f64; + ((n as f64 / dpr).round() as i32).max(1) + } + } + } +} + /// Resolve a wire-supplied `window_granularity` value to one of the two legal /// granularities, clamping anything else down to district spacing — **never /// trust the wire** (same posture as `window_n`/`normalize_window_center`). /// -/// **This is THE single widening point (Tyre C2, PR #191 review).** The -/// field only ever expresses finer-than-district integer multiples (see -/// [`AtlasLayerRequest::window_granularity`]'s doc for the full type-seam -/// contract); adding a future finer rung means adding its legal value here -/// and nowhere else. Do NOT add a value < 1 or attempt to encode -/// coarser-than-district rungs (region/orbital) through this function — -/// design doc §5/§9 R5 requires a signed/log-scale or enum redesign for that -/// direction, which this `u32` cannot express regardless of what this -/// function returns. +/// **This is THE single widening point (Tyre C2, PR #191 review) for the +/// LEGACY `u32` field only.** The field only ever expresses finer-than-district +/// integer multiples (see [`AtlasLayerRequest::window_granularity`]'s doc for +/// the full type-seam contract); adding a future finer rung means adding its +/// legal value here and nowhere else. Do NOT add a value < 1 or attempt to +/// encode coarser-than-district rungs (region/orbital) through this function +/// — that direction is [`WindowGranularity::Region`]'s job via +/// [`resolve_window_granularity_v2`], never a new magic `u32` value (the R5 +/// redesign this enum performs is EXACTLY the alternative to doing that). fn resolve_window_granularity(raw: u32) -> u32 { if raw == WINDOW_GRANULARITY_QUARTER { WINDOW_GRANULARITY_QUARTER @@ -84,6 +296,36 @@ fn resolve_window_granularity(raw: u32) -> u32 { } } +/// Resolve a request's granularity to a [`WindowGranularity`] — **the single +/// widening point for the full (finer- and coarser-than-district) vocabulary** +/// (T-1152, mirroring [`resolve_window_granularity`]'s role for the legacy +/// `u32` alone). Precedence (documented once, here — see +/// [`WindowGranularity`]'s struct doc for the rationale): +/// +/// 1. `window_granularity_v2` present → resolved directly (unknown/malformed +/// variants can't reach this function at all — `rmp_serde` rejects an +/// unrecognized enum variant name at decode time, so "unknown" for THIS +/// field means "absent", not "present but garbage"; the legacy `u32` +/// path is what actually needs the value-level fallback because a `u32` +/// has no closed vocabulary). +/// 2. `window_granularity_v2` absent → fall back to the legacy `u32` path via +/// [`resolve_window_granularity`], mapped onto the two variants it can +/// express. +/// +/// A future coarser-than-region rung is added by widening this function's +/// match AND [`WindowGranularity`]'s variant list together — never by +/// smuggling a new value through the legacy `u32` (that field's ceiling is +/// permanent per Tyre's note, not a restriction this function works around). +fn resolve_window_granularity_v2(req: &AtlasLayerRequest) -> WindowGranularity { + match req.window_granularity_v2 { + Some(g) => g, + None => match resolve_window_granularity(req.window_granularity) { + WINDOW_GRANULARITY_QUARTER => WindowGranularity::Quarter, + _ => WindowGranularity::District, + }, + } +} + /// Clamp `window_n` against BOTH the existing per-axis cap /// ([`DISTRICT_WINDOW_MAX_N`]) and the granularity-aware wire-size ceiling /// ([`WIRE_CAP_CELLS`]) — `window_n² × granularity² ≤ WIRE_CAP_CELLS` (T-1150 @@ -106,6 +348,62 @@ fn clamp_window_n(raw_n: u32, granularity: u32) -> u32 { n.min(cap_n.floor().max(1.0) as u32) } +/// [`WindowGranularity`]-aware twin of [`clamp_window_n`] (T-1152 step 4) — +/// the SAME two-stage discipline (per-axis clamp, THEN the wire-size +/// ceiling on the DERIVED cell count, never trusted from the wire), but +/// computed through [`WindowGranularity::cell_grid_side`] so it is correct +/// for BOTH directions (finer multiplies cell count; `Region` divides it — +/// see that method's doc) instead of assuming the finer-only +/// `n × multiplier` relationship [`clamp_window_n`] hard-codes. +/// +/// - **`District`/`Quarter`:** per-axis cap is [`DISTRICT_WINDOW_MAX_N`] +/// (64, unchanged) — byte-identical clamped `n` to [`clamp_window_n`] for +/// every input these two variants can produce (verified by +/// `clamp_window_n_v2_delegates_to_legacy_for_district_and_quarter`, +/// below). +/// - **`Region`:** per-axis cap is [`DISTRICT_WINDOW_MAX_N_REGION`] (6,400 — +/// see that constant's doc for the derivation), then a halving loop walks +/// `n` back if `cell_grid_side(n)` would still exceed `sqrt(WIRE_CAP_CELLS)` +/// region cells across. +/// +/// **This loop is defensive, not currently reachable — stated plainly, not +/// left implicit.** `DISTRICT_WINDOW_MAX_N_REGION` is DERIVED as +/// `sqrt(WIRE_CAP_CELLS) * DISTRICTS_PER_REGION` specifically so the +/// per-axis clamp alone already forecloses the loop's trigger condition: a +/// brute-force sweep of every `raw_n` in `[1, DISTRICT_WINDOW_MAX_N_REGION]` +/// shows `cell_grid_side(n)` never exceeds `sqrt(WIRE_CAP_CELLS)` (64), so +/// `n /= 2` never executes for any input the per-axis clamp lets through — +/// verified by `clamp_window_n_v2_region_per_axis_cap_alone_satisfies_wire_cap_for_all_inputs`, +/// which pins BOTH the invariant (`cell_grid_side(result)² ≤ WIRE_CAP_CELLS`) +/// AND the loop's current no-op status (`result == raw_n.clamp(1, +/// DISTRICT_WINDOW_MAX_N_REGION)` for every swept input). The loop is kept +/// anyway as the general, correct algorithm (region's `cell_grid_side` is a +/// ROUNDING division, not the finer rungs' exact multiplication, so there +/// is no closed-form inverse the way `cap_n = sqrt(WIRE_CAP_CELLS) / g` is +/// for the finer case) — it is the safety net for a FUTURE cap derivation +/// that doesn't land exactly on the boundary (a new rung from a later +/// measurement pass, or a `WIRE_CAP_CELLS` retune that isn't a perfect +/// square times `DISTRICTS_PER_REGION`). If a future constant change makes +/// the loop actually fire, the pinned no-op assertion above breaks loudly, +/// forcing a deliberate look rather than a silent behavior change. +fn clamp_window_n_v2(raw_n: u32, granularity: WindowGranularity) -> u32 { + match granularity { + WindowGranularity::District | WindowGranularity::Quarter => clamp_window_n( + raw_n, + granularity + .legacy_u32() + .unwrap_or(WINDOW_GRANULARITY_DISTRICT), + ), + WindowGranularity::Region => { + let mut n = raw_n.clamp(1, DISTRICT_WINDOW_MAX_N_REGION); + while granularity.cell_grid_side(n).pow(2) as u32 > WIRE_CAP_CELLS && n > 1 { + n /= 2; + } + n.max(1) + } + } +} + /// Quantized `window_min_wl_m` bands (T-1150, zoom ladder design doc §5): /// `0` (no cutoff) plus every entry of /// [`crate::atlas::detail_scatter::OCTAVE_WAVELENGTHS_M`] — the SAME array @@ -192,6 +490,17 @@ pub struct AtlasLayerRequest { /// the redesign R5 already flags, not a value to add here. #[serde(default)] pub window_granularity: u32, + /// The R5-redesigned granularity vocabulary (T-1152), able to express + /// coarser-than-district rungs the legacy `window_granularity: u32` + /// cannot (Tyre's wire-contract note — see [`WindowGranularity`]'s doc + /// for the full rationale). `#[serde(default)]` (`None`) is the absent + /// case: every pre-T-1152 client (and every T-1150 client that only ever + /// sends the legacy `u32`) omits this field entirely and is byte-compatible + /// — [`resolve_window_granularity_v2`] falls back to the legacy field + /// when this is `None`. When BOTH fields are present, THIS field wins + /// (documented once, on [`WindowGranularity`], not duplicated here). + #[serde(default)] + pub window_granularity_v2: Option, /// Octave cutoff for the invented-terrain scatter (T-1149's /// `min_wavelength_m`), in whole metres. `0` (absent) = no cutoff = the /// pre-T-1150 behavior. @@ -444,10 +753,25 @@ pub struct DistrictWindowLayer { /// The derived cell grid's actual side length is `n * granularity`. pub n: u32, /// Derivation granularity (T-1150): [`WINDOW_GRANULARITY_DISTRICT`] (1) - /// or [`WINDOW_GRANULARITY_QUARTER`] (4). Echoed so the client's cache - /// key and staleness guard can distinguish a district-spacing window from - /// a quarter-spacing window requested at the identical `(center, n)`. + /// or [`WINDOW_GRANULARITY_QUARTER`] (4) for finer-than-district rungs; + /// [`WINDOW_GRANULARITY_REGION_KEY`] (a reserved key-space tag, NOT a + /// spacing multiplier) when [`Self::granularity_v2`] is `Region` — see + /// that constant's doc. Kept unchanged (never repurposed or removed) for + /// wire back-compat with every pre-T-1152 client, which reads only this + /// field and has no concept of `granularity_v2`. Echoed so the client's + /// cache key and staleness guard can distinguish windows at different + /// finer-than-district rungs requested at the identical `(center, n)`. pub granularity: u32, + /// The R5-redesigned granularity (T-1152) — see [`WindowGranularity`]'s + /// doc. Always populated (never `None`): the server always resolves a + /// concrete rung internally via [`resolve_window_granularity_v2`] + /// regardless of which wire field the request used, so the response + /// always carries the enum echo alongside the legacy `u32` one. A + /// T-1152-aware client reads THIS field for staleness/cache-key + /// comparison; `granularity` (above) exists only for pre-T-1152 clients, + /// who never see `Region` responses in the first place (they have no way + /// to request one). + pub granularity_v2: WindowGranularity, /// The `min_wavelength_m` octave cutoff (T-1149) this window was derived /// with, in whole metres (`0` = no cutoff). Echoed for the same reason as /// `granularity` — two windows at identical `(center, n, granularity)` @@ -473,16 +797,26 @@ pub struct DistrictWindowLayer { pub glaciation: Vec, } -/// Key for the server-side window derive cache (T-1137, extended T-1150): -/// `(body_id, center, n, granularity, min_wl_m)`. D-227 purity means a cached -/// window is valid forever for a given body+seed — no staleness/TTL -/// invalidation is needed, only a bound on unbounded growth (see -/// [`DistrictWindowCache`]). `granularity`/`min_wl_m` MUST be part of the key -/// — the design doc's aliasing risk (§3): a granularity-4 request at the same -/// `(body, center, n)` as a granularity-1 request is a DIFFERENT payload and -/// must land in a different cache slot, never silently overwrite or be served -/// by the other. -pub type DistrictWindowKey = (String, DistrictPos, u32, u32, u32); +/// Key for the server-side window derive cache (T-1137, extended T-1150, +/// extended again T-1152): `(body_id, center, n, granularity, min_wl_m)`. +/// D-227 purity means a cached window is valid forever for a given +/// body+seed — no staleness/TTL invalidation is needed, only a bound on +/// unbounded growth (see [`DistrictWindowCache`]). `granularity`/`min_wl_m` +/// MUST be part of the key — the design doc's aliasing risk (§3): a +/// granularity-4 request at the same `(body, center, n)` as a granularity-1 +/// request is a DIFFERENT payload and must land in a different cache slot, +/// never silently overwrite or be served by the other. +/// +/// **The granularity slot is [`WindowGranularity`] itself (T-1152), not the +/// legacy `u32`** — carrying the full enum here (rather than relying on +/// [`WindowGranularity::key_u32`]'s reserved-sentinel trick alone) is what +/// makes a `Region` window's cache slot structurally distinct from a +/// `District`/`Quarter` one, satisfying the "carry the new representation" +/// requirement at the cache-key touch point directly rather than through an +/// encoding side-channel. `WindowGranularity`'s `Ord` derive (declaration +/// order: `Quarter < District < Region`) makes this legal as a `BTreeMap` +/// key (D-010 determinism — ordered iteration, no `HashMap`). +pub type DistrictWindowKey = (String, DistrictPos, u32, WindowGranularity, u32); /// Bounded LRU-ish cache of completed district-window derives (T-1137), a /// sibling to [`BodyWorldStateCache`] rather than a field on it: windows are @@ -565,15 +899,28 @@ struct WindowCell { /// cell is an independent function of its own world-metre position, nothing /// shared mutably. /// -/// `step_m` is the metre spacing between cells (T-1150): `DISTRICT_M` at -/// district granularity, `QUARTER_M` at quarter granularity — the caller -/// picks it, this function is granularity-agnostic (it only knows metres). +/// `granularity` (T-1150, widened to [`WindowGranularity`] T-1152) selects +/// BOTH the metre spacing between cells ([`WindowGranularity::spacing_m`]) +/// AND the derivation function: finer-than-district rungs (`District`, +/// `Quarter`) call `derive_at_metres` (full derivation, `invent_primitives` +/// included) exactly as T-1150 shipped; `Region` calls +/// [`crate::atlas::district_profile::derive_orbital_at_metres`] instead — the +/// design doc §2/§4 orbital row's region-baseline-blend-only path, no +/// `invent_primitives` call. This is the ONE place the two derivation +/// functions fork based on rung — everything else in the window-building +/// pipeline (`scatter_row`, the six-array packing, the cache/echo plumbing) +/// is identical regardless of which function ran, because both produce a +/// `DistrictProfile` and this function's WindowCell-packing tail (below) is +/// shared. +/// /// `half_cells` is HALF the cell-grid side (`side / 2`, already in the /// caller's cell units, not districts), so `center` (a `DistrictPos`, always /// district-scale) is converted to a world-metre origin once by the caller -/// and offset here in `step_m` units — this is what makes the quarter grid -/// cover the SAME world rect as the district grid at 4x the cell density -/// (design doc §2 Option B). +/// and offset here in `spacing_m()` units — this is what makes the quarter +/// grid cover the SAME world rect as the district grid at 4x the cell +/// density (design doc §2 Option B), and what makes a region-granularity +/// window cover a proportionally larger world rect at 1/100x the cell +/// density (D-243: `DISTRICTS_PER_REGION = 100`). #[allow(clippy::too_many_arguments)] fn derive_window_cell( seed: SeedChain, @@ -583,25 +930,33 @@ fn derive_window_cell( climate: &crate::atlas::district_profile::ClimateConstants, center_world_m: (f64, f64), half_cells: i32, - step_m: f64, + granularity: WindowGranularity, min_wavelength_m: f64, row: i32, col: i32, ) -> WindowCell { // Row 0 = northmost, matching aliveness_probe's render_window_panels // (derive_at_metres maps negative wy to negative lat_frac = north). + let step_m = granularity.spacing_m(); let wx = center_world_m.0 + (col - half_cells) as f64 * step_m; let wy = center_world_m.1 + (row - half_cells) as f64 * step_m; - let prof = crate::atlas::district_profile::derive_at_metres( - seed, - body_id, - params, - ta, - wx, - wy, - climate, - min_wavelength_m, - ); + let prof = match granularity { + WindowGranularity::Region => crate::atlas::district_profile::derive_orbital_at_metres( + seed, body_id, params, ta, wx, wy, climate, + ), + WindowGranularity::District | WindowGranularity::Quarter => { + crate::atlas::district_profile::derive_at_metres( + seed, + body_id, + params, + ta, + wx, + wy, + climate, + min_wavelength_m, + ) + } + }; WindowCell { morphology: prof.morphology_zone as u8, elev_q: prof.elev_q.clamp(0, 100) as u8, @@ -652,17 +1007,6 @@ fn center_to_world_m(center: DistrictPos) -> (f64, f64) { (center.0 as f64 * dm, center.1 as f64 * dm) } -/// Cell step size in metres for a given granularity (T-1150): district -/// spacing (2,048 m) or quarter spacing (512 m). Any other value resolves to -/// district spacing (mirrors [`resolve_window_granularity`]'s fallback). -fn step_m_for_granularity(granularity: u32) -> f64 { - if granularity == WINDOW_GRANULARITY_QUARTER { - crate::atlas::scale::QUARTER_M as f64 - } else { - DISTRICT_M as f64 - } -} - /// Build a [`DistrictWindowLayer`] by deriving every cell in the window /// around `center` (T-1137, extended T-1150). Mirrors /// `aliveness_probe::render_window_panels`'s derive loop exactly (the probe @@ -677,11 +1021,13 @@ fn step_m_for_granularity(granularity: u32) -> f64 { /// of clamping at the edge). /// /// `n` is always the window extent in DISTRICTS (design doc §2 Option B: "the -/// window's `n` stays the DISTRICT extent"). At `granularity = 1` the derived -/// cell grid is `n × n` districts; at `granularity = 4` it is `(4n) × (4n)` -/// quarters covering the SAME world rect — full reclassification at the finer -/// spacing (`derive_at_metres` with `min_wavelength_m` matching the rung), -/// never a coarser-cell interpolation. +/// window's `n` stays the DISTRICT extent"). The derived cell-grid side +/// length is [`WindowGranularity::cell_grid_side`] — `n × 4` at `Quarter`, +/// `n` at `District`, `round(n / 100)` at `Region` (T-1152, D-243's +/// `DISTRICTS_PER_REGION = 100`; see that method's doc for the full +/// finer-multiplies/coarser-divides rationale). Full reclassification at the +/// rung's own spacing (`derive_at_metres`/`derive_orbital_at_metres` per +/// [`derive_window_cell`]'s dispatch), never a coarser-cell interpolation. /// /// **Row-chunked `par_iter` (T-1151):** each cell is a pure function of its /// own position (D-227), so rows can derive in parallel with no shared @@ -689,10 +1035,13 @@ fn step_m_for_granularity(granularity: u32) -> f64 { /// task-dispatch overhead against the ~1.2 µs/cell derive cost (design doc /// §7: naive per-cell parallelization risks the dispatch overhead itself /// costing more than the work) — one Rayon task per row means `side` tasks of -/// `side` cells each, not `side²` tasks of one cell each. [`build_district_window_layer_serial`] -/// is kept alongside this as the golden-comparison baseline (T-1151 -/// acceptance: bit-identical serial vs. parallel output, exact row-major -/// array ordering preserved either way). +/// `side` cells each, not `side²` tasks of one cell each. This applies +/// unchanged to the `Region` rung (T-1152 step 4: "progressive capped-density +/// tiling on the SAME carrier ... No new message shape") — the SAME row-chunked +/// parallel loop, cache, and coalescing machinery serve every rung. +/// [`build_district_window_layer_serial`] is kept alongside this as the +/// golden-comparison baseline (T-1151 acceptance: bit-identical serial vs. +/// parallel output, exact row-major array ordering preserved either way). #[allow(clippy::too_many_arguments)] pub fn build_district_window_layer( seed: SeedChain, @@ -702,14 +1051,13 @@ pub fn build_district_window_layer( center: DistrictPos, n: u32, climate: &crate::atlas::district_profile::ClimateConstants, - granularity: u32, + granularity: WindowGranularity, min_wl_m: u32, ) -> DistrictWindowLayer { use rayon::prelude::*; - let side = (n * granularity.max(1)) as i32; + let side = granularity.cell_grid_side(n); let half = side / 2; - let step_m = step_m_for_granularity(granularity); let min_wavelength_m = min_wl_m as f64; let center_world_m = center_to_world_m(center); let cells = (side * side) as usize; @@ -738,7 +1086,7 @@ pub fn build_district_window_layer( climate, center_world_m, half, - step_m, + granularity, min_wavelength_m, row, col, @@ -765,7 +1113,8 @@ pub fn build_district_window_layer( DistrictWindowLayer { center, n, - granularity, + granularity: granularity.key_u32(), + granularity_v2: granularity, min_wl_m, morphology, elev_q, @@ -789,12 +1138,11 @@ fn build_district_window_layer_serial( center: DistrictPos, n: u32, climate: &crate::atlas::district_profile::ClimateConstants, - granularity: u32, + granularity: WindowGranularity, min_wl_m: u32, ) -> DistrictWindowLayer { - let side = (n * granularity.max(1)) as i32; + let side = granularity.cell_grid_side(n); let half = side / 2; - let step_m = step_m_for_granularity(granularity); let min_wavelength_m = min_wl_m as f64; let center_world_m = center_to_world_m(center); let cells = (side * side) as usize; @@ -815,7 +1163,7 @@ fn build_district_window_layer_serial( climate, center_world_m, half, - step_m, + granularity, min_wavelength_m, row, col, @@ -837,7 +1185,8 @@ fn build_district_window_layer_serial( DistrictWindowLayer { center, n, - granularity, + granularity: granularity.key_u32(), + granularity_v2: granularity, min_wl_m, morphology, elev_q, @@ -1253,10 +1602,13 @@ fn normalize_window_center(params: &BodyParams, center: DistrictPos) -> District /// processed the completion (the existing D-225 poll-and-recheck-cache /// pattern every other layer already uses, not a push). /// -/// `window_n` is clamped to `[1, DISTRICT_WINDOW_MAX_N]` AND the +/// `window_n` is clamped to `[1, DISTRICT_WINDOW_MAX_N]` (or +/// `DISTRICT_WINDOW_MAX_N_REGION` at `Region` granularity, T-1152) AND the /// granularity-aware `WIRE_CAP_CELLS` ceiling here — the ONE place that clamp -/// is applied; nothing downstream re-checks the wire value. `window_granularity` -/// is resolved via [`resolve_window_granularity`] at the same boundary (T-1150). +/// is applied; nothing downstream re-checks the wire value. The granularity +/// itself is resolved via [`resolve_window_granularity_v2`] at the same +/// boundary (T-1150, widened T-1152 — see that function's doc for the +/// legacy-`u32`-vs-`window_granularity_v2` precedence rule). #[allow(clippy::too_many_arguments)] fn serve_district_window( req: &AtlasLayerRequest, @@ -1268,8 +1620,8 @@ fn serve_district_window( conn_id: ConnectionId, ) -> Option { let raw_center = req.window_center?; - let granularity = resolve_window_granularity(req.window_granularity); - let n = clamp_window_n(req.window_n, granularity); + let granularity = resolve_window_granularity_v2(req); + let n = clamp_window_n_v2(req.window_n, granularity); // T-1150 design doc §5: quantize BEFORE either the cache key or the // DeriveWindow work item sees it — the raw wire value never reaches // either (same discipline as window_n's clamp above and @@ -1731,13 +2083,14 @@ mod tests { (10, -5), n, &climate, - WINDOW_GRANULARITY_DISTRICT, + WindowGranularity::District, 0, ); assert_eq!(layer.center, (10, -5)); assert_eq!(layer.n, n); assert_eq!(layer.granularity, WINDOW_GRANULARITY_DISTRICT); + assert_eq!(layer.granularity_v2, WindowGranularity::District); assert_eq!(layer.min_wl_m, 0); let cells = (n * n) as usize; assert_eq!(layer.morphology.len(), cells); @@ -1785,7 +2138,7 @@ mod tests { (0, 0), 1, &climate, - WINDOW_GRANULARITY_DISTRICT, + WindowGranularity::District, 0, ); assert_eq!(layer.n, 1); @@ -1819,7 +2172,7 @@ mod tests { (3, -2), n, &climate, - WINDOW_GRANULARITY_DISTRICT, + WindowGranularity::District, 0, ); let second = build_district_window_layer( @@ -1830,7 +2183,7 @@ mod tests { (3, -2), n, &climate, - WINDOW_GRANULARITY_DISTRICT, + WindowGranularity::District, 0, ); assert_eq!( @@ -1863,7 +2216,7 @@ mod tests { center, n, &climate, - WINDOW_GRANULARITY_DISTRICT, + WindowGranularity::District, 0, ); let serial = build_district_window_layer_serial( @@ -1874,7 +2227,7 @@ mod tests { center, n, &climate, - WINDOW_GRANULARITY_DISTRICT, + WindowGranularity::District, 0, ); assert_eq!( @@ -1942,7 +2295,7 @@ mod tests { center, n, &climate, - WINDOW_GRANULARITY_DISTRICT, + WindowGranularity::District, 0, ); let window_from_pass2 = build_district_window_layer( @@ -1953,7 +2306,7 @@ mod tests { center, n, &climate, - WINDOW_GRANULARITY_DISTRICT, + WindowGranularity::District, 0, ); assert_eq!( @@ -1972,13 +2325,14 @@ mod tests { #[test] fn district_window_cache_insert_get_and_evict() { let mut cache = DistrictWindowCache::new(2); - let key_a: DistrictWindowKey = ("Alpha".into(), (0, 0), 4, WINDOW_GRANULARITY_DISTRICT, 0); - let key_b: DistrictWindowKey = ("Beta".into(), (1, 1), 4, WINDOW_GRANULARITY_DISTRICT, 0); - let key_c: DistrictWindowKey = ("Gamma".into(), (2, 2), 4, WINDOW_GRANULARITY_DISTRICT, 0); + let key_a: DistrictWindowKey = ("Alpha".into(), (0, 0), 4, WindowGranularity::District, 0); + let key_b: DistrictWindowKey = ("Beta".into(), (1, 1), 4, WindowGranularity::District, 0); + let key_c: DistrictWindowKey = ("Gamma".into(), (2, 2), 4, WindowGranularity::District, 0); let mk = |center, n| DistrictWindowLayer { center, n, granularity: WINDOW_GRANULARITY_DISTRICT, + granularity_v2: WindowGranularity::District, min_wl_m: 0, morphology: vec![0; (n * n) as usize], elev_q: vec![0; (n * n) as usize], @@ -2026,6 +2380,7 @@ mod tests { window_center: Some((0, 0)), window_n: DISTRICT_WINDOW_MAX_N * 10, // wildly over the wire — must clamp, not trust window_granularity: 0, + window_granularity_v2: None, window_min_wl_m: 0, }; @@ -2086,6 +2441,7 @@ mod tests { window_center: Some((0, 0)), window_n: 32, window_granularity: WINDOW_GRANULARITY_QUARTER, + window_granularity_v2: None, window_min_wl_m: 0, }; @@ -2196,6 +2552,98 @@ mod tests { assert_eq!(clamp_window_n(8, WINDOW_GRANULARITY_QUARTER), 8); } + // ------------------------------------------------------------------- + // clamp_window_n_v2 (T-1152; PR #192 review — Hoshe, coordinator ruling + // 2026-07-22: test 2 reframed per the brute-force finding that the + // Region halving loop is unreachable at the CURRENT constants — see + // clamp_window_n_v2's doc comment for the full rationale) + // ------------------------------------------------------------------- + + /// The exact boundary: `n = DISTRICT_WINDOW_MAX_N_REGION` (6,400) is the + /// largest per-axis-legal `n`, and it lands EXACTLY on the wire-size + /// ceiling (`cell_grid_side(6400) = 64 = sqrt(WIRE_CAP_CELLS)`, + /// `64² = 4,096 = WIRE_CAP_CELLS`) — uncontested, meaning the request is + /// NOT further reduced by the halving loop; the per-axis clamp alone is + /// already exact at this boundary. + #[test] + fn clamp_window_n_v2_region_exact_boundary_n6400_uncontested() { + let result = clamp_window_n_v2(DISTRICT_WINDOW_MAX_N_REGION, WindowGranularity::Region); + assert_eq!( + result, DISTRICT_WINDOW_MAX_N_REGION, + "n=6400 must pass through unmodified — it already lands exactly on the ceiling" + ); + let side = WindowGranularity::Region.cell_grid_side(result) as u32; + assert_eq!( + side * side, + WIRE_CAP_CELLS, + "n=6400's cell_grid_side must land EXACTLY on WIRE_CAP_CELLS, not under or over it" + ); + } + + /// **Reframed per the coordinator's 2026-07-22 ruling (PR #192 review — + /// Hoshe).** The originally-briefed name/shape + /// (`clamp_window_n_v2_region_halving_loop_fires_above_boundary`, e.g. + /// n=6450) does not hold: `raw_n.clamp(1, DISTRICT_WINDOW_MAX_N_REGION)` + /// runs BEFORE the halving loop's condition is ever checked, so any + /// `raw_n > DISTRICT_WINDOW_MAX_N_REGION` is clamped to exactly 6,400 — + /// the SAME uncontested boundary the test above proves — before + /// `cell_grid_side` ever sees the raw value. A brute-force sweep (done + /// by hand before writing this test, see `clamp_window_n_v2`'s doc + /// comment) confirms `cell_grid_side(n)` never exceeds `sqrt(WIRE_CAP_CELLS)` + /// for ANY `n` in `[1, DISTRICT_WINDOW_MAX_N_REGION]` — so the halving + /// loop is unreachable at the CURRENT constant derivation, not a bug to + /// manufacture a test around (coordinator's option 1, not option 2). + /// + /// This test proves the ACTUAL property: the per-axis cap ALONE already + /// satisfies the wire-size ceiling for every reachable input, and pins + /// the loop's current no-op status explicitly — swept across + /// `[1, 2 × DISTRICT_WINDOW_MAX_N_REGION]` (double the legal range, so + /// wildly-oversized wire values are covered too, never trusting the + /// wire). If a FUTURE constant change (a new rung, a `WIRE_CAP_CELLS` + /// retune) ever makes the loop fire, the second assertion below breaks + /// LOUDLY — forcing a deliberate look rather than a silent behavior + /// change (exactly the safety-net role the loop exists for). + #[test] + fn clamp_window_n_v2_region_per_axis_cap_alone_satisfies_wire_cap_for_all_inputs() { + for raw_n in 1..=(2 * DISTRICT_WINDOW_MAX_N_REGION) { + let result = clamp_window_n_v2(raw_n, WindowGranularity::Region); + let side = WindowGranularity::Region.cell_grid_side(result) as u32; + assert!( + side * side <= WIRE_CAP_CELLS, + "raw_n={raw_n}: clamped result {result} (side {side}) exceeds WIRE_CAP_CELLS" + ); + assert_eq!( + result, + raw_n.clamp(1, DISTRICT_WINDOW_MAX_N_REGION), + "raw_n={raw_n}: the halving loop must be a no-op at current constants — \ + the per-axis clamp alone must already be the final answer" + ); + } + } + + /// `District`/`Quarter` through `clamp_window_n_v2` must be BYTE-IDENTICAL + /// to the legacy `clamp_window_n` for every input either variant can + /// legally carry — `clamp_window_n_v2` is documented as delegating to the + /// legacy function unchanged for these two rungs, this pins that claim + /// with a sweep rather than a handful of spot values. + #[test] + fn clamp_window_n_v2_delegates_to_legacy_for_district_and_quarter() { + // Sweep well past DISTRICT_WINDOW_MAX_N so the "never trust the wire" + // oversized-input case is covered too, not just in-range values. + for raw_n in 0..=(DISTRICT_WINDOW_MAX_N * 3) { + assert_eq!( + clamp_window_n_v2(raw_n, WindowGranularity::District), + clamp_window_n(raw_n, WINDOW_GRANULARITY_DISTRICT), + "District: clamp_window_n_v2 must match clamp_window_n exactly at raw_n={raw_n}" + ); + assert_eq!( + clamp_window_n_v2(raw_n, WindowGranularity::Quarter), + clamp_window_n(raw_n, WINDOW_GRANULARITY_QUARTER), + "Quarter: clamp_window_n_v2 must match clamp_window_n exactly at raw_n={raw_n}" + ); + } + } + // ------------------------------------------------------------------- // quantize_min_wl_m (T-1150, PR #191 review — Hoshe 1 / Tyre C3, design doc §5) // ------------------------------------------------------------------- @@ -2261,6 +2709,7 @@ mod tests { window_center: Some((10, -5)), window_n: 4, window_granularity: WINDOW_GRANULARITY_DISTRICT, + window_granularity_v2: None, window_min_wl_m: 4_000, }; let req_b = AtlasLayerRequest { @@ -2269,6 +2718,7 @@ mod tests { window_center: Some((10, -5)), window_n: 4, window_granularity: WINDOW_GRANULARITY_DISTRICT, + window_granularity_v2: None, window_min_wl_m: 4_300, }; @@ -2307,7 +2757,7 @@ mod tests { body_id, layer.center, layer.n, - layer.granularity, + layer.granularity_v2, layer.min_wl_m, ), *layer, @@ -2486,6 +2936,7 @@ mod tests { window_center: Some((12276, 3021)), window_n: 4, window_granularity: 0, + window_granularity_v2: None, window_min_wl_m: 0, }; let resp1 = handle_atlas_request( @@ -2517,7 +2968,7 @@ mod tests { body_id, layer.center, layer.n, - layer.granularity, + layer.granularity_v2, layer.min_wl_m, ), *layer, @@ -2542,6 +2993,7 @@ mod tests { window_center: Some((4, 383)), // the hand-computed canonical twin window_n: 4, window_granularity: 0, + window_granularity_v2: None, window_min_wl_m: 0, }; let resp2 = handle_atlas_request( @@ -2624,6 +3076,7 @@ mod tests { window_center: center, window_n: n, window_granularity: WINDOW_GRANULARITY_DISTRICT, + window_granularity_v2: None, window_min_wl_m: 0, }; let quarter_req = AtlasLayerRequest { @@ -2632,6 +3085,7 @@ mod tests { window_center: center, window_n: n, window_granularity: WINDOW_GRANULARITY_QUARTER, + window_granularity_v2: None, window_min_wl_m: 0, }; @@ -2671,7 +3125,7 @@ mod tests { body_id, layer.center, layer.n, - layer.granularity, + layer.granularity_v2, layer.min_wl_m, ), *layer, @@ -2723,6 +3177,8 @@ mod tests { assert_eq!(district_layer.granularity, WINDOW_GRANULARITY_DISTRICT); assert_eq!(quarter_layer.granularity, WINDOW_GRANULARITY_QUARTER); + assert_eq!(district_layer.granularity_v2, WindowGranularity::District); + assert_eq!(quarter_layer.granularity_v2, WindowGranularity::Quarter); // n echoes the DISTRICT extent unchanged at both granularities // (design doc §2: "the window's n stays the DISTRICT extent"). assert_eq!(district_layer.n, n); @@ -2749,6 +3205,227 @@ mod tests { ); } + /// **MANDATORY aliasing regression for the T-1152 coarser rung** — the + /// SAME discipline `granularity_4_and_granularity_1_requests_produce_distinct_cache_entries` + /// established for finer-than-district rungs, extended to `Region` + /// (T-1150 design doc §3's aliasing risk, generalized by the T-1152 + /// wire-contract note: "mirror the T-1150 aliasing tests for at least + /// one coarser rung"). A `Region`-granularity request and a + /// `District`-granularity request at the IDENTICAL `(body, center, n)` + /// must produce DISTINCT cache entries, distinct payload shapes, and the + /// clamp/echo contract must hold at the coarse rung too (PR #191 C1 + /// lesson generalized: the client's mirror of `clamp_window_n_v2` MUST + /// be derivable from the same constants this test exercises). + #[test] + fn region_and_district_requests_produce_distinct_cache_entries() { + let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY); + let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY); + let (_db, resolver, params_reader, _root) = + resolver_and_params_reader_with_radius("OrbitalAliasBody", 6371.0); + // See the district/quarter alias test above for the thread-count + // rationale (AnalyzeBody + two DeriveWindow items must all be able to + // dispatch concurrently within one drain_completions() call). + let queue = GenerationQueue::with_threads(3); + + let center = Some((10, -5)); + let n = 4u32; + + let district_req = AtlasLayerRequest { + body_id: "OrbitalAliasBody".to_string(), + up_to: CascadeLayer::Topography, + window_center: center, + window_n: n, + window_granularity: WINDOW_GRANULARITY_DISTRICT, + window_granularity_v2: None, + window_min_wl_m: 0, + }; + let region_req = AtlasLayerRequest { + body_id: "OrbitalAliasBody".to_string(), + up_to: CascadeLayer::Topography, + window_center: center, + window_n: n, + // Legacy field is irrelevant here — window_granularity_v2 takes + // precedence per resolve_window_granularity_v2's documented rule. + window_granularity: 0, + window_granularity_v2: Some(WindowGranularity::Region), + window_min_wl_m: 0, + }; + + handle_atlas_request( + &district_req, + &mut cache, + &mut window_cache, + &queue, + &resolver, + None, + Some(¶ms_reader), + 42, + 1, + test_conn_id(), + ); + handle_atlas_request( + ®ion_req, + &mut cache, + &mut window_cache, + &queue, + &resolver, + None, + Some(¶ms_reader), + 42, + 1, + test_conn_id(), + ); + + std::thread::sleep(Duration::from_millis(300)); + let completions = queue.drain_completions(); + for c in completions { + if let GenCompletion::WindowDerived { body_id, layer } = c { + if body_id == "OrbitalAliasBody" { + window_cache.insert( + ( + body_id, + layer.center, + layer.n, + layer.granularity_v2, + layer.min_wl_m, + ), + *layer, + ); + } + } + } + + assert_eq!( + window_cache.len(), + 2, + "district and region requests at the SAME (body, center, n) must occupy \ + TWO distinct cache entries, not alias onto one" + ); + + let district_resp = handle_atlas_request( + &district_req, + &mut cache, + &mut window_cache, + &queue, + &resolver, + None, + Some(¶ms_reader), + 42, + 2, + test_conn_id(), + ); + let region_resp = handle_atlas_request( + ®ion_req, + &mut cache, + &mut window_cache, + &queue, + &resolver, + None, + Some(¶ms_reader), + 42, + 2, + test_conn_id(), + ); + + let district_layer = district_resp + .district_window + .expect("district request must hit its own cached entry"); + let region_layer = region_resp + .district_window + .expect("region request must hit its own cached entry"); + + assert_eq!(district_layer.granularity_v2, WindowGranularity::District); + assert_eq!(region_layer.granularity_v2, WindowGranularity::Region); + // The legacy u32 echo must NEVER collide with a real multiplier — + // WINDOW_GRANULARITY_REGION_KEY is the reserved key-space tag (see + // that constant's doc), distinct from both WINDOW_GRANULARITY_DISTRICT + // (1) and WINDOW_GRANULARITY_QUARTER (4). + assert_eq!(region_layer.granularity, WINDOW_GRANULARITY_REGION_KEY); + assert_ne!(region_layer.granularity, district_layer.granularity); + + // n echoes the DISTRICT extent unchanged (design doc §2), same as + // every other rung — the derived CELL GRID is what differs. + assert_eq!(district_layer.n, n); + assert_eq!(region_layer.n, n); + assert_eq!(district_layer.morphology.len(), (n * n) as usize); + // n=4 districts is far under one region's 100-district side, so + // cell_grid_side rounds down to the minimum 1x1 region cell — + // exercising the "region divides, doesn't multiply" cell-count + // relationship WindowGranularity::cell_grid_side documents. + assert_eq!( + region_layer.morphology.len(), + 1, + "n=4 districts is far under one region's 100-district side; \ + cell_grid_side must round down to a single region cell, not zero \ + and not a district-sized grid" + ); + } + + /// The clamp/echo contract at the coarse (Region) rung (T-1152, the PR + /// #191 C1 lesson generalized): a region request whose `n` would derive + /// MORE than `sqrt(WIRE_CAP_CELLS)` region cells across must clamp `n` + /// down and echo the CLAMPED value — Stig's client-side mirror of + /// `clamp_window_n_v2` must be derivable from + /// `DISTRICT_WINDOW_MAX_N_REGION`/`WIRE_CAP_CELLS`/`DISTRICTS_PER_REGION` + /// alone, exactly as `_clamp_window_n_mirror()` already mirrors + /// `clamp_window_n` for the finer rungs. + #[test] + fn region_request_oversized_n_clamps_and_echoes_clamped_n() { + let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY); + let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY); + let (_db, resolver, params_reader, _root) = + resolver_and_params_reader_with_radius("OrbitalCapBody", 6371.0); + let queue = GenerationQueue::with_threads(1); + + // Far over DISTRICT_WINDOW_MAX_N_REGION (6,400) — must clamp, never + // trust the wire (same discipline as the district-rung oversized-n test). + let oversized_region_req = AtlasLayerRequest { + body_id: "OrbitalCapBody".to_string(), + up_to: CascadeLayer::Topography, + window_center: Some((0, 0)), + window_n: DISTRICT_WINDOW_MAX_N_REGION * 10, + window_granularity: 0, + window_granularity_v2: Some(WindowGranularity::Region), + window_min_wl_m: 0, + }; + + let resp = handle_atlas_request( + &oversized_region_req, + &mut cache, + &mut window_cache, + &queue, + &resolver, + None, + Some(¶ms_reader), + 42, + 1, + test_conn_id(), + ); + assert!(resp.district_window.is_none(), "first request — cache miss"); + + std::thread::sleep(Duration::from_millis(300)); + let completions = queue.drain_completions(); + let window_completion = completions.into_iter().find_map(|c| { + if let GenCompletion::WindowDerived { body_id, layer } = c { + if body_id == "OrbitalCapBody" { + return Some(layer); + } + } + None + }); + let layer = window_completion.expect("DeriveWindow must complete for OrbitalCapBody"); + assert_eq!(layer.granularity_v2, WindowGranularity::Region); + assert!( + layer.n <= DISTRICT_WINDOW_MAX_N_REGION, + "echoed n must be clamped to DISTRICT_WINDOW_MAX_N_REGION, not the raw oversized value" + ); + let side = layer.granularity_v2.cell_grid_side(layer.n); + assert!( + (side as u32) * (side as u32) <= WIRE_CAP_CELLS, + "clamped cell count must never exceed WIRE_CAP_CELLS at Region granularity either" + ); + } + /// The echoed `center` on `DistrictWindowLayer` is the NORMALIZED value, /// not the raw wire value — the client's D-227 staleness guard (D-226 /// T-1124 amendment §2) must see what was ACTUALLY derived, so it can @@ -2768,6 +3445,7 @@ mod tests { window_center: Some((12276, 3021)), // raw, out-of-range window_n: 4, window_granularity: 0, + window_granularity_v2: None, window_min_wl_m: 0, }; handle_atlas_request( @@ -2848,6 +3526,7 @@ mod tests { center: (10, -5), n: 2, granularity: WINDOW_GRANULARITY_DISTRICT, + granularity_v2: WindowGranularity::District, min_wl_m: 0, morphology: vec![0, 8, 14, 16], elev_q: vec![0, 45, 98, 60], @@ -3529,6 +4208,7 @@ mod tests { window_center: None, window_n: 0, window_granularity: 0, + window_granularity_v2: None, window_min_wl_m: 0, } } diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index eca7a6ff7..60a285a99 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -418,7 +418,7 @@ fn drain_generation_completions( body_id, layer.center, layer.n, - layer.granularity, + layer.granularity_v2, layer.min_wl_m, ), *layer, @@ -914,6 +914,7 @@ mod tests { window_center: None, window_n: 0, window_granularity: 0, + window_granularity_v2: None, window_min_wl_m: 0, }, )])); diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index be787b510..21c6b5590 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -1202,6 +1202,7 @@ mod inbound_tests { window_center: None, window_n: 0, window_granularity: 0, + window_granularity_v2: None, window_min_wl_m: 0, }; let frame = rmp_serde::to_vec_named(&req).unwrap(); @@ -1249,6 +1250,7 @@ mod inbound_tests { window_center: None, window_n: 0, window_granularity: 0, + window_granularity_v2: None, window_min_wl_m: 0, }) .unwrap(); diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index ff213b725..957802a26 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -372,6 +372,7 @@ fn single_tick_drains_all_ready_inbound_frames() { window_center: None, window_n: 0, window_granularity: 0, + window_granularity_v2: None, window_min_wl_m: 0, }; let payload = rmp_serde::to_vec_named(&req).expect("failed to serialize"); diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index c43192109..5b0f48cda 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -6,7 +6,7 @@ use settled_reach_server::atlas::layer1::Layer1Output; use settled_reach_server::atlas::layer_proxy::{ AtlasLayerResponse, AtlasLayerStatus, DistrictWindowLayer, QuarterFootprintEntry, QuarterFootprintLayer, RegionGridLayer, RoadGraphEdge, RoadGraphLayer, RoadGraphNode, - SettlementEntry, SettlementLayer, SettlementSizeClass, REGION_TEMP_NONE_DC, + SettlementEntry, SettlementLayer, SettlementSizeClass, WindowGranularity, REGION_TEMP_NONE_DC, WINDOW_GRANULARITY_DISTRICT, }; use settled_reach_server::atlas::region_profile::{SeasonPhase, WeatherState}; @@ -727,6 +727,7 @@ fn generate_atlas_layer_response_fixtures() { center: (10, -5), n: 2, granularity: WINDOW_GRANULARITY_DISTRICT, + granularity_v2: WindowGranularity::District, min_wl_m: 0, morphology: vec![0, 8, 14, 16], // OpenOcean, AlluvialPlain, Alpine, Wetland elev_q: vec![0, 45, 98, 60], diff --git a/server/tests/zoom_ladder_bench.rs b/server/tests/zoom_ladder_bench.rs index d917774e9..45fae8b76 100644 --- a/server/tests/zoom_ladder_bench.rs +++ b/server/tests/zoom_ladder_bench.rs @@ -17,11 +17,14 @@ use std::time::Instant; use settled_reach_server::atlas::district_profile::{ - derive_at_metres, BodyParams, ClimateConstants, + derive_at_metres, derive_orbital_at_metres, BodyParams, ClimateConstants, }; use settled_reach_server::atlas::drainage; use settled_reach_server::atlas::features::TerrainAnalysis; use settled_reach_server::atlas::heightmap::BodyHeightmap; +use settled_reach_server::atlas::layer_proxy::{ + build_district_window_layer, WindowGranularity, DISTRICT_WINDOW_MAX_N_REGION, WIRE_CAP_CELLS, +}; use settled_reach_server::atlas::scale; use settled_reach_server::seed::{SeedChain, SeedDomain}; @@ -151,3 +154,187 @@ fn bench_derive_at_metres_district_and_quarter_spacing() { println!(); } + +/// Time `n_cells` sequential `derive_orbital_at_metres` calls — the +/// region-baseline-blend-only path (T-1152, design doc §2/§4/§9 R1), no +/// `invent_primitives` call at any point. Mirrors `time_derive_sweep`'s shape +/// exactly so the two numbers are directly comparable. +fn time_orbital_sweep( + seed: SeedChain, + body_id: &str, + params: &BodyParams, + ta: &TerrainAnalysis, + climate: &ClimateConstants, + grid_side: u32, + step_m: f64, +) -> (std::time::Duration, f64) { + let n_cells = (grid_side * grid_side) as u64; + let t0 = Instant::now(); + for row in 0..grid_side { + for col in 0..grid_side { + let wx = col as f64 * step_m; + let wy = row as f64 * step_m; + let prof = derive_orbital_at_metres(seed, body_id, params, ta, wx, wy, climate); + std::hint::black_box(prof.elev_q); + } + } + let elapsed = t0.elapsed(); + let per_cell_ns = elapsed.as_secs_f64() * 1e9 / n_cells as f64; + (elapsed, per_cell_ns) +} + +/// T-1152 / design doc §9 R1: "MEASURE FIRST" — per-cell cost of the +/// orbital-mode region-baseline-blend-only path (no `invent_primitives`) at +/// coarse (region-scale, ≥205 km) spacings, plus a realistic full-orbital-frame +/// extrapolation (1600×900 canvas). This is the number the design doc's §4/§7 +/// planetary-rung cost story rested on as an UNMEASURED extrapolation — +/// this test replaces "extrapolated from the uncut per-cell rate" with an +/// actually-measured orbital-path rate. +#[test] +#[ignore] +fn bench_derive_orbital_at_metres_region_spacing() { + let hm = bench_hm(); + let ta = bench_ta(&hm); + let params = bench_params(); + let climate = ClimateConstants::default(); + let seed = SeedChain::root(99).derive(SeedDomain::Body, 1); + let grid_side = 64u32; // 4,096 cells/sweep, same shape as the district/quarter sweeps above + + println!("\n=== T-1152 orbital-rung derive_orbital_at_metres benchmark ==="); + println!( + "grid: {grid_side}x{grid_side} = {} cells/sweep\n", + grid_side * grid_side + ); + + let region_m = scale::REGION_M as f64; + + // Region spacing (204,800 m) — the coarsest named rung short of the + // planet-wide elastic seam (D-243). + let (elapsed, per_cell_ns) = + time_orbital_sweep(seed, "bench", ¶ms, &ta, &climate, grid_side, region_m); + println!( + "orbital, region spacing (204.8km): {:>8.2} ms total, {:>7.1} ns/cell ({:.3} µs/cell)", + elapsed.as_secs_f64() * 1000.0, + per_cell_ns, + per_cell_ns / 1000.0 + ); + + // Same spacing, for direct comparison: the FULL derive_at_metres path + // (invent_primitives included) at the SAME region spacing — quantifies + // exactly what skipping invention buys, at the spacing where it matters. + let (elapsed_full, per_cell_ns_full) = time_derive_sweep( + seed, "bench", ¶ms, &ta, &climate, grid_side, region_m, 0.0, + ); + println!( + "district-mode (full derive_at_metres) at region spacing: {:>8.2} ms total, {:>7.1} ns/cell ({:.3} µs/cell)", + elapsed_full.as_secs_f64() * 1000.0, + per_cell_ns_full, + per_cell_ns_full / 1000.0 + ); + println!( + "orbital speedup vs. full derive at the same spacing: {:.2}x\n", + per_cell_ns_full / per_cell_ns + ); + + // Realistic full-orbital-frame estimate: a 1600x900 canvas at + // ~1-2 px/cell equivalents (design doc §4's worked example resolution + // class). Single-thread extrapolation from the MEASURED per-cell rate — + // labelled as an extrapolation, not claimed as independently measured at + // full canvas size (the parallel/chunked throughput is a SEPARATE + // measurement, T-1151's row-chunked par_iter, already landed and reused + // unchanged by the orbital rung's serving path — see the ticket report). + for (label, px_per_cell) in [("1 px/cell", 1u32), ("2 px/cell", 2u32)] { + let cols = 1600 / px_per_cell; + let rows = 900 / px_per_cell; + let cells = (cols as u64) * (rows as u64); + let est_ms = cells as f64 * per_cell_ns / 1e6; + println!( + "full-canvas 1600x900 @ {label} ({cols}x{rows} = {cells} cells): \ + {est_ms:.1} ms single-thread (EXTRAPOLATED from the measured per-cell rate above)" + ); + } + + println!(); +} + +/// **T-1152 R1 — the number that actually governs interactive latency**, as +/// opposed to the full-canvas single-shot extrapolation above (which the +/// design doc's own carrier ruling makes moot — Jeroen's ruling is +/// progressive capped-density TILING, never a whole-canvas one-shot derive). +/// This measures a single served Region-granularity window tile through the +/// REAL production path (`build_district_window_layer`, including its +/// row-chunked `par_iter`, T-1151) at the wire-size cap — the same function +/// `serve_district_window`/`run_work_item`'s `DeriveWindow` arm calls, not a +/// hand-rolled sweep. This is the measured (not extrapolated) parallel +/// number the design doc's §7 flagged as missing ("no chunked-par_iter +/// benchmark has been run"). +#[test] +#[ignore] +fn bench_served_region_window_tile_at_wire_cap() { + let hm = bench_hm(); + let ta = bench_ta(&hm); + let params = bench_params(); + let climate = ClimateConstants::default(); + let seed = SeedChain::root(99).derive(SeedDomain::Body, 1); + + println!("\n=== T-1152 served Region-window-tile benchmark (real production path) ==="); + + // The largest n the server will ever actually derive at Region + // granularity is DISTRICT_WINDOW_MAX_N_REGION, clamped further by + // clamp_window_n_v2 to the WIRE_CAP_CELLS ceiling — use the SAME + // capped n a real client's oversized request would resolve to. + let n = DISTRICT_WINDOW_MAX_N_REGION; + + // Warm-up call (first call on a body pays no extra cost here since ta is + // already built — this just avoids counting one-time allocator warm-up + // noise in the timed sample). + let _ = build_district_window_layer( + seed, + "bench", + ¶ms, + &ta, + (0, 0), + n, + &climate, + WindowGranularity::Region, + 0, + ); + + let iterations = 20; + let t0 = Instant::now(); + let mut last_side = 0usize; + for _ in 0..iterations { + let layer = build_district_window_layer( + seed, + "bench", + ¶ms, + &ta, + (0, 0), + n, + &climate, + WindowGranularity::Region, + 0, + ); + last_side = (layer.morphology.len() as f64).sqrt().round() as usize; + std::hint::black_box(layer.elev_q.len()); + } + let elapsed = t0.elapsed(); + let per_call_ms = elapsed.as_secs_f64() * 1000.0 / iterations as f64; + + println!( + "n={n} (DISTRICT_WINDOW_MAX_N_REGION), derived {last_side}x{last_side} region cells \ + ({} cells, WIRE_CAP_CELLS={WIRE_CAP_CELLS}):", + last_side * last_side + ); + println!( + " {iterations} calls, {:.2} ms total, {per_call_ms:.3} ms/call \ + (row-chunked par_iter, {} Rayon threads available)", + elapsed.as_secs_f64() * 1000.0, + std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(0) + ); + println!( + " compare: shipped district n=64 cap measures ~5 ms/call (design doc §7, MEASURED)\n" + ); +}