feat(ui): step-canvas map component — RTT terrain + stepped zoom (D-255, T-1182)

The two-layer client rebuild per D-255(a)(b)(e), replacing the
_canvas.scale continuous-zoom model with one viewer, one path, all six
rungs:

- step_canvas_protocol.gd: StepCanvasRequest/Response codec against
  the T-1181 wire contract — incl. the discovered png_bytes subtlety
  (rmp_serde without serde_bytes emits a msgpack int-array, not bin;
  decode repacks via PackedByteArray before load_png_from_buffer) and
  the extent-echo rule (read the server-clamped extent, never assume
  the requested one).
- step_canvas/ component: transport (six-rung ladder, cursor-anchored
  scroll steps, edge-scroll/WASD pan with re-request on edge crossing,
  hard reset-to-Global), RTT terrain layer (Image.set_pixel colorize
  per the c1 measured ruling, texture.update reuse on step-cross,
  NEAREST coarse / LINEAR fine per rung), unscaled screen-space
  annotation sibling (courses + settlement markers at literal px),
  in-memory LRU cache (Tier 1; T-1183 layers the disk tiers beneath),
  request lifecycle (pending retry, staleness gate, extent echo).
- Full _canvas.scale retirement in the same change: the zoom-scaled
  canvas model, the _zs compensation family, select_rung /
  MAX_COVERAGE_M / compute_tile_grid, the orbital-mosaic-vs-window
  two-path split, _view_zoom/_canonical_fit_zoom — 10 source files
  deleted; their 14 test suites deleted with them (T-1157 dead-goldens
  rule; replacement visual-capture coverage is re-scoped T-1157).
- Surviving surfaces kept per the ticket: atlas_window_cache.gd's LRU
  shape (the ticket's named file atlas_window_tile_set.gd was the
  retiring orchestrator; the real LRU shape lives in
  atlas_window_cache.gd — cited in step_canvas_cache.gd), overlay
  colors, legend/overlay-bar chrome, AtlasViewer descend geometry.

Determinism boundary per D-255(e): the client interpolates only within
the closed server-supplied input set. 7 new gdUnit suites (164 cases)
incl. a real extent-echo bug caught by its own test during
implementation. Full client suite green (exit 0) with the live-gated
suites running against a worktree server build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 08:53:48 +02:00
co-authored by Claude Fable 5
parent e0a3ab35cf
commit d28d24fd26
43 changed files with 2858 additions and 12241 deletions
+42 -8
View File
@@ -31,6 +31,12 @@ static func _amp():
return load("res://scripts/protocol/atlas_map_protocol.gd")
## StepCanvasRequest/StepCanvasResponse codec (T-1182, D-255(c)) — same
## load()-by-path rationale as _bp()/_amp() above.
static func _scp():
return load("res://scripts/protocol/step_canvas_protocol.gd")
# -- Decode: bytes from server → GDScript types --------------------------------
@@ -874,15 +880,41 @@ static func decode_browse_response(bytes: PackedByteArray) -> Variant:
return browse_response_from_raw(decode_raw(bytes))
## Encode a StepCanvasRequest (T-1182, D-255(c)) — the stepped Atlas ladder's
## data-canvas request. Delegates to step_canvas_protocol.gd — see that file
## for the full wire-shape rationale (the sixth Inbound discriminator,
## PNG-per-field decode note).
static func encode_step_canvas_request(
body_id: String, rung: String, center: Vector2i, extent: Vector2i, min_wl_m: int = 0
) -> PackedByteArray:
return _scp().encode_step_canvas_request(_mp(), body_id, rung, center, extent, min_wl_m)
## Build a StepCanvasResponse from an already-decoded raw value. See
## step_canvas_protocol.gd for the full field-by-field wire-shape rationale.
static func step_canvas_response_from_raw(raw: Variant) -> Variant:
return _scp().step_canvas_response_from_raw(raw)
## Decode a StepCanvasResponse from MessagePack bytes. See
## step_canvas_response_from_raw.
static func decode_step_canvas_response(bytes: PackedByteArray) -> Variant:
return step_canvas_response_from_raw(decode_raw(bytes))
## Decode + classify one inbound frame (#960, D-225; T-949 adds starmap/
## citynames; T-1131/T-1133 adds browse). Returns {kind, value}, kind one of
## "snapshot"|"atlas"|"starmap"|"citynames"|"browse"|"unknown" — all msgpack
## maps, told apart by field, most-specific-first: StarMapResponse is the
## only kind with "data" and no "body_id"; CityNamesResponse the only one
## with "cities"; BrowseResponse the only one with its OWN "kind" field
## alongside "status"; anything else carrying "status" is AtlasLayerResponse.
## Lets receive_bytes decode the frame ONCE instead of double-decoding the
## 20 Hz snapshot path.
## citynames; T-1131/T-1133 adds browse; T-1182 adds step_canvas). Returns
## {kind, value}, kind one of
## "snapshot"|"atlas"|"starmap"|"citynames"|"browse"|"step_canvas"|"unknown" —
## all msgpack maps, told apart by field, most-specific-first: StarMapResponse
## is the only kind with "data" and no "body_id"; CityNamesResponse the only
## one with "cities"; BrowseResponse the only one with its OWN "kind" field
## alongside "status"; StepCanvasResponse the only one with "rung" (checked
## BEFORE the generic "status"-only AtlasLayerResponse fallback, since a
## step-canvas response also carries "status" and would otherwise be
## misrouted to "atlas"); anything else carrying "status" is
## AtlasLayerResponse. Lets receive_bytes decode the frame ONCE instead of
## double-decoding the 20 Hz snapshot path.
static func decode_inbound(bytes: PackedByteArray) -> Dictionary:
var raw = decode_raw(bytes)
if not raw is Dictionary:
@@ -895,6 +927,8 @@ static func decode_inbound(bytes: PackedByteArray) -> Dictionary:
return {"kind": "citynames", "value": city_names_response_from_raw(raw)}
if raw.has("kind") and raw.has("status"):
return {"kind": "browse", "value": browse_response_from_raw(raw)}
if raw.has("rung") and raw.has("status"):
return {"kind": "step_canvas", "value": step_canvas_response_from_raw(raw)}
if raw.has("status"):
return {"kind": "atlas", "value": atlas_response_from_raw(raw)}
return {"kind": "snapshot", "value": _decode_snapshot_from_raw(raw)}