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: , 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, lake_margin_q, relief_q, ## glaciation, flooded_q, courses, cliffs}. `lake_margin_q` (T-1188) is a MessagePack ## map key that did not exist before this codec version — an older server ## build's payload simply omits it (`d.get("lake_margin_q")` below returns ## null, decode_png_field() then returns an empty PackedByteArray, the ## same "field absent -> draws as the colorize fallback" posture every ## other optional plane on this wire already has); a client this new ## talking to that old a server is not a supported combination anyway ## (D-192 co-ship). The eight PNG-per-field dense planes (morphology/ ## elev_q/moisture_q/vegetation/lake_margin_q/relief_q/glaciation/flooded_q) are ## each a map {"png_bytes": [...]} — png_bytes is a Rust `Vec` with NO ## serde_bytes annotation anywhere in this codebase (confirmed: grep for ## serde_bytes across server/src returns nothing), so serde's blanket ## Vec 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. ## ## **Two possible decoded shapes for `png_bytes`, both handled (PR #203 ## review, Hoshe finding 1):** ## 1. **Array of ints** — the shape rmp_serde ACTUALLY produces today (no ## `serde_bytes` anywhere in server/src, confirmed by grep — see this ## file's own header doc): serde's blanket `Vec` impl serializes via ## `serialize_seq`, a msgpack ARRAY, which messagepack.gd's decoder turns ## into a plain GDScript `Array` of small ints. `PackedByteArray(array)` ## repacks this correctly. ## 2. **`[error: int, data: PackedByteArray]`** — the shape messagepack.gd's ## OWN `bin_8`/`bin_16`/`bin_32` decode path produces ## (`StreamPeerBuffer.get_partial_data()`'s documented `[Error, ## PackedByteArray]` pair), reachable the day the server adds ## `serde_bytes` to `png_bytes` (a `bin`-typed field is a strictly-smaller ## wire encoding for the same bytes, a plausible future optimization this ## codec must not silently mis-decode). **This shape is ALSO `is Array`** ## — it passed the old guard silently. Feeding it straight to ## `PackedByteArray(array)` does NOT error; it coerces each Array element ## to a byte, and a `PackedByteArray` element coerces to `0` (verified ## live: `PackedByteArray([0, PackedByteArray([1,2,3])])` == ## `PackedByteArray([0, 0])`) — a silent, undetectable byte-loss that ## would corrupt every PNG plane the day this shape appears, with no ## error anywhere in the path (the corrupted bytes still often decode as ## SOME image, just the wrong one). ## ## The fix: detect shape 2 explicitly (`size() == 2`, first element an ## `int`, second a `PackedByteArray`) and return the inner bytes directly — ## checked BEFORE the generic Array repack, so shape 2 never reaches the ## byte-coercion path at all. Any other/malformed input (not a Dictionary, ## no `png_bytes` key, an Array that matches neither shape) 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() var arr: Array = bytes_raw if arr.size() == 2 and arr[0] is int and arr[1] is PackedByteArray: return arr[1] # messagepack.gd's bin_8/16/32 [error, data] pair return PackedByteArray(arr) ## 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 [] ), "lake_margin_q": decode_png_field(d.get("lake_margin_q")), # T-1213: the server has encoded this since relief_q was added, and the # terrain layer has asked for it ever since — but this decode never # listed the key, so `canvas.get("relief_q")` was always null and the # plane arrived nowhere. The whole point of relief_q is that it is the # ONE field with signal below District (elev_q's 80 m steps quantise the # sub-district detail away), so its absence is exactly why those rungs # render as a flat wash. Measured before the fix: plane variety at # District was {morphology: 1, elev_q: 11, relief_q: 0, moisture_q: 25, # vegetation: 3} — a 0 meaning ABSENT, not merely constant. "relief_q": decode_png_field(d.get("relief_q")), "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")), } ## Narrows the wire's `center: (i64, i64)` / `extent: (u32, u32)` pairs (both ## StepCanvasResponse fields, step_canvas.rs) into Godot's `Vector2i`, whose ## components are 32-bit signed ints (`int32_t`, range ±2,147,483,647 — ## confirmed against Godot's own `Vector2i` type, not assumed). This ## narrowing is safe under the two invariants both fields actually carry on ## this wire, neither of them the full i64/u32 range in practice: ## - `center` — world-metres. Bounded by the body's own circumference (a ## center coordinate is never meaningfully larger than the body's half ## circumference, `pi * body_radius_km * 1000`): even a Jupiter-class ## body (~70,000 km radius) caps out around 2.2e8 m, three orders of ## magnitude under the int32 ceiling. No real or plausible body in this ## project's scope (`server/data/systems.db`'s body_radius_km column) ## approaches the ~682,000 km radius that WOULD overflow int32 metres. ## - `extent` — canvas cells. Hard-clamped server-side to ## STEP_CANVAS_MAX_EXTENT_AXIS (3,840 per axis, step_canvas.rs's ## `clamp_step_canvas_extent()`) before it ever reaches the wire — ## trivially in range regardless of what a malicious/buggy sender might ## have originally requested (the CLAMPED value is what's echoed, per ## this file's own "extent ECHO rule" doc on StepCanvasResponse). ## A value outside either invariant (a malformed/adversarial frame) still ## narrows via GDScript's own int() truncation rather than erroring — no ## worse than any other wire field this codec trusts once decoded, and no ## production code path can produce one. 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