diff --git a/.pql/changelog/ticket_history/2026-08.sql b/.pql/changelog/ticket_history/2026-08.sql index 31780f2ef..8d4754ef8 100644 --- a/.pql/changelog/ticket_history/2026-08.sql +++ b/.pql/changelog/ticket_history/2026-08.sql @@ -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 — 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 — 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; diff --git a/.pql/changelog/tickets/2026-08.sql b/.pql/changelog/tickets/2026-08.sql index 9e4f45677..b9a1672de 100644 --- a/.pql/changelog/tickets/2026-08.sql +++ b/.pql/changelog/tickets/2026-08.sql @@ -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 — 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; diff --git a/client/project.godot b/client/project.godot index d573e7546..8cfbf2681 100644 --- a/client/project.godot +++ b/client/project.godot @@ -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" diff --git a/client/scripts/protocol/step_canvas_protocol.gd b/client/scripts/protocol/step_canvas_protocol.gd index b03a1fa77..aebd23821 100644 --- a/client/scripts/protocol/step_canvas_protocol.gd +++ b/client/scripts/protocol/step_canvas_protocol.gd @@ -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` 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", []), diff --git a/client/tests/test_step_canvas_protocol.gd b/client/tests/test_step_canvas_protocol.gd index 74b2b3f1e..69a5c565d 100644 --- a/client/tests/test_step_canvas_protocol.gd +++ b/client/tests/test_step_canvas_protocol.gd @@ -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}] diff --git a/client/tests/visual_capture.gd b/client/tests/visual_capture.gd index 17b7978b2..fe2e978dd 100644 --- a/client/tests/visual_capture.gd +++ b/client/tests/visual_capture.gd @@ -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", {})), ] ) diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd index 1cfbeaebb..88d29c2e3 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd @@ -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 diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_terrain_layer.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_terrain_layer.gd index 1bd60608a..d127bcba9 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_terrain_layer.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_terrain_layer.gd @@ -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 diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd index 3d1f79b61..912d7fc86 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd @@ -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), diff --git a/project.yaml b/project.yaml index 94056db74..50916a9de 100644 --- a/project.yaml +++ b/project.yaml @@ -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 diff --git a/tooling/canvas_sources.py b/tooling/canvas_sources.py index 3d0840292..deddaba7d 100644 --- a/tooling/canvas_sources.py +++ b/tooling/canvas_sources.py @@ -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, )