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
+302
View File
@@ -332,3 +332,305 @@ THE OPEN DECISION — three readings, materially different work:
scale. Tuning only; 1.07% is near-invisible.
Not chosen here — it needs Jeroen''s call, and it touches D-258.', NULL, '2026-08-16 10:32:31', '2026-08-16 10:32:31.457', '2026-08-16 10:32:31.457', NULL, '09a8f8872c230a04e1cb4a59d8fb52c8', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT0TZC9MJV6KZTSRVRYQ327M', 'description', 'Build the deterministic expansion from rung 0 (heightmap.png 1024x512 16-bit elevation + reliefmap.png 1024x512 colour biome) to rung 0.5, sized per D-258''s 2-px-per-gridunit-at-large-display rule. Same body + same seed must produce the same layer every time (byte-identical, per D-227''s determinism discipline extended by this carve-out). The reliefmap is a PLURALITY, not ground truth: each reliefmap cell already voted-and-discarded the dominant biome across ~38 km of ground, so this generator un-summarises it -- it does not upscale/interpolate it. Three binding invariants from D-258: (1) biome edges are gradients, never lines -- transitions blend so no boundary falls on a rung-0 cell edge (the D-243 climate edge-fuzz rule applied to biome); (2) descending the ladder reveals COMPOSITION not sharpness -- a cell reading ''forest'' globally must be able to contain clearings/marsh/rock/scrub the vote suppressed, emerging deterministically as the ladder descends; (3) CONSERVATIVE invention is the binding acceptance gate -- downsampling rung 0.5 must reproduce the rung-0 summary it came from (a forest cell may gain marsh pockets but must still read as forest from orbit). Blocked on T-1212 (cost/size measurement) landing first. Depends on: nothing else in this epic to start scaffolding, but hydrology (sibling ticket) and this generator are tightly coupled -- coordinate sequencing with whoever picks up hydrology. See governance/decisions/architecture.md#d-258.
---
UNBLOCKED 2026-08-06 (T-1211 re-scope, Jeroen''s call). The T-1212 blocker edge is
removed: that measurement priced a hydrology move that is not happening, and the
descent ladder showed there is no expansion artefact to measure yet anyway.
BUILD IT AS A PURE FUNCTION FIRST, not as a stored layer. The stored-layer half of
D-258 was materially weakened by its own 2026-07-27 amendment (the "not locally
computable" argument for the D-227 carve-out does not hold, because the whole-body
solve it cited already runs once per body in layer1.rs and is sampled at every
rung). So follow the mechanism that exists — D-255(f) mechanism B,
compute-once-sample-everywhere — measure it, and only argue for storage on those
numbers. Do NOT open with a cache.
WHAT "FLAT" MEANS CONCRETELY, so the fix has a target. Ferrath''s heightmap is
1024x512 over a 38,089 km circumference: 37.2 km per source pixel. Global draws at
35.267 km/gridunit, roughly 1:1 with the source, and reads as a real map. Region
draws at 0.379 km/gridunit — 98x finer than any stored datum — and is a single
uniform colour with dither. District is 0.0038 km/gridunit, ~9,800x finer, also
uniform. So the acceptance bar is not subtle: at Region, ~98 gridunits across a
single source pixel must carry visible, deterministic, non-repeating composition
that still downsamples back to that pixel''s summary (D-258''s conservation
invariant, the binding gate).
VERIFY BY CAPTURE, NOT BY REASONING. The scenarios exist: atlas_GJ820Bc_land_*
(Region/District/Quarter/Block/Chunk, one land-anchored world point, no overlays)
in tests/visual.json. Captures run offscreen under gamescope at native 3440x1440
via tests/run-visual --screenshot <name> — they do not steal the desktop. Re-shoot
the ladder and look at it; a green unit test proves nothing here.
Note the ladder currently also reports courses=0 at every rung below Global —
rivers vanish on descent. That is tracked separately as T-1239 and is NOT this
ticket''s scope, but it will be visible in the same captures, so do not mistake it
for a failure of the un-summarisation work.
---
LADDER RE-SHOT COLD 2026-08-16 — the first ladder whose numbers can be trusted.
Every previous ladder, including the one this ticket''s re-scope was written from,
was captured against the developer''s persistent user:// cache (T-1239). The
harness now isolates user:// per capture, so these are fresh derives.
RESULT: the acceptance bar is NOT met. Region and below are still flat.
Flatness, measured rather than eyeballed (.cache/t1213_flatness.py samples the
terrain area, excluding the panels):
rung distinct R std G std B std lum p1-p99
Global 1581 24.05 35.75 36.21 145.69
Region 2923 4.80 8.31 4.19 33.59
District 53 2.56 4.90 2.56 13.72
Quarter 46 2.42 4.78 2.42 11.01
Note Region carries MORE distinct colours than Global (2923 vs 1581) while
holding a quarter of the structure. That is the dither/stipple pass adding colour
noise, not information — so distinct-count is precisely the wrong metric, and the
percentile luminance spread is the honest one. Structure falls ~92% from Global
to Quarter.
WHY IT IS FLAT — a channel mismatch, not a missing generator.
Composition IS working in the data. The conservation test, run explicitly against
real terrain, reports over a 2,048 m patch at the ladder''s own anchor:
conservation: majority class 3 at 98.9% across 2 classes {1: 175, 3: 16209}
So D-258 invariant 3 (conservation) holds and invariant 2 (composition) is
happening: 1.07% of the patch is a minority class.
But composition perturbs `moisture_q` and `slope_q`, which resolve into
`vegetation_class`. The base map draws neither. step_canvas_colorize.gd''s own
comment states it: "The base layer reads hue from morphology and lightness from
elev_q, and on a real body below Global that collapses: morphology resolves to
ONE zone". The ladder scenarios pass `[]` for overlays deliberately
(visual_scenarios.gd: "No overlays, deliberately: a colour ramp on top would mask
the very thing being judged"), so the composed fields are never rendered in the
shots that judge this work.
The un-summarisation lands in channels the default view does not draw. That is
why perfectly correct composition and a flat map coexist.
THE CONSERVATION TEST WAS NOT PROTECTING ANYTHING (fixed here).
Its "not a monoculture" assertion read:
assert!(tally.len() > 1 || share == 1.0, ...)
which is a TAUTOLOGY — a single-class tally has a 100% share by definition, so
both branches are always satisfiable and the check could never fail, including in
the exact case its message names ("or nothing was composed"). It was the only
thing standing behind invariant 2, and it stood behind nothing. Now split into
the two bounds the invariant actually has: majority > 50% (conservation, the
ceiling on invention) and minority >= 0.1% (composition, the floor). Measured
1.07% against a 0.1% floor.
It is also still `#[ignore]`d, so the binding acceptance gate for this epic runs
only when someone types it. Worth moving into a harness that runs — the
believability and window-derivation harnesses already load real bodies in the
normal `cargo test` path, so the precedent exists.
courses=0 BELOW GLOBAL IS NOT A BUG — it is arithmetic, and the ticket''s note
attributing it to T-1239 is superseded. Global now reports courses=73 drawn=18
(T-1239 fixed). Region/District/Quarter report courses=0 because the hydrology
network is 615 river cells on a 512x256 grid (74.4 km/cell) and a Region window
is 489 x 205 km = 0.0217% of a 462M km2 body: expected river cells in view = 615
x 0.000217 = 0.13. Zero is the likely outcome, not a failure. The real
observation underneath it is that 615 river cells on an Earth-sized body is a
sparse network (~45,000 km of total watercourse), which is a believability
question for hydrology, not a rendering one.
THE OPEN DECISION — three readings, materially different work:
(A) Make the DISPLAYED channels carry sub-source-pixel composition. The literal
reading of this ticket''s acceptance bar ("at Region, ~98 gridunits across a
single source pixel must carry visible, deterministic, non-repeating
composition"). Means morphology and/or elev_q gain a fine tier, which is
D-227/D-258 territory and the largest change.
(B) Accept that the base view is morphology + elevation, and judge composition
through the overlays — i.e. the ladder gains VEG/MST companion shots. Test
change only, but it concedes that the default map stays flat below Global.
(C) Raise inclusion density so the existing vegetation texture reads at map
scale. Tuning only; 1.07% is near-invisible.
Not chosen here — it needs Jeroen''s call, and it touches D-258.', 'Build the deterministic expansion from rung 0 (heightmap.png 1024x512 16-bit elevation + reliefmap.png 1024x512 colour biome) to rung 0.5, sized per D-258''s 2-px-per-gridunit-at-large-display rule. Same body + same seed must produce the same layer every time (byte-identical, per D-227''s determinism discipline extended by this carve-out). The reliefmap is a PLURALITY, not ground truth: each reliefmap cell already voted-and-discarded the dominant biome across ~38 km of ground, so this generator un-summarises it -- it does not upscale/interpolate it. Three binding invariants from D-258: (1) biome edges are gradients, never lines -- transitions blend so no boundary falls on a rung-0 cell edge (the D-243 climate edge-fuzz rule applied to biome); (2) descending the ladder reveals COMPOSITION not sharpness -- a cell reading ''forest'' globally must be able to contain clearings/marsh/rock/scrub the vote suppressed, emerging deterministically as the ladder descends; (3) CONSERVATIVE invention is the binding acceptance gate -- downsampling rung 0.5 must reproduce the rung-0 summary it came from (a forest cell may gain marsh pockets but must still read as forest from orbit). Blocked on T-1212 (cost/size measurement) landing first. Depends on: nothing else in this epic to start scaffolding, but hydrology (sibling ticket) and this generator are tightly coupled -- coordinate sequencing with whoever picks up hydrology. See governance/decisions/architecture.md#d-258.
---
UNBLOCKED 2026-08-06 (T-1211 re-scope, Jeroen''s call). The T-1212 blocker edge is
removed: that measurement priced a hydrology move that is not happening, and the
descent ladder showed there is no expansion artefact to measure yet anyway.
BUILD IT AS A PURE FUNCTION FIRST, not as a stored layer. The stored-layer half of
D-258 was materially weakened by its own 2026-07-27 amendment (the "not locally
computable" argument for the D-227 carve-out does not hold, because the whole-body
solve it cited already runs once per body in layer1.rs and is sampled at every
rung). So follow the mechanism that exists — D-255(f) mechanism B,
compute-once-sample-everywhere — measure it, and only argue for storage on those
numbers. Do NOT open with a cache.
WHAT "FLAT" MEANS CONCRETELY, so the fix has a target. Ferrath''s heightmap is
1024x512 over a 38,089 km circumference: 37.2 km per source pixel. Global draws at
35.267 km/gridunit, roughly 1:1 with the source, and reads as a real map. Region
draws at 0.379 km/gridunit — 98x finer than any stored datum — and is a single
uniform colour with dither. District is 0.0038 km/gridunit, ~9,800x finer, also
uniform. So the acceptance bar is not subtle: at Region, ~98 gridunits across a
single source pixel must carry visible, deterministic, non-repeating composition
that still downsamples back to that pixel''s summary (D-258''s conservation
invariant, the binding gate).
VERIFY BY CAPTURE, NOT BY REASONING. The scenarios exist: atlas_GJ820Bc_land_*
(Region/District/Quarter/Block/Chunk, one land-anchored world point, no overlays)
in tests/visual.json. Captures run offscreen under gamescope at native 3440x1440
via tests/run-visual --screenshot <name> — they do not steal the desktop. Re-shoot
the ladder and look at it; a green unit test proves nothing here.
Note the ladder currently also reports courses=0 at every rung below Global —
rivers vanish on descent. That is tracked separately as T-1239 and is NOT this
ticket''s scope, but it will be visible in the same captures, so do not mistake it
for a failure of the un-summarisation work.
---
LADDER RE-SHOT COLD 2026-08-16 — the first ladder whose numbers can be trusted.
Every previous ladder, including the one this ticket''s re-scope was written from,
was captured against the developer''s persistent user:// cache (T-1239). The
harness now isolates user:// per capture, so these are fresh derives.
RESULT: the acceptance bar is NOT met. Region and below are still flat.
Flatness, measured rather than eyeballed (.cache/t1213_flatness.py samples the
terrain area, excluding the panels):
rung distinct R std G std B std lum p1-p99
Global 1581 24.05 35.75 36.21 145.69
Region 2923 4.80 8.31 4.19 33.59
District 53 2.56 4.90 2.56 13.72
Quarter 46 2.42 4.78 2.42 11.01
Note Region carries MORE distinct colours than Global (2923 vs 1581) while
holding a quarter of the structure. That is the dither/stipple pass adding colour
noise, not information — so distinct-count is precisely the wrong metric, and the
percentile luminance spread is the honest one. Structure falls ~92% from Global
to Quarter.
WHY IT IS FLAT — a channel mismatch, not a missing generator.
Composition IS working in the data. The conservation test, run explicitly against
real terrain, reports over a 2,048 m patch at the ladder''s own anchor:
conservation: majority class 3 at 98.9% across 2 classes {1: 175, 3: 16209}
So D-258 invariant 3 (conservation) holds and invariant 2 (composition) is
happening: 1.07% of the patch is a minority class.
But composition perturbs `moisture_q` and `slope_q`, which resolve into
`vegetation_class`. The base map draws neither. step_canvas_colorize.gd''s own
comment states it: "The base layer reads hue from morphology and lightness from
elev_q, and on a real body below Global that collapses: morphology resolves to
ONE zone". The ladder scenarios pass `[]` for overlays deliberately
(visual_scenarios.gd: "No overlays, deliberately: a colour ramp on top would mask
the very thing being judged"), so the composed fields are never rendered in the
shots that judge this work.
The un-summarisation lands in channels the default view does not draw. That is
why perfectly correct composition and a flat map coexist.
THE CONSERVATION TEST WAS NOT PROTECTING ANYTHING (fixed here).
Its "not a monoculture" assertion read:
assert!(tally.len() > 1 || share == 1.0, ...)
which is a TAUTOLOGY — a single-class tally has a 100% share by definition, so
both branches are always satisfiable and the check could never fail, including in
the exact case its message names ("or nothing was composed"). It was the only
thing standing behind invariant 2, and it stood behind nothing. Now split into
the two bounds the invariant actually has: majority > 50% (conservation, the
ceiling on invention) and minority >= 0.1% (composition, the floor). Measured
1.07% against a 0.1% floor.
It is also still `#[ignore]`d, so the binding acceptance gate for this epic runs
only when someone types it. Worth moving into a harness that runs — the
believability and window-derivation harnesses already load real bodies in the
normal `cargo test` path, so the precedent exists.
courses=0 BELOW GLOBAL IS NOT A BUG — it is arithmetic, and the ticket''s note
attributing it to T-1239 is superseded. Global now reports courses=73 drawn=18
(T-1239 fixed). Region/District/Quarter report courses=0 because the hydrology
network is 615 river cells on a 512x256 grid (74.4 km/cell) and a Region window
is 489 x 205 km = 0.0217% of a 462M km2 body: expected river cells in view = 615
x 0.000217 = 0.13. Zero is the likely outcome, not a failure. The real
observation underneath it is that 615 river cells on an Earth-sized body is a
sparse network (~45,000 km of total watercourse), which is a believability
question for hydrology, not a rendering one.
THE OPEN DECISION — three readings, materially different work:
(A) Make the DISPLAYED channels carry sub-source-pixel composition. The literal
reading of this ticket''s acceptance bar ("at Region, ~98 gridunits across a
single source pixel must carry visible, deterministic, non-repeating
composition"). Means morphology and/or elev_q gain a fine tier, which is
D-227/D-258 territory and the largest change.
(B) Accept that the base view is morphology + elevation, and judge composition
through the overlays — i.e. the ladder gains VEG/MST companion shots. Test
change only, but it concedes that the default map stays flat below Global.
(C) Raise inclusion density so the existing vegetation texture reads at map
scale. Tuning only; 1.07% is near-invisible.
Not chosen here — it needs Jeroen''s call, and it touches D-258.
---
FIXED 2026-08-16 (option A). The un-summarisation was reaching the wire and
falling off it one line before the renderer.
ROOT CAUSE: `step_canvas_protocol.gd`''s decode dictionary listed every dense
plane EXCEPT relief_q. The server has encoded it since 5eb394b36
(step_canvas.rs:1234) and the terrain layer has asked for it by name ever since
(`canvas.get("relief_q")`), but the key was never put in the decoded dictionary,
so the plane arrived nowhere. The server half of relief_q landed; the protocol
half did not.
That is why the deep rungs were flat. relief_q is the ONE field with signal below
District — elev_q''s 80 m steps quantise sub-district detail away — and it was
absent. Measured plane variety at District, before: {morphology: 1, elev_q: 11,
relief_q: 0, moisture_q: 25, vegetation: 3}. A 0 there means ABSENT, not merely
constant; the distinction is what made this diagnosable, and it did not exist
until this session added the plane-variety readout.
TWO CHANGES:
1. Decode relief_q (one line, client/scripts/protocol/step_canvas_protocol.gd).
2. Shade with it, SIGNED and continuously (step_canvas_colorize.gd). It was
previously spent 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 landform — and `_ruggedness()` took absf(relief_q - 50), discarding the
sign the server deliberately preserved ("a hollow and a rise are different
ground"). Rises now lighten, hollows darken, under the stipple rather than
instead of it.
LADDER, before -> after (tooling/atlas-flatness, lum p1-p99):
Global 145.69 -> 145.69 unchanged, and 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 went from 7.6% to 29%. Plane variety after:
Region relief_q 101 values, District 91, Quarter 33.
HONEST STATE. The acceptance bar ("at Region, ~98 gridunits across a single
source pixel must carry visible, deterministic, non-repeating composition") is
MET. It is not yet cartographically finished: Region reads as heavy speckle
because ruggedness is now real data rather than an elev_q-gradient fallback, so
far more cells earn a stipple mark than the T-1194 tuning assumed (17,599
distinct colours there). District reads as soft blobby relief — form without
directionality, since the field is isotropic noise with no hillshade direction
and no drainage alignment. Both are tuning/grammar follow-ups on top of a channel
that now carries signal, not blockers.
REGRESSION COVER ADDED. 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 that walks all eight dense planes of
EncodedStepCanvas and fails if any is absent from the decode — verified by
disabling the fix and watching it fail by name.', NULL, '2026-08-16 13:05:35', '2026-08-16 13:05:35.162', '2026-08-16 13:05:35.162', NULL, 'fc36ad2f1acb58e73b53d53ac6402a95', 2) ON CONFLICT(hash) DO NOTHING;
+179
View File
@@ -431,3 +431,182 @@ THE OPEN DECISION — three readings, materially different work:
scale. Tuning only; 1.07% is near-invisible.
Not chosen here — it needs Jeroen''s call, and it touches D-258.', 'in_progress', 'high', NULL, NULL, 'D-258', '2026-07-26 21:54:15.245', '2026-08-16 10:32:31.457', NULL, '21d816226e574f3b6489810453220b5e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT0TZC9MJV6KZTSRVRYQ327M', 'story', '06FT0TX2W0BA10PRR7NMJ2362M', 'Rung-0.5 expanded-layer generator (deterministic un-summarisation of rung 0)', 'Build the deterministic expansion from rung 0 (heightmap.png 1024x512 16-bit elevation + reliefmap.png 1024x512 colour biome) to rung 0.5, sized per D-258''s 2-px-per-gridunit-at-large-display rule. Same body + same seed must produce the same layer every time (byte-identical, per D-227''s determinism discipline extended by this carve-out). The reliefmap is a PLURALITY, not ground truth: each reliefmap cell already voted-and-discarded the dominant biome across ~38 km of ground, so this generator un-summarises it -- it does not upscale/interpolate it. Three binding invariants from D-258: (1) biome edges are gradients, never lines -- transitions blend so no boundary falls on a rung-0 cell edge (the D-243 climate edge-fuzz rule applied to biome); (2) descending the ladder reveals COMPOSITION not sharpness -- a cell reading ''forest'' globally must be able to contain clearings/marsh/rock/scrub the vote suppressed, emerging deterministically as the ladder descends; (3) CONSERVATIVE invention is the binding acceptance gate -- downsampling rung 0.5 must reproduce the rung-0 summary it came from (a forest cell may gain marsh pockets but must still read as forest from orbit). Blocked on T-1212 (cost/size measurement) landing first. Depends on: nothing else in this epic to start scaffolding, but hydrology (sibling ticket) and this generator are tightly coupled -- coordinate sequencing with whoever picks up hydrology. See governance/decisions/architecture.md#d-258.
---
UNBLOCKED 2026-08-06 (T-1211 re-scope, Jeroen''s call). The T-1212 blocker edge is
removed: that measurement priced a hydrology move that is not happening, and the
descent ladder showed there is no expansion artefact to measure yet anyway.
BUILD IT AS A PURE FUNCTION FIRST, not as a stored layer. The stored-layer half of
D-258 was materially weakened by its own 2026-07-27 amendment (the "not locally
computable" argument for the D-227 carve-out does not hold, because the whole-body
solve it cited already runs once per body in layer1.rs and is sampled at every
rung). So follow the mechanism that exists — D-255(f) mechanism B,
compute-once-sample-everywhere — measure it, and only argue for storage on those
numbers. Do NOT open with a cache.
WHAT "FLAT" MEANS CONCRETELY, so the fix has a target. Ferrath''s heightmap is
1024x512 over a 38,089 km circumference: 37.2 km per source pixel. Global draws at
35.267 km/gridunit, roughly 1:1 with the source, and reads as a real map. Region
draws at 0.379 km/gridunit — 98x finer than any stored datum — and is a single
uniform colour with dither. District is 0.0038 km/gridunit, ~9,800x finer, also
uniform. So the acceptance bar is not subtle: at Region, ~98 gridunits across a
single source pixel must carry visible, deterministic, non-repeating composition
that still downsamples back to that pixel''s summary (D-258''s conservation
invariant, the binding gate).
VERIFY BY CAPTURE, NOT BY REASONING. The scenarios exist: atlas_GJ820Bc_land_*
(Region/District/Quarter/Block/Chunk, one land-anchored world point, no overlays)
in tests/visual.json. Captures run offscreen under gamescope at native 3440x1440
via tests/run-visual --screenshot <name> — they do not steal the desktop. Re-shoot
the ladder and look at it; a green unit test proves nothing here.
Note the ladder currently also reports courses=0 at every rung below Global —
rivers vanish on descent. That is tracked separately as T-1239 and is NOT this
ticket''s scope, but it will be visible in the same captures, so do not mistake it
for a failure of the un-summarisation work.
---
LADDER RE-SHOT COLD 2026-08-16 — the first ladder whose numbers can be trusted.
Every previous ladder, including the one this ticket''s re-scope was written from,
was captured against the developer''s persistent user:// cache (T-1239). The
harness now isolates user:// per capture, so these are fresh derives.
RESULT: the acceptance bar is NOT met. Region and below are still flat.
Flatness, measured rather than eyeballed (.cache/t1213_flatness.py samples the
terrain area, excluding the panels):
rung distinct R std G std B std lum p1-p99
Global 1581 24.05 35.75 36.21 145.69
Region 2923 4.80 8.31 4.19 33.59
District 53 2.56 4.90 2.56 13.72
Quarter 46 2.42 4.78 2.42 11.01
Note Region carries MORE distinct colours than Global (2923 vs 1581) while
holding a quarter of the structure. That is the dither/stipple pass adding colour
noise, not information — so distinct-count is precisely the wrong metric, and the
percentile luminance spread is the honest one. Structure falls ~92% from Global
to Quarter.
WHY IT IS FLAT — a channel mismatch, not a missing generator.
Composition IS working in the data. The conservation test, run explicitly against
real terrain, reports over a 2,048 m patch at the ladder''s own anchor:
conservation: majority class 3 at 98.9% across 2 classes {1: 175, 3: 16209}
So D-258 invariant 3 (conservation) holds and invariant 2 (composition) is
happening: 1.07% of the patch is a minority class.
But composition perturbs `moisture_q` and `slope_q`, which resolve into
`vegetation_class`. The base map draws neither. step_canvas_colorize.gd''s own
comment states it: "The base layer reads hue from morphology and lightness from
elev_q, and on a real body below Global that collapses: morphology resolves to
ONE zone". The ladder scenarios pass `[]` for overlays deliberately
(visual_scenarios.gd: "No overlays, deliberately: a colour ramp on top would mask
the very thing being judged"), so the composed fields are never rendered in the
shots that judge this work.
The un-summarisation lands in channels the default view does not draw. That is
why perfectly correct composition and a flat map coexist.
THE CONSERVATION TEST WAS NOT PROTECTING ANYTHING (fixed here).
Its "not a monoculture" assertion read:
assert!(tally.len() > 1 || share == 1.0, ...)
which is a TAUTOLOGY — a single-class tally has a 100% share by definition, so
both branches are always satisfiable and the check could never fail, including in
the exact case its message names ("or nothing was composed"). It was the only
thing standing behind invariant 2, and it stood behind nothing. Now split into
the two bounds the invariant actually has: majority > 50% (conservation, the
ceiling on invention) and minority >= 0.1% (composition, the floor). Measured
1.07% against a 0.1% floor.
It is also still `#[ignore]`d, so the binding acceptance gate for this epic runs
only when someone types it. Worth moving into a harness that runs — the
believability and window-derivation harnesses already load real bodies in the
normal `cargo test` path, so the precedent exists.
courses=0 BELOW GLOBAL IS NOT A BUG — it is arithmetic, and the ticket''s note
attributing it to T-1239 is superseded. Global now reports courses=73 drawn=18
(T-1239 fixed). Region/District/Quarter report courses=0 because the hydrology
network is 615 river cells on a 512x256 grid (74.4 km/cell) and a Region window
is 489 x 205 km = 0.0217% of a 462M km2 body: expected river cells in view = 615
x 0.000217 = 0.13. Zero is the likely outcome, not a failure. The real
observation underneath it is that 615 river cells on an Earth-sized body is a
sparse network (~45,000 km of total watercourse), which is a believability
question for hydrology, not a rendering one.
THE OPEN DECISION — three readings, materially different work:
(A) Make the DISPLAYED channels carry sub-source-pixel composition. The literal
reading of this ticket''s acceptance bar ("at Region, ~98 gridunits across a
single source pixel must carry visible, deterministic, non-repeating
composition"). Means morphology and/or elev_q gain a fine tier, which is
D-227/D-258 territory and the largest change.
(B) Accept that the base view is morphology + elevation, and judge composition
through the overlays — i.e. the ladder gains VEG/MST companion shots. Test
change only, but it concedes that the default map stays flat below Global.
(C) Raise inclusion density so the existing vegetation texture reads at map
scale. Tuning only; 1.07% is near-invisible.
Not chosen here — it needs Jeroen''s call, and it touches D-258.
---
FIXED 2026-08-16 (option A). The un-summarisation was reaching the wire and
falling off it one line before the renderer.
ROOT CAUSE: `step_canvas_protocol.gd`''s decode dictionary listed every dense
plane EXCEPT relief_q. The server has encoded it since 5eb394b36
(step_canvas.rs:1234) and the terrain layer has asked for it by name ever since
(`canvas.get("relief_q")`), but the key was never put in the decoded dictionary,
so the plane arrived nowhere. The server half of relief_q landed; the protocol
half did not.
That is why the deep rungs were flat. relief_q is the ONE field with signal below
District — elev_q''s 80 m steps quantise sub-district detail away — and it was
absent. Measured plane variety at District, before: {morphology: 1, elev_q: 11,
relief_q: 0, moisture_q: 25, vegetation: 3}. A 0 there means ABSENT, not merely
constant; the distinction is what made this diagnosable, and it did not exist
until this session added the plane-variety readout.
TWO CHANGES:
1. Decode relief_q (one line, client/scripts/protocol/step_canvas_protocol.gd).
2. Shade with it, SIGNED and continuously (step_canvas_colorize.gd). It was
previously spent 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 landform — and `_ruggedness()` took absf(relief_q - 50), discarding the
sign the server deliberately preserved ("a hollow and a rise are different
ground"). Rises now lighten, hollows darken, under the stipple rather than
instead of it.
LADDER, before -> after (tooling/atlas-flatness, lum p1-p99):
Global 145.69 -> 145.69 unchanged, and 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 went from 7.6% to 29%. Plane variety after:
Region relief_q 101 values, District 91, Quarter 33.
HONEST STATE. The acceptance bar ("at Region, ~98 gridunits across a single
source pixel must carry visible, deterministic, non-repeating composition") is
MET. It is not yet cartographically finished: Region reads as heavy speckle
because ruggedness is now real data rather than an elev_q-gradient fallback, so
far more cells earn a stipple mark than the T-1194 tuning assumed (17,599
distinct colours there). District reads as soft blobby relief — form without
directionality, since the field is isotropic noise with no hillshade direction
and no drainage alignment. Both are tuning/grammar follow-ups on top of a channel
that now carries signal, not blockers.
REGRESSION COVER ADDED. 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 that walks all eight dense planes of
EncodedStepCanvas and fails if any is absent from the decode — verified by
disabling the fix and watching it fail by name.', 'in_progress', 'high', NULL, NULL, 'D-258', '2026-07-26 21:54:15.245', '2026-08-16 13:05:35.162', NULL, 'e611e465ff1fd8de7b71b3de10702fec', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
+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),
+7 -1
View File
@@ -48,7 +48,13 @@ name: The Settled Reach
# code. Second no-op bump in two days — the false-positive rate is real, and is
# the price of the ruling that a false negative costs a week. Revisit if it
# becomes noise; do NOT add an override to dodge it.
version: 0.4.8
# 0.4.9 is a REQUIRED bump, not a gate formality (T-1213). The disk cache stores
# the DECODED canvas, and the decode was silently dropping the `relief_q` plane —
# so every 0.4.8-and-earlier entry physically lacks the field, and would keep
# rendering the deep rungs as a flat wash even against a build that reads it.
# This is the first bump in the series where a warm cache is wrong about CONTENT
# rather than merely stale.
version: 0.4.9
repository: settled-reach
+17
View File
@@ -87,6 +87,22 @@ CLIENT_STEP_CANVAS_DIR: Path = (
# The accessor the cache reads its invalidation tag through.
BUILD_VERSION_GD: Path = REPO_ROOT / "client" / "scripts" / "build_version.gd"
# The wire codec, which decides which planes EXIST client-side.
#
# Added 2026-08-16 after T-1213 proved the omission expensive: this file's decode
# dictionary was missing `relief_q`, so the plane never reached the renderer and
# the deep rungs rendered flat for weeks. The gate would not have flagged the fix,
# because the registry only covered ui/.../step_canvas/ and this lives under
# scripts/protocol/.
#
# It belongs here for a second, sharper reason: the disk cache stores the DECODED
# canvas, so a decode change alters what a cached entry contains. Entries written
# before that fix have no relief_q key at all and keep rendering flat until the
# version moves — exactly the stale-cache class this registry exists to catch.
STEP_CANVAS_PROTOCOL_GD: Path = (
REPO_ROOT / "client" / "scripts" / "protocol" / "step_canvas_protocol.gd"
)
def _rust_sources(directory: Path, label: str) -> tuple[Path, ...]:
"""Every .rs file in `directory`, collected by glob and fail-closed."""
@@ -127,6 +143,7 @@ def canvas_sources() -> tuple[Path, ...]:
SEED_RS,
*_gdscript_sources(CLIENT_STEP_CANVAS_DIR, "client step_canvas"),
BUILD_VERSION_GD,
STEP_CANVAS_PROTOCOL_GD,
)