fix(client): T-1153/T-1152 round 5 — nearest-wrap-image tile draw placement; canonical reset restores the fit zoom and re-fires

The wrapped tile column (Lendel: -6400 canonicalized to 12739) drew at
its canonical column — off-canvas right — leaving the mosaic's left
third black. nearest_wrap_image() re-expresses a tile column as the
wrap-image closest to held_center for DRAWING only (requests/cache keys
stay canonical). The draw-position test asserts overlap FRACTION, not
bare intersects() — the buggy placement still clipped ~2px of viewport
edge at Lendel scale, so intersects() alone would false-pass.

The full-zoom-out reset restored center but not the fit zoom, and its
'already there' guard keyed on a lagging field so it could only ever
fire once. The guard now also matches tile mode and compares _view_zoom
against the freshly computed fit — restoring the FULL canonical
transform (center, offset, fit zoom) and re-firing as a continued
gesture keeps zooming out (Jeroen's hard condition, both live shapes).

+8 revert-verified tests incl. the continued-gesture reset repro; smoke
stub gained get_body_radius_km (crash confirmed real under a real
driver before fixing). Suites 286 green; gdlint clean.
This commit is contained in:
2026-07-22 15:32:04 +02:00
parent 493a7345d3
commit ccedba4f24
6 changed files with 420 additions and 78 deletions
@@ -406,6 +406,38 @@ static func district_to_canvas_local(
return Vector2(local_col, local_row)
## Live round 5 fix (the tile-mosaic WRAP half of "the mosaic doesn't fully
## draw"): `compute_tile_grid()`'s tiles are CANONICAL columns (wrapped into
## `[0, cols)` — the correct, single-valued key for REQUESTS and cache
## coalescing), but a canonical column has infinitely many EQUIVALENT
## on-screen positions (`col`, `col - cols`, `col + cols`, ...), since
## longitude is periodic. `district_to_canvas_local()` is a pure LINEAR
## function with no wrap concept — fed a canonical column directly, it
## places the tile at exactly ONE of those wrap-images, which is only ever
## the visually-correct one by coincidence. Lendel's own repro: the tile
## whose pre-canonicalization center was -6400 canonicalizes to 12739
## (`-6400 mod 19139`) — correct for the request/cache key, but drawing at
## column 12739 directly places it canvas-local ~22308 (off-canvas RIGHT),
## when the tile's actual visible position (immediately west of the
## canonical origin) is at column -6400 (canvas-local ~3169, the LEFT
## third of the mosaic).
##
## The fix: before handing a tile's canonical column to
## `district_to_canvas_local()`, re-express it as whichever wrap-image
## (`canonical_col + k*cols` for integer `k`) is NEAREST `held_center.x` —
## the representative that's actually near the current view, matching how a
## real, non-tiling single-window pan already resolves the "which
## circumnavigation" question implicitly (screen_center_to_district()'s own
## RAW, un-wrapped output). `cols <= 0` (no-radius bodies, which never tile
## per compute_tile_grid()'s own doc) is a safe no-op passthrough — there is
## no periodicity to resolve.
static func nearest_wrap_image(canonical_col: int, held_center_col: int, cols: int) -> int:
if cols <= 0:
return canonical_col
var delta: int = posmod(canonical_col - held_center_col + cols / 2, cols) - cols / 2
return held_center_col + delta
## Live round 4 fix (the SECOND half of the "pitch black" repro, beyond
## district_to_canvas_local()'s tile-mosaic fix above): `_view_offset` is a
## PURE screen<->canvas-local transform, entirely independent of
@@ -63,6 +63,8 @@ const REGION_TEMP_NONE_DC: int = AtlasOverlayColors.REGION_TEMP_NONE_DC
const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd")
# T-1153, live round 3: TILE_N (the per-tile district extent) for the mosaic draw path.
const AtlasWindowGeometryRef := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
# Live round 5: `cols` (circumference in districts) for the mosaic's wrap-image draw fix.
const AtlasDescendGeometryRef := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd")
## T-1145 item 3: interim presentation toggle — true renders the smoothed
## Image/ImageTexture composite; false keeps the original crisp per-cell
@@ -177,6 +179,21 @@ func _draw() -> void:
## no per-tile placeholder draw, letting COLOR_BG show through as the honest
## "nothing here yet" read (the viewer's own `_draw()` already documents why
## no separate whole-viewport fade is needed on top of this).
##
## **Live round 5 fix:** `tile["center"]` is CANONICAL (wrapped into
## `[0, cols)` by `compute_tile_grid()` — correct for REQUESTS/cache keys,
## since longitude is periodic and a canonical column is the single-valued
## key both sides of the wire agree on). But `district_to_canvas_local()`
## is a pure LINEAR function with no wrap concept — handed a canonical
## column directly, it places the tile at exactly ONE of its infinitely
## many equivalent on-screen positions (`col + k*cols`), which is only the
## visually-correct one by coincidence. Lendel's own repro: the tile whose
## true position is immediately WEST of the canonical origin canonicalizes
## to column 12739 (`-6400 mod 19139`) — drawn there directly, it lands
## off-canvas RIGHT, leaving the mosaic's actual LEFT third black. Fixed by
## re-expressing each tile's column via `nearest_wrap_image()` — whichever
## wrap-image is closest to `held_center`, i.e. the one actually near the
## current view — BEFORE handing it to `district_to_canvas_local()`.
func _draw_tile_mosaic() -> void:
var tile_set = viewer.get_tile_set()
if tile_set == null:
@@ -187,6 +204,9 @@ func _draw_tile_mosaic() -> void:
var held_n: int = viewer.get_held_n()
var half_tile: float = float(AtlasWindowGeometryRef.TILE_N) * 0.5
var tiles: Array = tile_set.get_tiles()
var cols: int = int(
AtlasDescendGeometryRef.district_extent(viewer.get_body_radius_km()).get("cols", 0)
)
for i in range(tiles.size()):
var tile: Dictionary = tiles[i]
@@ -202,8 +222,9 @@ func _draw_tile_mosaic() -> void:
continue
var center: Vector2i = tile["center"]
var draw_col: int = AtlasWindowGeometryRef.nearest_wrap_image(center.x, held_center.x, cols)
var tile_top_left: Vector2 = Vector2(
float(center.x) - half_tile, float(center.y) - half_tile
float(draw_col) - half_tile, float(center.y) - half_tile
)
var local_origin: Vector2 = AtlasWindowGeometryRef.district_to_canvas_local(
tile_top_left, held_center, held_n, cell_px
@@ -63,15 +63,9 @@ const MAX_ZOOM: float = 64.0
const ZOOM_STEP: float = 1.15
## T-1145 item 2: WASD/arrow-key continuous pan speed, in CANVAS px/s at
## zoom=1.0 — the ACTUAL screen-space pan rate is this value times the
## CURRENT _view_zoom (see _process()'s pan tick), so panning covers the
## same amount of TERRAIN per second regardless of zoom level. A fixed
## SCREEN-px/s rate (no zoom scaling) would feel painfully slow zoomed in
## (each screen pixel is a fraction of a district) and uncontrollably fast
## zoomed out — scaling by zoom keeps the "how much world passes per
## second" feel constant, matching the ticket's "speed in screen px/s
## scaled by zoom" wording. ~6 districts/s at zoom=1.0 (96/16) — brisk
## enough to cross a default n=32 window in ~5s, not a crawl.
## zoom=1.0 — actual screen-space rate is this times CURRENT _view_zoom, so
## panning covers the same TERRAIN per second regardless of zoom level.
## ~6 districts/s at zoom=1.0 (96/16) — brisk, not a crawl.
const PAN_SPEED_CANVAS_PX_S: float = 96.0
## T-1145 item 2: cursor-to-edge distance (px) that triggers edge-scroll —
@@ -154,12 +148,10 @@ var _held_n: int = 32
## T-1153: the granularity_v2 tag ("Quarter"/"District"/"Region") this viewer
## is currently HOLDING (the last-adopted _window's own rung) — distinct from
## _window_request.get_granularity_v2(), which is what's most recently been
## REQUESTED (may be a finer/coarser rung already in flight while the held
## composite is still the previous rung's, per the progressive-refinement
## contract: hold the old composite, swap only when the new one arrives).
## Defaults to District — the ladder's historical entry rung, and the correct
## disposition for AtlasDescendGeometry click-through descent (still District,
## see enter()'s own doc).
## REQUESTED (may be a finer/coarser rung already in flight, per the
## progressive-refinement contract: hold the old composite, swap only when
## the new one arrives). Defaults to District — the ladder's historical
## entry rung (see enter()'s own doc).
var _held_granularity_v2: String = "District"
# ── Pan/zoom state ─────────────────────────────────────────────────────────
@@ -255,20 +247,14 @@ func _exit_tree() -> void:
SimBridge.atlas_layers_received.disconnect(_on_atlas_layers_received)
## Enter the window screen centered on `district_center` (a DistrictPos, from
## a click-through's derived position) at District granularity. n defaults to
## 32, half the server's hard cap. Kept as a thin District-rung wrapper over
## _enter_at_rung() (T-1153) — a click-to-descend-to-point shortcut on top of
## the continuous ladder (Jeroen's ruling). No screen currently calls this
## directly (the retired planetary click-through it served no longer exists
## — see atlas_app.gd's own doc); it survives as the landing point a future
## map-object click would wire into, and as a direct-call entry for tests.
## Enter the window screen centered on `district_center` at District
## granularity. n defaults to 32, half the server's hard cap. Thin
## District-rung wrapper over _enter_at_rung() (T-1153) — a
## click-to-descend-to-point shortcut; survives as a direct-call test entry.
##
## T-1142: `district_center` is canonicalized (wrap column / clamp row)
## BEFORE it becomes `_held_center` or reaches the request — matching the
## server's normalize_window_center() so the client's echo comparison never
## mismatches. Also fits-and-centers the view instead of resetting to
## zoom=1/offset=ZERO.
## server's normalize_window_center() so the echo comparison never mismatches.
func enter(
body: Dictionary,
system: Dictionary,
@@ -283,28 +269,19 @@ func enter(
## T-1153: enter the ladder at its TOP REST STATE — the canonical orbital
## frame (Jeroen's HARD condition: "the whole body fitted to the canvas,
## centered at the body's canonical origin"). This is the "regional" nav
## entry point (T-1152 client half): the player lands on a fully-derived
## Region-rung view of the whole body, then wheel-zoom descends CONTINUOUSLY
## from there — no separate planetary screen, no click-through required
## (though enter() below stays wired for a click-to-descend shortcut, per
## Jeroen's ruling).
## frame (Jeroen's HARD condition: whole body fitted to canvas, centered at
## the canonical origin). The "regional" nav entry point (T-1152 client
## half): the player lands on a fully-derived Region-rung view of the whole
## body, then wheel-zoom descends CONTINUOUSLY from there.
##
## Canonical origin = district (0,0) — "district (0,0) sits at lon 0 / the
## equator" (AtlasDescendGeometry's own doc). Canonical extent = the WHOLE
## equatorial circumference in districts, the same quantity
## is_fully_zoomed_out()/_maybe_reset_to_canonical_frame() test against, so
## entry and reset always agree on what "the top" means. No-radius bodies
## fall back to the District-rung default window (no circumference concept).
## 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 at most
## `AtlasWindowGeometry.MAX_COVERAGE_M["Region"]`, 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()`; a body whose circumference fits one Region window's
## ceiling still gets exactly one "tile" and stays single-window.
## 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()`.
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:
@@ -355,13 +332,8 @@ func _enter_tile_mode(body: Dictionary, system: Dictionary, radius_km: float) ->
##
## **C1 clamp-mirror, one layer up (live-round finding):** `n` MUST be
## clamped via `_clamp_window_n_mirror_v2()` BEFORE it becomes `_held_n` —
## mirroring what AtlasWindowRequest.request_now() already does to ITS OWN
## `_n` (PR #191 Tyre C1). Storing RAW `n` (e.g. enter_orbital()'s full
## district_extent().cols, tens of thousands at Region, vs. the server's
## clamped echo of at most 6,400) left `_held_n` permanently disagreeing
## with the server's echo — every orbital response silently rejected as
## stale forever. `_maybe_reselect_rung()`/`_maybe_refloat_window()` both
## read `_held_n` unchanged, so clamping here fixes every downstream caller.
## mirroring AtlasWindowRequest.request_now()'s own `_n` clamp (PR #191 Tyre
## C1). Raw `n` left `_held_n` disagreeing with the server's clamped echo.
func _enter_at_rung(
body: Dictionary,
system: Dictionary,
@@ -424,8 +396,7 @@ func leave() -> void:
## Named get_district_window(), NOT get_window() — Node already defines
## get_window() -> Window (the containing OS window); shadowing it with an
## incompatible return type is a Godot parse error (confirmed the hard way).
## get_window() -> Window; shadowing it with an incompatible type errors.
func get_district_window() -> Variant:
return _window
@@ -444,9 +415,8 @@ func get_tile_set() -> Variant:
## Live round 4: currently-HELD reference frame — AtlasWindowOverlay's
## mosaic draw path converts each tile's absolute district center into
## canvas-local space via these (see AtlasWindowGeometry.
## district_to_canvas_local()'s own doc for the shared convention).
## mosaic draw path converts tile centers into canvas-local space via these
## (see AtlasWindowGeometry.district_to_canvas_local()'s own doc).
func get_held_center() -> Vector2i:
return _held_center
@@ -455,6 +425,13 @@ func get_held_n() -> int:
return _held_n
## Live round 5: current body's radius — mosaic draw needs `cols` for
## nearest_wrap_image()'s wrap resolution. Mirrors the
## `_body.get("body_radius_km", 0.0)` pattern used throughout this file.
func get_body_radius_km() -> float:
return float(_body.get("body_radius_km", 0.0))
## 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
@@ -587,19 +564,19 @@ func _current_world_extent_m() -> float:
## §5 rung-selection rule + progressive refinement (T-1153): after a zoom
## step, recompute the legal rung for the NOW-displayed world extent. If it
## differs from what's HELD, request the new granularity centered on the
## CURRENT screen-center (_screen_center_district(), the same formula
## _maybe_refloat_window() uses).
## CURRENT screen-center (_screen_center_district(), same formula as
## _maybe_refloat_window()).
##
## **C1 clamp-mirror, a THIRD layer up (live round 3):** `_held_n` MUST be
## re-clamped via `_clamp_window_n_mirror_v2()` for the TARGET rung, not left
## at the PREVIOUS rung's clamp — crossing rungs changes the clamp ceiling,
## so a stale large `_held_n` fed into a smaller-rung request desyncs
## `_on_window_ready()`'s `w_n != _held_n` check and drops the refinement
## forever. Same bug as _enter_at_rung(), recurring at the CROSSING boundary.
## re-clamped via `_clamp_window_n_mirror_v2()` for the TARGET rung — a stale
## large `_held_n` desyncs `_on_window_ready()`'s `w_n != _held_n` check and
## drops the refinement forever.
##
## Progressive refinement: does NOT touch `_window`/`_held_granularity_v2` —
## the OLD composite keeps drawing until _on_window_ready() adopts the new
## one (§6 "no mode flip": never a blank frame, never clear-then-redraw).
## one (§6 "no mode flip"). Live round 5: this lag is exactly what made
## `_maybe_reset_to_canonical_frame()`'s OLD guard misfire — see that
## function's own doc.
func _maybe_reselect_rung() -> void:
if _held_n <= 0:
return
@@ -653,10 +630,26 @@ func _screen_center_district() -> Vector2i:
## Jeroen's HARD condition: "a full zoom-out resets to the original
## canonical planetary frame and location" — the ladder's TOP REST STATE,
## never a drifted pan/zoom-out state. Fires when the CURRENTLY DISPLAYED
## extent (at _held_granularity_v2, deliberately NOT the in-flight request's
## rung) covers the whole body AND the player isn't ALREADY at the canonical
## frame (re-snapping every tick would fight a zoom-in-from-the-top
## gesture). Returns true if it fired (caller skips _maybe_reselect_rung()).
## extent covers the whole body AND the player isn't ALREADY at the
## canonical frame (re-snapping every tick would fight a zoom-in-from-the-
## top gesture). Returns true if it fired (caller skips _maybe_reselect_rung()).
##
## **Live round 5 fix:** the "already there" guard checked only
## `_held_center`/`_held_granularity_v2` — a LAGGING field (updated only on
## response adoption, §6). A TILING body's `_held_granularity_v2` stays
## "Region" after zooming IN crosses `_tile_mode -> false` (no response
## landed yet); zooming back OUT misread that stale value as "already
## canonical," so `_view_zoom` shrank to MIN_ZOOM instead of the fit value.
## Fixed by also requiring `is_tile_mode()` to match a fresh entry's value.
##
## **Live round 5, SECOND fix (same repro, one tick later):** a real wheel
## gesture keeps sending zoom-out ticks AFTER the reset fires — `_zoom_at()`
## scales `_view_zoom` down every tick regardless, so it drifts below the
## fit value again almost immediately (the fit zoom sits right at the
## fully-zoomed-out threshold by construction). The mode/center/granularity
## guard then reads "already canonical" (true — those never moved) and
## skips re-firing, even though the ZOOM drifted away. `_view_zoom` must
## also be compared against the CURRENT fit zoom.
func _maybe_reset_to_canonical_frame() -> bool:
var radius_km: float = float(_body.get("body_radius_km", 0.0))
if radius_km <= 0.0:
@@ -664,7 +657,18 @@ func _maybe_reset_to_canonical_frame() -> bool:
var world_extent_m: float = _current_world_extent_m()
if not AtlasWindowGeometry.is_fully_zoomed_out(world_extent_m, radius_km):
return false
if _held_center == Vector2i.ZERO and _held_granularity_v2 == AtlasWindowRequest.GRANULARITY_V2_REGION:
var canonical_tile_mode: bool = AtlasWindowGeometry.compute_tile_grid(radius_km).size() > 1
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
var canonical_n: int = int(extent["cols"])
var fit: Dictionary = AtlasWindowGeometry.fit_window_view(
get_rect().size, canonical_n, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM
)
if (
_held_center == Vector2i.ZERO
and _held_granularity_v2 == AtlasWindowRequest.GRANULARITY_V2_REGION
and _tile_mode == canonical_tile_mode
and is_equal_approx(_view_zoom, float(fit["zoom"]))
):
return false # already at the canonical frame — don't fight a zoom-in-from-the-top gesture
enter_orbital(_body, _system)
return true
@@ -699,13 +703,9 @@ func set_view(zoom: float, offset: Vector2) -> void:
## — if so, float a NEW window centered on that point via the debounced path.
##
## T-1142 (item 6a): the edge-crossing decision is computed in RAW absolute
## district space (un-wrapped, un-clamped) — the held window's own local
## bounds are relative to _held_center as it was BEFORE this pan. Only the
## FINAL new_center is canonicalized (wrap column, clamp row), matching the
## server's normalize_window_center() so the echo comparison/cache key stay
## on the same canonical form. A pan straddling the antimeridian still
## floats correctly: the pre-canonicalization abs_col can go negative or
## past cols, and only the resulting new_center gets wrapped before use.
## district space — only the FINAL new_center is canonicalized (wrap column,
## clamp row), matching the server's normalize_window_center() so the echo
## comparison/cache key stay on the same canonical form.
func _maybe_refloat_window() -> void:
if _held_n <= 0:
return