fix(client): the deep rungs were flat because relief_q fell off the wire (T-1213)

`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>
This commit is contained in:
2026-08-16 15:22:50 +02:00
co-authored by Claude Opus 5
parent e5224b1a44
commit 3ec35b87c8
11 changed files with 690 additions and 7 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ config/name="The Settled Reach"
; in the editor and in a shipped build, where res://../project.yaml does not
; exist at all (T-1241). Kept honest by `make check-client-version`, which the
; pre-push hook runs — do not edit this by hand without moving project.yaml too.
config/version="0.4.8"
config/version="0.4.9"
run/main_scene="res://scenes/main_menu.tscn"
config/features=PackedStringArray("4.6", "GL Compatibility")
config/icon="res://icon.svg"
@@ -34,16 +34,16 @@ class_name StepCanvasProtocol
## (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, glaciation,
## flooded_q, courses, cliffs}. `lake_margin_q` (T-1188) is a MessagePack
## 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 seven PNG-per-field dense planes (morphology/
## elev_q/moisture_q/vegetation/lake_margin_q/glaciation/flooded_q) are
## (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
@@ -181,6 +181,16 @@ static func _decode_encoded_canvas(raw: Variant) -> Variant:
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", []),
+49
View File
@@ -176,6 +176,55 @@ func test_decode_encoded_canvas_passes_through_temp_dc_and_settlement_id_as_arra
assert_that(canvas["settlement_id"]).is_equal([0, 7])
## T-1213 REGRESSION. `relief_q` shipped server-side, the terrain layer asked for
## it by name, and this decode never listed the key — so the plane the deep rungs
## exist to draw arrived nowhere for weeks, and District rendered as a flat wash.
## Every test here passed throughout, because each asserted a field it already
## knew about; none asserted the SET.
##
## So this one is written against the wire contract rather than against a list of
## fields someone remembered: every dense plane the server encodes must survive
## the decode. Adding a plane to EncodedStepCanvas without adding it here now
## fails loudly instead of rendering as "that rung is just flat".
func test_decode_encoded_canvas_carries_every_dense_plane() -> void:
# The eight PNG-per-field planes of EncodedStepCanvas (step_canvas.rs).
var planes := [
"morphology",
"elev_q",
"moisture_q",
"vegetation",
"lake_margin_q",
"relief_q",
"glaciation",
"flooded_q",
]
var canvas_in := {"width": 1, "height": 1, "temp_dc": {"values": [0]}}
for plane: String in planes:
# A one-pixel L8 PNG payload in the array-of-ints shape the wire uses.
canvas_in[plane] = {"png_bytes": [137, 80, 78, 71]}
var raw := {
"body_id": "GJ380c",
"rung": "District",
"status": "Ready",
"canvas": canvas_in,
}
var decoded = Protocol.step_canvas_response_from_raw(raw)
var canvas: Dictionary = decoded["canvas"]
for plane: String in planes:
assert_bool(canvas.has(plane)).override_failure_message(
(
"decoded canvas is missing the '%s' plane — the server encodes it and "
+ "the renderer reads it, so a missing key here renders as a flat rung "
+ "rather than as an error (T-1213)"
)
% plane
).is_true()
assert_that(canvas[plane]).override_failure_message(
"plane '%s' decoded to null rather than bytes" % plane
).is_not_null()
func test_decode_encoded_canvas_passes_through_courses_and_cliffs_unshaped() -> void:
var courses := [{"edge_id": 1, "class": 2, "points": [[0, 0], [100, 100]], "terminus": "Mouth"}]
var cliffs := [{"point": [5, 5], "channel_depth_dm": 10, "cliff_edge": true}]
+2 -1
View File
@@ -335,7 +335,7 @@ func _log_atlas_view_transform(tree_root: Node, scenario_name: String) -> void:
(
"visual_capture: view-transform[%s] rung=%s world_center=%s held_extent=%s "
+ "canvas_position=%s canvas_scale=%s footprint_px=%s canvas_cells=%dx%d "
+ "courses=%d runs=%d longest=%.1fpx drawn=%d settlements=%d"
+ "courses=%d runs=%d longest=%.1fpx drawn=%d settlements=%d planes=%s"
)
% [
scenario_name,
@@ -352,6 +352,7 @@ func _log_atlas_view_transform(tree_root: Node, scenario_name: String) -> void:
float(summary.get("longest_run_px", 0.0)),
int(summary.get("drawn_course_count", 0)),
int(summary.get("settlement_count", 0)),
str(summary.get("plane_variety", {})),
]
)
@@ -88,6 +88,34 @@ const RUGGEDNESS_BASELINE_CELLS: int = 4
## uses (Region spans all 101 values; District 35 of them).
const RELIEF_TEXTURE_FULL_SCALE_Q: int = 25
## How far SIGNED relief may push the base lightness, as a fraction (T-1213).
##
## THE STIPPLE WAS SPENDING THE FIELD ON THE WRONG AXIS. `relief_q` arrived and
## was consumed, but only as a stipple PROBABILITY — so a ridge and a plain
## differed in dot density, which at one pixel per cell reads as noise rather
## than as landform. Measured on the 2026-08-16 cold ladder
## (tooling/atlas-flatness): Region carried 2,923 distinct colours against
## Global's 1,581 while holding a quarter of its structure. Colour count went UP
## and legibility went DOWN, which is the signature of texture standing in for
## form.
##
## Worse, `_ruggedness()` takes `absf(relief_q - 50)`, discarding the sign the
## server deliberately preserved — its own doc: "a hollow and a rise are
## different ground... the reverse is not recoverable". A valley and a ridge
## rendered identically.
##
## So relief now also shades CONTINUOUSLY and SIGNED: rises lighten, hollows
## darken, every cell every time. That is the oldest trick in relief cartography
## and it is what makes ground read as ground. The stipple stays, because
## ruggedness (unsigned) is a genuinely different quantity from elevation
## (signed) — grain over form, not grain instead of it.
##
## Kept gentle for the same reason the stipple is: this rides UNDER the hue and
## must not read as a second colour layer. 0.30 lets a full ±200 m swing move
## lightness by roughly a third, which is visible at a glance without swamping
## the morphology hue the legend is keyed to.
const RELIEF_SHADE_STRENGTH: float = 0.30
## One decoded plane set, pre-extracted from the five L8 Images + the two
## raw-array fields a caller needs per cell — built once per arrived canvas
## (see StepCanvasTerrainLayer.build_texture()), not re-decoded per pixel.
@@ -167,6 +195,16 @@ static func _texture(planes: CellPlanes, col: int, row: int, base: Color) -> Col
return base
var out: Color = base
# FORM FIRST, then grain (T-1213). Signed relief shading runs before the
# stipple so the marks land on already-modelled ground rather than carrying
# the shape by themselves — see RELIEF_SHADE_STRENGTH for why the density-only
# read was the thing making the deep rungs flat.
var shade: float = _signed_relief(planes, col, row)
if shade > 0.0:
out = out.lightened(RELIEF_SHADE_STRENGTH * shade)
elif shade < 0.0:
out = out.darkened(RELIEF_SHADE_STRENGTH * -shade)
var rug: float = _ruggedness(planes, col, row)
if rug > 0.0 and ScatterField.chance(_relief_salt, col, row, rug):
out = out.darkened(RELIEF_STIPPLE_STRENGTH * rug)
@@ -183,6 +221,31 @@ static func _texture(planes: CellPlanes, col: int, row: int, base: Color) -> Col
return out
## SIGNED local relief as a -1..1 fraction: +1 is a full-scale rise, -1 a full
## -scale hollow, 0.0 flat or unavailable (T-1213).
##
## The signed counterpart to [method _ruggedness], which magnitudes this same
## field for the stipple. Both are wanted and they are not the same question:
## "how broken is this ground" (unsigned, grain) versus "is this up or down"
## (signed, form).
##
## Absent plane reads FLAT (0.0), never a full swing — the same fail-safe
## _ruggedness documents, and for the same reason: `_l8_value` zero-fills a
## missing image, and 0 on this field means maximum hollow, so a payload without
## the plane would render every cell fully shaded rather than unshaded.
##
## Orbital rungs get no shading from here by construction: they skip the
## sub-district derive, so relief_q is a flat 50 and this returns 0.0. Global
## keeps reading its form from elev_q lightness, which is richly varied at that
## scale — the fallback _ruggedness applies for the stipple.
static func _signed_relief(planes: CellPlanes, col: int, row: int) -> float:
if planes.relief_q == null:
return 0.0
var rel: int = _l8_value(planes.relief_q, col, row)
var signed: float = (float(rel) - 50.0) / float(RELIEF_TEXTURE_FULL_SCALE_Q)
return clampf(signed, -1.0, 1.0)
## Local relief as a 0..1 fraction — the largest elev_q step to a 4-neighbour,
## normalized by the span at which terrain already reads as mountainous.
## Clamped at the edges by sampling the centre, so a canvas border neither
@@ -59,6 +59,16 @@ var _active_toggle: String = ""
var _footprint_px: Vector2 = Vector2.ZERO
var _held_rung: String = StepCanvasTransport.RUNG_DISTRICT
## Distinct value count per decoded plane, measured once per rebuild (T-1213).
##
## The recurring question on this ladder is not "what does the map look like" but
## "which planes still carry a signal at this rung", and it was being answered by
## inference every time. A plane that has collapsed to ONE value renders exactly
## like a plane that is absent, and both render like a renderer that is ignoring
## it — three very different defects with one appearance. Counting distinct
## values separates them from a single capture.
var _plane_variety: Dictionary = {}
## Rebuild (or reuse) the held texture from a decoded StepCanvasResponse's
## `canvas` Dictionary (step_canvas_protocol.gd's shape: width/height +
@@ -78,6 +88,7 @@ func rebuild_from_canvas(canvas: Dictionary, rung: String, active_toggle: String
return
var planes := _decode_planes(canvas, width, height)
_plane_variety = _measure_plane_variety(planes)
var img := Image.create(width, height, false, Image.FORMAT_RGBA8)
for row in range(height):
for col in range(width):
@@ -158,5 +169,47 @@ func get_held_rung() -> String:
return _held_rung
## Distinct value count per plane for the held canvas — see [member _plane_variety].
## A count of 1 means the plane arrived but says nothing at this rung; 0 means it
## did not arrive at all.
func get_plane_variety() -> Dictionary:
return _plane_variety
## Cells stepped between variety samples.
##
## A full scan is 5 planes x 1290x540 = ~3.5M get_pixel() calls per canvas
## adoption, which is real cost in shipping code for a diagnostic. Stride 4
## samples 1/16th of that and answers the question just as well: this counts
## DISTINCT VALUES, and the failure it exists to catch is a plane collapsing to
## one value (or vanishing). A field carrying 91 values does not hide 90 of them
## from every 4th cell.
const VARIETY_SAMPLE_STRIDE: int = 4
## Count distinct values per L8 plane. Runs once per canvas adoption, never per
## frame, and only over the planes whose collapse would flatten the map.
func _measure_plane_variety(planes: StepCanvasColorize.CellPlanes) -> Dictionary:
var out: Dictionary = {}
for entry: Array in [
["morphology", planes.morphology],
["elev_q", planes.elev_q],
["relief_q", planes.relief_q],
["moisture_q", planes.moisture_q],
["vegetation", planes.vegetation],
]:
var name: String = entry[0]
var img: Image = entry[1]
if img == null:
out[name] = 0 # absent, distinct from "present but constant" (1)
continue
var seen: Dictionary = {}
for row in range(0, img.get_height(), VARIETY_SAMPLE_STRIDE):
for col in range(0, img.get_width(), VARIETY_SAMPLE_STRIDE):
seen[img.get_pixel(col, row).r8] = true
out[name] = seen.size()
return out
func has_texture() -> bool:
return _texture != null
@@ -438,6 +438,9 @@ func get_current_canvas_summary() -> Dictionary:
# say WHICH stage dropped the rivers instead of only that they are gone.
"runs_built": _annotation_layer.get_runs_built() if _annotation_layer else 0,
"longest_run_px": _annotation_layer.get_longest_run_px() if _annotation_layer else 0.0,
# T-1213: distinct values per plane, so "this rung is flat" can name the
# plane that went flat rather than leaving it to inference.
"plane_variety": _terrain_layer.get_plane_variety() if _terrain_layer else {},
"course_count_by_class": course_count_by_class,
"cliff_count": (d.get("cliffs", []) as Array).size(),
"settlement_count": _count_distinct_settlements(d),