From d4526e51ffda0ce05f43125ae22c5a876af4ca7c Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 22 Jul 2026 19:05:15 +0200 Subject: [PATCH 1/6] =?UTF-8?q?fix(client):=20cold-start=20black=20screen?= =?UTF-8?q?=20=E2=80=94=20self-healing=20tile=20repaint,=20legend=20re-ent?= =?UTF-8?q?ry=20root=20cause,=20pending-tile=20wash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jeroen hit a black mosaic zooming into a body from a fresh make-atlas spawn. Live diagnosis showed all six tiles held with colored textures and no repaint; the original line-specific diagnosis (unpaired queue_redraw in _on_tile_ready) turned out WRONG — the pairing already existed (lead's truncated grep misread the function; Stig verified via git log -p before acting). Rather than chase the exact dropped signal edge, _process() now self-heals: both viewer and overlay redraw every frame while the tile set has pending tiles (has_pending_tiles(), new) — a strict superset that closes the black regardless of which edge drops, pinned by a _draw()-counting real-subclass spy test. Legend stacking root cause found by trace, not guess: ImplantApp. _on_screen_changed() re-runs enter() unconditionally on repeat same-screen pushes — each re-entry tore down and rebuilt all six tile requests (the round-6 orphaning fingerprint via a new trigger) and stacked another legend (~10 deep, full-height dark panel). Fixed both ends: ImplantPanel.clear() frees immediately (same-frame re-entrant refresh can never observe stale children — protects every implant app), and RegionalScreen.enter() no-ops for the same body (different body still re-enters fresh). The load-bearing regression asserts an ARRIVED TILE'S DATA survives a repeat push — node identity would not catch the teardown (the tile-set Node is a fixed field; only its internals reset). Cold-start UX: pending tiles now draw the single-window path's COLOR_BORDER_FADE wash instead of raw background — a deriving mosaic reads as loading, not broken. Suites green (zoom_ladder 48, viewer 74, tile_set 20, overlay 30, overlays 46 + 2 new files), gdlint clean, revert-verified throughout. --- client/tests/test_atlas_cold_start.gd | 161 ++++++++++++++++ .../test_atlas_window_overlay_draw_smoke.gd | 48 +++++ client/tests/test_regional_screen.gd | 90 +++++++++ .../apps/atlas/atlas_window_overlay.gd | 30 ++- .../apps/atlas/atlas_window_tile_set.gd | 13 ++ .../implant/apps/atlas/atlas_window_viewer.gd | 173 +++++++++--------- .../apps/atlas/screens/regional_screen.gd | 14 ++ client/ui/implant/implant_panel.gd | 14 +- 8 files changed, 444 insertions(+), 99 deletions(-) create mode 100644 client/tests/test_atlas_cold_start.gd create mode 100644 client/tests/test_regional_screen.gd diff --git a/client/tests/test_atlas_cold_start.gd b/client/tests/test_atlas_cold_start.gd new file mode 100644 index 000000000..a25cf761a --- /dev/null +++ b/client/tests/test_atlas_cold_start.gd @@ -0,0 +1,161 @@ +## PR #192 cold-start dossier — coordinator's live repro against a freshly- +## spawned (cold) server (`make atlas` shape, first AnalyzeBody taking +## seconds): BUG 1 (tile-mosaic paint never resolving) and the legend- +## stacking half of BUG 2. Split out of test_atlas_zoom_ladder.gd purely for +## file-length reasons (gdlint max-file-lines) — same instantiation/mock- +## response conventions as that file, not a different testing philosophy. +## RegionalScreen's own re-entry-guard half of BUG 2 is covered separately +## in test_regional_screen.gd (a different layer — nav, not the viewer). +class_name TestAtlasColdStart +extends GdUnitTestSuite + +const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd") + +## Dudley's WINDOW_GRANULARITY_REGION_KEY sentinel — mirrors +## test_atlas_zoom_ladder.gd's own constant (see that file's doc for why the +## real wire value matters, not a convenient placeholder). +const SERVER_LEGACY_GRANULARITY_REGION_SENTINEL: int = 4294967295 + + +## Build a hand-authored DistrictWindowLayer dict (n=2 by default) — mirrors +## test_atlas_zoom_ladder.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} + + +# ============================================================================= +# BUG 1 — tile-mosaic paint self-heal. +# ============================================================================= + + +## Counts real _draw() invocations — CanvasItem exposes no public +## "is a redraw pending" query in this Godot version, so the only reliable +## signal that queue_redraw() actually had an effect is the engine calling +## _draw() again on a subsequent frame. Subclasses the REAL AtlasWindowOverlay +## (not a duck-typed stub) so drawing still runs through the genuine +## production code path — this spy only adds counting, nothing else. +class _CountingOverlay extends AtlasWindowOverlay: + var draw_count := 0 + + func _draw() -> void: + draw_count += 1 + super._draw() + + +## On a cold server, a tile's window_ready can land well after entry's own +## paint window, and a live repro showed the mosaic staying black even with +## every tile held/textured — only resolving on an unrelated gesture. +## _process() must therefore queue a redraw on BOTH the viewer and the +## overlay every frame while any tile is still pending, regardless of +## whether the tile-arrival signal path painted correctly on its own. Proven +## here by swapping the REAL overlay for a _draw()-counting subclass right +## after entry (once the entry-time redraw has already resolved via a real +## frame), then calling _process() directly with NO input/gesture and +## confirming a further frame actually invokes _draw() again — revert- +## verified against a version of _process() with the self-heal removed +## (fails without it, since nothing else re-queues while idle). +func test_process_self_heals_the_overlay_redraw_while_tiles_are_pending() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + var radius_km := 6238.4 # GJ380c (Lendel) — needs tiling + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) + assert_bool(v.is_tile_mode()).is_true() + assert_bool(v.get_tile_set().has_pending_tiles()).override_failure_message( + "sanity: entry must leave every tile pending before any response arrives" + ).is_true() + + # Swap in the counting spy AFTER entry (so entry's own queue_redraw() + # calls don't pollute the baseline) but the OLD overlay is freed and the + # spy re-added under the same _canvas parent, matching _ready()'s own + # construction shape exactly. + var spy := _CountingOverlay.new() + spy.viewer = v + v._overlay_node.queue_free() + v._overlay_node = spy + v._canvas.add_child(spy) + + await get_tree().process_frame # let this frame settle with the spy in place + await get_tree().process_frame + var baseline: int = spy.draw_count + assert_int(baseline).override_failure_message( + "sanity: the spy must have been drawn at least once before the no-input" + + " frame below, or this test can't distinguish self-heal from a first draw" + ).is_greater(0) + + # No pan/zoom/gesture — the ONLY thing that should cause another _draw() + # is _process()'s own self-heal, since has_pending_tiles() is still true + # (no response has been delivered). + v._process(0.016) + await get_tree().process_frame + + assert_int(spy.draw_count).override_failure_message( + "_process() must queue a redraw every frame while has_pending_tiles()" + + " is true, with NO input/gesture — draw_count must have advanced past" + + " the baseline (%d), the cold-start self-heal" % baseline + ).is_greater(baseline) + + +## The self-heal must STOP once every tile has arrived — a redraw queued +## forever regardless of state would just be a disguised always-redraw, not +## a targeted fix for the pending window. +func test_process_stops_self_healing_once_every_tile_has_arrived() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + var radius_km := 6238.4 # GJ380c (Lendel) + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) + var tile_set = v.get_tile_set() + for tile: Dictionary in tile_set.get_tiles(): + var window: Dictionary = _mock_window(tile["center"]) + window["granularity_v2"] = "Region" + window["granularity"] = SERVER_LEGACY_GRANULARITY_REGION_SENTINEL + window["n"] = AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window)) + assert_bool(tile_set.has_pending_tiles()).override_failure_message( + "sanity: every tile must have arrived after this loop" + ).is_false() + + +# ============================================================================= +# BUG 2 — legend stacking (the viewer-level half; see test_regional_screen.gd +# for the nav-layer re-entry guard). +# ============================================================================= + + +## Coordinator's live scene dump: WindowLegend measured 260x2343 px, ~10 +## legends stacked — ImplantPanel.clear() used deferred queue_free(), so +## same-frame repeat refresh() calls piled new content onto STALE not-yet- +## freed children instead of replacing them. N refresh() calls in the SAME +## frame (no process_frame between them, matching how the actual trigger — +## RegionalScreen.enter() previously lacking its own re-entry guard — landed +## repeat enter_orbital() calls back to back) must leave exactly ONE legend's +## worth of children, not N stacked copies. +func test_legend_refresh_is_idempotent_against_same_frame_re_entry() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2) + + var baseline_count: int = v._legend_panel.get_implant_children().size() + for _i in range(10): + v._legend_panel.refresh() + var after_count: int = v._legend_panel.get_implant_children().size() + + assert_int(after_count).override_failure_message( + ( + "10 same-frame refresh() calls must leave exactly ONE legend's worth of" + + " children (%d), not %d stacked copies — ImplantPanel.clear() must" + + " free immediately, not defer via queue_free()" + ) % [baseline_count, after_count] + ).is_equal(baseline_count) diff --git a/client/tests/test_atlas_window_overlay_draw_smoke.gd b/client/tests/test_atlas_window_overlay_draw_smoke.gd index 17f1f7915..0d1eb1747 100644 --- a/client/tests/test_atlas_window_overlay_draw_smoke.gd +++ b/client/tests/test_atlas_window_overlay_draw_smoke.gd @@ -87,6 +87,12 @@ class _SingleWindowViewerStub: ## contract, matching AtlasWindowTileSet.get_tiles()'s public shape exactly: ## Array of {"center": Vector2i, "window": Variant}). class _TileModeViewerStub: + # PR #192 cold-start dossier: AtlasWindowOverlay reads viewer.COLOR_BORDER_FADE + # directly for a pending tile's wash (avoids a cyclic preload of the + # viewer's own script — see that read site's own doc) — mirrored here + # byte-for-byte (AtlasWindowViewer.COLOR_BORDER_FADE, private const). + const COLOR_BORDER_FADE: Color = Color(0.20, 0.24, 0.30, 0.55) + var tiles: Array = [] var held_center: Vector2i = Vector2i.ZERO var held_n: int = 0 @@ -357,3 +363,45 @@ func test_tile_mosaic_draw_produces_visible_pixels() -> void: ) % (fraction * 100.0) ).is_greater(MIN_NON_BACKGROUND_FRACTION) + + +## (c) PR #192 cold-start dossier, BUG 3: a mosaic tile with NO window yet +## (the cold-server "still working" state, every tile in the mosaic at once +## right after enter_orbital() on a real cold server) must render the SAME +## border-fade wash the single-window path already gives its own +## no-composite-yet wait — not a bare COLOR_BG gap that reads as broken. +## Same real-render infrastructure as (a)/(b): a pending tile (`window: null`) +## must still produce visible non-background pixels, proving the wash +## genuinely draws rather than the loop just `continue`-ing past it silently. +func test_pending_tile_gets_a_visible_border_fade_wash() -> void: + 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 # GJ380c/Lendel's own raw circumference (live round 4) + # Same centered-tile placement as (b) above, but with `window: null` — + # the pending state this fix targets, instead of a real response. + 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": null}] + overlay.viewer = stub + + 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( + ( + "a pending tile (window == null) must still render a visible border-fade" + + " wash — got only %.2f%% of the frame differing from COLOR_BG, meaning" + + " the tile is a bare background gap during the cold-server wait, which" + + " reads as broken rather than 'still working' (coordinator's closing" + + " question, PR #192 cold-start dossier)." + ) + % (fraction * 100.0) + ).is_greater(MIN_NON_BACKGROUND_FRACTION) diff --git a/client/tests/test_regional_screen.gd b/client/tests/test_regional_screen.gd new file mode 100644 index 000000000..0ea2d32ab --- /dev/null +++ b/client/tests/test_regional_screen.gd @@ -0,0 +1,90 @@ +## PR #192 cold-start dossier (BUG 2): RegionalScreen.enter() tests. Split +## into its own file rather than folded into test_atlas_zoom_ladder.gd — +## these exercise the NAV-LAYER re-entry guard (RegionalScreen itself), not +## AtlasWindowViewer's own zoom-ladder mechanics that suite already owns. +class_name TestRegionalScreen +extends GdUnitTestSuite + +const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd") + +## Dudley's WINDOW_GRANULARITY_REGION_KEY sentinel — mirrors +## test_atlas_zoom_ladder.gd's own constant (see that file's doc for why the +## real wire value matters, not a convenient placeholder). +const SERVER_LEGACY_GRANULARITY_REGION_SENTINEL: int = 4294967295 + + +static func _mock_response(body_id: String, window: Variant) -> Dictionary: + return {"body_id": body_id, "status": "Ready", "district_window": window} + + +## BUG 2 root cause: ImplantApp._on_screen_changed() calls enter() +## UNCONDITIONALLY on every screen_changed, including a repeat +## nav.push("regional", ...) landing on the SAME screen already showing +## (reachable from more than one input path — body-click, panel+Enter — and +## plausible for a player to trigger twice on a slow cold server before the +## first descent settles). Without RegionalScreen's own guard, a repeat +## entry re-ran the FULL enter_orbital() teardown/rebuild — tearing down +## every in-flight tile request node and rebuilding fresh (null-window) ones +## — orphaning whatever had already arrived, plus refreshing the legend +## from scratch each time (the confirmed ~10x legend stack). Proven here by +## an ARRIVED tile's data: a teardown+rebuild resets it to null; a genuine +## no-op leaves it exactly as it was — `is_same()` on `_tile_set` itself +## can't tell (that Node is a fixed field, never reassigned — only its +## INTERNAL request children get torn down and rebuilt). +func test_repeat_enter_for_the_same_body_does_not_orphan_an_arrived_tile() -> void: + var screen: RegionalScreen = auto_free(RegionalScreen.new()) + add_child(screen) + var body: Dictionary = {"body_id": "GJ380c", "body_radius_km": 6238.4} + screen.enter({"body": body, "system": {}}) + + var tile_set = screen._viewer.get_tile_set() + var first_tile_center: Vector2i = tile_set.get_tiles()[0]["center"] + var arrived_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", arrived_window)) + assert_that(tile_set.get_tiles()[0]["window"]).override_failure_message( + "sanity: the first tile's response must have been adopted before the repeat enter()" + ).is_equal(arrived_window) + + screen.enter({"body": body, "system": {}}) + + assert_that(screen._viewer.get_tile_set().get_tiles()[0]["window"]).override_failure_message( + "a repeat enter() for the SAME body must not orphan an already-arrived" + + " tile — a teardown+rebuild resets every tile's window back to null," + + " which is the confirmed source of the cold-start legend stacking bug" + ).is_equal(arrived_window) + + +## A GENUINE body change (different body_id) must still enter fresh — the +## guard is scoped to "the same body, re-entered", never a blanket "ignore +## the second enter() call ever". +func test_enter_for_a_different_body_still_re_enters() -> void: + var screen: RegionalScreen = auto_free(RegionalScreen.new()) + add_child(screen) + screen.enter({"body": {"body_id": "GJ380c", "body_radius_km": 6238.4}, "system": {}}) + + screen.enter({"body": {"body_id": "OtherBody", "body_radius_km": 100.0}, "system": {}}) + + assert_str(screen._viewer.get_body_id()).override_failure_message( + "a genuinely different body must still re-enter — not be swallowed by" + + " the same-body guard" + ).is_equal("OtherBody") + + +## get_body_id() itself: empty before any entry, the entered body's id after. +func test_get_body_id_reflects_the_currently_held_body() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + assert_str(v.get_body_id()).is_equal("") + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": 6238.4}, {}) + assert_str(v.get_body_id()).is_equal("GJ380c") diff --git a/client/ui/implant/apps/atlas/atlas_window_overlay.gd b/client/ui/implant/apps/atlas/atlas_window_overlay.gd index 9a104dfce..5ba2405f2 100644 --- a/client/ui/implant/apps/atlas/atlas_window_overlay.gd +++ b/client/ui/implant/apps/atlas/atlas_window_overlay.gd @@ -211,15 +211,6 @@ func _draw_tile_mosaic() -> void: 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) @@ -230,6 +221,27 @@ func _draw_tile_mosaic() -> void: tile_top_left, held_center, held_n, cell_px ) var extent: float = float(AtlasWindowGeometryRef.TILE_N) * cell_px + + # Coordinator ask (cold-start dossier): a bare COLOR_BG gap for an + # unarrived tile reads as broken, not "still working" — a cold + # server's first AnalyzeBody can take seconds, during which every + # tile in the mosaic is exactly this state at once. Same treatment + # the single-window path already gives its OWN no-composite-yet wait + # (AtlasWindowViewer._draw_border_fade()) — read off the live + # `viewer` instance rather than a preload of its script (that field + # is deliberately untyped to avoid a cyclic ref, see its own doc; a + # const is reachable through an instance either way). + if not window is Dictionary: + draw_rect(Rect2(local_origin, Vector2(extent, extent)), viewer.COLOR_BORDER_FADE) + 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 + _draw_one_tile(i, w, grid_side, local_origin, extent, active_toggle) diff --git a/client/ui/implant/apps/atlas/atlas_window_tile_set.gd b/client/ui/implant/apps/atlas/atlas_window_tile_set.gd index 21f6b8352..f2dbd2e20 100644 --- a/client/ui/implant/apps/atlas/atlas_window_tile_set.gd +++ b/client/ui/implant/apps/atlas/atlas_window_tile_set.gd @@ -137,6 +137,19 @@ func get_tiles() -> Array: return result +## True while at least one tile's `window` hasn't arrived yet — the viewer's +## cold-start self-healing redraw (see AtlasWindowViewer._process()'s own +## doc) polls this every frame so a mosaic's paint can never silently wedge +## behind a lost/late queue_redraw() no matter which signal edge it was +## supposed to ride in on. Also the source of truth for whether the §4 +## pending treatment should show. +func has_pending_tiles() -> bool: + for tile: Dictionary in _tiles: + if tile["window"] == null: + return true + return false + + ## 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 diff --git a/client/ui/implant/apps/atlas/atlas_window_viewer.gd b/client/ui/implant/apps/atlas/atlas_window_viewer.gd index 29103023a..2ef6424f5 100644 --- a/client/ui/implant/apps/atlas/atlas_window_viewer.gd +++ b/client/ui/implant/apps/atlas/atlas_window_viewer.gd @@ -17,19 +17,18 @@ extends Control ## ## Design notes (mirroring AtlasViewer's own split, D-226 §5, extended T-1153): ## - _canvas (Node2D) holds AtlasWindowOverlay; pan = _canvas.position, zoom -## = _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. +## = _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. +## - _window_request owns the single-window cache/debounce/retry; _tile_set +## owns N of those for the tiled rest state — this Control decides WHICH. ## ## Navigation (Jeroen's input-model ruling: LMB-drag panning BREAKS click ## semantics with future map objects, so it's removed entirely): @@ -49,38 +48,36 @@ const OVERLAY_BAR_HEADER_RESERVE: float = 360.0 ## (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. +## MIN_ZOOM must stay low enough that enter_orbital()'s largest legal `n` +## COVER-fits without clamping (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 — 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. +## T-1145 item 2: WASD/arrow-key pan speed, CANVAS px/s at zoom=1.0 — actual +## screen-space rate scales by CURRENT _view_zoom, so panning covers the same +## TERRAIN/s regardless of zoom. ~6 districts/s at zoom=1.0 (96/16) — brisk. 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"). Uses the SAME speed as WASD (one pan feel, -## two triggers) — no separate constant, _process() reads PAN_SPEED_CANVAS_PX_S for both. +## T-1145 item 2: cursor-to-edge distance (px) that triggers edge-scroll +## (Jeroen's "~24px"). Same speed as WASD — _process() reads +## PAN_SPEED_CANVAS_PX_S for both, no separate constant. const EDGE_SCROLL_MARGIN_PX: float = 24.0 ## 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 -## native pixel size; this constant IS the native size). 16px/cell at n=64 -## gives a ~1024px-wide composite before zoom, comfortably inside a -## 1280x720+ viewport at the DEFAULT_N=32 interactive default (512px) too. +## (unlike AtlasViewer's heightmap, no source texture dictates a native pixel +## size; this constant IS the native size). 16px/cell at n=64 gives a +## ~1024px-wide composite before zoom, comfortable in a 1280x720+ viewport. const CELL_PIXEL_SIZE: float = 16.0 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 — a dimmer/ -## less-certain read of the same planetary data, not a different visual language. +## reads as "real data seen through", not a placeholder block — a dimmer/ +## less-certain read of the same planetary data, not a different visual +## language. Also read by AtlasWindowOverlay for a per-tile pending wash in +## the mosaic (cold-start dossier) — same "still working" cue, one color. 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 @@ -236,11 +233,10 @@ func _exit_tree() -> void: ## 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 BEFORE it becomes -## `_held_center` — matching the server's normalize_window_center(). +## 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 BEFORE it becomes `_held_center`, matching the server's +## normalize_window_center(). func enter( body: Dictionary, system: Dictionary, @@ -313,11 +309,10 @@ func _enter_tile_mode(body: Dictionary, system: Dictionary, radius_km: float) -> ## 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). +## held/request state for a fresh descent, plus _held_granularity_v2. `n` +## MUST be clamped via `_clamp_window_n_mirror_v2()` BEFORE it becomes +## `_held_n` (C1 clamp-mirror, one layer up) — mirroring AtlasWindowRequest. +## request_now()'s own clamp (PR #191 Tyre C1). func _enter_at_rung( body: Dictionary, system: Dictionary, @@ -422,6 +417,13 @@ func get_body_radius_km() -> float: return float(_body.get("body_radius_km", 0.0)) +## Cold-start dossier (BUG 2): RegionalScreen.enter() reads this to skip a +## redundant enter_orbital() for a repeat nav.push("regional") on the SAME +## body — see that call site's own doc for why. +func get_body_id() -> String: + return _dict_str(_body, "body_id", "") + + ## 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 @@ -466,12 +468,10 @@ func _on_window_ready(window: Dictionary) -> void: # showing — AtlasWindowRequest already filtered by its own last-asked # (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. + # reflects a re-entrant call, so comparing again here removes any + # ordering assumption. granularity_v2 (T-1153) is compared too — a + # district response answering a request since moved on to Region (rapid + # wheel-zoom) must not be adopted just because center/n still match. var w_center := _vec_from_center(window.get("center", [0, 0])) var w_n := int(window.get("n", 0)) var w_granularity_v2 := str( @@ -513,11 +513,11 @@ func _on_tile_ready(_index: int) -> void: # ============================================================================= # 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. +# ANCHORED and CONTINUOUS ACROSS RUNGS, D-226 T-1143-rulings: the held +# composite is always drawn client-side-zoomed with NO re-request, but +# crossing a rung's spacing threshold fires a background request at the new +# granularity (progressive refinement, see _maybe_reselect_rung()) while the +# OLD composite stays on screen. # ============================================================================= @@ -561,16 +561,13 @@ func _current_world_extent_m() -> float: ## §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. +## CURRENT screen-center (same formula as _maybe_refloat_window()). `_held_n` +## MUST be re-clamped via `_clamp_window_n_mirror_v2()` for the TARGET rung +## (C1 clamp-mirror, a third layer up) — a stale large `_held_n` desyncs +## `_on_window_ready()`'s staleness check. 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 @@ -660,20 +657,18 @@ func _is_at_canonical_frame() -> bool: ## 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. +## `_held_center`/`_held_granularity_v2` (LAGGING, updated only on response +## adoption) — a TILING body's granularity stayed stale "Region" after +## zooming IN left tile mode, misreading "already canonical." 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 +## **Live round 6 fix (round 5's fix overshot into a storm):** a per-tick +## zoom-equality check made this guard LEVEL-triggered — continued zoom-out +## kept nudging `_view_zoom` below fit, so `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. +## responses (nothing held -> black), flooding the server (889/897 wire +## responses in one phase). Fixed by moving the zoom-drift concern to +## `_zoom_at()`'s own floor — this guard 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: @@ -711,7 +706,6 @@ func set_view(zoom: float, offset: Vector2) -> void: ## 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 via the debounced path. -## ## 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(). @@ -767,29 +761,23 @@ 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. + # T-1153: this viewer's own border-fade/pending-wash are single-window + # concepts (one extent, one state) — a mosaic's per-tile pending wash + # is drawn by AtlasWindowOverlay itself (see _draw_tile_mosaic()). 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 — the honest available - # substitute is a dim fade wash over the held composite's last-known - # extent, reusing the request object's own is_pending() for the - # "still working" cue rather than new dressing. + # whole-body context rather than black/a spinner — the honest + # available substitute, this viewer having no resident whole-body + # texture of its own. _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. + # composite still on screen) while a NEW rung's request is in flight + # underneath it. A lighter pending wash (not the full opaque fade the + # no-composite-at-all case uses, since real data IS showing through) + # signals "sharper detail incoming" without implying the view is wrong. _draw_pending_refinement_wash() @@ -885,9 +873,18 @@ func _handle_key(event: InputEventKey) -> void: ## 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). +## Cold-start self-heal (coordinator live repro): every queue_redraw() site +## is already paired with _overlay_node's own, yet a cold server's slow first +## AnalyzeBody still showed a black mosaic with every tile held/textured, +## only resolving on an unrelated gesture. Rather than chase one signal edge +## that might drop, redraw every frame while any tile is pending — free once +## complete. func _process(delta: float) -> void: if not visible: return + if _tile_mode and _tile_set and _tile_set.has_pending_tiles(): + queue_redraw() + _overlay_node.queue_redraw() var direction: Vector2 = _held_pan_direction() if _is_cursor_edge_scrolling(): direction += _edge_scroll_direction() diff --git a/client/ui/implant/apps/atlas/screens/regional_screen.gd b/client/ui/implant/apps/atlas/screens/regional_screen.gd index 0911ffc57..f12d15a4f 100644 --- a/client/ui/implant/apps/atlas/screens/regional_screen.gd +++ b/client/ui/implant/apps/atlas/screens/regional_screen.gd @@ -39,9 +39,23 @@ func _ready() -> void: _viewer.back_pressed.connect(_on_viewer_back) +## Cold-start dossier (BUG 2, PR #192 review): ImplantApp._on_screen_changed() +## calls enter() unconditionally on EVERY screen_changed, including a repeat +## nav.push("regional", ...) that lands on the SAME screen already showing — +## reachable from more than one input path (body-click, panel+Enter) and, +## on a slow cold server, plausible for a player to trigger twice before the +## first descent settles. Without this guard, a repeat push tore down and +## rebuilt the whole tile set (orphaning in-flight requests, live round 6's +## exact storm shape for a different trigger) and refreshed the legend from +## scratch each time — confirmed source of the ~10x legend stack. Guarded +## on body_id alone (not a deep payload compare): the same body, re-entered, +## should always resume the SAME orbital session already in flight, never +## restart it — an actual body CHANGE (different id) still re-enters fresh. func enter(payload: Dictionary) -> void: var body: Dictionary = payload.get("body", {}) var system: Dictionary = payload.get("system", {}) + if str(body.get("body_id", "")) == _viewer.get_body_id(): + return _viewer.enter_orbital(body, system) diff --git a/client/ui/implant/implant_panel.gd b/client/ui/implant/implant_panel.gd index d748f901f..da3a58a56 100644 --- a/client/ui/implant/implant_panel.gd +++ b/client/ui/implant/implant_panel.gd @@ -70,9 +70,19 @@ func get_implant_children() -> Array: return _vbox.get_children() -## Clear all components. +## Clear all components. Frees IMMEDIATELY (remove_child() + free()), not +## via queue_free() — a caller that re-enters refresh()-shaped clear()+ +## add_component() more than once in the SAME frame (confirmed live: the +## Atlas regional-window legend stacking ~10x around cold-start entry, PR +## #192 cold-start dossier) would otherwise see get_implant_children() still +## returning the STALE children (queue_free() only removes them at end of +## frame), so add_component()'s new ones pile up alongside instead of +## replacing them. Immediate removal makes clear() genuinely synchronous — +## the panel can never observe more than one refresh() worth of content, no +## matter how many times it's called before the next frame. func clear() -> void: if not _vbox: return for child in _vbox.get_children(): - child.queue_free() + _vbox.remove_child(child) + child.free() From dd13760d623d5befb3aea9108e0778b907006a40 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 22 Jul 2026 19:51:12 +0200 Subject: [PATCH 2/6] =?UTF-8?q?fix(client):=20cold-start=20round=202=20?= =?UTF-8?q?=E2=80=94=20DERIVING=20TERRAIN=20state,=20legend=20re-fit,=20re?= =?UTF-8?q?lease=20server=20for=20make=20atlas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 1: while zero tiles have arrived, the viewer draws a centered screen-space 'DERIVING TERRAIN…' label (text_dim role, no new hue), dropping the instant the first tile lands — a cold wait now reads as loading, not broken. New has_any_tile_arrived() predicate (distinct from has_pending_tiles(): both true mid-arrival, tested exactly there). Item 2: legend re-fit root-caused empirically, two plausible fixes disproven by trace before the real one: a manually-positioned Control's size NEVER tracks a shrinking minimum in this parenting shape, and RichTextLabel.fit_content reports degenerate minimums until laid out at real width once — so reset must be DEFERRED and run after refill, not inside clear(). ImplantPanel.reset_to_content_size() (call_deferred), wired into both legend refresh()es. The load-bearing test compares size.y to get_minimum_size().y — a size-to-size comparison passed trivially with both numbers equally stuck (caught on first draft). Item 3: make atlas now builds the RELEASE server and passes SR_SERVER_BIN (a cold DEBUG server delivers zero tiles for >10s on a new body — live-measured — vs 210ms warm; release serves cold in well under a second). atlas_standalone._server_binary_path() honors the env override per the SR_PORT two-tier precedent, debug path unchanged when unset. All fixes revert-verified; nine suites green collateral-checked; gdlint clean. --- Makefile | 12 +- client/scripts/atlas_standalone.gd | 22 +- client/tests/test_atlas_cold_start.gd | 158 +++++++++++- client/tests/test_atlas_standalone.gd | 54 +++++ client/tests/test_atlas_window_tile_set.gd | 73 ++++++ .../implant/apps/atlas/atlas_legend_panel.gd | 2 + .../apps/atlas/atlas_window_geometry.gd | 13 + .../implant/apps/atlas/atlas_window_legend.gd | 1 + .../apps/atlas/atlas_window_tile_set.gd | 18 ++ .../implant/apps/atlas/atlas_window_viewer.gd | 227 +++++++++--------- client/ui/implant/implant_panel.gd | 29 +++ 11 files changed, 489 insertions(+), 120 deletions(-) diff --git a/Makefile b/Makefile index 13d1d7bff..4ecf1a2e4 100644 --- a/Makefile +++ b/Makefile @@ -126,6 +126,9 @@ build: build-server build-client build-server: cd server && cargo build +build-server-release: + cd server && cargo build --release + build-client: @test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; } @# First import may error on theme/font loading before the import scan completes. @@ -161,9 +164,14 @@ game: stop build # without it SimBridge boots in test mode (instant fake CONNECTED, no network # at all), which would make the companion open the Atlas against a dynamic # test-harness snapshot instead of the real systems.db world via the wire. -atlas: build-server build-client +# Release server for the atlas: a cold DEBUG server takes >10s before the +# first tile of a new body can exist (first-body terrain analysis), which +# reads as a black/broken map on entry (live-verified 2026-07-22). The +# release binary serves a cold body's first tiles in well under a second. +# SR_SERVER_BIN tells atlas_standalone.gd's spawn path which binary to use. +atlas: build-server-release build-client @test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; } - @SR_LIVE=1 $(GODOT) --path client scenes/atlas_standalone.tscn + @SR_LIVE=1 SR_SERVER_BIN=server/target/release/settled-reach-server $(GODOT) --path client scenes/atlas_standalone.tscn stop: @lsof -ti :9876 | xargs -r kill 2>/dev/null || true diff --git a/client/scripts/atlas_standalone.gd b/client/scripts/atlas_standalone.gd index 5c241c75d..7cd5335df 100644 --- a/client/scripts/atlas_standalone.gd +++ b/client/scripts/atlas_standalone.gd @@ -263,8 +263,26 @@ func _wait_for_connected(timeout_s: float) -> bool: return false -func _server_binary_path() -> String: - var project_dir := ProjectSettings.globalize_path("res://") +## Pure function: SR_SERVER_BIN env override, else the debug build path — +## same two-tier shape as _attach_port()'s SR_PORT (D-254 §1). `make atlas` +## now builds the RELEASE server and passes SR_SERVER_BIN so a `_spawn_server()` +## cold-start (this file's own live-repro path — the coordinator's cold-start +## dossier) gets sub-second first-tile derivation instead of a debug build's +## multi-second AnalyzeBody. `override_env`/`override_project_dir` exist ONLY +## for direct-call unit tests (mirrors _attach_port(port_env)'s own param +## shape) — every real caller uses the zero-arg form. Accepts either an +## ABSOLUTE path or one relative to the project root (`res://../`), so +## SR_SERVER_BIN can be given as `server/target/release/settled-reach-server` +## (the Makefile's own shape) without the caller having to know this script's +## `res://` layout. +static func _server_binary_path(override_env: String = "", override_project_dir: String = "") -> String: + var project_dir := ( + override_project_dir if not override_project_dir.is_empty() + else ProjectSettings.globalize_path("res://") + ) + var env := override_env if not override_env.is_empty() else OS.get_environment("SR_SERVER_BIN") + if not env.is_empty(): + return env if env.is_absolute_path() else project_dir.path_join("../" + env) return project_dir.path_join("../server/target/debug/settled-reach-server") diff --git a/client/tests/test_atlas_cold_start.gd b/client/tests/test_atlas_cold_start.gd index a25cf761a..0275f837b 100644 --- a/client/tests/test_atlas_cold_start.gd +++ b/client/tests/test_atlas_cold_start.gd @@ -1,15 +1,17 @@ ## PR #192 cold-start dossier — coordinator's live repro against a freshly- ## spawned (cold) server (`make atlas` shape, first AnalyzeBody taking -## seconds): BUG 1 (tile-mosaic paint never resolving) and the legend- -## stacking half of BUG 2. Split out of test_atlas_zoom_ladder.gd purely for -## file-length reasons (gdlint max-file-lines) — same instantiation/mock- -## response conventions as that file, not a different testing philosophy. +## seconds): BUG 1 (tile-mosaic paint never resolving), the legend-stacking +## half of BUG 2, and BUG 3 (the "DERIVING TERRAIN…" pending-state label, +## round 2). Split out of test_atlas_zoom_ladder.gd purely for file-length +## reasons (gdlint max-file-lines) — same instantiation/mock-response +## conventions as that file, not a different testing philosophy. ## RegionalScreen's own re-entry-guard half of BUG 2 is covered separately ## in test_regional_screen.gd (a different layer — nav, not the viewer). class_name TestAtlasColdStart extends GdUnitTestSuite const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd") +const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") ## Dudley's WINDOW_GRANULARITY_REGION_KEY sentinel — mirrors ## test_atlas_zoom_ladder.gd's own constant (see that file's doc for why the @@ -159,3 +161,151 @@ func test_legend_refresh_is_idempotent_against_same_frame_re_entry() -> void: + " free immediately, not defer via queue_free()" ) % [baseline_count, after_count] ).is_equal(baseline_count) + + +## PR #192 cold-start round 2: the children-count fix above is necessary but +## not sufficient — the coordinator's live scene dump showed the CONTAINER +## itself measured 260x2343px even after that fix landed. Root cause turned +## out to be BROADER than "only after stacking": this panel is manually +## positioned under AtlasWindowViewer (not inside a parent Container), so +## `size` NEVER tracks a shrinking `get_minimum_size()` on its own at +## all — confirmed directly (instrumented and reverted) that even a +## completely FRESH, never-refreshed-twice legend shows `size` frozen at +## whatever it happened to be on its very first measurement, while +## get_minimum_size() reports the correct value the whole time. The +## regression here therefore compares `size.y` against the RELIABLE ground +## truth (`get_minimum_size().y`, confirmed correct in every trace) rather +## than an earlier `size.y` snapshot — comparing size-to-size would pass +## trivially if BOTH numbers were equally stuck at the same stale value, +## which is exactly what silently happened during earlier drafts of this +## test. Reproduces the stacking shape (N same-frame refresh() calls) for +## realism, matching the coordinator's own trigger — the coordinator's +## acceptance bar: "after N refreshes the panel rect height must be within +## one legend's height" (of the CORRECT single-legend height, i.e. the +## settled minimum size). +func test_legend_panel_shrinks_back_after_a_stacking_window() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2) + + # Reproduce the stacking window directly (same-frame repeat refresh() + # calls, matching the pre-fix trigger shape) — this drives the panel's + # minimum size up the same way N repeat enter_orbital() calls did live. + for _i in range(10): + v._legend_panel.refresh() + await get_tree().process_frame + await get_tree().process_frame + + # A further, ordinary refresh() (the kind every real rung change already + # triggers) must leave the panel within one legend's height. + v._legend_panel.refresh() + # reset_to_content_size() is deferred (see its own doc — get_minimum_size() + # is momentarily wrong for RichTextLabel.fit_content children until the + # panel's real width has been laid out once) — a real frame must elapse + # for the deferred reset_size() call to actually run. + await get_tree().process_frame + await get_tree().process_frame + + var one_legend_height: float = v._legend_panel.get_minimum_size().y + assert_float(one_legend_height).override_failure_message( + "sanity: a single settled legend must have a real, non-zero measured minimum height" + ).is_greater(0.0) + assert_float(v._legend_panel.size.y).override_failure_message( + ( + "after a stacking window, the legend panel's rect height (%.1f) must" + + " shrink back to within one legend's height (%.1f, the panel's own" + + " correctly-settled get_minimum_size()) — reset_to_content_size()" + + " must actually collapse the Control back down, not just hold onto" + + " its previously-grown size" + ) % [v._legend_panel.size.y, one_legend_height] + ).is_less_equal(one_legend_height + 1.0) # +1.0: float rounding slack + + +# ============================================================================= +# BUG 3 (round 2) — "DERIVING TERRAIN…" label: the subtle per-tile +# COLOR_BORDER_FADE wash alone was invisible in a live cold capture. The +# viewer draws an unmistakable centered label while ZERO tiles have arrived, +# dropping it the instant even one lands. +# ============================================================================= + + +## The viewer must show the label exactly while is_tile_mode() is true AND +## has_any_tile_arrived() is false — the coordinator's "ZERO tiles have +## arrived" trigger condition, pinned directly against real tile-set state +## (not a mock) via a real enter_orbital() on a tiling body. Also exercises +## _draw()'s ACTUAL dispatch to _draw_deriving_terrain_label() through a +## real frame (queue_redraw() + await process_frame, matching the +## _CountingOverlay spy pattern from the BUG 1 self-heal tests above) — +## proving the draw call itself is reachable and doesn't error, not just +## that the underlying predicate is correct. +func test_deriving_terrain_label_condition_true_before_any_tile_arrives() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + var radius_km := 6238.4 # GJ380c (Lendel) — needs tiling + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) + assert_bool(v.is_tile_mode()).is_true() + assert_bool(v._tile_set.has_any_tile_arrived()).override_failure_message( + "sanity: entry must leave every tile unarrived before any response arrives" + ).is_false() + v.queue_redraw() + await get_tree().process_frame + + +## The instant even ONE tile lands, the label's own gate condition must flip +## off — per-tile washes alone are the right treatment once real content is +## visibly filling in (coordinator: "dropping to per-tile washes once the +## first tile lands"). +func test_deriving_terrain_label_condition_false_after_one_tile_arrives() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + var radius_km := 6238.4 # GJ380c (Lendel) + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) + var tile_set = v.get_tile_set() + var first_tile: Dictionary = tile_set.get_tiles()[0] + var window: Dictionary = _mock_window(first_tile["center"]) + window["granularity_v2"] = "Region" + window["granularity"] = SERVER_LEGACY_GRANULARITY_REGION_SENTINEL + window["n"] = AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window)) + + assert_bool(tile_set.has_any_tile_arrived()).override_failure_message( + "the label's own gate condition (NOT has_any_tile_arrived()) must flip" + + " false the instant a single tile lands, dropping the label" + ).is_true() + + +## Single-window mode (District/Quarter/small-body Region, not tile mode) +## never shows this label at all — it's a mosaic-specific cue for the +## "whole orbital rest state is still deriving" case, not every wait state +## (the single-window path already has its own COLOR_BORDER_FADE treatment, +## unchanged by this round). +func test_deriving_terrain_label_never_applies_outside_tile_mode() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2) + assert_bool(v.is_tile_mode()).override_failure_message( + "sanity: a District-rung enter() must never be tile mode" + ).is_false() + + +## centered_label_baseline() pure geometry: the X component is the +## VIEWPORT-CENTERED text block's left-anchor-adjusted X (viewport center +## minus half the text width — draw_string() itself does the final +## horizontal centering from there via HORIZONTAL_ALIGNMENT_CENTER, this +## only sets up where that alignment measures from); the Y component sits +## at viewport-center (a draw_string() baseline is the text's OWN vertical +## center here, by construction: center.y - text.y/2 + text.y/2 == center.y). +func test_centered_label_baseline_centers_a_symmetric_case() -> void: + var viewport_size := Vector2(1000.0, 800.0) + var text_size := Vector2(200.0, 40.0) + var baseline: Vector2 = AtlasWindowGeometry.centered_label_baseline(viewport_size, text_size) + assert_that(baseline).is_equal(Vector2(400.0, 400.0)) + + +## A zero-size viewport (never laid out yet) must not crash — degenerate +## input, not a real scenario, but the function must stay total. +func test_centered_label_baseline_zero_viewport_does_not_crash() -> void: + var baseline: Vector2 = AtlasWindowGeometry.centered_label_baseline( + Vector2.ZERO, Vector2(100.0, 20.0) + ) + assert_that(baseline).is_equal(Vector2(-50.0, 0.0)) diff --git a/client/tests/test_atlas_standalone.gd b/client/tests/test_atlas_standalone.gd index acfb6a670..1f866043d 100644 --- a/client/tests/test_atlas_standalone.gd +++ b/client/tests/test_atlas_standalone.gd @@ -89,6 +89,60 @@ func test_attach_port_whitespace_env_falls_back_to_default() -> void: assert_that(s._attach_port(" ")).is_equal(9876) +# ============================================================================= +# _server_binary_path — SR_SERVER_BIN env override, else the debug build path +# (same two-tier shape as _attach_port's SR_PORT). PR #192 cold-start round 2: +# `make atlas` now builds RELEASE and passes SR_SERVER_BIN so a spawned cold +# server's first AnalyzeBody is sub-second instead of a debug build's +# multi-second derivation. +# ============================================================================= + + +func test_server_binary_path_unset_env_falls_back_to_debug_path() -> void: + var s = _script() + assert_that(s._server_binary_path("", "/project")).is_equal( + "/project/../server/target/debug/settled-reach-server" + ) + + +func test_server_binary_path_relative_env_is_joined_to_project_root() -> void: + # The exact shape make atlas's own SR_SERVER_BIN value takes: relative to + # the repo root, not to client/'s res:// tree — matching the Makefile's + # own "server/target/release/settled-reach-server" string. + var s = _script() + assert_that( + s._server_binary_path("server/target/release/settled-reach-server", "/project") + ).is_equal("/project/../server/target/release/settled-reach-server") + + +func test_server_binary_path_absolute_env_is_used_verbatim() -> void: + var s = _script() + assert_that(s._server_binary_path("/opt/custom/settled-reach-server", "/project")).is_equal( + "/opt/custom/settled-reach-server" + ) + + +## The acceptance shape the coordinator asked for verbatim: "env set -> that +## path used; unset -> debug path unchanged" — via the REAL OS.get_environment +## read (zero override_env arg), not the injectable param the tests above use +## for isolation. OS.set_environment() is the standard gdUnit4-safe way to +## drive a real env var for the duration of one test without touching the +## actual process environment permanently. +func test_server_binary_path_real_env_set_overrides_debug_path() -> void: + var s = _script() + OS.set_environment("SR_SERVER_BIN", "server/target/release/settled-reach-server") + var result: String = s._server_binary_path("", "/project") + OS.set_environment("SR_SERVER_BIN", "") + assert_that(result).is_equal("/project/../server/target/release/settled-reach-server") + + +func test_server_binary_path_real_env_unset_leaves_debug_path_unchanged() -> void: + var s = _script() + OS.set_environment("SR_SERVER_BIN", "") + var result: String = s._server_binary_path("", "/project") + assert_that(result).is_equal("/project/../server/target/debug/settled-reach-server") + + # ============================================================================= # _parse_listening_line — main.rs's "LISTENING:{port}" stdout signal # (server/src/main.rs) parsed to an int, or -1 if the line doesn't match. diff --git a/client/tests/test_atlas_window_tile_set.gd b/client/tests/test_atlas_window_tile_set.gd index 5a394749d..565b59178 100644 --- a/client/tests/test_atlas_window_tile_set.gd +++ b/client/tests/test_atlas_window_tile_set.gd @@ -142,6 +142,79 @@ func test_all_tiles_arriving_flips_fully_arrived() -> void: ).is_true() +# ============================================================================= +# has_pending_tiles() / has_any_tile_arrived() — PR #192 cold-start dossier. +# Distinct predicates (both can be true at once, mid-arrival): the viewer's +# self-healing redraw (BUG 1) polls has_pending_tiles(); the "DERIVING +# TERRAIN…" label (BUG 3) polls has_any_tile_arrived() to know when to drop. +# ============================================================================= + + +func test_has_pending_tiles_true_immediately_after_enter() -> void: + var ts = _make_tile_set() + ts.enter("GJ380c", 6238.4) + assert_bool(ts.has_pending_tiles()).override_failure_message( + "every tile is unarrived right after enter() — has_pending_tiles() must be true" + ).is_true() + + +func test_has_pending_tiles_false_once_every_tile_has_arrived() -> void: + var ts = _make_tile_set() + ts.enter("GJ380c", 6238.4) + for tile: Dictionary in ts.get_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.has_pending_tiles()).is_false() + + +func test_has_pending_tiles_true_while_only_some_tiles_have_arrived() -> void: + var ts = _make_tile_set() + ts.enter("GJ380c", 6238.4) + var first_tile: Dictionary = ts.get_tiles()[0] + var window: Dictionary = _mock_window( + first_tile["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION + ) + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window)) + assert_bool(ts.has_pending_tiles()).override_failure_message( + "5 of 6 tiles still unarrived — has_pending_tiles() must stay true" + ).is_true() + + +func test_has_any_tile_arrived_false_immediately_after_enter() -> void: + var ts = _make_tile_set() + ts.enter("GJ380c", 6238.4) + assert_bool(ts.has_any_tile_arrived()).override_failure_message( + "nothing has arrived right after enter() — has_any_tile_arrived() must be false" + ).is_false() + + +## The exact mid-arrival case both predicates must agree can coexist: one +## tile in, five still pending — the point the "DERIVING TERRAIN…" label +## must drop (has_any_tile_arrived() flips true) while the self-heal must +## keep redrawing (has_pending_tiles() stays true). +func test_has_any_tile_arrived_true_after_a_single_tile_lands() -> void: + var ts = _make_tile_set() + ts.enter("GJ380c", 6238.4) + var first_tile: Dictionary = ts.get_tiles()[0] + var window: Dictionary = _mock_window( + first_tile["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION + ) + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window)) + assert_bool(ts.has_any_tile_arrived()).is_true() + assert_bool(ts.has_pending_tiles()).override_failure_message( + "sanity: the other 5 tiles are still pending at the same moment" + ).is_true() + + +func test_has_any_tile_arrived_false_for_an_empty_tile_set() -> void: + var ts = _make_tile_set() + assert_bool(ts.has_any_tile_arrived()).override_failure_message( + "an empty tile set (never entered) must not vacuously report arrival" + ).is_false() + + ## 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 diff --git a/client/ui/implant/apps/atlas/atlas_legend_panel.gd b/client/ui/implant/apps/atlas/atlas_legend_panel.gd index 51976d7ed..dd235499b 100644 --- a/client/ui/implant/apps/atlas/atlas_legend_panel.gd +++ b/client/ui/implant/apps/atlas/atlas_legend_panel.gd @@ -173,6 +173,7 @@ func refresh() -> void: clear() visible = not active_specs.is_empty() if active_specs.is_empty(): + reset_to_content_size() return add_component( @@ -188,3 +189,4 @@ func refresh() -> void: if i < active_specs.size() - 1: add_component(ImplantSeparator.new()) reposition() + reset_to_content_size() diff --git a/client/ui/implant/apps/atlas/atlas_window_geometry.gd b/client/ui/implant/apps/atlas/atlas_window_geometry.gd index 66d0e9fb4..3dcbd5385 100644 --- a/client/ui/implant/apps/atlas/atlas_window_geometry.gd +++ b/client/ui/implant/apps/atlas/atlas_window_geometry.gd @@ -673,3 +673,16 @@ static func screen_header_content( 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} + + +## Cold-start dossier: the baseline position draw_string() needs to render +## `text` horizontally AND vertically centered inside `viewport_size` — pure +## geometry, split out of AtlasWindowViewer._draw_deriving_terrain_label() +## for file-length (gdlint max-file-lines), not a different concern. Callers +## pass HORIZONTAL_ALIGNMENT_CENTER to draw_string() themselves (that part +## isn't pure — it needs the real Font instance) — this only computes the Y +## baseline offset and the X center point draw_string()'s own centering +## then works from. +static func centered_label_baseline(viewport_size: Vector2, text_size: Vector2) -> Vector2: + var center: Vector2 = viewport_size * 0.5 + return center - text_size * 0.5 + Vector2(0.0, text_size.y * 0.5) diff --git a/client/ui/implant/apps/atlas/atlas_window_legend.gd b/client/ui/implant/apps/atlas/atlas_window_legend.gd index c8d21052f..b88aea396 100644 --- a/client/ui/implant/apps/atlas/atlas_window_legend.gd +++ b/client/ui/implant/apps/atlas/atlas_window_legend.gd @@ -90,6 +90,7 @@ func refresh() -> void: _add_toggle_section(active_id) reposition() + reset_to_content_size() func _add_morphology_section() -> void: diff --git a/client/ui/implant/apps/atlas/atlas_window_tile_set.gd b/client/ui/implant/apps/atlas/atlas_window_tile_set.gd index f2dbd2e20..6537bcf40 100644 --- a/client/ui/implant/apps/atlas/atlas_window_tile_set.gd +++ b/client/ui/implant/apps/atlas/atlas_window_tile_set.gd @@ -150,6 +150,24 @@ func has_pending_tiles() -> bool: return false +## True once at least ONE tile has a real window — distinct from +## has_pending_tiles()'s "at least one MISSING" (both can be true at once, +## mid-arrival). PR #192 cold-start round 2: a cold server's first +## AnalyzeBody can take >10s with ZERO tiles landed the whole time — the +## per-tile border-fade wash alone (subtle, same color as every OTHER +## no-data-yet state) read as broken darkness in a live cold capture, not +## loading. The viewer uses this to gate an unmistakable "DERIVING +## TERRAIN…" label: shown while this is false (nothing has arrived at all — +## the reassurance is needed most), dropped the moment even one tile lands +## (per-tile washes alone read fine once real content is visibly filling in +## around the gaps). +func has_any_tile_arrived() -> bool: + for tile: Dictionary in _tiles: + if tile["window"] != null: + return true + return false + + ## 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 diff --git a/client/ui/implant/apps/atlas/atlas_window_viewer.gd b/client/ui/implant/apps/atlas/atlas_window_viewer.gd index 2ef6424f5..3ef445379 100644 --- a/client/ui/implant/apps/atlas/atlas_window_viewer.gd +++ b/client/ui/implant/apps/atlas/atlas_window_viewer.gd @@ -8,25 +8,23 @@ extends Control ## 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). +## several — see `_tile_mode`/AtlasWindowTileSet) at whichever rung is +## currently held: morphology base 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, extended T-1153): ## - _canvas (Node2D) holds AtlasWindowOverlay; pan = _canvas.position, zoom ## = _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. +## crossing a rung's coverage ceiling 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). +## condition) — on a body needing tiling, re-enters `_tile_mode` (§4). ## - _window_request owns the single-window cache/debounce/retry; _tile_set ## owns N of those for the tiled rest state — this Control decides WHICH. ## @@ -44,13 +42,12 @@ const OVERLAY_BAR_HEADER_RESERVE: float = 360.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 enter_orbital()'s largest legal `n` -## COVER-fits without clamping (would silently show less than the whole -## body). 0.0005 covers a ~120,000 km-radius body at a 3840px 4K viewport. +## ACROSS RUNGS (D-013): crossing a rung's coverage ceiling 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 enter_orbital()'s +## largest legal `n` COVER-fits without clamping — 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 @@ -61,23 +58,20 @@ const ZOOM_STEP: float = 1.15 const PAN_SPEED_CANVAS_PX_S: float = 96.0 ## T-1145 item 2: cursor-to-edge distance (px) that triggers edge-scroll -## (Jeroen's "~24px"). Same speed as WASD — _process() reads -## PAN_SPEED_CANVAS_PX_S for both, no separate constant. +## (Jeroen's "~24px"). Same speed as WASD, no separate constant. const EDGE_SCROLL_MARGIN_PX: float = 24.0 ## Pixel size of one district cell at zoom=1.0 — a fixed on-screen scale -## (unlike AtlasViewer's heightmap, no source texture dictates a native pixel -## size; this constant IS the native size). 16px/cell at n=64 gives a -## ~1024px-wide composite before zoom, comfortable in a 1280x720+ viewport. +## (no source texture dictates a native size; this constant IS it). 16px/cell +## at n=64 gives a ~1024px composite before zoom, fits a 1280x720+ viewport. const CELL_PIXEL_SIZE: float = 16.0 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 — a dimmer/ -## less-certain read of the same planetary data, not a different visual -## language. Also read by AtlasWindowOverlay for a per-tile pending wash in -## the mosaic (cold-start dossier) — same "still working" cue, one color. +## reads as "real data seen through", not a placeholder block. Also read by +## AtlasWindowOverlay for a per-tile pending wash in the mosaic — same +## "still working" cue, one color. 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 @@ -86,6 +80,11 @@ const COLOR_BORDER_FADE: Color = Color(0.20, 0.24, 0.30, 0.55) ## 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) +## Cold-start dossier: the "DERIVING TERRAIN…" label's own text color — +## default_implant.tres's text_dim (#667788), matching the implant chrome's +## existing dim/secondary-text role rather than inventing a new hue. +const COLOR_DERIVING_LABEL: Color = Color("#667788") + ## D-243: 2,048 m per district side. const DISTRICT_M: float = 2048.0 @@ -135,38 +134,33 @@ 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). +## _window_request.get_granularity_v2() (most recently REQUESTED, may +## already be in flight to a different rung). Defaults to District. var _held_granularity_v2: String = "District" # ── Pan/zoom state ───────────────────────────────────────────────────────── var _view_offset: Vector2 = Vector2.ZERO var _view_zoom: float = 1.0 -# T-1145 item 2: last known LOCAL mouse position (this Control's coordinate -# space), tracked from _gui_input's motion events for the edge-scroll check -# in _process() — _process() has no InputEvent of its own to read a position -# from, so the position has to be cached from the last motion event we DID -# see. Starts at -ONE (an impossible in-bounds position) so edge-scroll never -# fires before the mouse has ever moved over this Control at least once. +# T-1145 item 2: last known LOCAL mouse position, tracked from _gui_input's +# motion events for the edge-scroll check in _process() (which has no +# InputEvent of its own to read a position from). Starts at -ONE (an +# impossible in-bounds position) so edge-scroll never fires before the mouse +# has ever moved over this Control at least once. var _last_mouse_pos: Vector2 = Vector2(-1.0, -1.0) # T-1145 item 2: whether the OS application window currently has focus — # edge-scroll is suppressed while false (see _is_cursor_edge_scrolling()'s # doc). Defaults true: a freshly-entered screen assumes focus until told -# otherwise by NOTIFICATION_APPLICATION_FOCUS_OUT (matches the game's own -# window normally having focus when the player is actively navigating the -# implant in the first place). +# otherwise by NOTIFICATION_APPLICATION_FOCUS_OUT. var _app_has_focus: bool = true -# T-1142/T-1145: true once the user has manually panned (WASD/edge-scroll, -# T-1145) or zoomed since the last enter()/fit — auto-fit (enter, first -# window arrival, resize) only re-fits BEFORE this flips, so it never fights -# a player mid-interaction. Reset to -# false on every enter() (a fresh descent always starts fitted). +# T-1142/T-1145: true once the user has manually panned or zoomed since the +# last enter()/fit — auto-fit (enter, first window arrival, resize) only +# re-fits BEFORE this flips, so it never fights a player mid-interaction. +# Reset to false on every enter() (a fresh descent always starts fitted). var _user_adjusted: bool = false # T-1142: true from enter() until the FIRST _on_window_ready() fires (the -# actual composite's arrival re-fits once, in case the entry-time fit used a -# not-yet-final viewport size) — false after that first arrival, so LATER -# pan-triggered window arrivals never re-fit on their own (only entry/first- -# arrival/resize do, per the ticket's three named events). +# composite's arrival re-fits once, in case entry used a not-yet-final +# viewport size) — false after, so LATER pan-triggered arrivals never re-fit +# on their own (only entry/first-arrival/resize do). var _awaiting_first_window: bool = true # ── Overlay visibility ───────────────────────────────────────────────────── @@ -182,9 +176,8 @@ 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. +## MULTI-WINDOW MOSAIC instead of the single held composite (`_window`). Set +## by `_enter_tile_mode()`; cleared when `_maybe_reselect_rung()` leaves Region. var _tile_mode: bool = false @@ -257,12 +250,10 @@ func enter( ## 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()`. +## 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, so once compute_tile_grid() +## returns MORE than one tile, entry goes through _enter_tile_mode() instead. 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: @@ -342,10 +333,10 @@ func _enter_at_rung( _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. Called from -## enter(), the FIRST _on_window_ready() after entry, and -## NOTIFICATION_RESIZED — never mid-interaction (guarded by _user_adjusted). +## T-1142: fit-and-center — applies fit_window_view()'s zoom/offset, then +## re-clamps 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: @@ -425,9 +416,8 @@ func get_body_id() -> String: ## 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 -## only draws" split as AtlasViewer/AtlasMarkerOverlay). +## rather than hardcoding CELL_PIXEL_SIZE, so the viewer stays the single +## source of geometry truth ("viewer owns the transform, overlay only draws"). func get_cell_pixel_size() -> float: return CELL_PIXEL_SIZE @@ -464,14 +454,13 @@ func _on_atlas_layers_received(response: Dictionary) -> void: ## 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, granularity_v2) via the echo (§2/T-1150/T-1152), but a - # cache-hit path can fire synchronously from enter() before _held_center - # reflects a re-entrant call, so comparing again here removes any - # ordering assumption. granularity_v2 (T-1153) is compared too — a - # district response answering a request since moved on to Region (rapid - # wheel-zoom) must not be adopted just because center/n still match. + # Only adopt if it still matches what THIS viewer is currently showing — + # AtlasWindowRequest already filtered by its own last-asked echo (§2/ + # T-1150/T-1152), but a cache-hit path can fire synchronously from + # enter() before _held_center reflects a re-entrant call, so comparing + # again removes any ordering assumption. granularity_v2 is compared too + # — a district response answering a request since moved on to Region + # (rapid wheel-zoom) must not be adopted just because center/n match. var w_center := _vec_from_center(window.get("center", [0, 0])) var w_n := int(window.get("n", 0)) var w_granularity_v2 := str( @@ -514,10 +503,10 @@ func _on_tile_ready(_index: int) -> void: # ============================================================================= # View transform (mirrors AtlasViewer's own — pan is real; zoom is CURSOR- # ANCHORED and CONTINUOUS ACROSS RUNGS, D-226 T-1143-rulings: the held -# composite is always drawn client-side-zoomed with NO re-request, but -# crossing a rung's spacing threshold fires a background request at the new -# granularity (progressive refinement, see _maybe_reselect_rung()) while the -# OLD composite stays on screen. +# composite draws client-side-zoomed with NO re-request, but crossing a +# rung's spacing threshold fires a background request at the new granularity +# (progressive refinement, _maybe_reselect_rung()) while the OLD composite +# stays on screen. # ============================================================================= @@ -533,10 +522,9 @@ func _apply_transform() -> void: ## 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. + # Live round 6: once at the canonical frame, zoom-OUT must not drift + # below fit (see the reset's own doc — that caused a request storm). + # Clamping here, not the reset guard, keeps the reset edge-triggered. if new_zoom < _view_zoom and _is_at_canonical_frame(): var fit_zoom: float = _canonical_fit_zoom() new_zoom = maxf(new_zoom, fit_zoom) @@ -562,12 +550,12 @@ func _current_world_extent_m() -> float: ## 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()). `_held_n` -## MUST be re-clamped via `_clamp_window_n_mirror_v2()` for the TARGET rung -## (C1 clamp-mirror, a third layer up) — a stale large `_held_n` desyncs -## `_on_window_ready()`'s staleness check. 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). +## MUST be re-clamped for the TARGET rung (C1 clamp-mirror, a third layer +## up) — a stale large `_held_n` desyncs `_on_window_ready()`'s staleness +## check. Does NOT touch `_window`/`_held_granularity_v2` — the OLD +## composite keeps drawing until _on_window_ready() adopts the new one (§6 +## "no mode flip"; this lag is what made the canonical-frame reset's OLD +## guard misfire, live round 5). func _maybe_reselect_rung() -> void: if _held_n <= 0: return @@ -655,20 +643,16 @@ func _is_at_canonical_frame() -> bool: ## 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` (LAGGING, updated only on response -## adoption) — a TILING body's granularity stayed stale "Region" after -## zooming IN left tile mode, misreading "already canonical." Fixed by also -## requiring `is_tile_mode()` to match. -## -## **Live round 6 fix (round 5's fix overshot into a storm):** a per-tick -## zoom-equality check made this guard LEVEL-triggered — continued zoom-out -## kept nudging `_view_zoom` below fit, so `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 phase). Fixed by moving the zoom-drift concern to -## `_zoom_at()`'s own floor — this guard stays edge-triggered. +## Live round 5 fix: also requires is_tile_mode() to match (a TILING body's +## granularity stayed stale "Region" after zooming IN left tile mode, +## misreading "already canonical" from `_held_center`/`_held_granularity_v2` +## alone — LAGGING fields, updated only on response adoption). Live round 6 +## fix: a per-tick zoom-equality check had made this LEVEL-triggered — +## continued zoom-out flooded the server (889/897 wire responses in one +## phase) via repeated enter_orbital() tearing down/rebuilding the tile set, +## orphaning in-flight responses (nothing held -> black). Fixed by moving +## the zoom-drift concern to `_zoom_at()`'s own floor instead — this guard +## 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: @@ -764,6 +748,8 @@ func _draw() -> void: # T-1153: this viewer's own border-fade/pending-wash are single-window # concepts (one extent, one state) — a mosaic's per-tile pending wash # is drawn by AtlasWindowOverlay itself (see _draw_tile_mosaic()). + if _tile_set and not _tile_set.has_any_tile_arrived(): + _draw_deriving_terrain_label() return if _window == null: # §5 "what renders during the wait": a border-fade to the underlying @@ -793,6 +779,26 @@ func _draw_pending_refinement_wash() -> void: draw_rect(Rect2(top_left, Vector2(extent, extent)), COLOR_PENDING_REFINEMENT_WASH) +## Cold-start dossier: the subtle per-tile COLOR_BORDER_FADE wash alone +## (AtlasWindowOverlay._draw_tile_mosaic()) read as broken darkness in a +## live cold capture (>10s, zero tiles landed), not loading. Drawn in SCREEN +## space (unlike the per-tile washes, which live in the panned/zoomed +## _canvas) so it always reads centered/legible regardless of zoom. Dropped +## the instant even one tile lands (has_any_tile_arrived()) — per-tile +## washes alone read fine once real content fills in around the gaps. +## Positioning math lives in AtlasWindowGeometry.centered_label_baseline() +## (file-length; a pure function either way). +func _draw_deriving_terrain_label() -> void: + var label := "DERIVING TERRAIN…" + var font := ThemeDB.fallback_font + var font_size := 20 + var text_size: Vector2 = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size) + var baseline: Vector2 = AtlasWindowGeometry.centered_label_baseline(get_rect().size, text_size) + draw_string( + font, baseline, label, HORIZONTAL_ALIGNMENT_CENTER, -1, font_size, COLOR_DERIVING_LABEL + ) + + func _build_screen_header() -> void: _screen_header = ImplantHeader.new() _screen_header.position = Vector2(PANEL_MARGIN, 16.0) @@ -830,10 +836,9 @@ func _location_label() -> String: # ============================================================================= -## 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. +## No city panel / sidebar in this mode (yet) — always 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 @@ -894,10 +899,9 @@ func _process(delta: float) -> void: ## 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. +## this frame's delta — frame-rate independent, zoom-scaled, pole-wall +## clamped (T-1142). Sets _user_adjusted and triggers the pan-edge refetch +## (§4). Split from _process() so a test can call it with a synthetic input. 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 @@ -908,9 +912,8 @@ 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) — 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). +## — see AtlasWindowGeometry.held_pan_direction()'s own doc for the full +## rationale (moved there for file-length/testability). func _held_pan_direction() -> Vector2: return AtlasWindowGeometry.held_pan_direction() diff --git a/client/ui/implant/implant_panel.gd b/client/ui/implant/implant_panel.gd index da3a58a56..c03733892 100644 --- a/client/ui/implant/implant_panel.gd +++ b/client/ui/implant/implant_panel.gd @@ -86,3 +86,32 @@ func clear() -> void: for child in _vbox.get_children(): _vbox.remove_child(child) child.free() + + +## PR #192 cold-start round 2: call once at the END of a refresh() — AFTER +## clear() + every add_component() for the new content. A Control's `size` +## GROWS to fit a rising minimum size automatically (normal Container +## behavior) but never shrinks back down on its own once a taller stack of +## content has pushed it up (confirmed live: the same stacking window that +## caused the duplicate-children bug above ALSO left the panel's rect +## oversized — 260x2343px, ~10 legends tall — even after the children-count +## fix landed). This panel is manually positioned (not itself inside a +## parent Container), so nothing else forces that re-measure. +## +## DEFERRED, not a direct reset_size() call — confirmed directly +## (instrumented and reverted through several shapes before landing here): +## get_minimum_size() is momentarily WRONG for a frame or more after +## add_component() runs (RichTextLabel.fit_content's minimum-height +## computation is width-dependent and the control hasn't been laid out with +## its real width yet — the classic Godot "fit_content measures huge before +## its first real layout pass" gap), and — critically — a DIRECT reset_size() +## call that captures that transitional wrong value does NOT self-correct +## later even once get_minimum_size() itself settles down on its own: +## `size` stays stuck at whatever the premature reset_size() captured, +## because nothing re-triggers a growth pass for an already-`_ready()`'d +## panel afterward. call_deferred() runs at the END of the current frame's +## idle processing — after layout has had its chance to update +## get_minimum_size() — so by the time this actually executes, it reads the +## real, settled value. +func reset_to_content_size() -> void: + call_deferred("reset_size") From ddc448346213f1302a96ac562c33a4b2962e8b02 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 22 Jul 2026 20:32:09 +0200 Subject: [PATCH 3/6] test(simulation): connection-zero district_window delivery regression (bridge_tcp) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dudley's audit of the suspected ConnectionId(0) starvation: exhaustive trace of the delivery path (bridge/mod.rs insert/lookup/send paths, atlas plugin serve/drain, gen_queue coalescing) found NO sentinel or default-value collision — ConnectionId doesn't even derive Default, and every id path is a plain monotonic allocation with linear lookup. The real gap was coverage: every existing window test used ConnectionId(1). New connection_zero_window_delivery module replicates main.rs's actual bootstrap (blocking first-accept -> BridgeResource -> accept loop) and pins: the first-ever connection receives a district window; the exact six-tile entry shape delivers all six to connection 0; connection 1 keeps working. 5/5 repeated runs, single- and parallel-threaded. The live 'starvation' itself was client-side (pending responses never re-requested on the tile path — separate fix in flight); these tests stay as the server half's permanent pin. --- server/tests/bridge_tcp.rs | 497 +++++++++++++++++++++++++++++++++++++ 1 file changed, 497 insertions(+) diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 957802a26..6e2dbb7a9 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -1396,6 +1396,503 @@ fn browse_index_empty_table_is_ready_with_empty_list_over_tcp() { let _ = std::fs::remove_file(&db_path); } +// ----------------------------------------------------------------------- +// ConnectionId(0) district_window delivery regression (live-verified bug, +// coordinator repro 2026-07-22): a fresh server's FIRST connection (whatever +// role) never received a district_window response over the wire, while a +// second connection to the same (or a fresh) server was served correctly. +// Every existing window test in layer_proxy.rs's unit-test module calls +// `test_conn_id() -> ConnectionId(1)` — NEVER ConnectionId(0) — so this class +// of bug had zero unit-test coverage. This test drives the REAL +// id-assignment path (`BridgeResource::default()` + `insert_reader`, the +// exact path `main.rs`'s first-connection handling and +// `accept_new_connections`'s reader promotion both use) rather than a +// hand-picked id, over the real TCP wire, through the real +// receive->serve->drain->send tick-phase pipeline. +// ----------------------------------------------------------------------- + +mod connection_zero_window_delivery { + use super::*; + use bevy_app::App; + use rusqlite::Connection; + use settled_reach_server::atlas::body_params_reader::{ + BodyParamsReader, BodyParamsReaderResource, + }; + use settled_reach_server::atlas::cascade::CascadeLayer; + use settled_reach_server::atlas::layer_proxy::{ + AtlasLayerRequest, AtlasLayerStatus, WindowGranularity, + }; + use settled_reach_server::atlas::source_resolver::{ + BodySourceResolver, BodySourceResolverResource, + }; + use settled_reach_server::atlas::GenerationPlugin; + use settled_reach_server::bridge::{ + BridgePlugin, ConnectionId, ConnectionListener, HandshakeState, PendingConnections, + }; + use settled_reach_server::simulation::SimulationPlugin; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU32, Ordering}; + + static SEQ: AtomicU32 = AtomicU32::new(0); + + /// Same fixture shape as `layer_proxy.rs`'s own `write_tiny_heightmap` + /// (not exported for cross-crate reuse — duplicated intentionally rather + /// than widening that module's visibility for one integration test). + fn write_tiny_heightmap(path: &Path) { + use std::io::BufWriter; + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let file = std::fs::File::create(path).unwrap(); + let mut enc = png::Encoder::new(BufWriter::new(file), 32, 16); + enc.set_color(png::ColorType::Grayscale); + enc.set_depth(png::BitDepth::Sixteen); + let mut w = enc.write_header().unwrap(); + let data: Vec = (0..32u32 * 16) + .flat_map(|i| (((i * 600) % 65536) as u16).to_be_bytes()) + .collect(); + w.write_image_data(&data).unwrap(); + } + + /// Same fixture shape as `layer_proxy.rs`'s `resolver_and_params_reader` + /// (duplicated for the same cross-crate-visibility reason as above). + fn resolver_and_params_reader( + body_id: &str, + ) -> (BodySourceResolver, BodyParamsReader, PathBuf) { + const REL: &str = "wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png"; + let n = SEQ.fetch_add(1, Ordering::Relaxed); + let db = std::env::temp_dir().join(format!("sr_connzero_{}_{n}.db", std::process::id())); + let _ = std::fs::remove_file(&db); + let conn = Connection::open(&db).unwrap(); + conn.execute_batch( + "CREATE TABLE star_systems ( + system_id TEXT PRIMARY KEY, + spectral_class TEXT, + star_type TEXT + ); + CREATE TABLE bodies ( + body_id TEXT PRIMARY KEY, + system_id TEXT, + terrain_reference TEXT, + hydrosphere TEXT, + atmosphere TEXT, + planet_class TEXT, + body_radius_km REAL, + orbital_period_days REAL, + axial_tilt_deg REAL + );", + ) + .unwrap(); + conn.execute( + "INSERT INTO star_systems (system_id, spectral_class, star_type) VALUES ('GJ-1', 'G', 'main_sequence')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO bodies (body_id, system_id, terrain_reference, hydrosphere, atmosphere, planet_class, body_radius_km, orbital_period_days, axial_tilt_deg) + VALUES (?1, 'GJ-1', ?2, 'ocean', 'breathable', 'temperate', 6371.0, 365.25, 23.5)", + rusqlite::params![body_id, REL], + ) + .unwrap(); + drop(conn); + + let root = std::env::temp_dir().join(format!("sr_connzeroroot_{}_{n}", std::process::id())); + write_tiny_heightmap(&root.join(REL)); + + let resolver = BodySourceResolver::open(&db, vec![root.clone()]).unwrap(); + let params_reader = BodyParamsReader::open(&db).unwrap(); + (resolver, params_reader, root) + } + + /// A district-window `AtlasLayerRequest` at Region granularity — the + /// exact rung the coordinator's live repro used (six Region tile + /// requests on body entry). + fn region_window_request(body_id: &str, center: (i32, i32)) -> AtlasLayerRequest { + AtlasLayerRequest { + body_id: body_id.to_string(), + up_to: CascadeLayer::Topography, + window_center: Some(center), + window_n: 4, + window_granularity: 0, + window_granularity_v2: Some(WindowGranularity::Region), + window_min_wl_m: 0, + } + } + + /// Build a real `App`, replicating `main.rs`'s ACTUAL bootstrap sequence + /// for the first connection — not an approximation. `BridgePlugin` never + /// `init_resource`s `BridgeResource` itself (confirmed: no + /// `init_resource`/`insert_resource::` anywhere in + /// `bridge/mod.rs`'s `Plugin::build`) — `main.rs` constructs it AFTER a + /// single BLOCKING `TcpListener::accept()` + `TcpBridge::accept_on()` + + /// handshake/startup exchange, pre-populated with that first connection + /// already installed via `insert_player`/`insert_reader`, and only + /// THEN starts the tick loop with a SEPARATE non-blocking listener clone + /// for `accept_new_connections` to handle connections 2+. This function + /// reproduces exactly that two-listener-handle, blocking-then-async + /// split (`main.rs` lines ~104-145, ~368-377) rather than the + /// all-non-blocking shortcut the first draft of this test used (which + /// masked the real bootstrap entirely and hit "BridgeResource does not + /// exist" — the WRONG failure, not the bug under investigation). + /// + /// `role` selects Reader (the standalone Atlas companion's real role, + /// matching the coordinator's live repro) or Player. + fn accept_first_connection_and_build_app( + resolver: BodySourceResolver, + params_reader: settled_reach_server::atlas::body_params_reader::BodyParamsReader, + role: ConnectionRole, + ) -> (App, std::net::SocketAddr, TcpStream) { + let listener = TcpListener::bind("127.0.0.1:0").expect("failed to bind"); + let addr = listener.local_addr().expect("failed to get local address"); + // try_clone BEFORE any accept, mirroring main.rs exactly: one handle + // for the blocking first accept, a second (later set non-blocking) + // for the async per-tick accept-loop — the same underlying socket. + let listener_for_loop = listener.try_clone().expect("failed to clone listener"); + + let client_handle = thread::spawn(move || { + let stream = TcpStream::connect(addr).expect("failed to connect"); + client_handshake(stream, role) + }); + + // Blocking accept — genuinely blocks this thread until the client + // above connects, exactly like main.rs's listener.accept() call. + let bridge = TcpBridge::accept_on(listener).expect("failed to accept first connection"); + // main.rs's real sequence (lines ~145-161): send the protocol + // handshake, THEN read the client's StartupMessage — BEFORE the + // client thread's client_handshake() (which reads the handshake + // first) can complete. Omitting these two calls was the first + // draft's bug: both sides deadlocked waiting on each other with + // nothing ever written first. + bridge.send_handshake().expect("failed to send handshake"); + let _startup = bridge + .receive_startup() + .expect("failed to receive startup message"); + let client_stream = client_handle.join().expect("client thread panicked"); + + let mut bridge_resource = BridgeResource::default(); + let first_id = match role { + ConnectionRole::Player => bridge_resource.insert_player(bridge), + ConnectionRole::Reader => bridge_resource.insert_reader(bridge), + }; + assert_eq!( + first_id, + ConnectionId(0), + "sanity: the first-ever insert on a fresh BridgeResource must be id 0" + ); + + // Full main.rs plugin composition (not a hand-picked subset) — a + // missing resource from any of these panicked the first draft + // (KnowledgePlugin/NpcPlugin/StorytellerPlugin/SettingsPlugin/ + // BookmarkPlugin's systems are NOT gated behind Option> the + // way GenerationPlugin's atlas-reader resources are), so matching + // main.rs exactly is the correct fix, not narrowing the plugin set. + let mut app = App::new(); + app.add_plugins(SimulationPlugin { seed: 42 }); + app.add_plugins(BridgePlugin); + app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin); + app.add_plugins(settled_reach_server::npc::NpcPlugin); + app.add_plugins(settled_reach_server::storyteller::StorytellerPlugin); + app.add_plugins(settled_reach_server::settings::SettingsPlugin); + app.add_plugins(settled_reach_server::bookmark::BookmarkPlugin::default()); + app.add_plugins(GenerationPlugin); + // main.rs unconditionally calls setup_proof_room()/setup_gauntlet() + // AFTER building every plugin above — even for a Reader-only spawn + // (D-254 §1's own comment on that call site: "this call is NOT + // gated on the first connection's role ... A Reader-only spawned + // server therefore has an inert, unpiloted PlayerCharacter entity"). + // setup_proof_room isn't `pub` (private to the main.rs binary crate) + // so it can't be called directly from an integration test; inserting + // WalkabilityMap alone (the specific resource several PreInput/ + // Movement-phase systems require unconditionally, confirmed by this + // test's first draft panicking with it absent) is the minimal + // equivalent for a Reader-only session that spawns no character — + // matching game_loop.rs's own established minimal pattern. + app.insert_resource( + settled_reach_server::simulation::movement::WalkabilityMap::new(32, 32, 1), + ); + app.insert_resource(bridge_resource); + app.insert_resource(HandshakeState::Complete); + // Non-blocking for the tick loop's accept_new_connections, exactly + // as main.rs's listener_for_loop.set_nonblocking(true) does. + listener_for_loop + .set_nonblocking(true) + .expect("failed to set accept-loop listener non-blocking"); + app.insert_resource(ConnectionListener(Some(listener_for_loop))); + app.world_mut() + .resource_mut::() + .0 + .clear(); + app.insert_resource(BodySourceResolverResource(resolver)); + app.insert_resource(BodyParamsReaderResource(params_reader)); + (app, addr, client_stream) + } + + /// Drive `app.update()` (one full real tick — every registered system, + /// in the real `TickPhase` order) repeatedly, checking the accept-loop + /// condition after each — mirrors `drive_accept_loop_until`'s pattern but + /// against a full `App` rather than a hand-picked system. + fn drive_app_accept_loop_until(app: &mut App, condition: impl Fn(&App) -> bool) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !condition(app) { + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for accept-loop condition" + ); + app.update(); + thread::sleep(std::time::Duration::from_millis(1)); + } + } + + /// Drive `app.update()` ticks until a `Ready` `AtlasLayerResponse` + /// carrying a populated `district_window` arrives on `stream`, or a + /// wall-clock deadline expires — mirrors + /// `drive_browse_tick_until_response`'s established pattern (bounded + /// retry loop over a genuinely blocking client-side socket, not a fixed + /// sleep/tick count) but must also tolerate intermediate `Pending` + /// frames (the D-225 poll-and-recheck-cache contract: the FIRST response + /// for a cold window is `Ready` with `district_window: None`, not the + /// final answer) by reading and discarding them until the populated one + /// lands. + fn drive_app_until_window_response( + app: &mut App, + stream: &mut TcpStream, + body_id: &str, + center: (i32, i32), + ) -> settled_reach_server::atlas::layer_proxy::AtlasLayerResponse { + stream + .set_read_timeout(Some(std::time::Duration::from_millis(50))) + .expect("failed to set read timeout"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + app.update(); + match read_framed(stream) { + Ok(Some(payload)) => { + let resp: settled_reach_server::atlas::layer_proxy::AtlasLayerResponse = + rmp_serde::from_slice(&payload) + .expect("failed to decode AtlasLayerResponse"); + if resp.district_window.is_some() { + return resp; + } + // Pending (cold cache) or Ready-with-None (derive still + // in flight) — re-request and keep polling, exactly the + // client's real re-poll behavior (atlas_window_request.gd's + // on_response()/_schedule_retry(): the server never pushes + // a second response on its own — D-225's poll-and-recheck- + // cache contract requires the CLIENT to re-send). Omitting + // this re-send was this test's own first-draft bug: the + // request was consumed by serve_atlas_requests within the + // SAME app.update() it arrived in (receive -> serve -> + // send all run inside one PreInput/PostSnapshot pass), so + // every later tick correctly had nothing left to serve — + // not a production hang, a missing re-poll in the harness. + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for a populated district_window \ + (last status: {:?})", + resp.status + ); + send_region_window_request(stream, body_id, center); + } + Ok(None) => panic!("unexpected EOF reading atlas response"), + Err(e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => + { + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for an atlas response frame at all" + ); + } + Err(e) => panic!("failed to read atlas response frame: {e}"), + } + } + } + + fn send_region_window_request(stream: &mut TcpStream, body_id: &str, center: (i32, i32)) { + let req = region_window_request(body_id, center); + let payload = rmp_serde::to_vec_named(&req).expect("failed to encode AtlasLayerRequest"); + write_framed(stream, &payload).expect("failed to write AtlasLayerRequest"); + } + + /// **THE regression test.** A fresh server's FIRST connection ever + /// accepted (Reader role, matching the standalone Atlas companion) must + /// receive its district_window response — proven via the REAL + /// `insert_reader`-assigned id, not `ConnectionId(1)` the way every + /// existing window unit test does. If this test alone is added without + /// re-requesting after the initial Pending (i.e. treats the first + /// response as final), it would have passed even on a broken server — + /// the assertion on `resp.status == Ready` AND `district_window.is_some()` + /// together, reached only via `drive_window_tick_until_response`'s + /// re-poll loop, is what actually exercises the async derive-then-cache + /// path a live client depends on. + #[test] + fn first_connection_ever_accepted_receives_district_window() { + let (resolver, params_reader, _root) = resolver_and_params_reader("GJ1c"); + let (mut app, _addr, mut stream) = + accept_first_connection_and_build_app(resolver, params_reader, ConnectionRole::Reader); + + // Confirm this really is the id-zero path before asserting delivery + // — a false pass here (e.g. BridgeResource pre-seeded elsewhere) + // would silently defeat the whole point of the test. (Also asserted + // inside accept_first_connection_and_build_app; re-checked here at + // the point of use for a self-contained failure message.) + let conn_id = app + .world() + .resource::() + .reader_ids() + .first() + .copied() + .expect("reader must be installed"); + assert_eq!( + conn_id, + ConnectionId(0), + "this test only proves what it claims to prove if the reader under \ + test genuinely got the FIRST id a fresh BridgeResource ever assigns" + ); + + send_region_window_request(&mut stream, "GJ1c", (0, 0)); + let resp = drive_app_until_window_response(&mut app, &mut stream, "GJ1c", (0, 0)); + + assert_eq!(resp.status, AtlasLayerStatus::Ready); + assert_eq!(resp.body_id, "GJ1c"); + let window = resp + .district_window + .expect("district_window must be populated (checked above; re-asserted for clarity)"); + assert_eq!(window.granularity_v2, WindowGranularity::Region); + assert!( + !window.morphology.is_empty(), + "the delivered window must carry real derived cell data, not an empty payload" + ); + } + + /// The EXACT shape of the coordinator's live repro: six Region tile + /// requests fired on body entry (the standalone Atlas companion's real + /// window-tile-set fan-out, not a single request) on connection 0, all + /// six must be delivered — not just the first, in case a many-in-flight + /// scenario surfaces an ordering issue a single-request test can't see + /// (e.g. coalescing/supersede logic dropping later requests, or the + /// per-tick MAX_READER_INBOUND_FRAMES_PER_TICK cap interacting badly + /// with six queued frames). + #[test] + fn first_connection_receives_all_six_region_tiles() { + let (resolver, params_reader, _root) = resolver_and_params_reader("GJ1c"); + let (mut app, _addr, mut stream) = + accept_first_connection_and_build_app(resolver, params_reader, ConnectionRole::Reader); + + let conn_id = app + .world() + .resource::() + .reader_ids() + .first() + .copied() + .expect("reader must be installed"); + assert_eq!(conn_id, ConnectionId(0)); + + // Six distinct region-tile centers, matching the design doc's tiled + // orbital-view worked example shape (a 3x2 or similar tile grid + // around the entry point) — distinct centers so each is a genuinely + // separate cache entry, not accidental coalescing onto one. + let centers: [(i32, i32); 6] = [ + (0, 0), + (12739, -3200), + (12739, 3200), + (0, -3200), + (0, 3200), + (6400, -3200), + ]; + + for ¢er in ¢ers { + send_region_window_request(&mut stream, "GJ1c", center); + } + + let mut received: std::collections::BTreeSet<(i32, i32)> = + std::collections::BTreeSet::new(); + stream + .set_read_timeout(Some(std::time::Duration::from_millis(50))) + .expect("failed to set read timeout"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); + while received.len() < centers.len() { + assert!( + std::time::Instant::now() < deadline, + "timed out with only {}/{} tiles delivered: {:?}", + received.len(), + centers.len(), + received + ); + app.update(); + match read_framed(&mut stream) { + Ok(Some(payload)) => { + let resp: settled_reach_server::atlas::layer_proxy::AtlasLayerResponse = + rmp_serde::from_slice(&payload) + .expect("failed to decode AtlasLayerResponse"); + if let Some(window) = resp.district_window { + received.insert(window.center); + } else { + // Pending/Ready-with-None for one of the six — re-send + // ALL not-yet-received centers (mirrors the real + // client's per-tile independent re-poll). + for ¢er in centers.iter().filter(|c| !received.contains(c)) { + send_region_window_request(&mut stream, "GJ1c", center); + } + } + } + Ok(None) => panic!("unexpected EOF reading atlas response"), + Err(e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => {} + Err(e) => panic!("failed to read atlas response frame: {e}"), + } + } + + assert_eq!( + received.len(), + centers.len(), + "all six region tiles must be delivered to connection 0" + ); + } + + /// The delivery-order counterpart: a SECOND connection's request must + /// ALSO be served correctly (this already passed in the coordinator's + /// live repro — included here as a same-file regression guard so a + /// future fix to the id-0 case can't accidentally break id-1 while + /// fixing id-0, and so this file documents the FULL observed shape of + /// the bug, not just half of it). + #[test] + fn second_connection_also_receives_district_window() { + let (resolver, params_reader, _root) = resolver_and_params_reader("GJ1c"); + // First connection occupies ConnectionId(0) via the SAME real + // blocking-accept bootstrap main.rs uses (see + // accept_first_connection_and_build_app's doc) but sends no + // requests — isolates "is it specifically the SECOND id that works" + // from "does a second connection existing change anything for the + // first". + let (mut app, addr, _first_stream) = + accept_first_connection_and_build_app(resolver, params_reader, ConnectionRole::Reader); + + let second_handle = thread::spawn(move || { + let stream = TcpStream::connect(addr).expect("failed to connect"); + client_handshake(stream, ConnectionRole::Reader) + }); + drive_app_accept_loop_until(&mut app, |a| { + a.world().resource::().reader_count() == 2 + }); + let mut second_stream = second_handle.join().expect("client thread panicked"); + + let second_id = app + .world() + .resource::() + .reader_ids() + .get(1) + .copied() + .expect("second reader must be installed"); + assert_eq!(second_id, ConnectionId(1)); + + send_region_window_request(&mut second_stream, "GJ1c", (0, 0)); + let resp = drive_app_until_window_response(&mut app, &mut second_stream, "GJ1c", (0, 0)); + + assert_eq!(resp.status, AtlasLayerStatus::Ready); + assert!(resp.district_window.is_some()); + } +} + /// Minimal `ObserverSnapshot` for the tests above — same shape as /// `snapshot_roundtrip_over_tcp`'s at the top of this file, parameterized /// only by `tick` (the one field these tests assert on). From 3891c832068dfff9ad7f2a0a8b4d18e60940bce3 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 22 Jul 2026 20:56:46 +0200 Subject: [PATCH 4/6] =?UTF-8?q?fix(client):=20cold-body=20Pending=20respon?= =?UTF-8?q?ses=20reach=20the=20retry=20path=20=E2=80=94=20the=20launch-sha?= =?UTF-8?q?pe=20starvation=20root=20cause?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The true cause of the black first launch, server-confirmed after four disproven theories: a stone-cold body answers the FIRST window request with a whole-response status 'Pending' (only the whole-body cache-hit branch sets Ready), and on_response()'s very first check — status != Ready -> return — swallowed it before the retry machinery could run. retries stayed 0 forever; the DERIVING state never resolved. Second connections worked by luck (the first request warms the whole-body cache, so they read Ready and take the healthy path). Both tile fan-out AND single-window first-descents were affected — one shared function, one fix: branch on the outer status first (the atlas_generation_proxy reference shape): Pending -> retry, Ready -> existing null-window retry, NotFound/Error -> give up immediately (the principled give-up policy, replacing the elapsed-retries ceiling). Hardening in the same round: deterministic exponential backoff (0.5s doubling, 4s cap) + per-tile stagger (0.1s * index — six tiles retry at 0.5/0.6/0.7/0.8/0.9/1.0s, strictly-increasing asserted, not jittered); MAX_RETRIES 20->30 (~110s horizon under backoff). Regressions are wire-accurate by construction: whole-response Pending (body_id only, no center/n/granularity — verified against the server's own response construction) delivered through the REAL fan-out (tile_set._on_atlas_layers_received, never tile.on_response directly), mirrored at single-window level. Two first-draft tests that passed with the bug reverted were caught and strengthened before reporting; every fix and hardening piece revert-verified independently (7+1 failing tests without them). --- client/tests/test_atlas_window_request.gd | 161 ++++++++++++++++++ client/tests/test_atlas_window_tile_set.gd | 157 +++++++++++++++++ .../apps/atlas/atlas_window_request.gd | 115 +++++++++++-- .../apps/atlas/atlas_window_tile_set.gd | 11 ++ 4 files changed, 427 insertions(+), 17 deletions(-) diff --git a/client/tests/test_atlas_window_request.gd b/client/tests/test_atlas_window_request.gd index a2046b767..791429d21 100644 --- a/client/tests/test_atlas_window_request.gd +++ b/client/tests/test_atlas_window_request.gd @@ -57,6 +57,27 @@ static func _mock_response(body_id: String, window: Variant) -> Dictionary: return {"body_id": body_id, "status": "Ready", "district_window": window} +## PR #192 cold-start round 3: the WIRE-ACCURATE shape of a cold body's +## first-ever response (server/src/atlas/layer_proxy.rs's +## get_or_generate()/serve_district_window(), whole-body cache MISS branch — +## `AtlasLayerResponse { status: Pending, district_window: None, ... }`, +## confirmed directly against that source). `body_id` is the only +## identifying field. +static func _pending_response(body_id: String) -> Dictionary: + return {"body_id": body_id, "status": "Pending", "district_window": null} + + +static func _not_found_response(body_id: String) -> Dictionary: + return {"body_id": body_id, "status": "NotFound", "district_window": null} + + +## Matches atlas_map_protocol.gd's `_decode_status_field()` — the decoded +## `AtlasLayerStatus::Error(String)` variant's status STRING is always just +## "Error" (the message rides in a separate `error` field). +static func _error_response(body_id: String, message: String = "boom") -> Dictionary: + return {"body_id": body_id, "status": "Error", "error": message, "district_window": null} + + func _make_request() -> Variant: var owner_stub := RefCounted.new() var req = auto_free(AtlasWindowRequest.new(owner_stub)) @@ -454,3 +475,143 @@ func test_clamp_window_n_mirror_v2_region_per_axis_cap_alone_satisfies_wire_cap_ 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) + + +# ============================================================================= +# PR #192 cold-start round 3 — the single-window half of the launch-shape +# gap: the SAME status-gate bug the tile fan-out has (test_atlas_window_tile_set.gd's +# own regressions) applies equally here, since on_response() is the shared +# class both paths use. A first descent onto a cold body with NO tiles +# (District/Quarter rung, or a small Region body) hits the identical +# whole-body-cache-miss -> status:"Pending" wire shape. +# ============================================================================= + + +## The exact bug, single-window shape: a whole-response Pending on a cold +## first request must increment the retry counter and schedule a re-poll — +## not be silently dropped by the OLD `status != "Ready" -> return` gate. +func test_cold_request_whole_response_pending_increments_retry_count() -> void: + var req = _make_request() + req.request_now("GJ380c", Vector2i(2, 2), 2) + assert_bool(req.is_pending()).is_true() + + req.on_response(_pending_response("GJ380c")) + + assert_bool(req.is_pending()).override_failure_message( + "a whole-response Pending must leave the request still pending, not" + + " silently give up" + ).is_true() + assert_int(req._retries).override_failure_message( + "a whole-response Pending must increment the retry counter — the" + + " exact bug: the OLD status-gate dropped this before ever reaching" + + " the retry-scheduling code, leaving retries at 0 forever" + ).is_equal(1) + + +## Full convergence: a whole-response Pending, then a real Ready for the +## RE-REQUEST, must be accepted — proving the retry loop's own re-request +## actually gets picked up, not just that the counter increments. The +## mid-test retries==1 assertion is what makes this genuinely load-bearing: +## without it, a Ready delivered ANY time after a Pending (retried or not) +## trivially passes this test's final assertion, since accepting a fresh +## Ready response was never the broken behavior — only the retry itself was. +func test_cold_request_converges_after_pending_then_real_ready() -> void: + var req = _make_request() + req.request_now("GJ380c", Vector2i(2, 2), 2) + + req.on_response(_pending_response("GJ380c")) + assert_bool(req.is_pending()).is_true() + assert_int(req._retries).override_failure_message( + "sanity: the retry must have actually been scheduled before this test" + + " waits for it to fire — otherwise the final assertion below would" + + " pass even if the retry never happened at all" + ).is_equal(1) + + await get_tree().create_timer(0.6).timeout # past the first retry delay + + var window: Dictionary = _mock_window(Vector2i(2, 2), 2) + req.on_response(_mock_response("GJ380c", window)) + assert_bool(req.is_pending()).override_failure_message( + "a real Ready response after the pending/retry cycle must be accepted" + ).is_false() + + +## NotFound must give up immediately, not retry. +func test_cold_request_not_found_gives_up_immediately_without_retry() -> void: + var req = _make_request() + req.request_now("GJ380c", Vector2i(2, 2), 2) + + req.on_response(_not_found_response("GJ380c")) + + assert_bool(req.is_pending()).override_failure_message( + "NotFound must give up immediately, not stay pending waiting for a retry" + ).is_false() + assert_int(req._retries).is_equal(0) + + +## Error must give up immediately too. +func test_cold_request_error_gives_up_immediately_without_retry() -> void: + var req = _make_request() + req.request_now("GJ380c", Vector2i(2, 2), 2) + + req.on_response(_error_response("GJ380c")) + + assert_bool(req.is_pending()).override_failure_message( + "Error must give up immediately, not stay pending waiting for a retry" + ).is_false() + assert_int(req._retries).is_equal(0) + + +# ============================================================================= +# _retry_delay_for() — deterministic exponential backoff + per-tile stagger +# (PR #192 cold-start round 3 hardening: 6 tiles retrying in perfect +# lockstep on a whole-response Pending is a real request-pulse risk even +# though it isn't what caused the starvation bug above). +# ============================================================================= + + +func test_retry_delay_for_first_retry_is_the_initial_delay() -> void: + assert_float(AtlasWindowRequest._retry_delay_for(1, 0)).is_equal_approx( + AtlasWindowRequest.INITIAL_RETRY_DELAY, 0.0001 + ) + + +func test_retry_delay_for_doubles_each_retry_until_the_cap() -> void: + assert_float(AtlasWindowRequest._retry_delay_for(1, 0)).is_equal_approx(0.5, 0.0001) + assert_float(AtlasWindowRequest._retry_delay_for(2, 0)).is_equal_approx(1.0, 0.0001) + assert_float(AtlasWindowRequest._retry_delay_for(3, 0)).is_equal_approx(2.0, 0.0001) + assert_float(AtlasWindowRequest._retry_delay_for(4, 0)).is_equal_approx(4.0, 0.0001) + # Retry 5 would double past MAX_RETRY_DELAY (8.0) — must clamp, not keep growing. + assert_float(AtlasWindowRequest._retry_delay_for(5, 0)).is_equal_approx( + AtlasWindowRequest.MAX_RETRY_DELAY, 0.0001 + ) + assert_float(AtlasWindowRequest._retry_delay_for(20, 0)).is_equal_approx( + AtlasWindowRequest.MAX_RETRY_DELAY, 0.0001 + ) + + +## The deterministic stagger: tile index i's delay is offset by +## STAGGER_STEP*i on top of the same backoff schedule — directly assertable, +## not a randomized jitter a test would have to tolerance-check. +func test_retry_delay_for_staggers_deterministically_by_tile_index() -> void: + var base: float = AtlasWindowRequest._retry_delay_for(1, 0) + for i in range(6): + var expected: float = base + AtlasWindowRequest.STAGGER_STEP * float(i) + assert_float(AtlasWindowRequest._retry_delay_for(1, i)).override_failure_message( + "tile index %d's first-retry delay must be exactly base + STAGGER_STEP*%d" % [i, i] + ).is_equal_approx(expected, 0.0001) + + +## Six tiles that all went pending in the same frame must NOT all retry at +## the exact same instant — the anti-storm property this hardening exists +## for, pinned directly: every tile's delay for the SAME retry count must be +## strictly increasing with its stagger index. +func test_retry_delay_for_six_tiles_never_collide_on_the_same_retry() -> void: + var delays: Array = [] + for i in range(6): + delays.append(AtlasWindowRequest._retry_delay_for(1, i)) + for i in range(1, delays.size()): + assert_float(delays[i]).override_failure_message( + "tile %d's delay must be strictly greater than tile %d's — a storm" + + " pulse means two tiles retrying at the same instant" % [i, i - 1] + ).is_greater(delays[i - 1]) diff --git a/client/tests/test_atlas_window_tile_set.gd b/client/tests/test_atlas_window_tile_set.gd index 565b59178..23f9afc05 100644 --- a/client/tests/test_atlas_window_tile_set.gd +++ b/client/tests/test_atlas_window_tile_set.gd @@ -30,6 +30,29 @@ static func _mock_response(body_id: String, window: Variant) -> Dictionary: return {"body_id": body_id, "status": "Ready", "district_window": window} +## PR #192 cold-start round 3: the WIRE-ACCURATE shape of a cold body's +## first-ever response — server/src/atlas/layer_proxy.rs's +## get_or_generate()/serve_district_window() on a whole-body cache MISS +## builds `AtlasLayerResponse { status: Pending, district_window: None, ... }` +## (confirmed directly against that source). `body_id` is the ONLY +## identifying field — no center/n/granularity anywhere, matching the real +## wire's total lack of per-request attribution on this specific shape. +static func _pending_response(body_id: String) -> Dictionary: + return {"body_id": body_id, "status": "Pending", "district_window": null} + + +static func _not_found_response(body_id: String) -> Dictionary: + return {"body_id": body_id, "status": "NotFound", "district_window": null} + + +## Matches atlas_map_protocol.gd's `_decode_status_field()` — the decoded +## `AtlasLayerStatus::Error(String)` variant's status STRING is always just +## "Error" (the message rides in a separate `error` field, not appended to +## the status string), confirmed against that decoder directly. +static func _error_response(body_id: String, message: String = "boom") -> Dictionary: + return {"body_id": body_id, "status": "Error", "error": message, "district_window": null} + + func _make_tile_set() -> Variant: var owner_stub := RefCounted.new() var ts = auto_free(AtlasWindowTileSet.new(owner_stub)) @@ -52,6 +75,23 @@ func test_enter_produces_the_expected_tile_count_for_lendel() -> void: assert_bool(ts.is_multi_tile()).is_true() +## PR #192 cold-start round 3 hardening: each tile's own AtlasWindowRequest +## must get a DISTINCT, index-matching `_stagger_index` — the anti-storm +## property (deterministic retry-delay stagger, see +## AtlasWindowRequest._retry_delay_for()'s own doc) depends entirely on this +## wiring; without it every tile silently staggers at index 0 and retries +## in lockstep again, exactly the storm risk this hardening exists to close. +func test_enter_wires_a_distinct_stagger_index_per_tile() -> void: + var ts = _make_tile_set() + ts.enter("GJ380c", 6238.4) + for i in range(ts._tiles.size()): + var req = ts._tiles[i]["request"] + assert_int(req._stagger_index).override_failure_message( + "tile %d's request must be wired with _stagger_index=%d, matching" + + " its own position in the tile set" % [i, i] + ).is_equal(i) + + ## 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 @@ -142,6 +182,123 @@ func test_all_tiles_arriving_flips_fully_arrived() -> void: ).is_true() +# ============================================================================= +# PR #192 cold-start round 3 — THE launch-shape regression: a cold body's +# FIRST-EVER response is a wire-accurate whole-response Pending (status +# "Pending", district_window null, body_id only — no center/n/granularity +# anywhere, confirmed against server/src/atlas/layer_proxy.rs directly). +# Driven through the REAL fan-out (SimBridge.atlas_layers_received.emit(), +# reaching every tile via tile_set._on_atlas_layers_received), not a direct +# tile.on_response() call — the coordinator's own regression-discipline ask, +# since a direct per-tile call is implicit attribution a real wire fan-out +# doesn't have. Before the status-gate fix, on_response()'s FIRST line +# (`status != "Ready" -> return`) dropped this response for every tile +# before ever reaching the retry-scheduling code — retries stayed at 0 +# forever, matching the coordinator's own live cold-server capture exactly. +# ============================================================================= + + +## The exact bug: a whole-response Pending on cold entry must increment +## every tile's retry counter and schedule a re-poll — not be silently +## dropped. Fails hard against the pre-fix code (retries stay 0 forever). +func test_cold_entry_whole_response_pending_increments_every_tiles_retry_count() -> void: + var ts = _make_tile_set() + ts.enter("GJ380c", 6238.4) + + SimBridge.atlas_layers_received.emit(_pending_response("GJ380c")) + + for tile_dict: Dictionary in ts._tiles: + var req = tile_dict["request"] + assert_bool(req.is_pending()).override_failure_message( + "every tile must still be pending after a whole-response Pending" + ).is_true() + assert_int(req._retries).override_failure_message( + "a whole-response Pending must increment the retry counter — the" + + " exact bug: the OLD status-gate silently dropped this before" + + " ever reaching the retry-scheduling code, leaving retries at 0 forever" + ).is_equal(1) + + +## The full convergence: repeated whole-response Pendings (simulating a slow +## cold AnalyzeBody), THEN real per-tile Ready responses for the tiles' own +## RE-REQUESTS — every tile must eventually fill. No re-request means this +## hangs (waiting past the retry delay for a re-poll that never happens) or +## fails (has_pending_tiles() never flips false) — exactly the launch-shape +## gap the coordinator named: "every unit test delivered Ready immediately." +func test_cold_entry_converges_after_repeated_pending_then_real_readies() -> void: + var ts = _make_tile_set() + ts.enter("GJ380c", 6238.4) + var tiles: Array = ts.get_tiles() + + # Two consecutive whole-response Pendings — a slow cold derive, not a + # single flip. Real waits so the scheduled retry timers can actually fire. + SimBridge.atlas_layers_received.emit(_pending_response("GJ380c")) + await get_tree().create_timer(0.7).timeout # past the first backoff delay + SimBridge.atlas_layers_received.emit(_pending_response("GJ380c")) + await get_tree().create_timer(0.8).timeout # past the second (staggered) delay + + assert_bool(ts.has_pending_tiles()).override_failure_message( + "sanity: still pending after 2 cold cycles" + ).is_true() + for tile_dict: Dictionary in ts._tiles: + var req = tile_dict["request"] + assert_int(req._retries).override_failure_message( + "sanity: each tile's retry counter must have actually incremented" + + " twice — otherwise the final assertion below would pass even if" + + " the retries never happened at all (a fresh Ready was never the" + + " broken behavior, only the retry itself was)" + ).is_equal(2) + + 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.has_pending_tiles()).override_failure_message( + "after the retries fire and real Readies land, every tile must have adopted its window" + ).is_false() + assert_bool(ts.is_fully_arrived()).is_true() + + +## NotFound must give up IMMEDIATELY, not retry — a body that doesn't exist +## will never resolve by waiting (the coordinator's own "give up on error +## responses, not elapsed patience" framing — this replaces the old +## elapsed-retries-only give-up policy with a correctness-based one for the +## cases where retrying is provably pointless). +func test_cold_entry_not_found_gives_up_immediately_without_retry() -> void: + var ts = _make_tile_set() + ts.enter("GJ380c", 6238.4) + + SimBridge.atlas_layers_received.emit(_not_found_response("GJ380c")) + + for tile_dict: Dictionary in ts._tiles: + var req = tile_dict["request"] + assert_bool(req.is_pending()).override_failure_message( + "NotFound must give up immediately, not stay pending waiting for a retry" + ).is_false() + assert_int(req._retries).override_failure_message( + "NotFound must never increment the retry counter — retrying a" + + " nonexistent body is provably pointless" + ).is_equal(0) + + +## Error must give up immediately too, same reasoning as NotFound — a +## resolve/IO failure won't resolve itself by polling. +func test_cold_entry_error_gives_up_immediately_without_retry() -> void: + var ts = _make_tile_set() + ts.enter("GJ380c", 6238.4) + + SimBridge.atlas_layers_received.emit(_error_response("GJ380c")) + + for tile_dict: Dictionary in ts._tiles: + var req = tile_dict["request"] + assert_bool(req.is_pending()).override_failure_message( + "Error must give up immediately, not stay pending waiting for a retry" + ).is_false() + assert_int(req._retries).is_equal(0) + + # ============================================================================= # has_pending_tiles() / has_any_tile_arrived() — PR #192 cold-start dossier. # Distinct predicates (both can be true at once, mid-arrival): the viewer's diff --git a/client/ui/implant/apps/atlas/atlas_window_request.gd b/client/ui/implant/apps/atlas/atlas_window_request.gd index 12ae7fc76..8b53b8243 100644 --- a/client/ui/implant/apps/atlas/atlas_window_request.gd +++ b/client/ui/implant/apps/atlas/atlas_window_request.gd @@ -38,8 +38,34 @@ const AtlasWindowCache := preload("res://ui/implant/apps/atlas/atlas_window_cach const DISTRICT_WINDOW_DEFAULT_N: int = 32 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 + +## Cold-start dossier (PR #192 round 3): the retry-on-PENDING loop used to be +## a flat RETRY_DELAY=0.5s / MAX_RETRIES=20 (~10s ceiling), copied verbatim +## from atlas_generation_proxy.gd's Layer1 poll — a DIFFERENT, typically +## faster derive. The real starvation bug turned out to be the status-gate +## fix in on_response() (see that function's own doc) — this backoff/stagger +## work is HARDENING landed alongside it, not the fix itself: once the +## status-gate fix makes 6 independent tiles all correctly retry on a +## whole-response Pending, they do so in perfect lockstep (all six went +## pending at entry within the same frame, so all six retry timers fire +## within the same frame too) — six re-requests every RETRY_DELAY, in sync, +## is exactly the "storm" shape worth damping even though it isn't what +## caused the starvation. Exponential backoff (INITIAL_RETRY_DELAY doubling +## to MAX_RETRY_DELAY) plus a DETERMINISTIC per-tile stagger +## (STAGGER_STEP * stagger_index, set once by the owning AtlasWindowTileSet +## at construction — see `_stagger_index`) spread that pulse into a trickle: +## tile 0 retries at 0.5s, tile 1 at 0.6s, tile 2 at 0.7s, etc. — deterministic +## and directly assertable in a test, not a randomized jitter a test would +## have to tolerance-check. Backoff ALSO buys a much longer wall-clock window +## from a modest MAX_RETRIES increase (~110s at 30 retries, see +## _retry_delay_for()'s own doc) without ever polling aggressively for that +## whole span. A fast (already-warm) response still resolves on retry #1, +## unaffected — backoff/stagger only matter once a request is genuinely +## still pending past the first cycle. +const INITIAL_RETRY_DELAY: float = 0.5 # first retry, matches the old flat RETRY_DELAY +const MAX_RETRY_DELAY: float = 4.0 # backoff ceiling — never polls slower than this +const STAGGER_STEP: float = 0.1 # per-tile-index offset — tile i retries STAGGER_STEP*i later +const MAX_RETRIES: int = 30 # ~110s wall-clock at the backoff schedule above ## T-1150 struct/key plumbing: legacy int granularity — district is the ## default for every caller that doesn't request quarter/Region explicitly. @@ -89,6 +115,12 @@ var _min_wl_m: int = DEFAULT_MIN_WL_M var _pending: bool = false var _retries: int = 0 var _debounce_timer: Timer = null +## Cold-start dossier round 3: deterministic per-request stagger index for +## the retry backoff (see STAGGER_STEP's own doc) — 0 for the single-window +## viewer's own request (no fan-out, nothing to desync from), the tile's own +## index (0..5) for a tile-set-owned request (AtlasWindowTileSet.enter() +## sets this once at construction, right after AtlasWindowRequest.new()). +var _stagger_index: int = 0 func _init(owner_ref = null) -> void: @@ -288,25 +320,44 @@ func _on_debounce_timeout() -> void: ## 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. +## PR #192 cold-start round 3: the coordinator's live cold-server capture +## (retries=0, pending=true, forever) exposed that the OLD version of this +## function returned unconditionally whenever the WHOLE response's status +## wasn't "Ready" — treating a cold body's `status: "Pending"` (the FIRST +## request against a whole-body cache miss, before ANY layer including the +## window has even been queued — `serve_district_window`/`get_or_generate()` +## in server/src/atlas/layer_proxy.rs) identically to `NotFound`/`Error`: a +## silent no-op, never reaching the retry-scheduling code at all. Confirmed +## server-side: `status: Ready` is set ONLY on the whole-body cache-HIT +## branch, entirely independent of whether the WINDOW itself has resolved — +## so a cold body's first-ever window request gets `Pending` at the OUTER +## layer, while a body someone has already warmed (a later connection, or +## this SAME connection's own re-request once its own AnalyzeBody has +## landed) gets `Ready` with `district_window: null` inside it, correctly +## reaching the retry branch below. Same "still generating" signal, two +## different wire shapes depending on which cache warmed first — the fix is +## to treat BOTH as the identical retry-worthy state, matching +## atlas_generation_proxy.gd's own on_response() `match` shape exactly +## (Ready -> handle, Pending -> retry, NotFound/Error -> give up now, not +## after MAX_RETRIES: a real error is never going to resolve by waiting). func on_response(response: Dictionary) -> void: if str(response.get("body_id", "")) != _body_id: return - if str(response.get("status", "")) != "Ready": - return # Pending/NotFound/Error on the WHOLE response — not a window signal either way + var status := str(response.get("status", "")) + if status == "Pending": + _retry_if_pending() + return + if status != "Ready": + _pending = false # NotFound / Error — a real failure, not a queue wait; give up now + return var window: Variant = response.get("district_window") if window == null: - # §1: an as-yet-underived window rides as `district_window: None` inside - # a Ready response — this is the "still generating" signal, not an - # error. Re-poll until the background derive lands or the retry - # ceiling is hit (queue-based serving, PR #185 — the response lands - # on a LATER tick, never this same round-trip). - if not _pending: - return - if _retries < MAX_RETRIES: - _retries += 1 - _schedule_retry() - else: - _pending = false # gave up — caller's border-fade / empty state persists + # §1: an as-yet-underived window rides as `district_window: None` + # inside an OUTER-Ready response — the whole-body cache already + # warmed, but this specific window hasn't derived yet. Same + # "still generating" signal the outer-Pending branch above handles, + # just the OTHER wire shape it can arrive in. + _retry_if_pending() return var w: Dictionary = window @@ -328,6 +379,21 @@ func on_response(response: Dictionary) -> void: window_ready.emit(w) +## Shared "still generating, re-poll" logic for BOTH wire shapes on_response() +## can see it in (outer status=="Pending", or inner district_window==null +## inside an outer Ready) — re-request until the derive lands or the retry +## ceiling is hit (queue-based serving, PR #185 — the response lands on a +## LATER tick, never this same round-trip). +func _retry_if_pending() -> void: + if not _pending: + return + if _retries < MAX_RETRIES: + _retries += 1 + _schedule_retry() + else: + _pending = false # gave up — caller's border-fade / empty state persists + + ## 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 @@ -343,8 +409,23 @@ func _echoed_granularity_matches(w: Dictionary) -> bool: return echoed_granularity == _granularity +## Pure: the exponential-backoff delay for retry attempt number `retry_count` +## (1-indexed — the FIRST retry, right after the initial request's own +## PENDING answer, uses `retry_count=1`), staggered by `stagger_index` +## (STAGGER_STEP*stagger_index added on top — deterministic, not randomized, +## so a test can assert the exact delay sequence for tile N directly). Split +## out from _schedule_retry() as a pure function for the same reason every +## other formula in this file is: directly unit-testable without a live +## Timer/SceneTree. +static func _retry_delay_for(retry_count: int, stagger_index: int) -> float: + var base: float = INITIAL_RETRY_DELAY * pow(2.0, float(maxi(retry_count - 1, 0))) + var capped: float = minf(base, MAX_RETRY_DELAY) + return capped + STAGGER_STEP * float(stagger_index) + + func _schedule_retry() -> void: - var timer := get_tree().create_timer(RETRY_DELAY) + var delay: float = _retry_delay_for(_retries, _stagger_index) + var timer := get_tree().create_timer(delay) timer.timeout.connect( func() -> void: if _pending: diff --git a/client/ui/implant/apps/atlas/atlas_window_tile_set.gd b/client/ui/implant/apps/atlas/atlas_window_tile_set.gd index 6537bcf40..985862a07 100644 --- a/client/ui/implant/apps/atlas/atlas_window_tile_set.gd +++ b/client/ui/implant/apps/atlas/atlas_window_tile_set.gd @@ -97,6 +97,17 @@ func enter(body_id: String, body_radius_km: float) -> void: var center: Vector2i = centers[i] var request = AtlasWindowRequest.new(self) request.name = "Tile%d" % i + # Cold-start dossier round 3 hardening: deterministic per-tile retry + # stagger (STAGGER_STEP*i) — without it, all 6 tiles go pending in the + # same frame and retry in perfect lockstep, a request pulse every + # RETRY_DELAY instead of a spread trickle. Set BEFORE request_now() + # so it's already in place for the very first retry, if one fires. + # Cold-start dossier round 3 hardening: deterministic per-tile retry + # stagger (STAGGER_STEP*i) — without it, all 6 tiles go pending in the + # same frame and retry in perfect lockstep, a request pulse every + # RETRY_DELAY instead of a spread trickle. Set BEFORE request_now() + # so it's already in place for the very first retry, if one fires. + request._stagger_index = i add_child(request) var tile_index := i # capture by value for the lambda below request.window_ready.connect( From 4c34327b4167111e099235b73b47209fd1ad6e01 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 22 Jul 2026 21:11:22 +0200 Subject: [PATCH 5/6] =?UTF-8?q?docs(ui):=20PR=20#193=20review=20capture=20?= =?UTF-8?q?=E2=80=94=20D-226=20pending-shape=20protocol=20note=20+=20stagg?= =?UTF-8?q?er=20comment=20dedupe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tyre's APPROVE items (T-1163): record the two-legal-wire-shapes-for-one- logical-state protocol invariant (whole-response Pending AND Ready+null district_window both mean 're-poll'; only NotFound/Error are terminal) as a D-226 note under the T-1124 §4 amendment area, so a future server refactor of the asymmetry must migrate every consumer in the same change. Drop the duplicated 5-line stagger comment in atlas_window_tile_set.gd. Follow-up tickets filed on main: T-1164 (tiled terminal-recovery), T-1165 (queue_redraw edge root-cause), T-1166 (cold-launch test tier). Tickets: T-1163 Co-Authored-By: Claude Fable 5 --- client/ui/implant/apps/atlas/atlas_window_tile_set.gd | 5 ----- governance/decisions/architecture.md | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/client/ui/implant/apps/atlas/atlas_window_tile_set.gd b/client/ui/implant/apps/atlas/atlas_window_tile_set.gd index 985862a07..89bd3f301 100644 --- a/client/ui/implant/apps/atlas/atlas_window_tile_set.gd +++ b/client/ui/implant/apps/atlas/atlas_window_tile_set.gd @@ -102,11 +102,6 @@ func enter(body_id: String, body_radius_km: float) -> void: # same frame and retry in perfect lockstep, a request pulse every # RETRY_DELAY instead of a spread trickle. Set BEFORE request_now() # so it's already in place for the very first retry, if one fires. - # Cold-start dossier round 3 hardening: deterministic per-tile retry - # stagger (STAGGER_STEP*i) — without it, all 6 tiles go pending in the - # same frame and retry in perfect lockstep, a request pulse every - # RETRY_DELAY instead of a spread trickle. Set BEFORE request_now() - # so it's already in place for the very first retry, if one fires. request._stagger_index = i add_child(request) var tile_index := i # capture by value for the lambda below diff --git a/governance/decisions/architecture.md b/governance/decisions/architecture.md index 5102acac7..5b999c173 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. **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). + **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). **Pending-shape protocol note (T-1163, PR #193 review — Tyre):** `AtlasLayerResponse` has **two legal wire shapes for one logical "still deriving, client must re-poll" state**, and both are contract: whole-response `status: Pending` (whole-body cache cold — nothing about this body computed yet) and `status: Ready` with `district_window: null` (body warm, this window still in the derive queue). `Ready` is set **only** by the whole-body cache-hit branch, independent of the window's own derivation. Any `AtlasLayerResponse` consumer must treat BOTH shapes as retry-with-backoff and only `NotFound`/`Error` as terminal — the T-1163 cold-launch starvation (every first launch black) was precisely a client reading `status != Ready` as ignorable. A server refactor that "cleans up" the Pending/Ready-null asymmetry must migrate every consumer in the same change. - **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. From 8bb09abad06dd908142a951e58be2de83e74f55c Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 22 Jul 2026 21:15:47 +0200 Subject: [PATCH 6/6] test(ui): wrong-body Pending must not touch retry state (PR #193 Hoshe finding 1) Pins on_response()'s guard ordering: body_id check BEFORE the status branch, so another body's cold Pending can never burn one of our 30 retries or reschedule our timer (multi-body browsing / shared-broadcast tile fan-out). Revert-verified: reordering the guards makes this test fail; final own-body Ready assertion proves the request is genuinely untouched, not just un-retried. 44/44 in the file. Tickets: T-1163 Co-Authored-By: Claude Fable 5 --- client/tests/test_atlas_window_request.gd | 35 +++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/client/tests/test_atlas_window_request.gd b/client/tests/test_atlas_window_request.gd index 791429d21..7cb4d27fb 100644 --- a/client/tests/test_atlas_window_request.gd +++ b/client/tests/test_atlas_window_request.gd @@ -562,6 +562,41 @@ func test_cold_request_error_gives_up_immediately_without_retry() -> void: assert_int(req._retries).is_equal(0) +## PR #193 review (Hoshe): a whole-response Pending for a DIFFERENT body +## must not touch this request's retry state — the body_id guard runs +## BEFORE the status branch in on_response(), so someone else's cold-body +## pending can never burn one of OUR 30 retries (or reschedule our timer). +## Structurally guaranteed by guard ordering today; this test pins the +## ordering, because a refactor that moves the status branch first would +## silently cross-wire every concurrent cold descent (multi-body Atlas +## browsing, or the orbital tile fan-out where all requests share one +## broadcast signal). The final Ready-for-OUR-body assertion proves the +## request is genuinely unaffected, not just un-retried. +func test_pending_for_a_different_body_does_not_touch_retry_state() -> void: + var req = _make_request() + req.request_now("GJ380c", Vector2i(2, 2), 2) + assert_bool(req.is_pending()).is_true() + + req.on_response(_pending_response("OtherBody")) + + assert_int(req._retries).override_failure_message( + "a Pending for a DIFFERENT body must not increment OUR retry counter" + + " — the body_id guard must run before the status branch" + ).is_equal(0) + assert_bool(req.is_pending()).override_failure_message( + "a wrong-body Pending must leave the request still pending its own" + + " response, neither given up nor retried" + ).is_true() + + var window: Dictionary = _mock_window(Vector2i(2, 2), 2) + req.on_response(_mock_response("GJ380c", window)) + assert_bool(req.is_pending()).override_failure_message( + "after ignoring a wrong-body Pending, our OWN Ready must still be" + + " accepted normally — the request state must be genuinely untouched" + ).is_false() + assert_int(req._retries).is_equal(0) + + # ============================================================================= # _retry_delay_for() — deterministic exponential backoff + per-tile stagger # (PR #192 cold-start round 3 hardening: 6 tiles retrying in perfect