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:
@@ -7,6 +7,7 @@ signal atlas_layers_received(response: Dictionary)
|
||||
signal star_map_received(response: Dictionary) # T-949: StarMapResponse
|
||||
signal city_names_received(response: Dictionary) # T-949: CityNamesResponse
|
||||
signal browse_response_received(response: Dictionary) # T-1131/T-1133: BrowseResponse
|
||||
signal step_canvas_received(response: Dictionary) # T-1182, D-255(c): StepCanvasResponse
|
||||
signal handshake_complete
|
||||
signal handshake_failed(reason: String)
|
||||
|
||||
@@ -568,6 +569,33 @@ func request_browse_detail(kind: String, entity_id: String) -> void:
|
||||
)
|
||||
|
||||
|
||||
## Request one step-canvas data canvas (T-1182, D-255(c)) — the stepped Atlas
|
||||
## ladder's per-rung terrain/annotation payload. Live mode only; the response
|
||||
## arrives via step_canvas_received. `rung` is one of the six bare-string rung
|
||||
## tags (StepCanvasTransport.RUNG_*); `center`/`extent` are sent unconditionally
|
||||
## even for Global (the server ignores them for that rung — see
|
||||
## step_canvas_protocol.gd's own doc). No client-side polling loop here — the
|
||||
## D-225 poll/cache/enqueue pattern means a Pending response is the caller's
|
||||
## cue to retry, mirroring request_atlas_layers()'s own "fire and let the
|
||||
## response routing decide" shape.
|
||||
func request_step_canvas(
|
||||
body_id: String, rung: String, center: Vector2i, extent: Vector2i, min_wl_m: int = 0
|
||||
) -> void:
|
||||
if test_mode or _bridge == null or state != ConnectionState.CONNECTED:
|
||||
return
|
||||
var bytes := Protocol.encode_step_canvas_request(body_id, rung, center, extent, min_wl_m)
|
||||
if bytes.is_empty():
|
||||
return
|
||||
var err: int = _bridge.send_message(bytes)
|
||||
if err != OK:
|
||||
push_error(
|
||||
(
|
||||
"SimBridge: failed to send step canvas request for %s/%s: %s"
|
||||
% [body_id, rung, error_string(err)]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Poll for snapshot from simulation.
|
||||
# In test mode delegates to test harness. In live mode, returns the last decoded snapshot.
|
||||
func poll_snapshot() -> Variant:
|
||||
@@ -609,6 +637,9 @@ func receive_bytes(bytes: PackedByteArray) -> void:
|
||||
if inbound.kind == "browse":
|
||||
browse_response_received.emit(inbound.value)
|
||||
return
|
||||
if inbound.kind == "step_canvas":
|
||||
step_canvas_received.emit(inbound.value)
|
||||
return
|
||||
if inbound.kind != "snapshot":
|
||||
push_warning("SimBridge: undecodable frame (%d bytes)" % bytes.size())
|
||||
return
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
class_name StepCanvasProtocol
|
||||
## StepCanvasRequest/StepCanvasResponse wire codec (T-1182, D-255(c)) — the
|
||||
## stepped Atlas ladder's tagged-envelope carrier, executing the D-225
|
||||
## migration server/src/atlas/step_canvas.rs already ships (T-1181, PR #201).
|
||||
## Split out of protocol.gd (not folded in), same rationale as
|
||||
## browse_protocol.gd/atlas_map_protocol.gd — stay under gdlint's
|
||||
## max-file-lines, one file per wire-shape family.
|
||||
##
|
||||
## `step_canvas: true` is the mandatory discriminator field — the SIXTH
|
||||
## `Inbound` shape server/src/bridge/mod.rs demuxes on (star_map/city_names/
|
||||
## browse/step_canvas, plus the two field-shape-disambiguated map probes).
|
||||
##
|
||||
## **Wire shapes (confirmed against server/src/atlas/step_canvas.rs, the
|
||||
## authoritative source over any doc drift — T-1181's IMPLEMENTATION REPORT +
|
||||
## WIRE SHAPE ADDENDUM, 2026-07-25):**
|
||||
## StepCanvasRequest — a map: {step_canvas: true, body_id: String,
|
||||
## rung: <bare string, one of "Global"|"Region"|"District"|"Quarter"|
|
||||
## "Block"|"Chunk">, center: [i64, i64], extent: [u32, u32],
|
||||
## min_wl_m: u32}. `rung` is a unit-variant enum, encoded as its Rust
|
||||
## variant NAME verbatim (rmp_serde's bare-enum convention, same as
|
||||
## granularity_v2/RoadNodeKind/CourseTerminus elsewhere on this wire).
|
||||
## `center`/`extent` are meaningless for Global (server ignores extent
|
||||
## entirely, center is echoed but unused) but are still sent — the
|
||||
## request shape carries them unconditionally, no per-rung omission (the
|
||||
## server-side struct has no Option on either field).
|
||||
## StepCanvasResponse — a map: {body_id, rung, center: [i64,i64],
|
||||
## extent: [u32,u32], min_wl_m: u32, status, canvas: null | EncodedStepCanvas}.
|
||||
## `extent` is the server-CLAMPED echo (PR #201 review) — ALWAYS read
|
||||
## this back, never assume the requested extent; `(0,0)` for Global (the
|
||||
## wire extent is never read for that rung, so there is no clamped value
|
||||
## to report). `status` is the same bare-string-or-{"Error":"msg"} shape
|
||||
## every other status enum on this wire uses (Ready|Pending|NotFound|
|
||||
## Error(String)) — decoded via the shared _decode_status_field() shape
|
||||
## (duplicated here per browse_protocol.gd's own "genuinely standalone"
|
||||
## precedent, not shared via a Callable).
|
||||
## EncodedStepCanvas — a map: {width, height, morphology, elev_q, temp_dc,
|
||||
## moisture_q, vegetation, settlement_id, glaciation, flooded_q, courses,
|
||||
## cliffs}. The six PNG-per-field dense planes (morphology/elev_q/
|
||||
## moisture_q/vegetation/glaciation/flooded_q) are each a map
|
||||
## {"png_bytes": [...]} — png_bytes is a Rust `Vec<u8>` with NO
|
||||
## serde_bytes annotation anywhere in this codebase (confirmed: grep for
|
||||
## serde_bytes across server/src returns nothing), so serde's blanket
|
||||
## Vec<T> impl serializes it via serialize_seq — a msgpack ARRAY of
|
||||
## small-integer elements, NOT a `bin` blob. messagepack.gd's decoder
|
||||
## therefore returns a plain GDScript Array (one int per byte), which
|
||||
## this codec repacks into a PackedByteArray via the direct
|
||||
## PackedByteArray(array) constructor before handing it to
|
||||
## Image.load_png_from_buffer() — decode_png_field() below is the one
|
||||
## place this conversion happens. temp_dc/settlement_id are the OTHER
|
||||
## two dense fields, shipped raw MessagePack-native (too wide for an
|
||||
## 8-bit PNG plane per EncodedTempField/EncodedSettlementField's own
|
||||
## server-side doc): {"values": [...]} — i16/u32 arrays respectively,
|
||||
## passed through as plain Arrays, no PackedByteArray repack (their
|
||||
## per-cell domain doesn't fit a byte anyway). courses/cliffs are sparse
|
||||
## MessagePack-native lists, passed through unshaped (the annotation
|
||||
## layer owns interpreting RiverCourse/CliffSegment's own per-entry
|
||||
## shape, matching atlas_map_protocol.gd's existing "raw passthrough,
|
||||
## caller reshapes" precedent for district_window's courses field).
|
||||
|
||||
|
||||
## Encode a StepCanvasRequest. `mp` is the loaded messagepack.gd module
|
||||
## (passed in rather than reloaded here, matching every other codec in this
|
||||
## cluster). `rung` is one of the six bare-string rung tags
|
||||
## (StepCanvasTransport.RUNG_* constants) — sent verbatim, no client-side
|
||||
## validation (the server rejects an unrecognized variant name at decode
|
||||
## time per step_canvas.rs's own doc: "Unknown -> rejected, never trusted
|
||||
## from the wire").
|
||||
static func encode_step_canvas_request(
|
||||
mp, body_id: String, rung: String, center: Vector2i, extent: Vector2i, min_wl_m: int = 0
|
||||
) -> PackedByteArray:
|
||||
var msg := {
|
||||
"step_canvas": true,
|
||||
"body_id": body_id,
|
||||
"rung": rung,
|
||||
"center": [center.x, center.y],
|
||||
"extent": [extent.x, extent.y],
|
||||
"min_wl_m": min_wl_m,
|
||||
}
|
||||
var result = mp.encode(msg)
|
||||
if result.status != null:
|
||||
push_error("StepCanvasProtocol: encode_step_canvas_request failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
return result.value
|
||||
|
||||
|
||||
## Shared status-enum decode — bare string ("Ready"/"Pending"/"NotFound") or
|
||||
## a single-key map ({"Error": "message"}) for the one data variant. Same
|
||||
## shape browse_protocol.gd/atlas_map_protocol.gd already implement for their
|
||||
## own status enums; duplicated here rather than shared via a Callable to
|
||||
## keep this file genuinely standalone (browse_protocol.gd's own stated
|
||||
## rationale for its copy).
|
||||
static func _decode_status_field(status_raw: Variant) -> Dictionary:
|
||||
if status_raw is String:
|
||||
return {"status": status_raw, "error": ""}
|
||||
if status_raw is Dictionary and status_raw.has("Error"):
|
||||
return {"status": "Error", "error": str(status_raw["Error"])}
|
||||
return {"status": "", "error": ""}
|
||||
|
||||
|
||||
## Repack a decoded {"png_bytes": [...]} field into a PackedByteArray ready
|
||||
## for Image.load_png_from_buffer(). `field_raw` is the raw decoded Variant
|
||||
## for one of the six PNG-per-field planes — null/malformed input returns an
|
||||
## empty PackedByteArray (the caller's Image decode then fails gracefully,
|
||||
## same "skip the bad entry" posture the rest of this cluster uses rather
|
||||
## than crashing on a malformed wire payload).
|
||||
static func decode_png_field(field_raw: Variant) -> PackedByteArray:
|
||||
if not field_raw is Dictionary:
|
||||
return PackedByteArray()
|
||||
var bytes_raw: Variant = (field_raw as Dictionary).get("png_bytes")
|
||||
if not bytes_raw is Array:
|
||||
return PackedByteArray()
|
||||
return PackedByteArray(bytes_raw)
|
||||
|
||||
|
||||
## Build an EncodedStepCanvas Dictionary (GDScript-side shape) from an
|
||||
## already-decoded raw value. Returns null if `raw` isn't a plausible canvas
|
||||
## (missing width/height). PNG fields are repacked to PackedByteArray here
|
||||
## (once, at decode time) — every downstream consumer (the terrain layer)
|
||||
## works with real PackedByteArray/Image objects, never re-touches the raw
|
||||
## msgpack Array shape.
|
||||
static func _decode_encoded_canvas(raw: Variant) -> Variant:
|
||||
if not raw is Dictionary:
|
||||
return null
|
||||
var d: Dictionary = raw
|
||||
if not d.has("width") or not d.has("height"):
|
||||
return null
|
||||
var temp_dc_raw: Variant = d.get("temp_dc")
|
||||
var settlement_id_raw: Variant = d.get("settlement_id")
|
||||
return {
|
||||
"width": int(d.get("width", 0)),
|
||||
"height": int(d.get("height", 0)),
|
||||
"morphology": decode_png_field(d.get("morphology")),
|
||||
"elev_q": decode_png_field(d.get("elev_q")),
|
||||
"temp_dc": (temp_dc_raw as Dictionary).get("values", []) if temp_dc_raw is Dictionary else [],
|
||||
"moisture_q": decode_png_field(d.get("moisture_q")),
|
||||
"vegetation": decode_png_field(d.get("vegetation")),
|
||||
"settlement_id":
|
||||
(
|
||||
(settlement_id_raw as Dictionary).get("values", [])
|
||||
if settlement_id_raw is Dictionary
|
||||
else []
|
||||
),
|
||||
"glaciation": decode_png_field(d.get("glaciation")),
|
||||
"flooded_q": decode_png_field(d.get("flooded_q")),
|
||||
"courses": d.get("courses", []),
|
||||
"cliffs": d.get("cliffs", []),
|
||||
}
|
||||
|
||||
|
||||
## Build a StepCanvasResponse Dictionary from an already-decoded raw value.
|
||||
## Returns null unless it carries "rung" AND "status" — "rung" is the field
|
||||
## no other decoded response on this wire has (atlas/citynames/browse all
|
||||
## lack it), making it a safe, unambiguous discriminator for
|
||||
## Protocol.decode_inbound()'s dispatch.
|
||||
static func step_canvas_response_from_raw(raw: Variant) -> Variant:
|
||||
if not raw is Dictionary or not raw.has("rung") or not raw.has("status"):
|
||||
return null
|
||||
var d: Dictionary = raw
|
||||
var decoded_status := _decode_status_field(d.get("status"))
|
||||
var center_raw: Variant = d.get("center", [0, 0])
|
||||
var extent_raw: Variant = d.get("extent", [0, 0])
|
||||
return {
|
||||
"body_id": str(d.get("body_id", "")),
|
||||
"rung": str(d.get("rung", "")),
|
||||
"center": _vec_from_pair(center_raw),
|
||||
"extent": _vec_from_pair(extent_raw),
|
||||
"min_wl_m": int(d.get("min_wl_m", 0)),
|
||||
"status": decoded_status["status"],
|
||||
"error": decoded_status["error"],
|
||||
"canvas": _decode_encoded_canvas(d.get("canvas")),
|
||||
}
|
||||
|
||||
|
||||
static func _vec_from_pair(pair: Variant) -> Vector2i:
|
||||
if pair is Array and pair.size() >= 2:
|
||||
return Vector2i(int(pair[0]), int(pair[1]))
|
||||
return Vector2i.ZERO
|
||||
Reference in New Issue
Block a user