fix(client): cold-start round 2 — DERIVING TERRAIN state, legend re-fit, release server for make atlas

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.
This commit is contained in:
2026-07-22 19:51:12 +02:00
parent d4526e51ff
commit dd13760d62
11 changed files with 489 additions and 120 deletions
+10 -2
View File
@@ -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
+20 -2
View File
@@ -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")
+154 -4
View File
@@ -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))
+54
View File
@@ -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.
@@ -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
@@ -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()
@@ -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)
@@ -90,6 +90,7 @@ func refresh() -> void:
_add_toggle_section(active_id)
reposition()
reset_to_content_size()
func _add_morphology_section() -> void:
@@ -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
@@ -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`
## aloneLAGGING 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()
+29
View File
@@ -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")