`relief_q` is the one field with signal below District — elev_q's 80 m steps
quantise sub-district detail away, which is precisely why relief_q was invented.
The server has encoded it since 5eb394b36 and the terrain layer has asked for it
by name ever since. step_canvas_protocol.gd's decode dictionary never listed the
key, so `canvas.get("relief_q")` was always null and the plane arrived nowhere.
The server half of that change landed; the protocol half did not.
That is the whole reason Region and below rendered as a flat wash. Measured plane
variety at District before the fix:
{morphology: 1, elev_q: 11, relief_q: 0, moisture_q: 25, vegetation: 3}
A 0 there means ABSENT, not constant — a distinction the capture could not make
until this commit adds it, and the reason two earlier sessions read the flatness
as a missing generator rather than a missing key.
Also spends the field properly. It drove a stipple PROBABILITY only, so a ridge
and a plain differed in dot density, which at one pixel per cell reads as noise;
and `_ruggedness()` took absf(relief_q - 50), discarding the sign the server
deliberately preserved ("a hollow and a rise are different ground... the reverse
is not recoverable"). Relief now shades continuously and signed — rises lighten,
hollows darken — UNDER the stipple rather than instead of it. Ruggedness
(unsigned) and elevation (signed) are different questions and both are worth
asking.
Ladder, before -> after (tooling/atlas-flatness, lum p1-p99):
Global 145.69 -> 145.69 unchanged, correct: relief_q is flat 50 at
orbital rungs by construction
Region 33.59 -> 71.01 2.1x
District 13.72 -> 77.01 5.6x
Quarter 11.01 -> 42.56 3.9x
Structure retention Global->Quarter: 7.6% -> 29%.
NOT finished, and the ticket says so: Region now reads as heavy speckle, because
ruggedness is real data instead of an elev_q-gradient fallback and far more cells
earn a mark than the T-1194 tuning assumed; District reads as soft blobby relief,
form without directionality. Both are grammar/tuning follow-ups on a channel that
finally carries signal.
0.4.9 is a REQUIRED bump. The disk cache stores the DECODED canvas, so every
earlier entry physically lacks the field and would keep rendering flat against a
build that reads it — the first bump in this series where a warm cache is wrong
about CONTENT, not merely stale. tooling/canvas_sources.py gains
step_canvas_protocol.gd for the same reason: it decides which planes exist, the
cache stores its output, and the T-1242 gate would not have flagged this fix
while the registry stopped at ui/.../step_canvas/.
Regression cover: every protocol test passed throughout the weeks the plane was
missing, because each asserted a field it already knew about and none asserted
the SET. There is now a test walking all eight dense planes of EncodedStepCanvas,
verified by disabling the fix and watching it fail by name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
252 lines
14 KiB
GDScript
252 lines
14 KiB
GDScript
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, 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<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.
|
|
##
|
|
## **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<u8>` 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
|