From dd13760d623d5befb3aea9108e0778b907006a40 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 22 Jul 2026 19:51:12 +0200 Subject: [PATCH] =?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")