Merge remote-tracking branch 'origin/main' into atlas-latitude-fix

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
2026-07-25 13:30:16 +02:00
10 changed files with 861 additions and 15 deletions
+1
View File
@@ -9,6 +9,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
### Fixed
- **Climate now respects latitude on both hemispheres** (T-1186) — a sign-convention bug made every northern hemisphere read as polar (+90°) and the southern hemisphere read as compressed northern tropics: temperate lakes at 76°S, ice only at the top of the world map. Temperature baselines, glaciation, and everything derived from them now band correctly north AND south of the equator — the same spot near the antarctic circle that used to derive as an 18 °C ice-free lake now comes out at 2 °C under light glaciation
- **Lakes deepen from the shore** (T-1188) — lake shorelines no longer render as hard flat-blue step-edges: every lake cell now carries its settled-hydrology depth band, and the map shades lakes from pale at the water's edge to dark at the deepest point of the basin — the same visual grammar ocean coastlines already had. (Game version 0.4.0 → 0.4.1: old on-disk map caches refresh automatically)
- **The Atlas map now fills the screen properly** (T-1189, T-1192) — the whole-body Global view now fills the frame at an integer texel scale (never blurry, never a sliver in the corner) and centers in the viewport, with the legend sitting beside the map instead of on top of it. The Region zoom step no longer shows the planet repeating side-by-side or smearing into stripes past the poles — the map view is capped at the body's actual size and letterboxed, so what you see is the planet once, correctly framed
### Added
- **The Atlas remembers across restarts** (D-255, T-1183) — every map view you visit is now kept on disk as well as in memory: relaunch the game, reopen a body, and previously-visited zoom steps draw instantly from the local cache with no server round-trip — only genuinely new ground fetches. Storage stays tidy on its own: fine-grained views of a body you haven't visited in ~two weeks quietly reclaim their space, each body's deep-zoom footprint is capped, and the whole-body overview of every visited body is kept forever. The cache is update-safe by construction — entries from an older game version are silently refetched, never misread
+24
View File
@@ -7,6 +7,7 @@ class_name TestStepCanvasLegend
extends GdUnitTestSuite
const LegendScript := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_legend.gd")
const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd")
func test_legend_starts_hidden_before_refresh() -> void:
@@ -68,3 +69,26 @@ func test_reposition_sets_a_fixed_panel_margin_position() -> void:
auto_free(legend)
legend.reposition()
assert_that(legend.position).is_equal(Vector2(LegendScript.PANEL_MARGIN, 60.0))
## T-1192 review fix: RESERVED_COLUMN_PX is now a direct read of
## StepCanvasTransport.LEGEND_COLUMN_PX (not an independently-typed
## literal), so a const-vs-const cross-pin test would be structurally
## unable to fail — it is the SAME value by construction. What's still
## worth proving directly is the geometric guarantee that constant is FOR:
## the legend's own ACTUAL laid-out right edge (`reposition()`'s position.x
## + the panel's real minimum width) must land at or before the reserved
## column, with room to spare — never right up against it, and certainly
## never past it into where the canvas is centered from.
func test_legend_actual_right_edge_stays_inside_the_reserved_column() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
var legend = LegendScript.new(v)
auto_free(legend)
legend.reposition()
var actual_right_edge: float = legend.position.x + legend.custom_minimum_size.x
assert_float(actual_right_edge).override_failure_message(
"the legend's real laid-out right edge must stay inside the column"
+ " StepCanvasViewer reserves for it, with margin to spare"
).is_less(StepCanvasTransport.LEGEND_COLUMN_PX)
+243
View File
@@ -188,3 +188,246 @@ func test_canvas_footprint_px_is_extent_times_display_ratio() -> void:
func test_half_extent_m_is_half_the_cell_count_times_spacing() -> void:
var half: float = StepCanvasTransport.half_extent_m("District", 64)
assert_float(half).is_equal_approx(64.0 * 0.5 * 2048.0, 0.01)
# =============================================================================
# T-1189: extent cap to the body's own region grid — the sideways-repeat /
# pole-smear fix. The Global echo IS the cap (its canvas already equals the
# body's region grid, D-255(a): "the Global canvas IS the whole body at
# region spacing"), so no unit conversion is needed — Region shares Global's
# gridunit spacing exactly.
# =============================================================================
func test_cap_extent_to_body_clamps_region_to_the_global_echo() -> void:
# T-1183 eyeball: 384x216 requested at Region on GJ1c, whose Global echo
# is 177x88 — the requested extent overruns the body on both axes.
var capped: Vector2i = StepCanvasTransport.cap_extent_to_body(
Vector2i(384, 216), "Region", Vector2i(177, 88)
)
assert_that(capped).is_equal(Vector2i(177, 88))
func test_cap_extent_to_body_is_a_no_op_when_already_inside_the_grid() -> void:
var capped: Vector2i = StepCanvasTransport.cap_extent_to_body(
Vector2i(100, 40), "Region", Vector2i(177, 88)
)
assert_that(capped).is_equal(Vector2i(100, 40))
## Shape-generic per the ticket: the guard compares SPACING, not rung name,
## so it caps ANY rung sharing Global's spacing, not just a hardcoded
## "Region" check. District's spacing (2048 m) differs from Global's
## (204,800 m), so it must NEVER be capped by the body-grid cell count —
## capping cell counts across mismatched spacings would be a unit error.
func test_cap_extent_to_body_leaves_finer_rungs_uncapped() -> void:
var capped: Vector2i = StepCanvasTransport.cap_extent_to_body(
Vector2i(3000, 3000), "District", Vector2i(177, 88)
)
assert_that(capped).is_equal(Vector2i(3000, 3000))
## Cold-start fallback (T-1189, StepCanvasViewer's own documented choice):
## before any Global response has arrived, `_global_body_extent` is ZERO —
## cap_extent_to_body() must leave the request UNCAPPED on a non-positive
## axis (server clamps independently) rather than clamping to zero cells.
func test_cap_extent_to_body_uncapped_when_global_echo_not_yet_available() -> void:
var capped: Vector2i = StepCanvasTransport.cap_extent_to_body(
Vector2i(384, 216), "Region", Vector2i.ZERO
)
assert_that(capped).is_equal(Vector2i(384, 216))
## A mixed case: one axis of the Global echo has arrived-and-is-real, the
## other is still ZERO (shouldn't happen in practice since both arrive
## together, but the function must handle each axis independently rather
## than assuming both-or-neither).
func test_cap_extent_to_body_caps_only_the_positive_echo_axis() -> void:
var capped: Vector2i = StepCanvasTransport.cap_extent_to_body(
Vector2i(384, 216), "Region", Vector2i(177, 0)
)
assert_that(capped).is_equal(Vector2i(177, 216))
# =============================================================================
# T-1189/T-1192: shared letterbox/centering mechanism
# =============================================================================
func test_center_offset_centers_a_smaller_canvas_in_a_larger_viewport() -> void:
var offset: Vector2 = StepCanvasTransport.center_offset(
Vector2(800.0, 400.0), Vector2(1920.0, 1080.0)
)
assert_that(offset).is_equal(Vector2((1920.0 - 800.0) * 0.5, (1080.0 - 400.0) * 0.5))
func test_center_offset_is_zero_when_canvas_exactly_fills_the_viewport() -> void:
var offset: Vector2 = StepCanvasTransport.center_offset(
Vector2(1920.0, 1080.0), Vector2(1920.0, 1080.0)
)
assert_that(offset).is_equal(Vector2.ZERO)
func test_center_offset_goes_negative_when_the_canvas_overflows_the_viewport() -> void:
# A canvas bigger than the viewport on an axis crops rather than shrinks
# (matches every fixed rung's own "canvas can exceed the viewport"
# precedent) — a negative offset on that axis is the correct, honest
# result, not clamped to zero.
var offset: Vector2 = StepCanvasTransport.center_offset(
Vector2(2000.0, 400.0), Vector2(1920.0, 1080.0)
)
assert_float(offset.x).is_less(0.0)
assert_float(offset.y).is_greater(0.0)
## D-255 texel-exactness: an integer-px/gridunit canvas (fit_scale_ratio())
## can still land on an ODD-vs-viewport remainder that halves to a .5px
## boundary — GJ1c's own 1593x792 footprint in a 1628x1080 available area
## ((1628-1593)*0.5 = 17.5) is the real case this guards. The offset must be
## FLOORED to a whole pixel, never left fractional (a fractional offset
## would blur the texel grid this whole mechanism exists to keep crisp).
func test_center_offset_floors_a_half_pixel_remainder_to_a_whole_pixel() -> void:
var offset: Vector2 = StepCanvasTransport.center_offset(
Vector2(1593.0, 792.0), Vector2(1628.0, 1080.0)
)
assert_float(offset.x).is_equal_approx(17.0, 0.001)
assert_float(offset.y).is_equal_approx(144.0, 0.001)
# =============================================================================
# T-1192: Global integer PIXELS-PER-GRIDUNIT fit — D-255 amendment
# 2026-07-25 (rung-0's display ratio is viewport-fitted per body to an
# INTEGER px/gridunit ratio; the fractional-fit branch a prior round of this
# ticket carried was a mis-citation of D-255 — the record's actual mandate
# is unqualified texel-exact — and independently a real bug (Hoshe): a
# canvas exceeding the viewport on one axis could fit_scale() down to
# sub-1x, violating the "never below native resolution" invariant. Fixed at
# the root by fitting the INTEGER ratio, not a fraction of the whole
# footprint — this lattice is fine-grained (per gridunit, not per 5px-base
# footprint step), so the coverage-threshold escape hatch this section used
# to need does not come up: the achievable ratios are close enough together
# that the largest one that fits is always a good use of the frame.
# =============================================================================
func test_fit_scale_ratio_picks_the_largest_px_per_gridunit_that_fits_both_axes() -> void:
# GJ1c reference case (T-1183 eyeball): 177x88 gridunits against a full
# 1920x1080 viewport (no legend reservation) — floor(1920/177)=10,
# floor(1080/88)=12, the tighter axis (x) wins.
var ratio: int = StepCanvasTransport.fit_scale_ratio(
Vector2(177.0, 88.0), Vector2(1920.0, 1080.0)
)
assert_int(ratio).is_equal(10)
## The GJ1c reference case AFTER the legend column is reserved (T-1192, the
## StepCanvasViewer end-to-end scenario): available area shrinks to
## 1628x1080 — floor(1628/177)=9, floor(1080/88)=12 — the lead's own cited
## reference number for this exact case.
func test_fit_scale_ratio_matches_the_gj1c_legend_reserved_reference_case() -> void:
var ratio: int = StepCanvasTransport.fit_scale_ratio(
Vector2(177.0, 88.0), Vector2(1628.0, 1080.0)
)
assert_int(ratio).is_equal(9)
## GJ1c at 4K, legend column reserved (3840 - 292 = 3548 available) — the
## lead's own cited reference number for a large viewport.
func test_fit_scale_ratio_matches_the_gj1c_4k_reference_case() -> void:
var ratio: int = StepCanvasTransport.fit_scale_ratio(
Vector2(177.0, 88.0), Vector2(3548.0, 2160.0)
)
assert_int(ratio).is_equal(20)
## Hoshe's repro, now a regression test: a canvas whose BASE footprint
## (885x440 px at the old 5x5-multiple framing) exceeds a narrow 348px-wide
## available viewport used to make fit_scale() return 0.39x — a sub-1x
## downscale violating "never below native resolution". The integer
## px/gridunit ratio floors at 1 instead: draws at native (1 px/gridunit)
## and crops/pans, exactly like every other fixed rung's own
## exceeds-the-viewport precedent.
func test_fit_scale_ratio_floors_at_one_when_gridunits_exceed_a_narrow_viewport() -> void:
var ratio: int = StepCanvasTransport.fit_scale_ratio(
Vector2(177.0, 88.0), Vector2(348.0, 1080.0)
)
assert_int(ratio).is_equal(1)
## A small moon's Global canvas (few gridunits) in a large viewport still
## only wins as large an integer ratio as fits — no special-casing for a
## small canvas, same formula, much larger achievable ratio.
func test_fit_scale_ratio_a_tiny_moon_canvas_wins_a_large_integer_ratio() -> void:
var ratio: int = StepCanvasTransport.fit_scale_ratio(
Vector2(20.0, 20.0), Vector2(1920.0, 1080.0)
)
assert_int(ratio).is_equal(54)
## Exact-fit boundary: the viewport is PRECISELY `extent * 3` on both axes —
## the ratio must land exactly on 3, not overshoot to 4 (floor(exact) must
## not round up) and not undershoot to 2 (an exact multiple is a legal fit,
## not treated as "just barely doesn't fit").
func test_fit_scale_ratio_exact_multiple_boundary_lands_on_the_multiple() -> void:
var ratio: int = StepCanvasTransport.fit_scale_ratio(
Vector2(100.0, 50.0), Vector2(300.0, 150.0)
)
assert_int(ratio).is_equal(3)
## One pixel short of the exact multiple must drop to the NEXT integer down
## — confirms the boundary isn't fuzzy/off-by-one in the other direction.
func test_fit_scale_ratio_one_pixel_short_of_the_multiple_drops_a_step() -> void:
var ratio: int = StepCanvasTransport.fit_scale_ratio(
Vector2(100.0, 50.0), Vector2(299.0, 150.0)
)
assert_int(ratio).is_equal(2)
func test_fit_scale_ratio_is_bounded_by_the_tighter_axis() -> void:
# Wide-but-short viewport: x could fit 10x, y only fits 1x — the smaller
# wins (never overflow either axis).
var ratio: int = StepCanvasTransport.fit_scale_ratio(
Vector2(100.0, 100.0), Vector2(1000.0, 150.0)
)
assert_int(ratio).is_equal(1)
func test_fit_scale_ratio_handles_a_zero_extent_axis_without_dividing_by_zero() -> void:
var ratio: int = StepCanvasTransport.fit_scale_ratio(Vector2.ZERO, Vector2(800.0, 600.0))
assert_int(ratio).is_equal(1)
## fit_scale_from_ratio() converts the INTEGER px/gridunit ratio into the
## `_canvas.scale` multiplier applied on top of a texture already rendered
## at the rung's own base display ratio (5x5 for Global) — this multiplier
## itself may be a non-integer float (9/5 = 1.8), and that is CORRECT:
## texel-exactness is about the final ratio being a whole number, not the
## Node2D scale field.
func test_fit_scale_from_ratio_divides_by_the_base_display_ratio() -> void:
var scale: float = StepCanvasTransport.fit_scale_from_ratio(9, 5.0)
assert_float(scale).is_equal_approx(1.8, 0.001)
func test_fit_scale_from_ratio_at_the_gj1c_4k_reference_case() -> void:
var scale: float = StepCanvasTransport.fit_scale_from_ratio(20, 5.0)
assert_float(scale).is_equal_approx(4.0, 0.001)
func test_fit_scale_from_ratio_handles_a_zero_base_ratio_without_dividing_by_zero() -> void:
var scale: float = StepCanvasTransport.fit_scale_from_ratio(9, 0.0)
assert_float(scale).is_greater(0.0)
# =============================================================================
# T-1192: shared legend-column reservation constant
# =============================================================================
## StepCanvasTransport is the CANONICAL source for this width (T-1192
## review fix) — step_canvas_legend.gd's own RESERVED_COLUMN_PX is now a
## direct read of THIS constant, not an independently-typed literal, so
## there is no separate cross-pin test needed here; this just pins the
## canonical value itself (260 panel width + 16*2 margin = 292).
func test_legend_column_px_is_the_panel_width_plus_margin_on_both_sides() -> void:
assert_float(StepCanvasTransport.LEGEND_COLUMN_PX).is_equal_approx(292.0, 0.01)
+321
View File
@@ -10,6 +10,12 @@ extends GdUnitTestSuite
const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd")
## GJ1c's own region-grid shape from the T-1183 eyeball (177x88) — reused
## across the T-1189/T-1192 section below so every test is grounded in the
## actual regression captured in
## .cache/screenshots/t1183-eyeball-run2/02-region.png.
const GJ1C_GLOBAL_EXTENT := Vector2i(177, 88)
func test_enter_lands_on_the_global_opener() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
@@ -385,3 +391,318 @@ func test_disk_sweep_timeout_handler_runs_background_sweep_for_the_current_body(
# consistent, mirroring the enter()-sweep smoke test above.
v._on_disk_sweep_timeout()
assert_int(v.get_request().get_disk_cache().entry_count("T1183_sweep_smoke_test_body")).is_equal(0)
# =============================================================================
# T-1189: extent cap wired end-to-end (viewer -> transport), plus the
# cache-key consistency the ticket calls out explicitly ("capping happens
# BEFORE the request is issued so keys stay consistent"). No live server —
# a Global canvas is landed via the SAME Tier-1 cache-hit path
# StepCanvasRequest's own tests use (get_cache().put() + request_now()'s
# synchronous cache-hit emit), so the full _on_canvas_ready wiring runs for
# real rather than being shortcut.
# =============================================================================
## Land a Global canvas of the given size into the viewer's OWN cache (Tier
## 1), then fire the request that the real cache-hit path serves
## synchronously — same mechanism test_step_canvas_request.gd's own
## cache-hit tests use, now driven through the viewer so _on_canvas_ready()
## and _global_body_extent actually populate through the real signal wiring.
static func _land_global_canvas(v: StepCanvasViewer, width: int, height: int) -> void:
var req: Variant = v.get_request()
req.get_cache().put(
v.get_body_id(),
"Global",
Vector2i.ZERO,
Vector2i.ZERO,
TestStepCanvasViewer._synthetic_canvas(width, height)
)
v._fire_request() # Global's own request — served from the cache hit just landed
func test_global_canvas_arrival_populates_the_body_extent_cap_source() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {})
TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y)
assert_that(v._global_body_extent).is_equal(GJ1C_GLOBAL_EXTENT)
## The T-1183 eyeball regression itself: once the Global echo has landed,
## scrolling to Region and firing its request must produce a CAPPED extent
## — never the raw viewport-fit 384x216 that overran the body on both axes.
func test_region_request_extent_is_capped_to_the_landed_global_extent() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(1920.0, 1080.0) # the T-1183 eyeball's own viewport
v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {})
TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y)
v._scroll_rung(1, Vector2(960.0, 540.0)) # descend to Region — fires the capped request
var extent: Vector2i = v._request_extent()
assert_int(extent.x).override_failure_message(
"Region's requested extent must never exceed the body's own region-grid width"
).is_less_equal(GJ1C_GLOBAL_EXTENT.x)
assert_int(extent.y).override_failure_message(
"Region's requested extent must never exceed the body's own region-grid height"
).is_less_equal(GJ1C_GLOBAL_EXTENT.y)
assert_that(extent).is_equal(GJ1C_GLOBAL_EXTENT) # 1920x1080 viewport-fit exceeds 177x88 on both axes
## Cold-start fallback (documented on StepCanvasViewer._request_extent()):
## before ANY Global response has landed, `_global_body_extent` is still
## ZERO — the Region request must go out UNCAPPED (server clamps
## independently) rather than silently collapsing to a zero-cell request.
func test_region_request_extent_is_uncapped_before_the_global_echo_lands() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(1920.0, 1080.0)
v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {})
# No _land_global_canvas() call — simulates scrolling in before the
# FIRST (Global) request's response has arrived.
v._scroll_rung(1, Vector2(960.0, 540.0))
var extent: Vector2i = v._request_extent()
var uncapped: Vector2i = StepCanvasTransport.viewport_fit_extent(v.size, "Region")
assert_that(extent).override_failure_message(
"before the Global echo lands, the Region request must be the ordinary"
+ " uncapped viewport-fit extent, not silently zeroed"
).is_equal(uncapped)
## Cache-key discipline (T-1182/T-1183, ticket's own explicit call-out): the
## cap must be applied BEFORE _fire_request() builds the request, so the
## extent that becomes part of the cache key is the SAME capped value that
## gets served — a request for "the same spot" must always resolve to the
## same key, capped or not, never a key built from one extent and served
## under another.
func test_capped_extent_matches_what_the_request_actually_sends() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(1920.0, 1080.0)
v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {})
TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y)
v._scroll_rung(1, Vector2(960.0, 540.0)) # Region — fires _fire_request() internally
# _request_extent() is the SAME function _fire_request() calls to build
# the outbound request/cache key — calling it again here must be
# idempotent and match what was actually requested (no separate,
# divergent cap path).
var extent_now: Vector2i = v._request_extent()
assert_that(extent_now).is_equal(GJ1C_GLOBAL_EXTENT)
# The Tier-1 cache key StepCanvasCache builds from this SAME extent must
# be a real, findable key once a response for it lands — proving the
# capped extent (not the raw viewport-fit one) is what keys the cache.
var req: Variant = v.get_request()
var center: Vector2i = StepCanvasTransport.snap_to_gridunit(v._world_center, "Region")
var canvas := TestStepCanvasViewer._synthetic_canvas(GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y)
req.get_cache().put("T1189_extent_letterbox_test_body", "Region", center, extent_now, canvas)
assert_bool(
req.get_cache().has("T1189_extent_letterbox_test_body", "Region", center, GJ1C_GLOBAL_EXTENT, 0)
).override_failure_message(
"a cache entry stored under the CAPPED extent must be reachable"
+ " under that same capped extent — key consistency"
).is_true()
## Shape-generic guard: District's spacing differs from Global's, so its
## request extent must be completely unaffected by a landed Global canvas —
## proving the cap is spacing-keyed, not applied indiscriminately to every
## rung once a Global extent is known.
func test_district_request_extent_is_never_capped_by_the_global_extent() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(1920.0, 1080.0)
v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {})
TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y)
v._scroll_rung(1, Vector2(960.0, 540.0)) # Region
v._scroll_rung(1, Vector2(960.0, 540.0)) # District
var extent: Vector2i = v._request_extent()
var uncapped: Vector2i = StepCanvasTransport.viewport_fit_extent(v.size, "District")
assert_that(extent).is_equal(uncapped)
## Hoshe (review round 2): _global_body_extent must NOT leak across a body
## switch — land a Global extent for body A, enter() body B, and prove BOTH
## that the cap source itself reads back ZERO for the new body AND that a
## Region request for body B before ITS OWN Global echo lands goes out
## UNCAPPED (never silently capped by body A's leftover grid). The reset
## already exists at StepCanvasViewer.enter() ("a new body has its own
## region grid") — this proves it, through the real enter()/land/enter()
## sequence rather than asserting the field directly only.
func test_global_body_extent_resets_on_a_different_body_and_does_not_leak() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(1920.0, 1080.0)
v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {})
TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y)
assert_that(v._global_body_extent).override_failure_message(
"test setup: body A must actually have a landed cap source"
).is_equal(GJ1C_GLOBAL_EXTENT)
v.enter({"body_id": "T1189_extent_cap_body_b", "body_radius_km": 3000.0}, {})
assert_that(v._global_body_extent).override_failure_message(
"entering a DIFFERENT body must reset the cap source to ZERO — body"
+ " A's region grid must never leak into body B's requests"
).is_equal(Vector2i.ZERO)
v._scroll_rung(1, Vector2(960.0, 540.0)) # Region, for body B — no Global echo yet
var extent: Vector2i = v._request_extent()
var uncapped: Vector2i = StepCanvasTransport.viewport_fit_extent(v.size, "Region")
assert_that(extent).override_failure_message(
"body B's Region request, before body B's own Global echo has"
+ " landed, must go out UNCAPPED — never capped by body A's stale"
+ " leftover region-grid extent"
).is_equal(uncapped)
# =============================================================================
# T-1189/T-1192: shared letterbox mechanism — centering + Global fit scale.
# =============================================================================
## T-1192's own headline defect: the Global canvas must no longer draw
## top-left-anchored at Vector2.ZERO — once a canvas lands, the viewer must
## have computed a non-zero centering offset (unless the canvas happens to
## exactly fill the viewport, not the case here).
func test_global_canvas_arrival_centers_the_view_not_top_left() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(1920.0, 1080.0)
v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {})
TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y)
assert_that(v._view_offset).override_failure_message(
"a landed Global canvas smaller than the viewport must be CENTERED,"
+ " never left at the raw top-left Vector2.ZERO anchor"
).is_not_equal(Vector2.ZERO)
## D-255 amendment 2026-07-25 texel-exactness: the `_canvas.scale` value
## itself is NOT required to be a whole number (9/5 = 1.8 is entirely
## legitimate) — what MUST be exact is the resulting on-screen
## pixels-per-gridunit ratio. Verified end-to-end through the real viewer
## wiring: `_canvas_scale * base_display_ratio` (the rung's own display
## ratio, 5.0 for Global) must land on an exact integer, for both the
## legend-reserved 1920x1080 case (R=9, scale=1.8) AND the 4K case (R=20,
## scale=4.0) — the SAME formula, no separate coverage-threshold branch.
func test_global_canvas_scale_yields_an_exact_integer_pixels_per_gridunit_ratio() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(1920.0, 1080.0)
v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {})
TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y)
assert_that(v._canvas.scale).is_equal(Vector2(v._canvas_scale, v._canvas_scale))
var base_ratio: float = StepCanvasTransport.display_ratio_for_rung("Global")
var effective_px_per_gridunit: float = v._canvas_scale * base_ratio
assert_float(effective_px_per_gridunit).override_failure_message(
"the FINAL on-screen pixels-per-gridunit ratio must be an exact"
+ " integer even when the _canvas.scale multiplier itself is not"
).is_equal_approx(roundf(effective_px_per_gridunit), 0.001)
# The lead's own cited reference number for this exact scenario.
assert_float(effective_px_per_gridunit).is_equal_approx(9.0, 0.001)
## Same invariant at a large (4K-class) viewport, where the integer ratio
## (20) happens to make the _canvas.scale multiplier itself a whole number
## too (20/5 = 4.0) — confirms the 1080p case above isn't a coincidence of
## a small viewport, just the same formula at a different achievable ratio.
func test_global_canvas_scale_at_4k_also_yields_an_exact_integer_ratio() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(3840.0, 2160.0)
v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {})
TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y)
var base_ratio: float = StepCanvasTransport.display_ratio_for_rung("Global")
var effective_px_per_gridunit: float = v._canvas_scale * base_ratio
assert_float(effective_px_per_gridunit).is_equal_approx(20.0, 0.001)
## Fixed rungs must NEVER receive the Global fit multiplier — `_canvas.scale`
## stays 1.0 once the player has descended past Global, even though a
## Global canvas was landed earlier in the same session.
func test_fixed_rung_canvas_scale_stays_one_after_descending_from_global() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(1920.0, 1080.0)
v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {})
TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y)
v._scroll_rung(1, Vector2(960.0, 540.0)) # Region
v._terrain_layer.rebuild_from_canvas(
TestStepCanvasViewer._synthetic_canvas(200, 100), v.get_held_rung(), ""
)
v._recompute_canvas_transform()
assert_float(v._canvas_scale).is_equal_approx(1.0, 0.001)
assert_that(v._canvas.scale).is_equal(Vector2.ONE)
## Legend non-overlap (T-1192: "lay the legend out beside the canvas... never
## over it"): the legend panel sits at a fixed left-column position
## (PANEL_MARGIN, ...) with a known width — once a Global canvas is landed
## and centered, its drawn rect's LEFT edge must be at or past the legend's
## own right edge, never underneath it.
func test_global_canvas_left_edge_never_overlaps_the_legend_column() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(1920.0, 1080.0)
v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {})
TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y)
var canvas_left_edge: float = v._view_offset.x
assert_float(canvas_left_edge).override_failure_message(
"the Global canvas's drawn left edge must be at or past the reserved"
+ " legend column — the legend must never be covered by the map"
).is_greater_equal(StepCanvasTransport.LEGEND_COLUMN_PX - 0.01)
## Resize must re-fit/re-center a HELD canvas, not just a freshly-arriving
## one — _notification(NOTIFICATION_RESIZED) wires _recompute_canvas_transform()
## for exactly this case.
func test_resize_recenters_an_already_held_global_canvas() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(1920.0, 1080.0)
v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {})
TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y)
var offset_before: Vector2 = v._view_offset
v.size = Vector2(1280.0, 720.0)
v._notification(Control.NOTIFICATION_RESIZED)
assert_that(v._view_offset).override_failure_message(
"a resize must re-center the held canvas for the NEW viewport size"
).is_not_equal(offset_before)
## Hoshe (review round 2): a narrow viewport where GJ1c's canvas exceeds
## the available width on the gridunit lattice itself (177 gridunits >
## available px after the legend column is reserved) used to make the OLD
## fractional-fit branch return a sub-1x scale (0.39x) — a real downscale
## below native resolution, violating "never below native". The integer
## px/gridunit ratio floors at 1 instead: `_canvas_scale` must never drop
## under 1.0, end-to-end through the real viewer wiring, not just the pure
## transport function this mirrors.
func test_global_canvas_scale_never_drops_below_native_on_a_narrow_viewport() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(348.0 + StepCanvasTransport.LEGEND_COLUMN_PX, 1080.0)
v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {})
TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y)
var base_ratio: float = StepCanvasTransport.display_ratio_for_rung("Global")
assert_float(v._canvas_scale * base_ratio).override_failure_message(
"the effective pixels-per-gridunit ratio must floor at 1 (native),"
+ " never a sub-1x downscale, even on a viewport this narrow"
).is_equal_approx(1.0, 0.001)
@@ -6,11 +6,19 @@ extends Node2D
## positions world->screen transformed per-frame via a plain linear map
## (StepCanvasTransport.world_m_to_canvas_local()). This is the layer that
## makes `_zs()`/`_zs_stroke()`/`_zs_ring_radius()` structurally
## unnecessary: because this Node2D is NEVER scaled (no `.scale` write
## anywhere in this file, unlike the retired `_canvas.scale` model), a
## constant like COURSE_WIDTH_PX below already IS the on-screen width with
## no compensating division — "by construction, not by discipline" per
## Stig's round-1 design doc.
## unnecessary: this Node2D itself is NEVER scaled (no `.scale` write
## anywhere in THIS file), so a constant like COURSE_WIDTH_PX below already
## IS the width in ITS OWN local space with no compensating division — "by
## construction, not by discipline" per Stig's round-1 design doc.
##
## T-1192 note: the OWNING `_canvas` Node2D (StepCanvasViewer) carries the
## Global integer-fit multiplier as ITS OWN `.scale` (1.0 for every other
## rung) — this layer, as a child, inherits it like the terrain layer does,
## so a marker drawn here lands on the correct enlarged-canvas position AND
## reads visually bigger in step with the enlarged terrain pixels (the
## correct "2x zoom, 2x dot" map read), not a mismatch. Set once per canvas
## adoption by the owner (never per-frame here), so it stays the single
## sanctioned display-time scale, not a second one to reconcile against.
##
## Data source: the SAME decoded StepCanvasResponse `canvas` Dictionary the
## terrain layer reads (`courses`/`cliffs`/`settlement_id` — sparse
@@ -12,11 +12,19 @@ extends ImplantPanel
## No `class_name` on purpose, matching every other viewer-owned helper in
## this cluster.
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd")
const PANEL_MARGIN: float = 16.0
const LEGEND_PANEL_WIDTH: float = 260.0
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd")
## T-1192 review fix: StepCanvasTransport.LEGEND_COLUMN_PX is now the ONE
## canonical source for this width — this file no longer computes its own
## independently-typed literal sum. A read of the transport constant, not a
## derivation, so the two sides can never drift apart by construction
## (transport is static-only, no scene-tree dependency in this direction —
## legend already preloads it above for AtlasOverlayColors-style helpers).
const RESERVED_COLUMN_PX: float = StepCanvasTransport.LEGEND_COLUMN_PX
const MORPHOLOGY_FAMILY_ROWS: Array = [
{"label": "water", "zones": [0, 1]},
@@ -36,6 +36,18 @@ extends Node2D
## already present in the texture — this is presentation resampling of a
## closed input set, not invention of a new one, matching D-255(e)'s own
## "texture-to-viewport resize" exemption.
##
## **T-1192 outer fit scale:** the owning `_canvas` Node2D
## (StepCanvasViewer._recompute_canvas_transform()) may additionally carry
## its OWN `.scale` — for the Global rung, `StepCanvasTransport.
## fit_scale_from_ratio()`'s per-body integer pixels-per-gridunit fit
## (D-255 amendment 2026-07-25: rung-0's display ratio is viewport-fitted
## per body to an INTEGER px/gridunit ratio, not a fixed 5x5); always 1.0
## for every fixed rung — applied on top of this node's own texel-exact
## `_footprint_px` draw. That is a SECOND texture-to-viewport resize, same
## D-255(e) exemption, kept as a parent-transform multiply rather than a
## second internal scale field so this node's own footprint math never has
## to know it exists.
const StepCanvasColorize := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd")
const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd")
@@ -85,6 +85,16 @@ const DISPLAY_RATIO_BY_RUNG: Dictionary = {
## server-side, per step_canvas_protocol.gd's own doc).
const FIXED_CANVAS_MAX_AXIS: int = 3_840
## T-1192: the on-screen column width reserved for the Atlas legend panel
## (panel width plus a margin on each side). THIS is the canonical value —
## step_canvas_legend.gd's own RESERVED_COLUMN_PX is a direct read of this
## constant (review fix: was previously an independently-typed literal sum
## on the legend side, which could silently drift from this one), so the
## Global integer-fit computation and the legend's own sizing can never
## disagree by construction — "legend beside, never over" only holds if
## both sides agree on the SAME reserved width.
const LEGEND_COLUMN_PX: float = 260.0 + 16.0 * 2.0
## The rung name at ladder index `i`, clamped to the legal [0, 5] range —
## the one place RUNG_LADDER is indexed into, so a caller passing an
@@ -188,6 +198,59 @@ static func canvas_footprint_px(rung: String, extent_cells: Vector2i) -> Vector2
return Vector2(extent_cells) * ratio
## Shared letterbox/centering mechanism (T-1189 + T-1192, one geometry both
## the Region-rung body-cap remainder AND the Global-rung raw-scale-fill
## defect resolve through — the tickets interact at exactly this seam,
## Jeroen's own instruction: "build ONE letterbox/centering mechanism both
## use, not two"). Given a canvas footprint (px, already at whatever scale
## the caller wants drawn) and the viewport to center it in, returns the
## `Vector2` top-left offset that centers it — the remainder splits evenly
## on both sides (a true letterbox, not edge-anchored). A canvas at or larger
## than the viewport on an axis gets a zero/negative offset on that axis (no
## letterbox needed there — it already fills or overflows, matching ordinary
## pan-and-crop behavior on that axis rather than shrinking the canvas).
## FLOORED to a whole pixel on each axis (D-255 texel-exactness): an
## integer-px/gridunit canvas (fit_scale_ratio()) still produces an
## odd-vs-even remainder half that can land on a .5px boundary — that would
## reintroduce a fractional-pixel blur edge on the very rung this exists to
## keep texel-exact, so the offset itself is snapped to the pixel grid.
static func center_offset(footprint_px: Vector2, viewport_px: Vector2) -> Vector2:
var raw: Vector2 = (viewport_px - footprint_px) * 0.5
return Vector2(floorf(raw.x), floorf(raw.y))
## D-255 premise (2), texel-exact: the largest INTEGER pixels-per-gridunit
## ratio `R` at which a `extent_cells`-gridunit canvas fits inside
## `viewport_px` on BOTH axes, floored at 1 (never below native resolution
## — an over-viewport canvas draws at 1 px/gridunit and crops/pans, same as
## every fixed rung's own "canvas can exceed the viewport" precedent).
## `R` is a PIXELS-PER-GRIDUNIT ratio, not a multiple of the whole footprint
## — this is the fine-grained lattice D-255's amendment (2026-07-25)
## clarifies rung-0's display ratio to be viewport-fitted-per-body against:
## GJ1c's 177x88-gridunit canvas at a 1920x1080 viewport (legend column
## reserved) lands on R=9 px/gridunit (1593x792, ~98% width), not a coarse
## multiple of the BASE 5px/gridunit footprint (885/1770/2655 — the old,
## too-coarse lattice that made a fractional escape hatch look necessary).
static func fit_scale_ratio(extent_cells: Vector2, viewport_px: Vector2) -> int:
if extent_cells.x <= 0.0 or extent_cells.y <= 0.0:
return 1
var max_x: int = int(floor(viewport_px.x / extent_cells.x))
var max_y: int = int(floor(viewport_px.y / extent_cells.y))
return maxi(1, mini(max_x, max_y))
## Convert a target pixels-per-gridunit ratio `R` (fit_scale_ratio()'s
## return) into the `_canvas.scale` multiplier applied ON TOP OF a texture
## already rendered at `base_display_ratio` px/gridunit (canvas_footprint_px()
## — the rung's own DISPLAY_RATIO_BY_RUNG entry). The multiplier itself may
## be fractional (e.g. 9/5 = 1.8) — texel-exactness is NOT about the scale
## factor being a whole number, it is about the FINAL on-screen pixel count
## per gridunit (`R`) being an exact integer, so every source texel lands on
## a whole number of screen pixels with no fractional-pixel blur boundary.
static func fit_scale_from_ratio(ratio: int, base_display_ratio: float) -> float:
return float(ratio) / maxf(base_display_ratio, 0.0001)
## Fit a fixed-rung request's extent (in gridunits) to the viewport, capped
## at FIXED_CANVAS_MAX_AXIS per axis (D-255(a)/(b)'s own budget) and at the
## rung's own display ratio — this is the CLIENT's half of "viewport-sized
@@ -206,6 +269,39 @@ static func viewport_fit_extent(viewport_px: Vector2, rung: String) -> Vector2i:
return Vector2i(w, h)
## Cap a fixed-rung request extent to the body's own region grid (T-1189):
## `derive_orbital_at_metres` wraps longitude/clamps latitude server-side, so
## a viewport-fit extent wider or taller than the body's grid makes a rung
## that SHARES Global's gridunit spacing (currently Region only, matched by
## SPACING not by name — see below) request more of the body than exists,
## producing the visible "continent repeats sideways / rows smear past the
## pole" defect (T-1183 eyeball, GJ1c). `global_extent` is the Global rung's
## own echoed canvas size — `StepCanvasRung::global_cell_counts()` server-side
## IS the body's region grid (cols = regions_per_equator(R), rows = cols/2),
## so it is exactly the cap: no unit conversion needed since Region's
## gridunit spacing equals Global's (both RUNG_SPACING_M[RUNG_GLOBAL]).
##
## Shape-generic per the ticket's own instruction: this caps ANY rung whose
## spacing matches Global's (a spacing comparison, not `rung ==
## RUNG_REGION`), so a future rung sharing that spacing is covered for free.
## Every other rung's spacing is strictly finer than Global's, so its
## viewport-fit canvas physically cannot cover more than one region's worth
## of ground per axis at any real viewport size — the guard is a no-op for
## them by construction, not by an explicit exclusion list. A non-positive
## `global_extent` axis (the Global echo genuinely hasn't arrived yet — see
## StepCanvasViewer's own cold-start fallback doc) leaves that axis
## uncapped, matching the ticket's "request uncapped and let the server
## clamp" fallback choice.
static func cap_extent_to_body(
extent: Vector2i, rung: String, global_extent: Vector2i
) -> Vector2i:
if not is_equal_approx(spacing_for_rung(rung), spacing_for_rung(RUNG_GLOBAL)):
return extent
var w: int = extent.x if global_extent.x <= 0 else mini(extent.x, global_extent.x)
var h: int = extent.y if global_extent.y <= 0 else mini(extent.y, global_extent.y)
return Vector2i(w, h)
## WASD + arrow keys, read via Input.is_key_pressed() on the PHYSICAL keycode
## (not an InputMap action) — same rationale the retired
## atlas_window_geometry.gd's own held_pan_direction() documented: this
@@ -16,8 +16,16 @@ extends Control
## - StepCanvasAnnotationLayer (Node2D child of `_canvas`, drawn AFTER the
## terrain layer): unscaled screen-space courses/settlement markers.
## Both layers live under ONE `_canvas` Node2D whose `.position` is the pan
## offset ONLY — there is no `.scale` write anywhere in this file (the whole
## point of the retirement: "there is no more zoom-scaled canvas").
## offset PLUS the T-1189/T-1192 letterbox centering offset, and whose
## `.scale` is the T-1192 Global integer-fit multiplier (1.0 for every other
## rung). This is NOT the retired `_canvas.scale` zoom model: that scale
## changed continuously, per input frame, and had to be compensated for at
## every draw call (`_zs`/`_zs_stroke`/`_zs_ring_radius`). This scale is set
## ONCE per canvas adoption (_on_canvas_ready()/resize), identically for both
## child layers via ordinary Node2D transform inheritance, and is exactly
## D-255(e)'s sanctioned "texture-to-viewport resize" — a fixed, closed-set
## display-time scale, not a per-frame invented one. See
## _recompute_canvas_transform() for the one place both fields are set.
##
## Stepped transport (D-255(a)): a discrete rung INDEX (0-5,
## StepCanvasTransport.RUNG_LADDER), never a float zoom. Mouse wheel scrolls
@@ -93,11 +101,35 @@ var _world_center: Vector2 = Vector2.ZERO
var _held_rung: String = StepCanvasTransport.RUNG_GLOBAL
var _held_extent: Vector2i = Vector2i.ZERO
# ── Pan state (position only — NO scale/zoom field anywhere) ─────────────
## T-1189: the body's own region grid (cols, rows), i.e. the LAST ADOPTED
## Global canvas's own width/height — set only in _on_canvas_ready() when
## `_held_rung == RUNG_GLOBAL` (see there). This is the cap source for
## StepCanvasTransport.cap_extent_to_body(): distinct from `_held_extent`
## (which is overwritten by whatever rung is CURRENTLY held, so it stops
## meaning "the body's grid" the moment the player descends past Global).
## ZERO until the first Global response actually arrives — see
## _request_extent()'s own cold-start fallback doc for what happens then.
var _global_body_extent: Vector2i = Vector2i.ZERO
# ── Pan state ───────────────────────────────────────────────────────────
var _view_offset: Vector2 = Vector2.ZERO
var _last_mouse_pos: Vector2 = Vector2(-1.0, -1.0)
var _app_has_focus: bool = true
## T-1192 Global fit scale — the ONE display-time scale this viewer ever
## writes (see the class doc). Always 1.0 for a fixed rung (its canvas is
## already texel-exact at its own display ratio; T-1189's letterbox only
## adds centered margin, never an extra scale). For Global, the `_canvas.scale`
## multiplier that puts the FINAL on-screen pixels-per-gridunit at the
## largest INTEGER ratio that fits the (legend-reserved) viewport, floored
## at 1 (D-255 amendment 2026-07-25 — rung-0's display ratio is
## viewport-fitted per body to an integer, never a non-integer/sub-1x
## fraction) — see StepCanvasTransport.fit_scale_ratio()'s own doc. The
## multiplier itself (`ratio / base_display_ratio`) may be a non-integer
## float — that is expected and correct, since texel-exactness is about the
## RATIO being integer, not the Node2D scale field's raw value.
var _canvas_scale: float = 1.0
# ── Overlay visibility ─────────────────────────────────────────────────────
var _overlay_visibility: Dictionary = {}
@@ -173,6 +205,7 @@ func enter(body: Dictionary, system: Dictionary) -> void:
_world_center = Vector2.ZERO
_held_rung = StepCanvasTransport.RUNG_GLOBAL
_held_extent = Vector2i.ZERO
_global_body_extent = Vector2i.ZERO # a new body has its own region grid
_view_offset = Vector2.ZERO
_request.reset()
_annotation_layer.clear_frame()
@@ -255,11 +288,28 @@ func _fire_request() -> void:
_request.request_now(get_body_id(), _held_rung, center, extent)
## T-1189: cap the viewport-fit extent to the body's own region grid BEFORE
## the request is issued, so the cache key (which includes extent) is
## consistent with what actually gets served — never cap the echo
## afterward. Cold-start fallback (documented on `_global_body_extent`
## above): if no Global response has arrived yet for this body,
## `_global_body_extent` is still ZERO and cap_extent_to_body() leaves the
## request uncapped on the affected axis — chosen over "fetch Global first"
## because the ladder is already strictly sequential (enter() always lands
## on Global before any deeper rung is reachable, RegionalScreen/D-255(a)),
## so the ONLY way to reach a Region request before the Global echo lands is
## scrolling in fast while the FIRST request (already in flight) hasn't
## returned — a real but narrow window, not the common case, and the
## viewport-fit extent is already server-clamped independently (D-255(a)/(b):
## "never trust the echo to equal the request"), so this fallback is honest,
## not silently wrong — it just misses the ADDITIONAL client-side
## sideways-repeat guard for that one request.
func _request_extent() -> Vector2i:
var viewport: Vector2 = get_rect().size
if viewport == Vector2.ZERO:
viewport = Vector2(1280.0, 720.0)
return StepCanvasTransport.viewport_fit_extent(viewport, _held_rung)
var fit: Vector2i = StepCanvasTransport.viewport_fit_extent(viewport, _held_rung)
return StepCanvasTransport.cap_extent_to_body(fit, _held_rung, _global_body_extent)
func _on_step_canvas_received(response: Dictionary) -> void:
@@ -272,14 +322,68 @@ func _on_step_canvas_received(response: Dictionary) -> void:
## hold-fetch-swap contract — Stig round-1 §2).
func _on_canvas_ready(canvas: Dictionary) -> void:
_held_extent = _request.get_held_extent()
if _held_rung == StepCanvasTransport.RUNG_GLOBAL:
_global_body_extent = _held_extent
_rebuild_terrain_texture(canvas)
_annotation_layer.set_frame(canvas, _world_center, _held_rung, _held_extent)
_recompute_canvas_transform()
_refresh_screen_header()
if _legend_panel:
_legend_panel.refresh()
queue_redraw()
## T-1189/T-1192 shared letterbox mechanism: recompute `_canvas.scale` (the
## Global integer-fit multiplier, 1.0 elsewhere) and re-center `_view_offset`
## on the FRESHLY adopted canvas's own footprint. Called once per canvas
## adoption (_on_canvas_ready(), the only place a new texture size can
## appear) and on viewport resize (the fit target itself changed) — never
## per-frame, matching the class doc's "set once per canvas adoption" scale
## discipline. Deliberately overwrites any in-progress pan: a fresh/resized
## canvas re-centers, same as the retired top-left reset it replaces (every
## `_view_offset = Vector2.ZERO` reset site below).
##
## Global reserves StepCanvasTransport.LEGEND_COLUMN_PX on the left before
## fitting/centering (T-1192: "lay the legend out beside the canvas... never
## over it") — the legend is always-visible chrome at every rung, but only
## Global's canvas is small enough at typical viewports for the naive
## full-viewport center to land it under the legend's fixed corner position
## (a fixed rung's viewport-fit canvas already fills the available area by
## construction, so its letterbox remainder is comparatively too small to
## reach the legend column in practice). Fixed rungs center in the FULL
## viewport, unchanged.
func _recompute_canvas_transform() -> void:
if _terrain_layer == null:
return # NOTIFICATION_RESIZED can fire mid-_ready(), before children exist
var raw_footprint: Vector2 = _terrain_layer.get_footprint_px()
if raw_footprint == Vector2.ZERO:
return
_canvas_scale = _letterbox_scale_for(raw_footprint)
_canvas.scale = Vector2(_canvas_scale, _canvas_scale)
_view_offset = _centered_view_offset()
_apply_transform()
## The `_canvas.scale` multiplier for a given raw (unscaled) footprint at
## the CURRENTLY held rung — Global's per-body integer pixels-per-gridunit
## fit (StepCanvasTransport.fit_scale_ratio()/fit_scale_from_ratio(),
## reserving the legend column), 1.0 for every fixed rung. Split out from
## _recompute_canvas_transform() so _centered_view_offset() (the drift-check
## baseline) can share the exact same scale decision without re-deriving it,
## keeping the two callers structurally unable to disagree.
func _letterbox_scale_for(raw_footprint: Vector2) -> float:
if _held_rung != StepCanvasTransport.RUNG_GLOBAL:
return 1.0
var base_ratio: float = StepCanvasTransport.display_ratio_for_rung(_held_rung)
var extent_cells: Vector2 = raw_footprint / maxf(base_ratio, 0.0001)
var viewport: Vector2 = get_rect().size
var available: Vector2 = Vector2(
maxf(viewport.x - StepCanvasTransport.LEGEND_COLUMN_PX, 1.0), viewport.y
)
var ratio: int = StepCanvasTransport.fit_scale_ratio(extent_cells, available)
return StepCanvasTransport.fit_scale_from_ratio(ratio, base_ratio)
func _rebuild_terrain_texture(canvas: Variant = null) -> void:
var c: Variant = canvas if canvas != null else _terrain_layer._canvas_ref
if not c is Dictionary:
@@ -339,10 +443,37 @@ func _scroll_rung(direction: int, cursor_local: Vector2) -> void:
## True while at rung 0 but the view has drifted from the canonical
## un-panned Global frame (world_center/view_offset both ZERO) — the
## condition _scroll_rung()'s hard-reset gesture fires on.
## un-panned Global frame — `world_center` ZERO (unchanged) AND
## `view_offset` at its own CENTERED baseline (T-1189/T-1192: no longer the
## literal ZERO top-left corner, now `_centered_view_offset()`'s letterbox
## position) — the condition _scroll_rung()'s hard-reset gesture fires on.
func _is_global_view_drifted() -> bool:
return _world_center != Vector2.ZERO or _view_offset != Vector2.ZERO
return _world_center != Vector2.ZERO or _view_offset != _centered_view_offset()
## The letterbox-centered `_view_offset` for the CURRENTLY held canvas
## footprint/scale — the "no pan drift" baseline _is_global_view_drifted()
## compares against, what a hard reset restores, and what
## _recompute_canvas_transform() itself applies on a fresh canvas arrival.
## T-1192: Global centers within the viewport MINUS the reserved legend
## column, then shifts right by that column so the canvas never sits under
## the legend's own fixed corner position ("beside, never over"). Falls back
## to ZERO before any canvas has arrived (matches
## `_terrain_layer.get_footprint_px()` returning ZERO pre-arrival — there is
## nothing to center yet).
func _centered_view_offset() -> Vector2:
if _terrain_layer == null:
return Vector2.ZERO # mirrors _recompute_canvas_transform()'s own pre-_ready() guard
var raw_footprint: Vector2 = _terrain_layer.get_footprint_px()
if raw_footprint == Vector2.ZERO:
return Vector2.ZERO
var is_global: bool = _held_rung == StepCanvasTransport.RUNG_GLOBAL
var reserved_left: float = StepCanvasTransport.LEGEND_COLUMN_PX if is_global else 0.0
var viewport: Vector2 = get_rect().size
var available: Vector2 = Vector2(maxf(viewport.x - reserved_left, 1.0), viewport.y)
var scale: float = _letterbox_scale_for(raw_footprint)
var scaled_footprint: Vector2 = raw_footprint * scale
return StepCanvasTransport.center_offset(scaled_footprint, available) + Vector2(reserved_left, 0.0)
## Hard reset to the Global opener (D-255(a): "a hard full-zoom-out reset to
@@ -353,7 +484,7 @@ func _reset_to_global() -> void:
_rung_index = 0
_held_rung = StepCanvasTransport.RUNG_GLOBAL
_world_center = Vector2.ZERO
_view_offset = Vector2.ZERO
_view_offset = _centered_view_offset()
_fire_request()
_refresh_screen_header()
queue_redraw()
@@ -485,6 +616,7 @@ func _notification(what: int) -> void:
_position_overlay_bar()
if _legend_panel:
_legend_panel.reposition()
_recompute_canvas_transform()
elif what == NOTIFICATION_APPLICATION_FOCUS_OUT:
_app_has_focus = false
elif what == NOTIFICATION_APPLICATION_FOCUS_IN:
+1
View File
@@ -2281,6 +2281,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
- **Implementation:** Phase 4 (epic [T-750](../../.pql)). Implementation chain (measurement-informed): server step-canvas serving via the tagged envelope (the migration), the client two-layer component (RTT terrain + unscaled screen-space annotations) replacing the `_canvas.scale` model, the three-tier cache, the two lake tickets (morphology-sourcing from `HydrologyResult`; basin-outlet→D8 wiring, pre-cleared by T-1170 Ruling 7b). Ticket reconciliations: **T-1176** (this design discussion) closes as delivered; **T-1158** (viewer decomposition) cancelled — the canonical-frame machinery it would extract changes shape under stepped zoom; **T-1175** (nature polish) re-scoped onto the new annotation layer (c1 = CPU-first, its measurement-⑥ blocker discharged); **T-1157** (visual-capture goldens) re-scoped as the stepped mechanism's verification story; **T-1174** (batch-vs-window derive divergence) kept, priority raised (the envelope depends on batch/window derive agreeing); **T-1152/T-1153** stay `done`, their continuous-zoom + coverage-walk + Region-tile-mosaic code and test suites retire with the `_canvas.scale` model. Measurement appendix, per-agent positions, and the full deprecation sweep: `docs/workshops/body-map-viewer/`.
- **Cross-reference:** [D-166](#d-166) (development cascade — the 2026-07-23 corollary repoint; this is the stepped successor to the continuous ladder), [D-226](#d-226) (live-pause harness — the windowed-family ceiling re-scoped, item-(d) per-request + floor-partial-restore, the T-1143 rulings this supersedes; item-(4) `AtlasAgentInterface` the cache cap protects), [D-227](#d-227) (derive-don't-store — the four cache/derive amendments: TTL-split, version-tag, seed-chaining, lakes; the discipline every cache tier obeys), [D-243](#d-243) (spatial scale ladder — the rungs gridunit snaps to, the Global rung-0 elastic-seam view, the `gridunit` vocabulary), [D-225](#d-225) (layer-stream proxy — the tagged-envelope deferral this discharges), [D-192](#d-192) (no version handshake — persistent-cache boundary), [D-253](#d-253) (region transient state — the sim-state planes the map-time split carries), [D-010](#d-010) (determinism — server-owns-derivation, client-art-function; the cache-accelerated-pure-function and byte-identical-paths tests), [D-169](#d-169)/[D-170](#d-170) (implant UI — the map component lives in the implant Atlas app, occludes gameplay via HudGroups), [D-239](#d-239) §6 (the frozen 17-zone `MorphologyZone` vocabulary the lake fix reuses, never widens). Tickets: [T-1176](../../.pql) (design), [T-1177](../../.pql)/[T-1178](../../.pql)/[T-1179](../../.pql)/[T-1180](../../.pql) (measurements ①–⑤), the reconciled T-1152/T-1153/T-1157/T-1158/T-1174/T-1175 above.
- **Dissent:** None.
- **Amendment (2026-07-25, T-1192 / PR #205 — rung-0 integer viewport-fit display ratio):** Premise (2)'s "texel-exact, drawn at the display ratio" is **clarified for rung 0**: the Global opener's display ratio is **viewport-fitted per body** — the largest integer screen-px per gridunit that fits the legend-reserved viewport on both axes, floored at 1×1 (an over-viewport canvas draws at native ratio and crops/pans like every fixed rung) — rather than a fixed tunable. Texel-exactness is preserved by construction (every gridunit an exact integer pixel square); the letterbox remainder centres the canvas; fixed rungs keep their tuned constant display ratios. **Non-integer resting-state scaling remains unsanctioned** — a PR #205 review finding (Tyre) removed an implemented fractional-fit branch whose comments cited this record for an exception it does not contain; the [D-227](#d-227) 2026-07-23 corollary's "only sanctioned display-time scaling" (the transient between-step magnification) is joined only by this integer viewport fit, nothing else.
---