From d4526e51ffda0ce05f43125ae22c5a876af4ca7c Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 22 Jul 2026 19:05:15 +0200 Subject: [PATCH] =?UTF-8?q?fix(client):=20cold-start=20black=20screen=20?= =?UTF-8?q?=E2=80=94=20self-healing=20tile=20repaint,=20legend=20re-entry?= =?UTF-8?q?=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()